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 Unverified
B5690EEEBB952194The 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. | |||
| 9 | 9 | ||
| 10 | 10 | - one static binary. | |
| 11 | 11 | - 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. | |
| 13 | 13 | - three run modes (interactive tui, print, json). | |
| 14 | 14 | - built-in telegram bot. | |
| 15 | 15 | - 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 | |||
| 272 | 272 | - `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). | |
| 273 | 273 | - `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). | |
| 274 | 274 | - `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. | |
| 276 | 276 | - `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. | |
| 277 | 277 | ||
| 278 | 278 | 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). | |||
| 226 | 226 | ||
| 227 | 227 | Tool names live in the same namespace as built-in tools (`read`, | |
| 228 | 228 | `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`, | |
| 230 | 230 | `skill`). Conflicts with active built-ins are silently shadowed by the built-in. | |
| 231 | 231 | 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 | |
| 233 | 233 | extension cannot claim or replace it. | |
| 234 | 234 | ||
| 235 | 235 | 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`. | |||
| 204 | 204 | | `tool_call` | `id`, `name`, `args` | The model proposed a tool call | | |
| 205 | 205 | | `tool_execution_started` | `id`, `name` | Guard and confirmation checks passed; the tool is about to execute | | |
| 206 | 206 | | `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. | | |
| 208 | 208 | | `tool_result` | `id`, `is_error`, `content` | Tool finished | | |
| 209 | 209 | | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends | | |
| 210 | 210 | | `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: | |||
| 256 | 256 | ||
| 257 | 257 | ## Versioning | |
| 258 | 258 | ||
| 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 | |||
| 315 | 315 | } | |
| 316 | 316 | ag.SetSessionTimeContext(s.Meta.Started, s.Meta.Timezone, s.Meta.TimezoneOffset) | |
| 317 | 317 | 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 | + | } | |
| 318 | 326 | return s, nil, nil | |
| 319 | 327 | } | |
| 320 | 328 | } | |
| 321 | 329 | s, err := core.NewSession(ZutHome(), args.CWD, r.Provider, r.Model, version) | |
| 322 | 330 | if err == nil { | |
| 323 | 331 | 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 | + | } | |
| 324 | 337 | } | |
| 325 | 338 | return s, nil, err | |
| 326 | 339 | } | |
M packages/agent/build.go +3 -3
| @@ -123,7 +123,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||
| 123 | 123 | // Web capability and other native names remain reserved even when their | |
| 124 | 124 | // policy excludes the current session. An extension must not turn a | |
| 125 | 125 | // 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 { | |
| 127 | 127 | continue | |
| 128 | 128 | } | |
| 129 | 129 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1375,7 +1375,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1375 | 1375 | "glob": &tools.GlobTool{CWD: cwd, Sandbox: sandbox}, | |
| 1376 | 1376 | "ast": &tools.ASTTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1377 | 1377 | "update_goal": &tools.UpdateGoalTool{}, | |
| 1378 | - | "update_plan": &tools.UpdatePlanTool{}, | |
| 1378 | + | "plan": &tools.PlanTool{}, | |
| 1379 | 1379 | } | |
| 1380 | 1380 | if webSearchAllowedForRegistry(args) { | |
| 1381 | 1381 | for name, tool := range tools.NewWebTools() { | |
| @@ -1526,7 +1526,7 @@ func subagentActionAllowed(args Args, action string) bool { | |||
| 1526 | 1526 | return false | |
| 1527 | 1527 | } | |
| 1528 | 1528 | ||
| 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"} | |
| 1530 | 1530 | ||
| 1531 | 1531 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1532 | 1532 | var out []ToolSummary | |
M packages/agent/build_grep_test.go +6 -6
| @@ -30,19 +30,19 @@ func TestBuildToolRegistryWebCapabilityBundle(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | - | func TestBuildToolRegistryIncludesUpdatePlanByDefaultAndExplicitSelection(t *testing.T) { | |
| 33 | + | func TestBuildToolRegistryIncludesPlanByDefaultAndExplicitSelection(t *testing.T) { | |
| 34 | 34 | cwd := t.TempDir() | |
| 35 | 35 | 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]) | |
| 38 | 38 | } | |
| 39 | 39 | ||
| 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) | |
| 41 | 41 | if len(selected) != 1 { | |
| 42 | 42 | t.Fatalf("selected registry has %d tools, want one", len(selected)) | |
| 43 | 43 | } | |
| 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]) | |
| 46 | 46 | } | |
| 47 | 47 | } | |
| 48 | 48 | ||
M packages/agent/cli.go +96 -0
| @@ -425,6 +425,10 @@ type sessionResumeCandidate struct { | |||
| 425 | 425 | provider string | |
| 426 | 426 | model string | |
| 427 | 427 | 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 | |
| 428 | 432 | } | |
| 429 | 433 | ||
| 430 | 434 | // prepareSessionResume opens and validates a selected session without | |
| @@ -472,6 +476,10 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr | |||
| 472 | 476 | ||
| 473 | 477 | fullMessageCount := len(msgs) | |
| 474 | 478 | 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) | |
| 475 | 483 | storedProvider := strings.TrimSpace(sess.Meta.Provider) | |
| 476 | 484 | storedModel := strings.TrimSpace(sess.Meta.Model) | |
| 477 | 485 | currentProvider = strings.TrimSpace(currentProvider) | |
| @@ -486,6 +494,7 @@ func prepareSessionResumeWithOptions(path string, current *core.Agent, currentPr | |||
| 486 | 494 | lastTurn: lastTurn, | |
| 487 | 495 | provider: currentProvider, | |
| 488 | 496 | model: currentModel, | |
| 497 | + | planSnapshots: planSnapshots, | |
| 489 | 498 | } | |
| 490 | 499 | ||
| 491 | 500 | // 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 | |||
| 586 | 595 | if len(ag.Messages()) == 0 && | |
| 587 | 596 | strings.TrimSpace(sess.Meta.Provider) == strings.TrimSpace(currentProvider) && | |
| 588 | 597 | strings.TrimSpace(sess.Meta.Model) == strings.TrimSpace(currentModel) { | |
| 598 | + | ag.SetPlan(sess.Meta.PlanSteps()) | |
| 589 | 599 | return candidate, nil | |
| 590 | 600 | } | |
| 591 | 601 | 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 | |||
| 606 | 616 | return sessionResumeCandidate{}, errors.Join(fmt.Errorf("close session: %w", closeErr), joinSessionCloseError(nil, candidate.session)) | |
| 607 | 617 | } | |
| 608 | 618 | 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()) | |
| 609 | 625 | return candidate, nil | |
| 610 | 626 | } | |
| 611 | 627 | ||
| @@ -708,6 +724,9 @@ var ( | |||
| 708 | 724 | errGoalStaleUpdate = errors.New("stale goal update") | |
| 709 | 725 | errGoalStorageUnavailable = errors.New("goal storage unavailable") | |
| 710 | 726 | errGoalStateWrite = errors.New("goal state write") | |
| 727 | + | ||
| 728 | + | errPlanStorageUnavailable = errors.New("plan storage unavailable") | |
| 729 | + | errPlanStateWrite = errors.New("plan state write") | |
| 711 | 730 | ) | |
| 712 | 731 | ||
| 713 | 732 | 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 | |||
| 715 | 734 | if _, ok := tools.GoalUpdateFromResult(result); ok { | |
| 716 | 735 | return goalCommitError(errGoalStorageUnavailable) | |
| 717 | 736 | } | |
| 737 | + | if result.PlanOperation != nil && !result.IsError { | |
| 738 | + | return planCommitError(errPlanStorageUnavailable) | |
| 739 | + | } | |
| 718 | 740 | return nil | |
| 719 | 741 | } | |
| 720 | 742 | 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 | |||
| 728 | 750 | if err := persistGoalToolResult(sess, result, goalMaxTokenBudget...); err != nil { | |
| 729 | 751 | return goalCommitError(err) | |
| 730 | 752 | } | |
| 753 | + | if err := persistPlanToolResult(sess, result); err != nil { | |
| 754 | + | return planCommitError(err) | |
| 755 | + | } | |
| 731 | 756 | return nil | |
| 732 | 757 | } | |
| 733 | 758 | ||
| @@ -752,6 +777,21 @@ func safeGoalCommitMessage(err error) string { | |||
| 752 | 777 | } | |
| 753 | 778 | } | |
| 754 | 779 | ||
| 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 | + | ||
| 755 | 795 | func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTokenBudget ...*uint64) error { | |
| 756 | 796 | if sess == nil { | |
| 757 | 797 | return nil | |
| @@ -809,6 +849,23 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||
| 809 | 849 | return nil | |
| 810 | 850 | } | |
| 811 | 851 | ||
| 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 | + | ||
| 812 | 869 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| 813 | 870 | if mgr == nil { | |
| 814 | 871 | return | |
| @@ -1863,6 +1920,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1863 | 1920 | ||
| 1864 | 1921 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 1865 | 1922 | 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 | |
| 1866 | 1927 | if !args.NoSess && ag != nil { | |
| 1867 | 1928 | var sessErr error | |
| 1868 | 1929 | sess, sessErr = openOrCreateSession(ctx, args, r, ag, version) | |
| @@ -1883,6 +1944,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1883 | 1944 | sess = candidate.session | |
| 1884 | 1945 | ag = candidate.agent | |
| 1885 | 1946 | sessBaselineMsgs = candidate.fullMessageCount | |
| 1947 | + | initialPlanSnapshots = candidate.planSnapshots | |
| 1886 | 1948 | activeProvider = candidate.provider | |
| 1887 | 1949 | activeModel = candidate.model | |
| 1888 | 1950 | r.Provider = candidate.provider | |
| @@ -2037,6 +2099,29 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2037 | 2099 | } | |
| 2038 | 2100 | return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...) | |
| 2039 | 2101 | } | |
| 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 | + | } | |
| 2040 | 2125 | bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) { | |
| 2041 | 2126 | if a == nil { | |
| 2042 | 2127 | return | |
| @@ -2242,6 +2327,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2242 | 2327 | } | |
| 2243 | 2328 | sess = candidate.session | |
| 2244 | 2329 | 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()) | |
| 2245 | 2333 | candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset) | |
| 2246 | 2334 | sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint | |
| 2247 | 2335 | // 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 | |||
| 2282 | 2370 | persistMu.Unlock() | |
| 2283 | 2371 | if iv != nil { | |
| 2284 | 2372 | iv.RefreshGoal() | |
| 2373 | + | iv.RefreshPlan(candidate.planSnapshots) | |
| 2285 | 2374 | } | |
| 2286 | 2375 | if candidate.session.Meta.Parent != "" { | |
| 2287 | 2376 | extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates) | |
| @@ -2468,6 +2557,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2468 | 2557 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome())) | |
| 2469 | 2558 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | |
| 2470 | 2559 | 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) | |
| 2471 | 2563 | } | |
| 2472 | 2564 | ||
| 2473 | 2565 | // Re-scope the subagent dashboard to the new session. | |
| @@ -2741,6 +2833,10 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2741 | 2833 | PersistGoalRuntime: persistGoalRuntime, | |
| 2742 | 2834 | CurrentGoal: currentGoal, | |
| 2743 | 2835 | CurrentGoalHistory: currentGoalHistory, | |
| 2836 | + | CurrentPlan: currentPlan, | |
| 2837 | + | PersistPlan: persistPlan, | |
| 2838 | + | PersistedPlan: persistedPlan, | |
| 2839 | + | InitialPlanSnapshots: initialPlanSnapshots, | |
| 2744 | 2840 | EnsureMission: ensureMission, | |
| 2745 | 2841 | CurrentSessionPath: func() string { | |
| 2746 | 2842 | persistMu.Lock() | |
M packages/agent/ext/ext.go +2 -2
| @@ -422,10 +422,10 @@ func (e *Extension) Command(name, description string, fn CommandHandler) { | |||
| 422 | 422 | // | |
| 423 | 423 | // Conflicts with active built-in tool names (read, write, edit, bash, | |
| 424 | 424 | // 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 | |
| 426 | 426 | // silently shadowed by the built-in. Every native public-web name, plus | |
| 427 | 427 | // 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 | |
| 429 | 429 | // extension cannot claim or replace it. | |
| 430 | 430 | func (e *Extension) Tool(name, description string, schema json.RawMessage, fn ToolHandler) { | |
| 431 | 431 | 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 { | |||
| 352 | 352 | agent.RetryBaseDelay = main.RetryBaseDelay | |
| 353 | 353 | agent.OnEvent = main.OnEvent | |
| 354 | 354 | 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()) | |
| 355 | 359 | return agent | |
| 356 | 360 | } | |
| 357 | 361 | ||
M packages/agent/modes/btw_dialog_test.go +26 -0
| @@ -3,6 +3,7 @@ package modes | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "slices" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| @@ -325,3 +326,28 @@ func TestBtwToolConfirmationRoutesThroughSideAgent(t *testing.T) { | |||
| 325 | 326 | t.Fatal("main confirmation was not retained after resolving the side-chat call") | |
| 326 | 327 | } | |
| 327 | 328 | } | |
| 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 { | |||
| 394 | 394 | // PersistGoalRuntime records execution bookkeeping without adding a mission | |
| 395 | 395 | // transition to goal history. | |
| 396 | 396 | 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 | |
| 397 | 413 | // GoalMaxTokenBudget is optional. Nil means autonomous goals are unlimited. | |
| 398 | 414 | GoalMaxTokenBudget *uint64 | |
| 399 | 415 | ||
| @@ -479,6 +495,7 @@ type chatCacheKey struct { | |||
| 479 | 495 | welcomeShowVer bool | |
| 480 | 496 | expandAll bool | |
| 481 | 497 | tailLimit int | |
| 498 | + | planRev uint64 | |
| 482 | 499 | renderedMessageCount int | |
| 483 | 500 | viewCacheRev uint64 | |
| 484 | 501 | } | |
| @@ -727,6 +744,8 @@ type Interactive struct { | |||
| 727 | 744 | goalStatus core.GoalStatus | |
| 728 | 745 | planCurrent int | |
| 729 | 746 | planTotal int | |
| 747 | + | planSnapshots map[string]core.PlanUpdate | |
| 748 | + | planRevision uint64 | |
| 730 | 749 | goalRun *goalContinuationRun | |
| 731 | 750 | reloadStatusSeq uint64 | |
| 732 | 751 | extStatuses map[string]map[string]extensionStatus | |
| @@ -1154,6 +1173,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 1154 | 1173 | if len(i.view.Messages) > initialResumeTailLimit { | |
| 1155 | 1174 | i.view.TailLimit = initialResumeTailLimit | |
| 1156 | 1175 | } | |
| 1176 | + | i.seedPlanState() | |
| 1157 | 1177 | } | |
| 1158 | 1178 | i.sessionTitle = core.NormalizeSessionTitle(cfg.InitialSessionTitle) | |
| 1159 | 1179 | 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) { | |||
| 218 | 218 | case "/exit": | |
| 219 | 219 | return true | |
| 220 | 220 | 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 | |
| 221 | 231 | if i.agent != nil { | |
| 222 | 232 | i.agent.SetMessages(nil) | |
| 233 | + | i.agent.SetPlan(nil) | |
| 223 | 234 | } | |
| 224 | - | i.mu.Lock() | |
| 225 | 235 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 226 | 236 | i.toolOrder = nil | |
| 227 | 237 | i.toolGate = map[string]int{} | |
| @@ -234,12 +244,24 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 234 | 244 | i.scrollOffset = 0 | |
| 235 | 245 | i.extNotes = nil | |
| 236 | 246 | i.reloadErrors = nil | |
| 247 | + | i.planSnapshots = nil | |
| 248 | + | i.planCurrent = 0 | |
| 249 | + | i.planTotal = 0 | |
| 250 | + | i.planRevision++ | |
| 237 | 251 | handoff, persistHandoff := i.resetCompactContinuationLocked() | |
| 238 | 252 | i.view.InvalidateRenderCache() | |
| 239 | 253 | i.mu.Unlock() | |
| 240 | 254 | if persistHandoff { | |
| 241 | 255 | i.persistCompactHandoff(handoff) | |
| 242 | 256 | } | |
| 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 | + | } | |
| 243 | 265 | case "/help": | |
| 244 | 266 | i.mu.Lock() | |
| 245 | 267 | i.helpBlock = renderHelpBlock(i.cfg.Theme, i.lastCols(), i.llamaConfigured) | |
| @@ -1202,9 +1224,11 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1202 | 1224 | // cross-provider /model swap. | |
| 1203 | 1225 | var carryMsgs []provider.Message | |
| 1204 | 1226 | var carryCost provider.Usage | |
| 1227 | + | var carryPlan []core.PlanStep | |
| 1205 | 1228 | if i.agent != nil { | |
| 1206 | 1229 | carryMsgs = i.agent.Messages() | |
| 1207 | 1230 | carryCost = i.agent.Cost() | |
| 1231 | + | carryPlan = i.agent.CurrentPlan() | |
| 1208 | 1232 | } | |
| 1209 | 1233 | ||
| 1210 | 1234 | ag, p, md, err := builder(m.Provider, m.ID) | |
| @@ -1224,6 +1248,10 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1224 | 1248 | if len(carryMsgs) > 0 { | |
| 1225 | 1249 | ag.SetMessages(carryMsgs) | |
| 1226 | 1250 | } | |
| 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) | |
| 1227 | 1255 | ag.SeedCost(carryCost) | |
| 1228 | 1256 | ||
| 1229 | 1257 | 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) { | |||
| 197 | 197 | } | |
| 198 | 198 | case core.EvPlanUpdate: | |
| 199 | 199 | 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++ | |
| 200 | 212 | case core.EvUsage: | |
| 201 | 213 | i.cumUsage = e.Cumulative | |
| 202 | 214 | 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 @@ | |||
| 1 | 1 | package modes | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | "sync" | |
| 4 | 8 | "testing" | |
| 9 | + | "time" | |
| 5 | 10 | ||
| 6 | 11 | "github.com/bnema/zut/packages/core" | |
| 12 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | 13 | ) | |
| 8 | 14 | ||
| 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 | + | ||
| 9 | 33 | func TestInteractiveTracksCurrentPlanStep(t *testing.T) { | |
| 10 | 34 | 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{ | |
| 12 | 36 | {Step: "Inspect", Status: core.PlanCompleted}, | |
| 13 | 37 | {Step: "Implement", Status: core.PlanInProgress}, | |
| 14 | 38 | {Step: "Verify", Status: core.PlanPending}, | |
| 15 | 39 | }}}) | |
| 16 | 40 | ||
| 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) | |
| 19 | 46 | } | |
| 20 | 47 | ||
| 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{ | |
| 22 | 49 | {Step: "Inspect", Status: core.PlanCompleted}, | |
| 23 | 50 | }}}) | |
| 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) | |
| 26 | 359 | } | |
| 27 | 360 | } | |
M packages/agent/modes/interactive_rendering.go +6 -0
| @@ -72,6 +72,7 @@ func (i *Interactive) chatCacheKeyLocked(cols int) chatCacheKey { | |||
| 72 | 72 | welcomeShowVer: showVer, | |
| 73 | 73 | expandAll: i.view.ExpandAll, | |
| 74 | 74 | tailLimit: i.view.TailLimit, | |
| 75 | + | planRev: i.planRevision, | |
| 75 | 76 | renderedMessageCount: len(i.view.Messages), | |
| 76 | 77 | viewCacheRev: i.view.RenderCacheRevision, | |
| 77 | 78 | } | |
| @@ -245,6 +246,11 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 245 | 246 | i.view.Messages = nil | |
| 246 | 247 | i.view.MessagesRevision = 0 | |
| 247 | 248 | } | |
| 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 | |
| 248 | 254 | // Transcript rewinds and compaction can remove messages that an info block | |
| 249 | 255 | // was anchored after. Clamp those anchors once so later turns still append | |
| 250 | 256 | // 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 | |||
| 216 | 216 | i.toolCalls = map[string]*tui.ToolCallView{} | |
| 217 | 217 | i.toolOrder = nil | |
| 218 | 218 | i.toolGate = map[string]int{} | |
| 219 | - | i.planCurrent = 0 | |
| 220 | - | i.planTotal = 0 | |
| 221 | 219 | i.extNotes = nil // ext notes are one-shot; a new prompt clears them | |
| 222 | 220 | i.scrollOffset = 0 // jump back to the bottom on new turn | |
| 223 | 221 | // 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 | |||
| 452 | 450 | } | |
| 453 | 451 | } | |
| 454 | 452 | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || hasScheduled || agentQueued > 0 || continueGoal || interruptedReturn, offer, recoverContextOverflow, shouldAutoCompact) | |
| 455 | - | i.planCurrent = 0 | |
| 456 | - | i.planTotal = 0 | |
| 457 | 453 | // interruptedReturn already reserves the turn slot (like the | |
| 458 | 454 | // compact handoff path below): the reserved continuation owns | |
| 459 | 455 | // 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 { | |||
| 110 | 110 | m["id"] = e.ID | |
| 111 | 111 | m["text"] = e.Text | |
| 112 | 112 | 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 | + | } | |
| 113 | 118 | if e.Update.Explanation != nil { | |
| 114 | 119 | m["explanation"] = *e.Update.Explanation | |
| 115 | 120 | } else { | |
M packages/agent/modes/json_activity_test.go +5 -2
| @@ -85,11 +85,11 @@ func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) { | |||
| 85 | 85 | ||
| 86 | 86 | func TestEventToJSONPlanUpdate(t *testing.T) { | |
| 87 | 87 | explanation := "Discovery complete" | |
| 88 | - | event := EventToJSON(core.EvPlanUpdate{Update: core.PlanUpdate{ | |
| 88 | + | event := EventToJSON(core.EvPlanUpdate{CallID: "call-7", Update: core.PlanUpdate{ | |
| 89 | 89 | Explanation: &explanation, | |
| 90 | 90 | Plan: []core.PlanStep{{Step: "Implement", Status: core.PlanInProgress}}, | |
| 91 | 91 | }}) | |
| 92 | - | if event["type"] != "plan_update" || event["explanation"] != "Discovery complete" { | |
| 92 | + | if event["type"] != "plan_update" || event["explanation"] != "Discovery complete" || event["id"] != "call-7" { | |
| 93 | 93 | t.Fatalf("plan event = %#v", event) | |
| 94 | 94 | } | |
| 95 | 95 | plan, ok := event["plan"].([]core.PlanStep) | |
| @@ -100,6 +100,9 @@ func TestEventToJSONPlanUpdate(t *testing.T) { | |||
| 100 | 100 | if withoutExplanation["explanation"] != nil { | |
| 101 | 101 | t.Fatalf("missing explanation = %#v, want null", withoutExplanation["explanation"]) | |
| 102 | 102 | } | |
| 103 | + | if _, ok := withoutExplanation["id"]; ok { | |
| 104 | + | t.Fatalf("missing call id = %#v, want omitted", withoutExplanation["id"]) | |
| 105 | + | } | |
| 103 | 106 | } | |
| 104 | 107 | ||
| 105 | 108 | 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) { | |||
| 349 | 349 | ||
| 350 | 350 | case "clear": | |
| 351 | 351 | s.agent.SetMessages(nil) | |
| 352 | + | s.agent.SetPlan(nil) | |
| 352 | 353 | s.writeResponse(id, cmd, nil) | |
| 353 | 354 | ||
| 354 | 355 | 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 | |||
| 49 | 49 | return fmt.Errorf("build scheduled session agent: %w", err) | |
| 50 | 50 | } | |
| 51 | 51 | 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 | + | } | |
| 52 | 60 | if cumulative, lastTurn, usageErr := core.SessionUsageDetail(path); usageErr == nil { | |
| 53 | 61 | ag.SeedCost(cumulative) | |
| 54 | 62 | 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 { | |||
| 79 | 79 | ||
| 80 | 80 | // Tools is the list of tools to enable. Nil/empty enables the ordinary | |
| 81 | 81 | // 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 | |
| 83 | 83 | // explicitly contains "web_search"; that selector enables web_search, | |
| 84 | 84 | // web_open, web_find, and web_click. Pass NoTools=true to disable everything. | |
| 85 | 85 | Tools []string | |
| @@ -208,6 +208,36 @@ func (r *Runtime) SetMessages(msgs []Message) { | |||
| 208 | 208 | r.agent.SetMessages(out) | |
| 209 | 209 | } | |
| 210 | 210 | ||
| 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 | + | ||
| 211 | 241 | // Cost returns the cumulative usage and dollar cost for this Runtime. | |
| 212 | 242 | func (r *Runtime) Cost() Usage { | |
| 213 | 243 | if r.agent == nil { | |
M packages/agent/sdk/types.go +1 -0
| @@ -200,6 +200,7 @@ func toEvent(ev core.AgentEvent) Event { | |||
| 200 | 200 | out.ID = e.ID | |
| 201 | 201 | out.Text = e.Text | |
| 202 | 202 | case core.EvPlanUpdate: | |
| 203 | + | out.ID = e.CallID | |
| 203 | 204 | out.Explanation = e.Update.Explanation | |
| 204 | 205 | plan := make([]PlanStep, len(e.Update.Plan)) | |
| 205 | 206 | 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) { | |||
| 37 | 37 | ||
| 38 | 38 | func TestToEventPreservesPlanUpdate(t *testing.T) { | |
| 39 | 39 | explanation := "Discovery complete" | |
| 40 | - | event := toEvent(core.EvPlanUpdate{Update: core.PlanUpdate{ | |
| 40 | + | event := toEvent(core.EvPlanUpdate{CallID: "call-7", Update: core.PlanUpdate{ | |
| 41 | 41 | Explanation: &explanation, | |
| 42 | 42 | Plan: []core.PlanStep{ | |
| 43 | 43 | {Step: "Implement", Status: core.PlanInProgress}, | |
| 44 | 44 | {Step: "Verify", Status: core.PlanPending}, | |
| 45 | 45 | }, | |
| 46 | 46 | }}) | |
| 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 { | |
| 48 | 48 | t.Fatalf("plan event = %#v", event) | |
| 49 | 49 | } | |
| 50 | 50 | if event.Plan == nil || len(*event.Plan) != 2 || (*event.Plan)[0].Status != "in_progress" { | |
| @@ -59,6 +59,9 @@ func TestToEventPreservesPlanUpdate(t *testing.T) { | |||
| 59 | 59 | if !strings.Contains(string(encoded), `"plan":[]`) { | |
| 60 | 60 | t.Fatalf("empty plan JSON = %s, want explicit empty list", encoded) | |
| 61 | 61 | } | |
| 62 | + | if strings.Contains(string(encoded), `"id"`) { | |
| 63 | + | t.Fatalf("call id should be omitted when empty: %s", encoded) | |
| 64 | + | } | |
| 62 | 65 | } | |
| 63 | 66 | ||
| 64 | 67 | 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 | |||
| 283 | 283 | if name == "" { | |
| 284 | 284 | return nil, fmt.Errorf("resident child has an empty tool name") | |
| 285 | 285 | } | |
| 286 | - | if name == tools.SubagentToolName || name == "update_goal" { | |
| 286 | + | if name == tools.SubagentToolName || name == "update_goal" || name == tools.PlanToolName { | |
| 287 | 287 | return nil, fmt.Errorf("resident child tool %q is not allowed", name) | |
| 288 | 288 | } | |
| 289 | 289 | tool, ok := catalogue[name] | |
M packages/agent/subagent_factory_test.go +2 -1
| @@ -211,6 +211,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||
| 211 | 211 | "bash": nil, | |
| 212 | 212 | "subagent": nil, | |
| 213 | 213 | "update_goal": nil, | |
| 214 | + | "plan": nil, | |
| 214 | 215 | } | |
| 215 | 216 | registry, err := residentChildRegistry(catalogue, []string{"read"}) | |
| 216 | 217 | if err != nil { | |
| @@ -219,7 +220,7 @@ func TestResidentChildRegistryUsesExactToolListAndForbidsDelegation(t *testing.T | |||
| 219 | 220 | if len(registry) != 1 { | |
| 220 | 221 | t.Fatalf("registry = %#v", registry) | |
| 221 | 222 | } | |
| 222 | - | for _, name := range []string{"subagent", "update_goal", "missing"} { | |
| 223 | + | for _, name := range []string{"subagent", "update_goal", "plan", "missing"} { | |
| 223 | 224 | _, err := residentChildRegistry(catalogue, []string{name}) | |
| 224 | 225 | if err == nil || !strings.Contains(err.Error(), name) { | |
| 225 | 226 | 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 | |||
| 358 | 358 | allTools := make([]string, 0, len(catalogue)) | |
| 359 | 359 | for name := range catalogue { | |
| 360 | 360 | switch name { | |
| 361 | - | case tools.SubagentToolName, "update_goal": | |
| 361 | + | case tools.SubagentToolName, "update_goal", tools.PlanToolName: | |
| 362 | 362 | continue | |
| 363 | 363 | } | |
| 364 | 364 | if _, ok := resolved.ToolRegistry[name]; !ok { | |
M packages/agent/subagent_runtime_test.go +2 -1
| @@ -154,6 +154,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |||
| 154 | 154 | "host-only-tool": nil, | |
| 155 | 155 | "subagent": nil, | |
| 156 | 156 | "update_goal": nil, | |
| 157 | + | "plan": nil, | |
| 157 | 158 | } | |
| 158 | 159 | spec, err := runtime.buildResidentChildSpec(context.Background(), tools.ResidentSpawnRequest{Task: "review"}, parent) | |
| 159 | 160 | if err != nil { | |
| @@ -165,7 +166,7 @@ func TestResidentChildSpecOmitsParentOnlyTools(t *testing.T) { | |||
| 165 | 166 | if !slices.Contains(spec.Tools, "read") { | |
| 166 | 167 | t.Fatalf("generic child lost supported tool: %v", spec.Tools) | |
| 167 | 168 | } | |
| 168 | - | for _, name := range []string{"subagent", "update_goal"} { | |
| 169 | + | for _, name := range []string{"subagent", "update_goal", "plan"} { | |
| 169 | 170 | if slices.Contains(spec.Tools, name) { | |
| 170 | 171 | t.Fatalf("generic child kept forbidden tool %q: %v", name, spec.Tools) | |
| 171 | 172 | } | |
M packages/agent/systemprompt.go +4 -0
| @@ -89,6 +89,8 @@ func BuildSystemPrompt(o SystemPromptOpts) string { | |||
| 89 | 89 | sb.WriteString("\n\n") | |
| 90 | 90 | sb.WriteString(taskExecutionGuidance) | |
| 91 | 91 | sb.WriteString("\n\n") | |
| 92 | + | sb.WriteString(planGuidance) | |
| 93 | + | sb.WriteString("\n\n") | |
| 92 | 94 | sb.WriteString(skillPriorityGuidance) | |
| 93 | 95 | sb.WriteString("\n\n") | |
| 94 | 96 | sb.WriteString(writingGuidance) | |
| @@ -109,6 +111,8 @@ const compactedSummaryHandoffInstruction = `When you see a "## Context Summary ( | |||
| 109 | 111 | ||
| 110 | 112 | 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.` | |
| 111 | 113 | ||
| 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 | + | ||
| 112 | 116 | 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.` | |
| 113 | 117 | ||
| 114 | 118 | 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) { | |||
| 55 | 55 | } | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 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 | + | ||
| 58 | 94 | func TestBuildSystemPromptAlwaysIncludesTaskAndSkillGuidance(t *testing.T) { | |
| 59 | 95 | for _, custom := range []string{"", "Custom identity"} { | |
| 60 | 96 | 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 { | |||
| 166 | 166 | ||
| 167 | 167 | mu sync.Mutex | |
| 168 | 168 | 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 | |
| 169 | 172 | // rev increments whenever the transcript slice is replaced or a | |
| 170 | 173 | // message is appended. The TUI uses it as a cheap redraw cache key | |
| 171 | 174 | // 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 | |||
| 1331 | 1334 | continue | |
| 1332 | 1335 | } | |
| 1333 | 1336 | 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 | + | } | |
| 1334 | 1363 | if a.CommitToolResult != nil { | |
| 1335 | 1364 | if err := a.CommitToolResult(tc.ID, res); err != nil { | |
| 1336 | 1365 | res = ToolResult{ | |
| @@ -1358,8 +1387,12 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun | |||
| 1358 | 1387 | if a.OnToolResult != nil { | |
| 1359 | 1388 | a.OnToolResult(tc.ID, res) | |
| 1360 | 1389 | } | |
| 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}) | |
| 1363 | 1396 | } | |
| 1364 | 1397 | sink(EvToolResult{ID: tc.ID, Result: res}) | |
| 1365 | 1398 | } | |
M packages/core/compact.go +75 -0
| @@ -172,6 +172,17 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 172 | 172 | // a tool_use ID that doesn't exist. | |
| 173 | 173 | tail = repairOrphanedToolResults(tail) | |
| 174 | 174 | ||
| 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 | + | ||
| 175 | 186 | next := make([]provider.Message, 0, 2+len(tail)) | |
| 176 | 187 | if latestContext != nil { | |
| 177 | 188 | next = append(next, *latestContext) | |
| @@ -193,6 +204,70 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s | |||
| 193 | 204 | return summary, nil | |
| 194 | 205 | } | |
| 195 | 206 | ||
| 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 | + | ||
| 196 | 271 | // repairOrphanedToolResults removes tool_result content blocks (and | |
| 197 | 272 | // entire messages that become empty) when the matching tool_use ID | |
| 198 | 273 | // 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 { | |||
| 171 | 171 | ||
| 172 | 172 | func (EvToolProgress) Type() string { return "tool_progress" } | |
| 173 | 173 | ||
| 174 | - | // PlanStepStatus is the state of one update_plan checklist step. | |
| 174 | + | // PlanStepStatus is the state of one plan checklist step. | |
| 175 | 175 | type PlanStepStatus string | |
| 176 | 176 | ||
| 177 | 177 | const ( | |
| @@ -192,8 +192,11 @@ type PlanUpdate struct { | |||
| 192 | 192 | Plan []PlanStep `json:"plan"` | |
| 193 | 193 | } | |
| 194 | 194 | ||
| 195 | - | // EvPlanUpdate reports a successful update_plan call before its tool result. | |
| 195 | + | // EvPlanUpdate reports a successful plan call before its tool result. | |
| 196 | 196 | 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 | |
| 197 | 200 | Update PlanUpdate | |
| 198 | 201 | } | |
| 199 | 202 | ||
M packages/core/intercept_test.go +2 -3
| @@ -85,11 +85,10 @@ func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | func TestExecuteToolsEmitsPlanUpdateBeforeResult(t *testing.T) { | |
| 88 | - | update := PlanUpdate{Plan: []PlanStep{{Step: "Implement", Status: PlanInProgress}}} | |
| 89 | 88 | agent := NewAgent(nil, "test", "", Registry{ | |
| 90 | 89 | "result": &resultTool{result: ToolResult{ | |
| 91 | 90 | Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}, | |
| 92 | - | Details: update, | |
| 91 | + | Details: PlanOperation{Action: "set", Steps: []PlanStep{{Step: "Implement", Status: PlanInProgress}}}, | |
| 93 | 92 | }}, | |
| 94 | 93 | }) | |
| 95 | 94 | message := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{ | |
| @@ -107,7 +106,7 @@ func TestExecuteToolsEmitsPlanUpdateBeforeResult(t *testing.T) { | |||
| 107 | 106 | t.Fatalf("events = %#v, want plan update and tool result", events) | |
| 108 | 107 | } | |
| 109 | 108 | 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" { | |
| 111 | 110 | t.Fatalf("first event = %#v, want plan update", events[0]) | |
| 112 | 111 | } | |
| 113 | 112 | 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 { | |||
| 149 | 149 | // GoalHistory records prior durable goal states in chronological order. | |
| 150 | 150 | // It stays linear: there is only one current goal at a time. | |
| 151 | 151 | 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"` | |
| 152 | 155 | ||
| 153 | 156 | // Parent is the ID of the session this one was forked from, or | |
| 154 | 157 | // empty for top-level sessions. The tree picker walks parents | |
| @@ -463,6 +466,9 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err | |||
| 463 | 466 | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: %w", path, err) | |
| 464 | 467 | } | |
| 465 | 468 | 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) | |
| 466 | 472 | snapshot.CompactionGeneration = generation | |
| 467 | 473 | snapshot.ExtensionState = extensionState | |
| 468 | 474 | ||
| @@ -1313,6 +1319,42 @@ func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||
| 1313 | 1319 | return nil | |
| 1314 | 1320 | } | |
| 1315 | 1321 | ||
| 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 = ©Plan | |
| 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 | + | ||
| 1316 | 1358 | // SupersedeGoal atomically archives the active goal as superseded and starts | |
| 1317 | 1359 | // its replacement in the same mission. Mission assignment enforces the normal | |
| 1318 | 1360 | // manager transition bound. | |
| @@ -1422,6 +1464,46 @@ func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||
| 1422 | 1464 | return nil | |
| 1423 | 1465 | } | |
| 1424 | 1466 | ||
| 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 | + | ||
| 1425 | 1507 | func normalizeSessionGoalMeta(meta *SessionMeta) { | |
| 1426 | 1508 | if meta == nil || meta.Goal == nil { | |
| 1427 | 1509 | return | |
| @@ -1643,7 +1725,7 @@ func (s *Session) Close() error { | |||
| 1643 | 1725 | defer s.writerMu.Unlock() | |
| 1644 | 1726 | flushErr := s.buf.Flush() | |
| 1645 | 1727 | 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 { | |
| 1647 | 1729 | // Best-effort cleanup. We deliberately don't propagate the | |
| 1648 | 1730 | // remove error: if it fails (file already gone, perms changed) | |
| 1649 | 1731 | // 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) { | |||
| 231 | 231 | Mission: cloneSessionMission(snapshot.Meta.Mission), | |
| 232 | 232 | Goal: cloneSessionGoal(snapshot.Meta.Goal), | |
| 233 | 233 | GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory), | |
| 234 | + | Plan: cloneSessionPlan(snapshot.Meta.Plan), | |
| 234 | 235 | } | |
| 235 | 236 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | |
| 236 | 237 | if err != nil { | |
| @@ -466,6 +467,224 @@ func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit | |||
| 466 | 467 | return cloneGoalHistory(parent.GoalHistory) | |
| 467 | 468 | } | |
| 468 | 469 | ||
| 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 | + | ||
| 469 | 688 | 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) { | |
| 470 | 689 | dir := SessionsDir(root, cwd) | |
| 471 | 690 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| @@ -515,6 +734,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 515 | 734 | Mission: missionAtBranch(parent, messages, limit), | |
| 516 | 735 | Goal: cloneSessionGoal(goal), | |
| 517 | 736 | GoalHistory: goalHistoryAtBranch(parent, messages, limit), | |
| 737 | + | Plan: planAtBranch(parent, messages, limit), | |
| 518 | 738 | } | |
| 519 | 739 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| 520 | 740 | if err != nil { | |
M packages/core/tool.go +4 -0
| @@ -56,6 +56,10 @@ type ToolResult struct { | |||
| 56 | 56 | // Context describes workspace resources read, discovered, or mutated by | |
| 57 | 57 | // this result. Core uses it only for provider-visible stale-result pruning. | |
| 58 | 58 | 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 | |
| 59 | 63 | } | |
| 60 | 64 | ||
| 61 | 65 | // Registry is a name->Tool map. | |
M packages/tui/view.go +77 -64
| @@ -1,16 +1,15 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | 4 | "encoding/json" | |
| 6 | 5 | "fmt" | |
| 7 | - | "io" | |
| 8 | 6 | "os" | |
| 9 | 7 | "regexp" | |
| 10 | 8 | "strconv" | |
| 11 | 9 | "strings" | |
| 12 | 10 | "unicode/utf8" | |
| 13 | 11 | ||
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 14 | 13 | "github.com/bnema/zut/packages/provider" | |
| 15 | 14 | "github.com/mattn/go-runewidth" | |
| 16 | 15 | ) | |
| @@ -129,9 +128,19 @@ type View struct { | |||
| 129 | 128 | // short args) for the call, so a tool_result message can render | |
| 130 | 129 | // the box top edge without having to look back at the assistant | |
| 131 | 130 | // 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 | |
| 135 | 144 | Streaming string // current assistant text delta | |
| 136 | 145 | StreamingActive bool | |
| 137 | 146 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| @@ -220,6 +229,9 @@ type msgVisualKey struct { | |||
| 220 | 229 | compactUser bool | |
| 221 | 230 | imageProto ImageProtocol | |
| 222 | 231 | 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 | |
| 223 | 235 | } | |
| 224 | 236 | ||
| 225 | 237 | // msgCacheKey identifies a cached message render. hash is a 64-bit | |
| @@ -281,6 +293,7 @@ func (v *View) CloneForRender() *View { | |||
| 281 | 293 | clone.toolCallLabels = cloneStringMap(v.toolCallLabels) | |
| 282 | 294 | clone.toolCallNames = cloneStringMap(v.toolCallNames) | |
| 283 | 295 | clone.toolCallArgs = cloneRawMessageMap(v.toolCallArgs) | |
| 296 | + | clone.PlanUpdates = clonePlanUpdateMap(v.PlanUpdates) | |
| 284 | 297 | clone.liveBodyHigh = cloneIntMap(v.liveBodyHigh) | |
| 285 | 298 | if v.renderCache != nil { | |
| 286 | 299 | clone.renderCache = make(map[msgCacheKey][]string, len(v.renderCache)) | |
| @@ -331,6 +344,25 @@ func cloneIntMap(src map[string]int) map[string]int { | |||
| 331 | 344 | return dst | |
| 332 | 345 | } | |
| 333 | 346 | ||
| 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 | + | ||
| 334 | 366 | // AdoptRenderCacheFrom transfers caches produced from a render snapshot | |
| 335 | 367 | // back to the owning view. Callers must ensure the transcript revision is | |
| 336 | 368 | // still current before adopting them. | |
| @@ -348,6 +380,8 @@ func (v *View) AdoptRenderCacheFrom(snapshot *View) { | |||
| 348 | 380 | v.toolCallLabels = snapshot.toolCallLabels | |
| 349 | 381 | v.toolCallNames = snapshot.toolCallNames | |
| 350 | 382 | v.toolCallArgs = snapshot.toolCallArgs | |
| 383 | + | v.PlanUpdates = snapshot.PlanUpdates | |
| 384 | + | v.PlanRevision = snapshot.PlanRevision | |
| 351 | 385 | v.toolPathRevision = snapshot.toolPathRevision | |
| 352 | 386 | } | |
| 353 | 387 | ||
| @@ -786,6 +820,7 @@ func (v *View) renderMessageCached(idx int, m provider.Message, width int, turnO | |||
| 786 | 820 | compactUser: v.CompactUser, | |
| 787 | 821 | imageProto: v.ImageProto, | |
| 788 | 822 | theme: themeKey, | |
| 823 | + | planRev: v.PlanRevision, | |
| 789 | 824 | } | |
| 790 | 825 | if v.MessagesRevision != 0 { | |
| 791 | 826 | cacheIdx := idx - v.messageCacheStart | |
| @@ -945,65 +980,18 @@ func fnv64aWrite(h uint64, p []byte) uint64 { | |||
| 945 | 980 | return h | |
| 946 | 981 | } | |
| 947 | 982 | ||
| 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 { | |
| 998 | 984 | lines := []string{v.Theme.FGColor(v.Theme.Muted, "• ") + Bold("Updated Plan")} | |
| 999 | 985 | bodyWidth := width - 4 | |
| 1000 | 986 | if bodyWidth < 1 { | |
| 1001 | 987 | bodyWidth = 1 | |
| 1002 | 988 | } | |
| 1003 | 989 | 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 | + | } | |
| 1007 | 995 | } | |
| 1008 | 996 | } | |
| 1009 | 997 | if len(update.Plan) == 0 { | |
| @@ -1013,9 +1001,9 @@ func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string { | |||
| 1013 | 1001 | marker := "□ " | |
| 1014 | 1002 | style := func(text string) string { return Dim(text) } | |
| 1015 | 1003 | switch step.Status { | |
| 1016 | - | case "completed": | |
| 1004 | + | case core.PlanCompleted: | |
| 1017 | 1005 | marker = "✓ " | |
| 1018 | - | case "in_progress": | |
| 1006 | + | case core.PlanInProgress: | |
| 1019 | 1007 | style = func(text string) string { return v.Theme.FGColor(v.Theme.Accent, Bold(text)) } | |
| 1020 | 1008 | } | |
| 1021 | 1009 | stepWidth := bodyWidth - 2 | |
| @@ -1042,6 +1030,25 @@ func (v *View) renderPlanUpdate(update planUpdateArgs, width int) []string { | |||
| 1042 | 1030 | return lines | |
| 1043 | 1031 | } | |
| 1044 | 1032 | ||
| 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 | + | ||
| 1045 | 1052 | func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []string { | |
| 1046 | 1053 | var lines []string | |
| 1047 | 1054 | ||
| @@ -1152,8 +1159,8 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str | |||
| 1152 | 1159 | case provider.RoleTool: | |
| 1153 | 1160 | for _, c := range m.Content { | |
| 1154 | 1161 | 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 { | |
| 1157 | 1164 | lines = append(lines, v.renderPlanUpdate(update, width)...) | |
| 1158 | 1165 | continue | |
| 1159 | 1166 | } | |
| @@ -1365,10 +1372,16 @@ func hashThemeColor(h uint64, c TerminalColor) uint64 { | |||
| 1365 | 1372 | } | |
| 1366 | 1373 | ||
| 1367 | 1374 | 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 { | |
| 1370 | 1380 | return v.renderPlanUpdate(update, width) | |
| 1371 | 1381 | } | |
| 1382 | + | if !tc.Done && !tc.Error { | |
| 1383 | + | return v.renderPlanCompact(tc, width) | |
| 1384 | + | } | |
| 1372 | 1385 | } | |
| 1373 | 1386 | ||
| 1374 | 1387 | var lines []string | |
M packages/tui/view_plan_test.go +174 -70
| @@ -1,31 +1,45 @@ | |||
| 1 | 1 | package tui | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 4 | + | "fmt" | |
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 8 | 9 | "github.com/bnema/zut/packages/provider" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 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{ | |
| 22 | 17 | {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)}, | |
| 24 | 19 | }}, | |
| 25 | 20 | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 26 | 21 | provider.ToolResultBlock{CallID: callID, Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}, | |
| 27 | 22 | }}, | |
| 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 | + | } | |
| 29 | 43 | ||
| 30 | 44 | plain := stripANSI(strings.Join(view.Build(60), "\n")) | |
| 31 | 45 | for _, want := range []string{ | |
| @@ -39,51 +53,77 @@ func TestViewRendersUpdatePlanAsChecklist(t *testing.T) { | |||
| 39 | 53 | t.Fatalf("rendered plan omitted %q:\n%s", want, plain) | |
| 40 | 54 | } | |
| 41 | 55 | } | |
| 42 | - | if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") { | |
| 56 | + | if strings.Contains(plain, "plan ") || strings.Contains(plain, "Plan updated") { | |
| 43 | 57 | t.Fatalf("rendered plan leaked generic tool chrome:\n%s", plain) | |
| 44 | 58 | } | |
| 45 | 59 | } | |
| 46 | 60 | ||
| 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 | + | } | |
| 50 | 83 | plain := stripANSI(strings.Join(view.RenderToolCall(ToolCallView{ | |
| 51 | 84 | 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"}`, | |
| 55 | 87 | Done: true, | |
| 56 | 88 | }, 50), "\n")) | |
| 57 | 89 | if !strings.Contains(plain, "Updated Plan") || !strings.Contains(plain, "□ Implement") { | |
| 58 | 90 | t.Fatalf("live plan rendering = %q", plain) | |
| 59 | 91 | } | |
| 60 | - | if strings.Contains(plain, "update_plan") || strings.Contains(plain, "Plan updated") { | |
| 92 | + | if strings.Contains(plain, "plan:") || strings.Contains(plain, "Plan updated") { | |
| 61 | 93 | t.Fatalf("live plan leaked generic tool chrome: %q", plain) | |
| 62 | 94 | } | |
| 63 | 95 | } | |
| 64 | 96 | ||
| 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 | + | ||
| 65 | 114 | func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) { | |
| 66 | 115 | 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"}`) | |
| 76 | 117 | seed := func() *View { | |
| 77 | 118 | return &View{ | |
| 78 | 119 | Theme: Dark, | |
| 79 | 120 | Messages: messages, | |
| 80 | 121 | MessagesRevision: 7, | |
| 81 | 122 | 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 | + | }, | |
| 87 | 127 | } | |
| 88 | 128 | } | |
| 89 | 129 | assertChecklist := func(t *testing.T, view *View) { | |
| @@ -102,47 +142,111 @@ func TestViewRenderSnapshotsPreservePlanMetadata(t *testing.T) { | |||
| 102 | 142 | assertChecklist(t, owner) | |
| 103 | 143 | } | |
| 104 | 144 | ||
| 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") | |
| 130 | 167 | } | |
| 131 | 168 | } | |
| 132 | 169 | ||
| 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) { | |
| 134 | 174 | 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 | + | } | |
| 143 | 206 | ||
| 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 | + | } | |
| 144 | 214 | plain := stripANSI(strings.Join(view.Build(40), "\n")) | |
| 145 | 215 | if !strings.Contains(plain, "(no steps provided)") { | |
| 146 | 216 | t.Fatalf("empty plan rendering = %q", plain) | |
| 147 | 217 | } | |
| 148 | 218 | } | |
| 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 | + | } | |