feat(agent): add persistent autonomous goals
- commit
- 7e9841fcf077e93770552aec1122b6b4c46ce522
- author
- brice · · 2026-08-09 18:12 UTC
- committer
- brice · · 2026-08-09 18:33 UTC
- parent
- e5afabc
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +1113 -34
M README.md +10 -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 | - | - seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | |
| 12 | + | - eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search 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). | |
| @@ -239,6 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 239 | 239 | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | |
| 240 | 240 | - `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. See [docs/lsp.md](docs/lsp.md). | |
| 241 | 241 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 242 | + | - `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`. | |
| 242 | 243 | ||
| 243 | 244 | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | |
| 244 | 245 | ||
| @@ -295,6 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 295 | 296 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 296 | 297 | | `/fast` | Toggle fast mode for subsequent model calls. | | |
| 297 | 298 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | |
| 299 | + | | `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. | | |
| 298 | 300 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | |
| 299 | 301 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 300 | 302 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | |
| @@ -315,6 +317,12 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 315 | 317 | ||
| 316 | 318 | Extension-registered commands appear under a divider at the bottom of the popup, sorted by name. | |
| 317 | 319 | ||
| 320 | + | ### `/goal` | |
| 321 | + | ||
| 322 | + | In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | |
| 323 | + | ||
| 324 | + | A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it. | |
| 325 | + | ||
| 318 | 326 | ### Shell escape (`!command`) | |
| 319 | 327 | ||
| 320 | 328 | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | |
| @@ -1084,7 +1092,7 @@ packages/agent/extproto/ extension wire-format types | |||
| 1084 | 1092 | packages/agent/modes/ interactive tui, print, json, dialogs | |
| 1085 | 1093 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | |
| 1086 | 1094 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | |
| 1087 | - | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox | |
| 1095 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox | |
| 1088 | 1096 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | |
| 1089 | 1097 | packages/agent/subagents/ named profiles, supervisor, and background runtime | |
| 1090 | 1098 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | |
| @@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go. | |||||
| 9 | 9 | ||||
| 10 | - one static binary. | 10 | - one static binary. | ||
| 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. | 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 | - | - seven core built-in tools (read, write, edit, bash, create_worktree, lsp, and web_search); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. | 12 | + | - eight core built-in tools (read, write, edit, bash, create_worktree, lsp, web_search, and update_goal); the conditional `skill` tool is available when skills are enabled. See [docs/web-search.md](docs/web-search.md) for web-search egress and availability boundaries. |
| 13 | - three run modes (interactive tui, print, json). | 13 | - three run modes (interactive tui, print, json). | ||
| 14 | - built-in telegram bot. | 14 | - built-in telegram bot. | ||
| 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). | 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). | ||
| @@ -239,6 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||||
| 239 | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | 239 | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | ||
| 240 | - `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. See [docs/lsp.md](docs/lsp.md). | 240 | - `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. See [docs/lsp.md](docs/lsp.md). | ||
| 241 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | 241 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | ||
| 242 | + | - `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`. | |||
| 242 | 243 | ||||
| 243 | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | 244 | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | ||
| 244 | 245 | ||||
| @@ -295,6 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 295 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 296 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 296 | | `/fast` | Toggle fast mode for subsequent model calls. | | 297 | | `/fast` | Toggle fast mode for subsequent model calls. | | ||
| 297 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | 298 | | `/orchestrator` | Toggle automatic subagent orchestration. When disabled, subagent tools remain available for explicit delegation. | | ||
| 299 | + | | `/goal <objective>` | Start an autonomous interactive-session goal. Use `/goal` for status and `/goal pause`, `resume`, or `clear` for control. | | |||
| 298 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | 300 | | `/llama` | Connect to the configured llama.cpp router, load, unload, or remove cached models, and search/download GGUF models from Hugging Face with live progress. Shown after llama.cpp login is configured. | | ||
| 299 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | 301 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | ||
| 300 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | 302 | | `/fork` | Pick a previous user message and fork the current session after that turn. The selected turn becomes branch context and no provider turn starts during the checkout. | | ||
| @@ -315,6 +317,12 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 315 | 317 | ||||
| 316 | Extension-registered commands appear under a divider at the bottom of the popup, sorted by name. | 318 | Extension-registered commands appear under a divider at the bottom of the popup, sorted by name. | ||
| 317 | 319 | ||||
| 320 | + | ### `/goal` | |||
| 321 | + | ||||
| 322 | + | In interactive mode, `/goal <objective>` starts work immediately and keeps the objective active across normal provider turn boundaries. While the goal is active, zut starts a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The model marks the goal `done` or `blocked` with the built-in `update_goal` tool. The current state appears in the status bar and is persisted with the session. `esc` pauses an interrupted goal, and terminal provider errors block it instead of retrying indefinitely. Use `/goal` to inspect it, `/goal pause` or `/goal resume` to control execution, and `/goal clear` to remove it. | |||
| 323 | + | ||||
| 324 | + | A branch that copies the complete transcript inherits the goal. Forking from an earlier turn clears it because the later objective did not necessarily exist at that point. Manual `/compact` leaves an active goal recorded but does not start a hidden follow-up; run `/goal resume` to continue it. | |||
| 325 | + | ||||
| 318 | ### Shell escape (`!command`) | 326 | ### Shell escape (`!command`) | ||
| 319 | 327 | ||||
| 320 | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | 328 | Type `!` followed by a command to run it directly without going through the model. Everything after the `!` is passed to the same shell the `bash` tool uses (`/bin/bash -c` when available on Unix, then `bash -c` from `PATH`, with POSIX `/bin/sh -c` as a fallback; `cmd /C` on Windows), runs in the session working directory, and honors the `/jail` sandbox. The command, merged output, and exit status are appended to the transcript as user context, so the model can use them on the next turn. Running the command does not itself start a model turn. A running `!command` shares the busy state with the agent: `esc` cancels it, and you cannot start one while a turn (or another shell escape) is in flight. | ||
| @@ -1084,7 +1092,7 @@ packages/agent/extproto/ extension wire-format types | |||||
| 1084 | packages/agent/modes/ interactive tui, print, json, dialogs | 1092 | packages/agent/modes/ interactive tui, print, json, dialogs | ||
| 1085 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | 1093 | packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface) | ||
| 1086 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | 1094 | packages/agent/modes/telegram/ telegram adapter, api client, daemon | ||
| 1087 | - | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox | 1095 | + | packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox |
| 1088 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | 1096 | packages/agent/skills/ skill discovery, frontmatter parser, skill tool | ||
| 1089 | packages/agent/subagents/ named profiles, supervisor, and background runtime | 1097 | packages/agent/subagents/ named profiles, supervisor, and background runtime | ||
| 1090 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | 1098 | packages/agent/sdk/ public Go SDK for embedding zut in-process (package sdk) | ||
M packages/agent/build.go +3 -2
| @@ -101,7 +101,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||
| 101 | 101 | // capability policy excludes the current session. An extension must | |
| 102 | 102 | // not turn a normal CLI opt-out into a differently implemented search | |
| 103 | 103 | // capability with the same model-visible name. | |
| 104 | - | if info.Name == "web_search" { | |
| 104 | + | if info.Name == "web_search" || info.Name == "update_goal" { | |
| 105 | 105 | continue | |
| 106 | 106 | } | |
| 107 | 107 | if _, exists := r.ToolRegistry[info.Name]; exists { | |
| @@ -1291,6 +1291,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||
| 1291 | 1291 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | |
| 1292 | 1292 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | |
| 1293 | 1293 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | |
| 1294 | + | "update_goal": &tools.UpdateGoalTool{}, | |
| 1294 | 1295 | } | |
| 1295 | 1296 | if webSearchAllowedForRegistry(args) { | |
| 1296 | 1297 | all["web_search"] = tools.NewWebSearchTool() | |
| @@ -1377,7 +1378,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||
| 1377 | 1378 | return false | |
| 1378 | 1379 | } | |
| 1379 | 1380 | ||
| 1380 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"} | |
| 1381 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"} | |
| 1381 | 1382 | ||
| 1382 | 1383 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | |
| 1383 | 1384 | var out []ToolSummary | |
| @@ -101,7 +101,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) { | |||||
| 101 | // capability policy excludes the current session. An extension must | 101 | // capability policy excludes the current session. An extension must | ||
| 102 | // not turn a normal CLI opt-out into a differently implemented search | 102 | // not turn a normal CLI opt-out into a differently implemented search | ||
| 103 | // capability with the same model-visible name. | 103 | // capability with the same model-visible name. | ||
| 104 | - | if info.Name == "web_search" { | 104 | + | if info.Name == "web_search" || info.Name == "update_goal" { |
| 105 | continue | 105 | continue | ||
| 106 | } | 106 | } | ||
| 107 | if _, exists := r.ToolRegistry[info.Name]; exists { | 107 | if _, exists := r.ToolRegistry[info.Name]; exists { | ||
| @@ -1291,6 +1291,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled | |||||
| 1291 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | 1291 | "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit}, | ||
| 1292 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | 1292 | "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox}, | ||
| 1293 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | 1293 | "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox}, | ||
| 1294 | + | "update_goal": &tools.UpdateGoalTool{}, | |||
| 1294 | } | 1295 | } | ||
| 1295 | if webSearchAllowedForRegistry(args) { | 1296 | if webSearchAllowedForRegistry(args) { | ||
| 1296 | all["web_search"] = tools.NewWebSearchTool() | 1297 | all["web_search"] = tools.NewWebSearchTool() | ||
| @@ -1377,7 +1378,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool { | |||||
| 1377 | return false | 1378 | return false | ||
| 1378 | } | 1379 | } | ||
| 1379 | 1380 | ||||
| 1380 | - | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search"} | 1381 | + | var nativeToolSummaryOrder = []string{"read", "write", "edit", "bash", "create_worktree", "lsp", "web_search", "update_goal"} |
| 1381 | 1382 | ||||
| 1382 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | 1383 | func toolSummaries(reg core.Registry, args Args) []ToolSummary { | ||
| 1383 | var out []ToolSummary | 1384 | var out []ToolSummary | ||
M packages/agent/build_test.go +23 -8
| @@ -36,6 +36,15 @@ func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool { | |||
| 36 | 36 | return &tools.ReadTool{} | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | type updateGoalConflictSource struct{} | |
| 40 | + | ||
| 41 | + | func (updateGoalConflictSource) Tools() []ExtensionToolInfo { | |
| 42 | + | return []ExtensionToolInfo{{Extension: "test", Name: "update_goal"}} | |
| 43 | + | } | |
| 44 | + | func (updateGoalConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool { | |
| 45 | + | return &tools.ReadTool{} | |
| 46 | + | } | |
| 47 | + | ||
| 39 | 48 | type unorderedExtensionSource struct{} | |
| 40 | 49 | ||
| 41 | 50 | func (unorderedExtensionSource) Tools() []ExtensionToolInfo { | |
| @@ -84,11 +93,14 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) { | |||
| 84 | 93 | } | |
| 85 | 94 | } | |
| 86 | 95 | ||
| 87 | - | func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) { | |
| 96 | + | func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) { | |
| 88 | 97 | r := Resolved{ToolRegistry: make(core.Registry)} | |
| 89 | 98 | r.MergeExtensionTools(webSearchConflictSource{}) | |
| 90 | - | if _, ok := r.ToolRegistry["web_search"]; ok { | |
| 91 | - | t.Fatal("extension replaced reserved native web_search name") | |
| 99 | + | r.MergeExtensionTools(updateGoalConflictSource{}) | |
| 100 | + | for _, name := range []string{"web_search", "update_goal"} { | |
| 101 | + | if _, ok := r.ToolRegistry[name]; ok { | |
| 102 | + | t.Fatalf("extension replaced reserved native %s name", name) | |
| 103 | + | } | |
| 92 | 104 | } | |
| 93 | 105 | } | |
| 94 | 106 | ||
| @@ -103,7 +115,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) { | |||
| 103 | 115 | for _, summary := range r.ToolSummary { | |
| 104 | 116 | names = append(names, summary.Name) | |
| 105 | 117 | } | |
| 106 | - | want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta" | |
| 118 | + | want := "read,write,edit,bash,create_worktree,web_search,update_goal,alpha,zeta" | |
| 107 | 119 | if got := strings.Join(names, ","); got != want { | |
| 108 | 120 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | |
| 109 | 121 | } | |
| @@ -153,6 +165,9 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 153 | 165 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | |
| 154 | 166 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | |
| 155 | 167 | } | |
| 168 | + | if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok { | |
| 169 | + | t.Fatalf("default registry update_goal = %#v", registry["update_goal"]) | |
| 170 | + | } | |
| 156 | 171 | write, ok := registry["write"].(*tools.WriteTool) | |
| 157 | 172 | if !ok || write.LSP == nil || !write.LSPDiagnostics { | |
| 158 | 173 | t.Fatalf("write tool LSP wiring = %#v", write) | |
| @@ -161,8 +176,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 161 | 176 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | |
| 162 | 177 | t.Fatalf("edit tool LSP wiring = %#v", edit) | |
| 163 | 178 | } | |
| 164 | - | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 { | |
| 165 | - | t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled)) | |
| 179 | + | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 7 { | |
| 180 | + | t.Fatalf("LSP-disabled registry has %d tools, want 7", len(disabled)) | |
| 166 | 181 | } | |
| 167 | 182 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | |
| 168 | 183 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | |
| @@ -193,12 +208,12 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||
| 193 | 208 | } | |
| 194 | 209 | } | |
| 195 | 210 | ||
| 196 | - | func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) { | |
| 211 | + | func TestToolSummariesIncludeGoalUpdates(t *testing.T) { | |
| 197 | 212 | root := t.TempDir() | |
| 198 | 213 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false) | |
| 199 | 214 | ||
| 200 | 215 | summaries := toolSummaries(registry, Args{}) | |
| 201 | - | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"} | |
| 216 | + | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search", "update_goal"} | |
| 202 | 217 | if len(summaries) != len(want) { | |
| 203 | 218 | t.Fatalf("tool summaries = %#v, want %v", summaries, want) | |
| 204 | 219 | } | |
| @@ -36,6 +36,15 @@ func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool { | |||||
| 36 | return &tools.ReadTool{} | 36 | return &tools.ReadTool{} | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | type updateGoalConflictSource struct{} | |||
| 40 | + | ||||
| 41 | + | func (updateGoalConflictSource) Tools() []ExtensionToolInfo { | |||
| 42 | + | return []ExtensionToolInfo{{Extension: "test", Name: "update_goal"}} | |||
| 43 | + | } | |||
| 44 | + | func (updateGoalConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool { | |||
| 45 | + | return &tools.ReadTool{} | |||
| 46 | + | } | |||
| 47 | + | ||||
| 39 | type unorderedExtensionSource struct{} | 48 | type unorderedExtensionSource struct{} | ||
| 40 | 49 | ||||
| 41 | func (unorderedExtensionSource) Tools() []ExtensionToolInfo { | 50 | func (unorderedExtensionSource) Tools() []ExtensionToolInfo { | ||
| @@ -84,11 +93,14 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) { | |||||
| 84 | } | 93 | } | ||
| 85 | } | 94 | } | ||
| 86 | 95 | ||||
| 87 | - | func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) { | 96 | + | func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) { |
| 88 | r := Resolved{ToolRegistry: make(core.Registry)} | 97 | r := Resolved{ToolRegistry: make(core.Registry)} | ||
| 89 | r.MergeExtensionTools(webSearchConflictSource{}) | 98 | r.MergeExtensionTools(webSearchConflictSource{}) | ||
| 90 | - | if _, ok := r.ToolRegistry["web_search"]; ok { | 99 | + | r.MergeExtensionTools(updateGoalConflictSource{}) |
| 91 | - | t.Fatal("extension replaced reserved native web_search name") | 100 | + | for _, name := range []string{"web_search", "update_goal"} { |
| 101 | + | if _, ok := r.ToolRegistry[name]; ok { | |||
| 102 | + | t.Fatalf("extension replaced reserved native %s name", name) | |||
| 103 | + | } | |||
| 92 | } | 104 | } | ||
| 93 | } | 105 | } | ||
| 94 | 106 | ||||
| @@ -103,7 +115,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) { | |||||
| 103 | for _, summary := range r.ToolSummary { | 115 | for _, summary := range r.ToolSummary { | ||
| 104 | names = append(names, summary.Name) | 116 | names = append(names, summary.Name) | ||
| 105 | } | 117 | } | ||
| 106 | - | want := "read,write,edit,bash,create_worktree,web_search,alpha,zeta" | 118 | + | want := "read,write,edit,bash,create_worktree,web_search,update_goal,alpha,zeta" |
| 107 | if got := strings.Join(names, ","); got != want { | 119 | if got := strings.Join(names, ","); got != want { | ||
| 108 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | 120 | t.Fatalf("merged tool summary order = %q, want %q", got, want) | ||
| 109 | } | 121 | } | ||
| @@ -153,6 +165,9 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 153 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | 165 | if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok { | ||
| 154 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | 166 | t.Fatalf("default registry web_search = %#v", registry["web_search"]) | ||
| 155 | } | 167 | } | ||
| 168 | + | if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok { | |||
| 169 | + | t.Fatalf("default registry update_goal = %#v", registry["update_goal"]) | |||
| 170 | + | } | |||
| 156 | write, ok := registry["write"].(*tools.WriteTool) | 171 | write, ok := registry["write"].(*tools.WriteTool) | ||
| 157 | if !ok || write.LSP == nil || !write.LSPDiagnostics { | 172 | if !ok || write.LSP == nil || !write.LSPDiagnostics { | ||
| 158 | t.Fatalf("write tool LSP wiring = %#v", write) | 173 | t.Fatalf("write tool LSP wiring = %#v", write) | ||
| @@ -161,8 +176,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 161 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | 176 | if !ok || edit.LSP == nil || edit.LSPDiagnostics { | ||
| 162 | t.Fatalf("edit tool LSP wiring = %#v", edit) | 177 | t.Fatalf("edit tool LSP wiring = %#v", edit) | ||
| 163 | } | 178 | } | ||
| 164 | - | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 6 { | 179 | + | if disabled := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, true, true); len(disabled) != 7 { |
| 165 | - | t.Fatalf("LSP-disabled registry has %d tools, want 6", len(disabled)) | 180 | + | t.Fatalf("LSP-disabled registry has %d tools, want 7", len(disabled)) |
| 166 | } | 181 | } | ||
| 167 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | 182 | readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true) | ||
| 168 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | 183 | if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil { | ||
| @@ -193,12 +208,12 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) { | |||||
| 193 | } | 208 | } | ||
| 194 | } | 209 | } | ||
| 195 | 210 | ||||
| 196 | - | func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) { | 211 | + | func TestToolSummariesIncludeGoalUpdates(t *testing.T) { |
| 197 | root := t.TempDir() | 212 | root := t.TempDir() | ||
| 198 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false) | 213 | registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false) | ||
| 199 | 214 | ||||
| 200 | summaries := toolSummaries(registry, Args{}) | 215 | summaries := toolSummaries(registry, Args{}) | ||
| 201 | - | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search"} | 216 | + | want := []string{"read", "write", "edit", "bash", "create_worktree", "web_search", "update_goal"} |
| 202 | if len(summaries) != len(want) { | 217 | if len(summaries) != len(want) { | ||
| 203 | t.Fatalf("tool summaries = %#v, want %v", summaries, want) | 218 | t.Fatalf("tool summaries = %#v, want %v", summaries, want) | ||
| 204 | } | 219 | } | ||
M packages/agent/cli.go +54 -10
| @@ -692,17 +692,38 @@ func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, res | |||
| 692 | 692 | if sess == nil { | |
| 693 | 693 | return | |
| 694 | 694 | } | |
| 695 | - | details, ok := result.Details.(extensions.ToolResultDetails) | |
| 696 | - | if !ok || details.Extension == "" || len(details.State) == 0 { | |
| 697 | - | return | |
| 695 | + | if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 { | |
| 696 | + | if err := sess.AppendExtensionState(details.Extension, details.State); err != nil { | |
| 697 | + | fmt.Fprintf(os.Stderr, "extension state: %v\n", err) | |
| 698 | + | } else if mgr != nil { | |
| 699 | + | mgr.UpdateSessionState(details.Extension, details.State) | |
| 700 | + | } | |
| 698 | 701 | } | |
| 699 | - | if err := sess.AppendExtensionState(details.Extension, details.State); err != nil { | |
| 700 | - | fmt.Fprintf(os.Stderr, "extension state: %v\n", err) | |
| 701 | - | return | |
| 702 | + | if err := persistGoalToolResult(sess, result); err != nil { | |
| 703 | + | fmt.Fprintf(os.Stderr, "goal state: %v\n", err) | |
| 704 | + | } | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | func persistToolResultForCurrentSession(sessionTransitionMu *sync.RWMutex, persistMu *sync.Mutex, currentSession func() *core.Session, mgr *extensions.Manager, result core.ToolResult) { | |
| 708 | + | sessionTransitionMu.RLock() | |
| 709 | + | defer sessionTransitionMu.RUnlock() | |
| 710 | + | persistMu.Lock() | |
| 711 | + | defer persistMu.Unlock() | |
| 712 | + | persistExtensionToolResult(mgr, currentSession(), result) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |
| 716 | + | if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |
| 717 | + | return nil | |
| 702 | 718 | } | |
| 703 | - | if mgr != nil { | |
| 704 | - | mgr.UpdateSessionState(details.Extension, details.State) | |
| 719 | + | update, ok := tools.GoalUpdateFromResult(result) | |
| 720 | + | if !ok { | |
| 721 | + | return nil | |
| 705 | 722 | } | |
| 723 | + | goal := *sess.Meta.Goal | |
| 724 | + | goal.Status = update.Status | |
| 725 | + | goal.Reason = update.Reason | |
| 726 | + | return sess.UpdateGoal(&goal) | |
| 706 | 727 | } | |
| 707 | 728 | ||
| 708 | 729 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| @@ -1802,10 +1823,27 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1802 | 1823 | } | |
| 1803 | 1824 | return append(json.RawMessage(nil), sess.Meta.CompactHandoff...) | |
| 1804 | 1825 | } | |
| 1805 | - | persistToolResult := func(_ string, result core.ToolResult) { | |
| 1826 | + | persistGoal := func(goal *core.SessionGoal) error { | |
| 1827 | + | sessionTransitionMu.RLock() | |
| 1828 | + | defer sessionTransitionMu.RUnlock() | |
| 1829 | + | persistMu.Lock() | |
| 1830 | + | defer persistMu.Unlock() | |
| 1831 | + | if sess == nil { | |
| 1832 | + | return errors.New("session persistence is disabled") | |
| 1833 | + | } | |
| 1834 | + | return sess.UpdateGoal(goal) | |
| 1835 | + | } | |
| 1836 | + | currentGoal := func() *core.SessionGoal { | |
| 1806 | 1837 | persistMu.Lock() | |
| 1807 | 1838 | defer persistMu.Unlock() | |
| 1808 | - | persistExtensionToolResult(extMgr, sess, result) | |
| 1839 | + | if sess == nil || sess.Meta.Goal == nil { | |
| 1840 | + | return nil | |
| 1841 | + | } | |
| 1842 | + | goal := *sess.Meta.Goal | |
| 1843 | + | return &goal | |
| 1844 | + | } | |
| 1845 | + | persistToolResult := func(_ string, result core.ToolResult) { | |
| 1846 | + | persistToolResultForCurrentSession(&sessionTransitionMu, &persistMu, func() *core.Session { return sess }, extMgr, result) | |
| 1809 | 1847 | } | |
| 1810 | 1848 | wireAgentPersist := func(a *core.Agent) *core.Agent { | |
| 1811 | 1849 | if a == nil { | |
| @@ -1964,6 +2002,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1964 | 2002 | } | |
| 1965 | 2003 | committed = true | |
| 1966 | 2004 | persistMu.Unlock() | |
| 2005 | + | if iv != nil { | |
| 2006 | + | iv.RefreshGoal() | |
| 2007 | + | } | |
| 1967 | 2008 | if candidate.session.Meta.Parent != "" { | |
| 1968 | 2009 | extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates) | |
| 1969 | 2010 | } | |
| @@ -2144,6 +2185,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2144 | 2185 | if iv != nil { | |
| 2145 | 2186 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome())) | |
| 2146 | 2187 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | |
| 2188 | + | iv.RefreshGoal() | |
| 2147 | 2189 | } | |
| 2148 | 2190 | ||
| 2149 | 2191 | // Re-scope the subagent dashboard to the new session. | |
| @@ -2401,6 +2443,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2401 | 2443 | ChangeCWD: changeCWD, | |
| 2402 | 2444 | PersistCompactHandoff: persistCompactHandoff, | |
| 2403 | 2445 | CurrentCompactHandoff: currentCompactHandoff, | |
| 2446 | + | PersistGoal: persistGoal, | |
| 2447 | + | CurrentGoal: currentGoal, | |
| 2404 | 2448 | CurrentSessionPath: func() string { | |
| 2405 | 2449 | persistMu.Lock() | |
| 2406 | 2450 | defer persistMu.Unlock() | |
| @@ -692,17 +692,38 @@ func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, res | |||||
| 692 | if sess == nil { | 692 | if sess == nil { | ||
| 693 | return | 693 | return | ||
| 694 | } | 694 | } | ||
| 695 | - | details, ok := result.Details.(extensions.ToolResultDetails) | 695 | + | if details, ok := result.Details.(extensions.ToolResultDetails); ok && details.Extension != "" && len(details.State) > 0 { |
| 696 | - | if !ok || details.Extension == "" || len(details.State) == 0 { | 696 | + | if err := sess.AppendExtensionState(details.Extension, details.State); err != nil { |
| 697 | - | return | 697 | + | fmt.Fprintf(os.Stderr, "extension state: %v\n", err) |
| 698 | + | } else if mgr != nil { | |||
| 699 | + | mgr.UpdateSessionState(details.Extension, details.State) | |||
| 700 | + | } | |||
| 698 | } | 701 | } | ||
| 699 | - | if err := sess.AppendExtensionState(details.Extension, details.State); err != nil { | 702 | + | if err := persistGoalToolResult(sess, result); err != nil { |
| 700 | - | fmt.Fprintf(os.Stderr, "extension state: %v\n", err) | 703 | + | fmt.Fprintf(os.Stderr, "goal state: %v\n", err) |
| 701 | - | return | 704 | + | } |
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | func persistToolResultForCurrentSession(sessionTransitionMu *sync.RWMutex, persistMu *sync.Mutex, currentSession func() *core.Session, mgr *extensions.Manager, result core.ToolResult) { | |||
| 708 | + | sessionTransitionMu.RLock() | |||
| 709 | + | defer sessionTransitionMu.RUnlock() | |||
| 710 | + | persistMu.Lock() | |||
| 711 | + | defer persistMu.Unlock() | |||
| 712 | + | persistExtensionToolResult(mgr, currentSession(), result) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |||
| 716 | + | if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |||
| 717 | + | return nil | |||
| 702 | } | 718 | } | ||
| 703 | - | if mgr != nil { | 719 | + | update, ok := tools.GoalUpdateFromResult(result) |
| 704 | - | mgr.UpdateSessionState(details.Extension, details.State) | 720 | + | if !ok { |
| 721 | + | return nil | |||
| 705 | } | 722 | } | ||
| 723 | + | goal := *sess.Meta.Goal | |||
| 724 | + | goal.Status = update.Status | |||
| 725 | + | goal.Reason = update.Reason | |||
| 726 | + | return sess.UpdateGoal(&goal) | |||
| 706 | } | 727 | } | ||
| 707 | 728 | ||||
| 708 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | 729 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | ||
| @@ -1802,10 +1823,27 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1802 | } | 1823 | } | ||
| 1803 | return append(json.RawMessage(nil), sess.Meta.CompactHandoff...) | 1824 | return append(json.RawMessage(nil), sess.Meta.CompactHandoff...) | ||
| 1804 | } | 1825 | } | ||
| 1805 | - | persistToolResult := func(_ string, result core.ToolResult) { | 1826 | + | persistGoal := func(goal *core.SessionGoal) error { |
| 1827 | + | sessionTransitionMu.RLock() | |||
| 1828 | + | defer sessionTransitionMu.RUnlock() | |||
| 1829 | + | persistMu.Lock() | |||
| 1830 | + | defer persistMu.Unlock() | |||
| 1831 | + | if sess == nil { | |||
| 1832 | + | return errors.New("session persistence is disabled") | |||
| 1833 | + | } | |||
| 1834 | + | return sess.UpdateGoal(goal) | |||
| 1835 | + | } | |||
| 1836 | + | currentGoal := func() *core.SessionGoal { | |||
| 1806 | persistMu.Lock() | 1837 | persistMu.Lock() | ||
| 1807 | defer persistMu.Unlock() | 1838 | defer persistMu.Unlock() | ||
| 1808 | - | persistExtensionToolResult(extMgr, sess, result) | 1839 | + | if sess == nil || sess.Meta.Goal == nil { |
| 1840 | + | return nil | |||
| 1841 | + | } | |||
| 1842 | + | goal := *sess.Meta.Goal | |||
| 1843 | + | return &goal | |||
| 1844 | + | } | |||
| 1845 | + | persistToolResult := func(_ string, result core.ToolResult) { | |||
| 1846 | + | persistToolResultForCurrentSession(&sessionTransitionMu, &persistMu, func() *core.Session { return sess }, extMgr, result) | |||
| 1809 | } | 1847 | } | ||
| 1810 | wireAgentPersist := func(a *core.Agent) *core.Agent { | 1848 | wireAgentPersist := func(a *core.Agent) *core.Agent { | ||
| 1811 | if a == nil { | 1849 | if a == nil { | ||
| @@ -1964,6 +2002,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1964 | } | 2002 | } | ||
| 1965 | committed = true | 2003 | committed = true | ||
| 1966 | persistMu.Unlock() | 2004 | persistMu.Unlock() | ||
| 2005 | + | if iv != nil { | |||
| 2006 | + | iv.RefreshGoal() | |||
| 2007 | + | } | |||
| 1967 | if candidate.session.Meta.Parent != "" { | 2008 | if candidate.session.Meta.Parent != "" { | ||
| 1968 | extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates) | 2009 | extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates) | ||
| 1969 | } | 2010 | } | ||
| @@ -2144,6 +2185,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2144 | if iv != nil { | 2185 | if iv != nil { | ||
| 2145 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome())) | 2186 | startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome())) | ||
| 2146 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | 2187 | iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths) | ||
| 2188 | + | iv.RefreshGoal() | |||
| 2147 | } | 2189 | } | ||
| 2148 | 2190 | ||||
| 2149 | // Re-scope the subagent dashboard to the new session. | 2191 | // Re-scope the subagent dashboard to the new session. | ||
| @@ -2401,6 +2443,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2401 | ChangeCWD: changeCWD, | 2443 | ChangeCWD: changeCWD, | ||
| 2402 | PersistCompactHandoff: persistCompactHandoff, | 2444 | PersistCompactHandoff: persistCompactHandoff, | ||
| 2403 | CurrentCompactHandoff: currentCompactHandoff, | 2445 | CurrentCompactHandoff: currentCompactHandoff, | ||
| 2446 | + | PersistGoal: persistGoal, | |||
| 2447 | + | CurrentGoal: currentGoal, | |||
| 2404 | CurrentSessionPath: func() string { | 2448 | CurrentSessionPath: func() string { | ||
| 2405 | persistMu.Lock() | 2449 | persistMu.Lock() | ||
| 2406 | defer persistMu.Unlock() | 2450 | defer persistMu.Unlock() | ||
M packages/agent/cli_session_test.go +89 -0
| @@ -7,17 +7,106 @@ import ( | |||
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | + | "runtime" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "testing" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| 15 | 16 | "github.com/bnema/zut/packages/agent/modes" | |
| 17 | + | "github.com/bnema/zut/packages/agent/tools" | |
| 16 | 18 | "github.com/bnema/zut/packages/core" | |
| 17 | 19 | "github.com/bnema/zut/packages/provider" | |
| 18 | 20 | "github.com/google/uuid" | |
| 19 | 21 | ) | |
| 20 | 22 | ||
| 23 | + | func TestPersistToolResultFinishesBeforeSessionTransition(t *testing.T) { | |
| 24 | + | root := t.TempDir() | |
| 25 | + | original, err := core.NewSession(root, "original", "provider", "model", "test") | |
| 26 | + | if err != nil { | |
| 27 | + | t.Fatal(err) | |
| 28 | + | } | |
| 29 | + | defer original.Close() | |
| 30 | + | next, err := core.NewSession(root, "next", "provider", "model", "test") | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatal(err) | |
| 33 | + | } | |
| 34 | + | defer next.Close() | |
| 35 | + | for _, sess := range []*core.Session{original, next} { | |
| 36 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var transitionMu sync.RWMutex | |
| 42 | + | var persistMu sync.Mutex | |
| 43 | + | current := original | |
| 44 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}} | |
| 45 | + | persistMu.Lock() | |
| 46 | + | persisted := make(chan struct{}) | |
| 47 | + | go func() { | |
| 48 | + | persistToolResultForCurrentSession(&transitionMu, &persistMu, func() *core.Session { return current }, nil, result) | |
| 49 | + | close(persisted) | |
| 50 | + | }() | |
| 51 | + | ||
| 52 | + | deadline := time.Now().Add(2 * time.Second) | |
| 53 | + | for transitionMu.TryLock() { | |
| 54 | + | transitionMu.Unlock() | |
| 55 | + | if time.Now().After(deadline) { | |
| 56 | + | persistMu.Unlock() | |
| 57 | + | t.Fatal("tool-result callback did not acquire the session read lock") | |
| 58 | + | } | |
| 59 | + | runtime.Gosched() | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | transitioned := make(chan struct{}) | |
| 63 | + | go func() { | |
| 64 | + | transitionMu.Lock() | |
| 65 | + | persistMu.Lock() | |
| 66 | + | current = next | |
| 67 | + | persistMu.Unlock() | |
| 68 | + | transitionMu.Unlock() | |
| 69 | + | close(transitioned) | |
| 70 | + | }() | |
| 71 | + | persistMu.Unlock() | |
| 72 | + | select { | |
| 73 | + | case <-persisted: | |
| 74 | + | case <-time.After(2 * time.Second): | |
| 75 | + | t.Fatal("tool result did not persist") | |
| 76 | + | } | |
| 77 | + | select { | |
| 78 | + | case <-transitioned: | |
| 79 | + | case <-time.After(2 * time.Second): | |
| 80 | + | t.Fatal("session transition did not finish") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone { | |
| 84 | + | t.Fatalf("original goal = %#v, want done", original.Meta.Goal) | |
| 85 | + | } | |
| 86 | + | if next.Meta.Goal == nil || next.Meta.Goal.Status != core.GoalActive { | |
| 87 | + | t.Fatalf("next goal = %#v, want active", next.Meta.Goal) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) { | |
| 92 | + | sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | defer sess.Close() | |
| 97 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil { | |
| 98 | + | t.Fatal(err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}} | |
| 102 | + | if err := persistGoalToolResult(sess, result); err != nil { | |
| 103 | + | t.Fatal(err) | |
| 104 | + | } | |
| 105 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" { | |
| 106 | + | t.Fatalf("persisted goal = %#v", got) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 21 | 110 | type failingStdinReader struct{} | |
| 22 | 111 | ||
| 23 | 112 | func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } | |
| @@ -7,17 +7,106 @@ import ( | |||||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | "path/filepath" | 9 | "path/filepath" | ||
| 10 | + | "runtime" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | "sync" | 12 | "sync" | ||
| 12 | "testing" | 13 | "testing" | ||
| 13 | "time" | 14 | "time" | ||
| 14 | 15 | ||||
| 15 | "github.com/bnema/zut/packages/agent/modes" | 16 | "github.com/bnema/zut/packages/agent/modes" | ||
| 17 | + | "github.com/bnema/zut/packages/agent/tools" | |||
| 16 | "github.com/bnema/zut/packages/core" | 18 | "github.com/bnema/zut/packages/core" | ||
| 17 | "github.com/bnema/zut/packages/provider" | 19 | "github.com/bnema/zut/packages/provider" | ||
| 18 | "github.com/google/uuid" | 20 | "github.com/google/uuid" | ||
| 19 | ) | 21 | ) | ||
| 20 | 22 | ||||
| 23 | + | func TestPersistToolResultFinishesBeforeSessionTransition(t *testing.T) { | |||
| 24 | + | root := t.TempDir() | |||
| 25 | + | original, err := core.NewSession(root, "original", "provider", "model", "test") | |||
| 26 | + | if err != nil { | |||
| 27 | + | t.Fatal(err) | |||
| 28 | + | } | |||
| 29 | + | defer original.Close() | |||
| 30 | + | next, err := core.NewSession(root, "next", "provider", "model", "test") | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatal(err) | |||
| 33 | + | } | |||
| 34 | + | defer next.Close() | |||
| 35 | + | for _, sess := range []*core.Session{original, next} { | |||
| 36 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var transitionMu sync.RWMutex | |||
| 42 | + | var persistMu sync.Mutex | |||
| 43 | + | current := original | |||
| 44 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}} | |||
| 45 | + | persistMu.Lock() | |||
| 46 | + | persisted := make(chan struct{}) | |||
| 47 | + | go func() { | |||
| 48 | + | persistToolResultForCurrentSession(&transitionMu, &persistMu, func() *core.Session { return current }, nil, result) | |||
| 49 | + | close(persisted) | |||
| 50 | + | }() | |||
| 51 | + | ||||
| 52 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 53 | + | for transitionMu.TryLock() { | |||
| 54 | + | transitionMu.Unlock() | |||
| 55 | + | if time.Now().After(deadline) { | |||
| 56 | + | persistMu.Unlock() | |||
| 57 | + | t.Fatal("tool-result callback did not acquire the session read lock") | |||
| 58 | + | } | |||
| 59 | + | runtime.Gosched() | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | transitioned := make(chan struct{}) | |||
| 63 | + | go func() { | |||
| 64 | + | transitionMu.Lock() | |||
| 65 | + | persistMu.Lock() | |||
| 66 | + | current = next | |||
| 67 | + | persistMu.Unlock() | |||
| 68 | + | transitionMu.Unlock() | |||
| 69 | + | close(transitioned) | |||
| 70 | + | }() | |||
| 71 | + | persistMu.Unlock() | |||
| 72 | + | select { | |||
| 73 | + | case <-persisted: | |||
| 74 | + | case <-time.After(2 * time.Second): | |||
| 75 | + | t.Fatal("tool result did not persist") | |||
| 76 | + | } | |||
| 77 | + | select { | |||
| 78 | + | case <-transitioned: | |||
| 79 | + | case <-time.After(2 * time.Second): | |||
| 80 | + | t.Fatal("session transition did not finish") | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone { | |||
| 84 | + | t.Fatalf("original goal = %#v, want done", original.Meta.Goal) | |||
| 85 | + | } | |||
| 86 | + | if next.Meta.Goal == nil || next.Meta.Goal.Status != core.GoalActive { | |||
| 87 | + | t.Fatalf("next goal = %#v, want active", next.Meta.Goal) | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) { | |||
| 92 | + | sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | defer sess.Close() | |||
| 97 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil { | |||
| 98 | + | t.Fatal(err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}} | |||
| 102 | + | if err := persistGoalToolResult(sess, result); err != nil { | |||
| 103 | + | t.Fatal(err) | |||
| 104 | + | } | |||
| 105 | + | if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" { | |||
| 106 | + | t.Fatalf("persisted goal = %#v", got) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 21 | type failingStdinReader struct{} | 110 | type failingStdinReader struct{} | ||
| 22 | 111 | ||||
| 23 | func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } | 112 | func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") } | ||
M packages/agent/modes/compact_queue_test.go +91 -0
| @@ -17,6 +17,7 @@ type compactQueueClient struct { | |||
| 17 | 17 | compactionStarted chan struct{} | |
| 18 | 18 | releaseCompaction chan struct{} | |
| 19 | 19 | followUpRequest chan provider.Request | |
| 20 | + | onFollowUp func() | |
| 20 | 21 | ||
| 21 | 22 | mu sync.Mutex | |
| 22 | 23 | calls int | |
| @@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) ( | |||
| 45 | 46 | return | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 49 | + | if c.onFollowUp != nil { | |
| 50 | + | c.onFollowUp() | |
| 51 | + | } | |
| 48 | 52 | c.followUpRequest <- req | |
| 49 | 53 | out <- provider.EventDone{ | |
| 50 | 54 | Stop: provider.StopEnd, | |
| @@ -137,6 +141,93 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) | |||
| 137 | 141 | close(client.releaseCompaction) | |
| 138 | 142 | } | |
| 139 | 143 | ||
| 144 | + | func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) { | |
| 145 | + | var goalMu sync.Mutex | |
| 146 | + | goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive} | |
| 147 | + | currentGoal := func() *core.SessionGoal { | |
| 148 | + | goalMu.Lock() | |
| 149 | + | defer goalMu.Unlock() | |
| 150 | + | return cloneSessionGoal(goal) | |
| 151 | + | } | |
| 152 | + | persistGoal := func(next *core.SessionGoal) error { | |
| 153 | + | goalMu.Lock() | |
| 154 | + | goal = cloneSessionGoal(next) | |
| 155 | + | goalMu.Unlock() | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | client := &compactQueueClient{ | |
| 159 | + | compactionStarted: make(chan struct{}), | |
| 160 | + | releaseCompaction: make(chan struct{}), | |
| 161 | + | followUpRequest: make(chan provider.Request, 1), | |
| 162 | + | onFollowUp: func() { | |
| 163 | + | // Pause before publishing the follow-up so the loop stops after one turn. | |
| 164 | + | _ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused}) | |
| 165 | + | }, | |
| 166 | + | } | |
| 167 | + | agent := core.NewAgent(client, "test-model", "", goalToolRegistry()) | |
| 168 | + | agent.SetMessages([]provider.Message{ | |
| 169 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}}, | |
| 170 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}}, | |
| 171 | + | }) | |
| 172 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal}) | |
| 173 | + | interactive.runCtx = context.Background() | |
| 174 | + | ||
| 175 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd}) | |
| 176 | + | select { | |
| 177 | + | case <-client.compactionStarted: | |
| 178 | + | case <-time.After(2 * time.Second): | |
| 179 | + | t.Fatal("automatic compaction did not start") | |
| 180 | + | } | |
| 181 | + | close(client.releaseCompaction) | |
| 182 | + | ||
| 183 | + | select { | |
| 184 | + | case req := <-client.followUpRequest: | |
| 185 | + | if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" { | |
| 186 | + | t.Fatalf("goal continuation tail = %#v", req.Messages) | |
| 187 | + | } | |
| 188 | + | case <-time.After(2 * time.Second): | |
| 189 | + | t.Fatal("active goal did not continue after automatic compaction") | |
| 190 | + | } | |
| 191 | + | waitInteractiveIdle(t, interactive) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) { | |
| 195 | + | goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive} | |
| 196 | + | client := &compactQueueClient{ | |
| 197 | + | compactionStarted: make(chan struct{}), | |
| 198 | + | releaseCompaction: make(chan struct{}), | |
| 199 | + | followUpRequest: make(chan provider.Request, 1), | |
| 200 | + | } | |
| 201 | + | agent := core.NewAgent(client, "test-model", "", goalToolRegistry()) | |
| 202 | + | agent.SetMessages([]provider.Message{ | |
| 203 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}}, | |
| 204 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}}, | |
| 205 | + | }) | |
| 206 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 207 | + | Agent: agent, | |
| 208 | + | CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }, | |
| 209 | + | PersistGoal: func(next *core.SessionGoal) error { | |
| 210 | + | goal = cloneSessionGoal(next) | |
| 211 | + | return nil | |
| 212 | + | }, | |
| 213 | + | }) | |
| 214 | + | interactive.runCtx = context.Background() | |
| 215 | + | ||
| 216 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual}) | |
| 217 | + | select { | |
| 218 | + | case <-client.compactionStarted: | |
| 219 | + | case <-time.After(2 * time.Second): | |
| 220 | + | t.Fatal("manual compaction did not start") | |
| 221 | + | } | |
| 222 | + | close(client.releaseCompaction) | |
| 223 | + | waitInteractiveIdle(t, interactive) | |
| 224 | + | select { | |
| 225 | + | case req := <-client.followUpRequest: | |
| 226 | + | t.Fatalf("manual compaction started goal continuation: %#v", req) | |
| 227 | + | default: | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | ||
| 140 | 231 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | |
| 141 | 232 | client := &compactQueueClient{ | |
| 142 | 233 | compactionStarted: make(chan struct{}), | |
| @@ -17,6 +17,7 @@ type compactQueueClient struct { | |||||
| 17 | compactionStarted chan struct{} | 17 | compactionStarted chan struct{} | ||
| 18 | releaseCompaction chan struct{} | 18 | releaseCompaction chan struct{} | ||
| 19 | followUpRequest chan provider.Request | 19 | followUpRequest chan provider.Request | ||
| 20 | + | onFollowUp func() | |||
| 20 | 21 | ||||
| 21 | mu sync.Mutex | 22 | mu sync.Mutex | ||
| 22 | calls int | 23 | calls int | ||
| @@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) ( | |||||
| 45 | return | 46 | return | ||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 49 | + | if c.onFollowUp != nil { | |||
| 50 | + | c.onFollowUp() | |||
| 51 | + | } | |||
| 48 | c.followUpRequest <- req | 52 | c.followUpRequest <- req | ||
| 49 | out <- provider.EventDone{ | 53 | out <- provider.EventDone{ | ||
| 50 | Stop: provider.StopEnd, | 54 | Stop: provider.StopEnd, | ||
| @@ -137,6 +141,93 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T) | |||||
| 137 | close(client.releaseCompaction) | 141 | close(client.releaseCompaction) | ||
| 138 | } | 142 | } | ||
| 139 | 143 | ||||
| 144 | + | func TestActiveGoalContinuesAfterAutomaticCompaction(t *testing.T) { | |||
| 145 | + | var goalMu sync.Mutex | |||
| 146 | + | goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive} | |||
| 147 | + | currentGoal := func() *core.SessionGoal { | |||
| 148 | + | goalMu.Lock() | |||
| 149 | + | defer goalMu.Unlock() | |||
| 150 | + | return cloneSessionGoal(goal) | |||
| 151 | + | } | |||
| 152 | + | persistGoal := func(next *core.SessionGoal) error { | |||
| 153 | + | goalMu.Lock() | |||
| 154 | + | goal = cloneSessionGoal(next) | |||
| 155 | + | goalMu.Unlock() | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | client := &compactQueueClient{ | |||
| 159 | + | compactionStarted: make(chan struct{}), | |||
| 160 | + | releaseCompaction: make(chan struct{}), | |||
| 161 | + | followUpRequest: make(chan provider.Request, 1), | |||
| 162 | + | onFollowUp: func() { | |||
| 163 | + | // Pause before publishing the follow-up so the loop stops after one turn. | |||
| 164 | + | _ = persistGoal(&core.SessionGoal{Objective: "finish the goal", Status: core.GoalPaused}) | |||
| 165 | + | }, | |||
| 166 | + | } | |||
| 167 | + | agent := core.NewAgent(client, "test-model", "", goalToolRegistry()) | |||
| 168 | + | agent.SetMessages([]provider.Message{ | |||
| 169 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}}, | |||
| 170 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first result"}}}, | |||
| 171 | + | }) | |||
| 172 | + | interactive := NewInteractive(InteractiveConfig{Agent: agent, CurrentGoal: currentGoal, PersistGoal: persistGoal}) | |||
| 173 | + | interactive.runCtx = context.Background() | |||
| 174 | + | ||||
| 175 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginAfterTurnThreshold, lastStop: provider.StopEnd}) | |||
| 176 | + | select { | |||
| 177 | + | case <-client.compactionStarted: | |||
| 178 | + | case <-time.After(2 * time.Second): | |||
| 179 | + | t.Fatal("automatic compaction did not start") | |||
| 180 | + | } | |||
| 181 | + | close(client.releaseCompaction) | |||
| 182 | + | ||||
| 183 | + | select { | |||
| 184 | + | case req := <-client.followUpRequest: | |||
| 185 | + | if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Meta[goalContinueMetaKey] != "true" { | |||
| 186 | + | t.Fatalf("goal continuation tail = %#v", req.Messages) | |||
| 187 | + | } | |||
| 188 | + | case <-time.After(2 * time.Second): | |||
| 189 | + | t.Fatal("active goal did not continue after automatic compaction") | |||
| 190 | + | } | |||
| 191 | + | waitInteractiveIdle(t, interactive) | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func TestActiveGoalDoesNotContinueAfterManualCompaction(t *testing.T) { | |||
| 195 | + | goal := &core.SessionGoal{Objective: "finish the goal", Status: core.GoalActive} | |||
| 196 | + | client := &compactQueueClient{ | |||
| 197 | + | compactionStarted: make(chan struct{}), | |||
| 198 | + | releaseCompaction: make(chan struct{}), | |||
| 199 | + | followUpRequest: make(chan provider.Request, 1), | |||
| 200 | + | } | |||
| 201 | + | agent := core.NewAgent(client, "test-model", "", goalToolRegistry()) | |||
| 202 | + | agent.SetMessages([]provider.Message{ | |||
| 203 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "request"}}}, | |||
| 204 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "result"}}}, | |||
| 205 | + | }) | |||
| 206 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 207 | + | Agent: agent, | |||
| 208 | + | CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }, | |||
| 209 | + | PersistGoal: func(next *core.SessionGoal) error { | |||
| 210 | + | goal = cloneSessionGoal(next) | |||
| 211 | + | return nil | |||
| 212 | + | }, | |||
| 213 | + | }) | |||
| 214 | + | interactive.runCtx = context.Background() | |||
| 215 | + | ||||
| 216 | + | interactive.runCompact(context.Background(), compactContinuationRequest{origin: compactOriginManual}) | |||
| 217 | + | select { | |||
| 218 | + | case <-client.compactionStarted: | |||
| 219 | + | case <-time.After(2 * time.Second): | |||
| 220 | + | t.Fatal("manual compaction did not start") | |||
| 221 | + | } | |||
| 222 | + | close(client.releaseCompaction) | |||
| 223 | + | waitInteractiveIdle(t, interactive) | |||
| 224 | + | select { | |||
| 225 | + | case req := <-client.followUpRequest: | |||
| 226 | + | t.Fatalf("manual compaction started goal continuation: %#v", req) | |||
| 227 | + | default: | |||
| 228 | + | } | |||
| 229 | + | } | |||
| 230 | + | ||||
| 140 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | 231 | func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) { | ||
| 141 | client := &compactQueueClient{ | 232 | client := &compactQueueClient{ | ||
| 142 | compactionStarted: make(chan struct{}), | 233 | compactionStarted: make(chan struct{}), | ||
A packages/agent/modes/goal.go +262 -0
| @@ -0,0 +1,262 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |
| 9 | + | "github.com/bnema/zut/packages/core" | |
| 10 | + | "github.com/bnema/zut/packages/provider" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const goalContinueMetaKey = "zut_goal_continue" | |
| 14 | + | ||
| 15 | + | func (i *Interactive) goalUpdatesAvailable() bool { | |
| 16 | + | ag := i.Agent() | |
| 17 | + | if ag == nil { | |
| 18 | + | return false | |
| 19 | + | } | |
| 20 | + | _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName] | |
| 21 | + | return ok | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |
| 25 | + | if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() { | |
| 26 | + | return provider.Message{}, false | |
| 27 | + | } | |
| 28 | + | goal := i.cfg.CurrentGoal() | |
| 29 | + | if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { | |
| 30 | + | return provider.Message{}, false | |
| 31 | + | } | |
| 32 | + | text := goal.Objective + | |
| 33 | + | "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal." | |
| 34 | + | return provider.Message{ | |
| 35 | + | Role: provider.RoleUser, | |
| 36 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 37 | + | Meta: map[string]string{goalContinueMetaKey: "true"}, | |
| 38 | + | }, true | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (i *Interactive) goalIdleLocked() bool { | |
| 42 | + | return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0 | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (i *Interactive) goalContinuationIfIdle() (provider.Message, bool) { | |
| 46 | + | message, ok := i.goalContinuationMessage() | |
| 47 | + | if !ok { | |
| 48 | + | return provider.Message{}, false | |
| 49 | + | } | |
| 50 | + | i.mu.Lock() | |
| 51 | + | defer i.mu.Unlock() | |
| 52 | + | if !i.goalIdleLocked() { | |
| 53 | + | return provider.Message{}, false | |
| 54 | + | } | |
| 55 | + | return message, true | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | |
| 59 | + | message, ok := i.goalContinuationMessage() | |
| 60 | + | if !ok { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | i.mu.Lock() | |
| 64 | + | if !i.goalIdleLocked() { | |
| 65 | + | i.mu.Unlock() | |
| 66 | + | return false | |
| 67 | + | } | |
| 68 | + | i.busy = true | |
| 69 | + | i.mu.Unlock() | |
| 70 | + | i.startGoalContinuation(parent, message) | |
| 71 | + | return true | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) { | |
| 75 | + | goalMessage, goalActive := i.goalContinuationMessage() | |
| 76 | + | i.mu.Lock() | |
| 77 | + | ag := i.agent | |
| 78 | + | if ag == nil { | |
| 79 | + | i.busy = false | |
| 80 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 81 | + | i.mu.Unlock() | |
| 82 | + | runPendingIdleWork(pendingIdleWork) | |
| 83 | + | i.invalidate() | |
| 84 | + | return | |
| 85 | + | } | |
| 86 | + | if len(i.queued) > 0 { | |
| 87 | + | next := i.queued[0] | |
| 88 | + | i.queued = i.queued[1:] | |
| 89 | + | i.mu.Unlock() | |
| 90 | + | i.startTurn(parent, next) | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | if ag.QueuedMessageCount() > 0 { | |
| 94 | + | i.mu.Unlock() | |
| 95 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 96 | + | return | |
| 97 | + | } | |
| 98 | + | if !goalActive || userMessageText(goalMessage) != userMessageText(message) { | |
| 99 | + | i.busy = false | |
| 100 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |
| 101 | + | i.mu.Unlock() | |
| 102 | + | runPendingIdleWork(pendingIdleWork) | |
| 103 | + | i.invalidate() | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | i.mu.Unlock() | |
| 107 | + | ag.AppendUserContext(userMessageText(message), message.Meta) | |
| 108 | + | ||
| 109 | + | // Persistence callbacks run synchronously from AppendUserContext, so never | |
| 110 | + | // hold the TUI mutex across it. Re-check user work that may have arrived | |
| 111 | + | // while the hidden continuation was being persisted. | |
| 112 | + | i.mu.Lock() | |
| 113 | + | if len(i.queued) > 0 { | |
| 114 | + | next := i.queued[0] | |
| 115 | + | i.queued = i.queued[1:] | |
| 116 | + | i.mu.Unlock() | |
| 117 | + | i.startTurn(parent, next) | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | i.mu.Unlock() | |
| 121 | + | i.startTurnRequest(parent, "", nil, true, false) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal { | |
| 125 | + | if goal == nil { | |
| 126 | + | return nil | |
| 127 | + | } | |
| 128 | + | copyGoal := *goal | |
| 129 | + | return ©Goal | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) { | |
| 133 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |
| 134 | + | return | |
| 135 | + | } | |
| 136 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |
| 137 | + | if goal == nil || goal.Status != core.GoalActive { | |
| 138 | + | return | |
| 139 | + | } | |
| 140 | + | goal.Status = status | |
| 141 | + | goal.Reason = reason | |
| 142 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |
| 143 | + | i.ReportError(fmt.Errorf("persist goal: %w", err)) | |
| 144 | + | return | |
| 145 | + | } | |
| 146 | + | i.setGoalStatus(goal) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) { | |
| 150 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |
| 151 | + | i.setGoalCommandError("goal: session persistence is unavailable") | |
| 152 | + | return | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | current := copySessionGoal(i.cfg.CurrentGoal()) | |
| 156 | + | if len(parts) == 1 { | |
| 157 | + | if current == nil { | |
| 158 | + | i.setGoalCommandStatus("no autonomous goal") | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective) | |
| 162 | + | if current.Reason != "" { | |
| 163 | + | status += " (" + current.Reason + ")" | |
| 164 | + | } | |
| 165 | + | i.setGoalCommandStatus(status) | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | startIfIdle := false | |
| 170 | + | startTitle := false | |
| 171 | + | arg := "" | |
| 172 | + | if len(parts) == 2 { | |
| 173 | + | arg = strings.ToLower(parts[1]) | |
| 174 | + | } | |
| 175 | + | switch arg { | |
| 176 | + | case "pause": | |
| 177 | + | if current == nil { | |
| 178 | + | i.setGoalCommandError("goal: no goal to pause") | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | current.Status = core.GoalPaused | |
| 182 | + | current.Reason = "" | |
| 183 | + | case "resume": | |
| 184 | + | if current == nil { | |
| 185 | + | i.setGoalCommandError("goal: no goal to resume") | |
| 186 | + | return | |
| 187 | + | } | |
| 188 | + | current.Status = core.GoalActive | |
| 189 | + | current.Reason = "" | |
| 190 | + | startIfIdle = true | |
| 191 | + | case "clear": | |
| 192 | + | if err := i.cfg.PersistGoal(nil); err != nil { | |
| 193 | + | i.setGoalCommandError("goal: " + err.Error()) | |
| 194 | + | return | |
| 195 | + | } | |
| 196 | + | i.setGoalStatus(nil) | |
| 197 | + | i.setGoalCommandStatus("autonomous goal cleared") | |
| 198 | + | return | |
| 199 | + | default: | |
| 200 | + | objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) | |
| 201 | + | if objective == "" { | |
| 202 | + | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear") | |
| 203 | + | return | |
| 204 | + | } | |
| 205 | + | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive} | |
| 206 | + | startIfIdle = true | |
| 207 | + | startTitle = true | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | if startIfIdle && !i.goalUpdatesAvailable() { | |
| 211 | + | i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools") | |
| 212 | + | return | |
| 213 | + | } | |
| 214 | + | if err := i.cfg.PersistGoal(current); err != nil { | |
| 215 | + | i.setGoalCommandError("goal: " + err.Error()) | |
| 216 | + | return | |
| 217 | + | } | |
| 218 | + | i.setGoalStatus(current) | |
| 219 | + | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | |
| 220 | + | if startTitle { | |
| 221 | + | i.maybeStartSessionTitle(ctx, current.Objective) | |
| 222 | + | } | |
| 223 | + | if startIfIdle { | |
| 224 | + | i.requestGoalContinuationIfIdle(ctx) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // RefreshGoal reloads the status badge after the CLI commits a session switch. | |
| 229 | + | func (i *Interactive) RefreshGoal() { | |
| 230 | + | if i.cfg.CurrentGoal == nil { | |
| 231 | + | i.setGoalStatus(nil) | |
| 232 | + | return | |
| 233 | + | } | |
| 234 | + | i.setGoalStatus(i.cfg.CurrentGoal()) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func (i *Interactive) setGoalStatus(goal *core.SessionGoal) { | |
| 238 | + | status := core.GoalStatus("") | |
| 239 | + | if goal != nil { | |
| 240 | + | status = goal.Status | |
| 241 | + | } | |
| 242 | + | i.mu.Lock() | |
| 243 | + | i.goalStatus = status | |
| 244 | + | i.mu.Unlock() | |
| 245 | + | i.invalidate() | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func (i *Interactive) setGoalCommandStatus(message string) { | |
| 249 | + | i.mu.Lock() | |
| 250 | + | i.statusOK = message | |
| 251 | + | i.statusErr = "" | |
| 252 | + | i.mu.Unlock() | |
| 253 | + | i.invalidate() | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (i *Interactive) setGoalCommandError(message string) { | |
| 257 | + | i.mu.Lock() | |
| 258 | + | i.statusOK = "" | |
| 259 | + | i.statusErr = message | |
| 260 | + | i.mu.Unlock() | |
| 261 | + | i.invalidate() | |
| 262 | + | } | |
| @@ -0,0 +1,262 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |||
| 9 | + | "github.com/bnema/zut/packages/core" | |||
| 10 | + | "github.com/bnema/zut/packages/provider" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const goalContinueMetaKey = "zut_goal_continue" | |||
| 14 | + | ||||
| 15 | + | func (i *Interactive) goalUpdatesAvailable() bool { | |||
| 16 | + | ag := i.Agent() | |||
| 17 | + | if ag == nil { | |||
| 18 | + | return false | |||
| 19 | + | } | |||
| 20 | + | _, ok := ag.ToolsSnapshot()[agenttools.UpdateGoalToolName] | |||
| 21 | + | return ok | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||
| 25 | + | if i.cfg.CurrentGoal == nil || !i.goalUpdatesAvailable() { | |||
| 26 | + | return provider.Message{}, false | |||
| 27 | + | } | |||
| 28 | + | goal := i.cfg.CurrentGoal() | |||
| 29 | + | if goal == nil || goal.Status != core.GoalActive || strings.TrimSpace(goal.Objective) == "" { | |||
| 30 | + | return provider.Message{}, false | |||
| 31 | + | } | |||
| 32 | + | text := goal.Objective + | |||
| 33 | + | "\n\nContinue working autonomously toward this active goal. Do not stop at a progress update. Continue until the goal is complete or blocked, then call update_goal." | |||
| 34 | + | return provider.Message{ | |||
| 35 | + | Role: provider.RoleUser, | |||
| 36 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 37 | + | Meta: map[string]string{goalContinueMetaKey: "true"}, | |||
| 38 | + | }, true | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (i *Interactive) goalIdleLocked() bool { | |||
| 42 | + | return !i.busy && i.agent != nil && len(i.queued) == 0 && i.agent.QueuedMessageCount() == 0 | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func (i *Interactive) goalContinuationIfIdle() (provider.Message, bool) { | |||
| 46 | + | message, ok := i.goalContinuationMessage() | |||
| 47 | + | if !ok { | |||
| 48 | + | return provider.Message{}, false | |||
| 49 | + | } | |||
| 50 | + | i.mu.Lock() | |||
| 51 | + | defer i.mu.Unlock() | |||
| 52 | + | if !i.goalIdleLocked() { | |||
| 53 | + | return provider.Message{}, false | |||
| 54 | + | } | |||
| 55 | + | return message, true | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (i *Interactive) requestGoalContinuationIfIdle(parent context.Context) bool { | |||
| 59 | + | message, ok := i.goalContinuationMessage() | |||
| 60 | + | if !ok { | |||
| 61 | + | return false | |||
| 62 | + | } | |||
| 63 | + | i.mu.Lock() | |||
| 64 | + | if !i.goalIdleLocked() { | |||
| 65 | + | i.mu.Unlock() | |||
| 66 | + | return false | |||
| 67 | + | } | |||
| 68 | + | i.busy = true | |||
| 69 | + | i.mu.Unlock() | |||
| 70 | + | i.startGoalContinuation(parent, message) | |||
| 71 | + | return true | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func (i *Interactive) startGoalContinuation(parent context.Context, message provider.Message) { | |||
| 75 | + | goalMessage, goalActive := i.goalContinuationMessage() | |||
| 76 | + | i.mu.Lock() | |||
| 77 | + | ag := i.agent | |||
| 78 | + | if ag == nil { | |||
| 79 | + | i.busy = false | |||
| 80 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 81 | + | i.mu.Unlock() | |||
| 82 | + | runPendingIdleWork(pendingIdleWork) | |||
| 83 | + | i.invalidate() | |||
| 84 | + | return | |||
| 85 | + | } | |||
| 86 | + | if len(i.queued) > 0 { | |||
| 87 | + | next := i.queued[0] | |||
| 88 | + | i.queued = i.queued[1:] | |||
| 89 | + | i.mu.Unlock() | |||
| 90 | + | i.startTurn(parent, next) | |||
| 91 | + | return | |||
| 92 | + | } | |||
| 93 | + | if ag.QueuedMessageCount() > 0 { | |||
| 94 | + | i.mu.Unlock() | |||
| 95 | + | i.startTurnRequest(parent, "", nil, true, false) | |||
| 96 | + | return | |||
| 97 | + | } | |||
| 98 | + | if !goalActive || userMessageText(goalMessage) != userMessageText(message) { | |||
| 99 | + | i.busy = false | |||
| 100 | + | pendingIdleWork := i.takePendingIdleWorkLocked() | |||
| 101 | + | i.mu.Unlock() | |||
| 102 | + | runPendingIdleWork(pendingIdleWork) | |||
| 103 | + | i.invalidate() | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | i.mu.Unlock() | |||
| 107 | + | ag.AppendUserContext(userMessageText(message), message.Meta) | |||
| 108 | + | ||||
| 109 | + | // Persistence callbacks run synchronously from AppendUserContext, so never | |||
| 110 | + | // hold the TUI mutex across it. Re-check user work that may have arrived | |||
| 111 | + | // while the hidden continuation was being persisted. | |||
| 112 | + | i.mu.Lock() | |||
| 113 | + | if len(i.queued) > 0 { | |||
| 114 | + | next := i.queued[0] | |||
| 115 | + | i.queued = i.queued[1:] | |||
| 116 | + | i.mu.Unlock() | |||
| 117 | + | i.startTurn(parent, next) | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | i.mu.Unlock() | |||
| 121 | + | i.startTurnRequest(parent, "", nil, true, false) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal { | |||
| 125 | + | if goal == nil { | |||
| 126 | + | return nil | |||
| 127 | + | } | |||
| 128 | + | copyGoal := *goal | |||
| 129 | + | return ©Goal | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) { | |||
| 133 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |||
| 134 | + | return | |||
| 135 | + | } | |||
| 136 | + | goal := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 137 | + | if goal == nil || goal.Status != core.GoalActive { | |||
| 138 | + | return | |||
| 139 | + | } | |||
| 140 | + | goal.Status = status | |||
| 141 | + | goal.Reason = reason | |||
| 142 | + | if err := i.cfg.PersistGoal(goal); err != nil { | |||
| 143 | + | i.ReportError(fmt.Errorf("persist goal: %w", err)) | |||
| 144 | + | return | |||
| 145 | + | } | |||
| 146 | + | i.setGoalStatus(goal) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) { | |||
| 150 | + | if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil { | |||
| 151 | + | i.setGoalCommandError("goal: session persistence is unavailable") | |||
| 152 | + | return | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | current := copySessionGoal(i.cfg.CurrentGoal()) | |||
| 156 | + | if len(parts) == 1 { | |||
| 157 | + | if current == nil { | |||
| 158 | + | i.setGoalCommandStatus("no autonomous goal") | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective) | |||
| 162 | + | if current.Reason != "" { | |||
| 163 | + | status += " (" + current.Reason + ")" | |||
| 164 | + | } | |||
| 165 | + | i.setGoalCommandStatus(status) | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | startIfIdle := false | |||
| 170 | + | startTitle := false | |||
| 171 | + | arg := "" | |||
| 172 | + | if len(parts) == 2 { | |||
| 173 | + | arg = strings.ToLower(parts[1]) | |||
| 174 | + | } | |||
| 175 | + | switch arg { | |||
| 176 | + | case "pause": | |||
| 177 | + | if current == nil { | |||
| 178 | + | i.setGoalCommandError("goal: no goal to pause") | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | current.Status = core.GoalPaused | |||
| 182 | + | current.Reason = "" | |||
| 183 | + | case "resume": | |||
| 184 | + | if current == nil { | |||
| 185 | + | i.setGoalCommandError("goal: no goal to resume") | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 188 | + | current.Status = core.GoalActive | |||
| 189 | + | current.Reason = "" | |||
| 190 | + | startIfIdle = true | |||
| 191 | + | case "clear": | |||
| 192 | + | if err := i.cfg.PersistGoal(nil); err != nil { | |||
| 193 | + | i.setGoalCommandError("goal: " + err.Error()) | |||
| 194 | + | return | |||
| 195 | + | } | |||
| 196 | + | i.setGoalStatus(nil) | |||
| 197 | + | i.setGoalCommandStatus("autonomous goal cleared") | |||
| 198 | + | return | |||
| 199 | + | default: | |||
| 200 | + | objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) | |||
| 201 | + | if objective == "" { | |||
| 202 | + | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear") | |||
| 203 | + | return | |||
| 204 | + | } | |||
| 205 | + | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive} | |||
| 206 | + | startIfIdle = true | |||
| 207 | + | startTitle = true | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | if startIfIdle && !i.goalUpdatesAvailable() { | |||
| 211 | + | i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools") | |||
| 212 | + | return | |||
| 213 | + | } | |||
| 214 | + | if err := i.cfg.PersistGoal(current); err != nil { | |||
| 215 | + | i.setGoalCommandError("goal: " + err.Error()) | |||
| 216 | + | return | |||
| 217 | + | } | |||
| 218 | + | i.setGoalStatus(current) | |||
| 219 | + | i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective)) | |||
| 220 | + | if startTitle { | |||
| 221 | + | i.maybeStartSessionTitle(ctx, current.Objective) | |||
| 222 | + | } | |||
| 223 | + | if startIfIdle { | |||
| 224 | + | i.requestGoalContinuationIfIdle(ctx) | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ||||
| 228 | + | // RefreshGoal reloads the status badge after the CLI commits a session switch. | |||
| 229 | + | func (i *Interactive) RefreshGoal() { | |||
| 230 | + | if i.cfg.CurrentGoal == nil { | |||
| 231 | + | i.setGoalStatus(nil) | |||
| 232 | + | return | |||
| 233 | + | } | |||
| 234 | + | i.setGoalStatus(i.cfg.CurrentGoal()) | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func (i *Interactive) setGoalStatus(goal *core.SessionGoal) { | |||
| 238 | + | status := core.GoalStatus("") | |||
| 239 | + | if goal != nil { | |||
| 240 | + | status = goal.Status | |||
| 241 | + | } | |||
| 242 | + | i.mu.Lock() | |||
| 243 | + | i.goalStatus = status | |||
| 244 | + | i.mu.Unlock() | |||
| 245 | + | i.invalidate() | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func (i *Interactive) setGoalCommandStatus(message string) { | |||
| 249 | + | i.mu.Lock() | |||
| 250 | + | i.statusOK = message | |||
| 251 | + | i.statusErr = "" | |||
| 252 | + | i.mu.Unlock() | |||
| 253 | + | i.invalidate() | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func (i *Interactive) setGoalCommandError(message string) { | |||
| 257 | + | i.mu.Lock() | |||
| 258 | + | i.statusOK = "" | |||
| 259 | + | i.statusErr = message | |||
| 260 | + | i.mu.Unlock() | |||
| 261 | + | i.invalidate() | |||
| 262 | + | } | |||
A packages/agent/modes/goal_test.go +231 -0
| @@ -0,0 +1,231 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "strings" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |
| 12 | + | "github.com/bnema/zut/packages/core" | |
| 13 | + | "github.com/bnema/zut/packages/provider" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func goalToolRegistry() core.Registry { | |
| 17 | + | return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type goalContinuationClient struct { | |
| 21 | + | mu sync.Mutex | |
| 22 | + | requests []provider.Request | |
| 23 | + | onSecond func() | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (c *goalContinuationClient) Name() string { return "goal-continuation" } | |
| 27 | + | ||
| 28 | + | func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 29 | + | c.mu.Lock() | |
| 30 | + | c.requests = append(c.requests, req) | |
| 31 | + | call := len(c.requests) | |
| 32 | + | onSecond := c.onSecond | |
| 33 | + | c.mu.Unlock() | |
| 34 | + | if call == 2 && onSecond != nil { | |
| 35 | + | onSecond() | |
| 36 | + | } | |
| 37 | + | out := make(chan provider.Event, 1) | |
| 38 | + | out <- provider.EventDone{ | |
| 39 | + | Stop: provider.StopEnd, | |
| 40 | + | Message: provider.Message{ | |
| 41 | + | Role: provider.RoleAssistant, | |
| 42 | + | Content: []provider.Content{provider.TextBlock{Text: "working"}}, | |
| 43 | + | }, | |
| 44 | + | } | |
| 45 | + | close(out) | |
| 46 | + | return out, nil | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) { | |
| 50 | + | var mu sync.Mutex | |
| 51 | + | var goal *core.SessionGoal | |
| 52 | + | currentGoal := func() *core.SessionGoal { | |
| 53 | + | mu.Lock() | |
| 54 | + | defer mu.Unlock() | |
| 55 | + | return cloneSessionGoal(goal) | |
| 56 | + | } | |
| 57 | + | persistGoal := func(next *core.SessionGoal) error { | |
| 58 | + | mu.Lock() | |
| 59 | + | goal = cloneSessionGoal(next) | |
| 60 | + | mu.Unlock() | |
| 61 | + | return nil | |
| 62 | + | } | |
| 63 | + | client := &goalContinuationClient{onSecond: func() { | |
| 64 | + | _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused}) | |
| 65 | + | }} | |
| 66 | + | turnFlushed := make(chan struct{}, 2) | |
| 67 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 68 | + | Agent: core.NewAgent(client, "model", "system", goalToolRegistry()), | |
| 69 | + | CurrentGoal: currentGoal, | |
| 70 | + | PersistGoal: persistGoal, | |
| 71 | + | FlushSession: func() { | |
| 72 | + | turnFlushed <- struct{}{} | |
| 73 | + | }, | |
| 74 | + | }) | |
| 75 | + | interactive.runCtx = context.Background() | |
| 76 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |
| 77 | + | for turn := 1; turn <= 2; turn++ { | |
| 78 | + | select { | |
| 79 | + | case <-turnFlushed: | |
| 80 | + | case <-time.After(2 * time.Second): | |
| 81 | + | t.Fatalf("turn %d did not finish", turn) | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | client.mu.Lock() | |
| 86 | + | defer client.mu.Unlock() | |
| 87 | + | if len(client.requests) != 2 { | |
| 88 | + | t.Fatalf("provider requests = %d, want 2", len(client.requests)) | |
| 89 | + | } | |
| 90 | + | messages := client.requests[1].Messages | |
| 91 | + | if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" { | |
| 92 | + | t.Fatalf("continuation request tail = %#v", messages) | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) { | |
| 97 | + | var goal *core.SessionGoal | |
| 98 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 99 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |
| 100 | + | CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }, | |
| 101 | + | PersistGoal: func(next *core.SessionGoal) error { | |
| 102 | + | goal = cloneSessionGoal(next) | |
| 103 | + | return nil | |
| 104 | + | }, | |
| 105 | + | }) | |
| 106 | + | interactive.busy = true // exercise controls without launching a provider turn | |
| 107 | + | ||
| 108 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |
| 109 | + | if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" { | |
| 110 | + | t.Fatalf("started goal = %#v", goal) | |
| 111 | + | } | |
| 112 | + | if interactive.goalStatus != core.GoalActive { | |
| 113 | + | t.Fatalf("started badge = %q", interactive.goalStatus) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | interactive.runSlash(context.Background(), "/goal pause") | |
| 117 | + | if goal.Status != core.GoalPaused { | |
| 118 | + | t.Fatalf("paused goal = %#v", goal) | |
| 119 | + | } | |
| 120 | + | if interactive.goalStatus != core.GoalPaused { | |
| 121 | + | t.Fatalf("paused badge = %q", interactive.goalStatus) | |
| 122 | + | } | |
| 123 | + | interactive.runSlash(context.Background(), "/goal resume") | |
| 124 | + | if goal.Status != core.GoalActive { | |
| 125 | + | t.Fatalf("resumed goal = %#v", goal) | |
| 126 | + | } | |
| 127 | + | if interactive.goalStatus != core.GoalActive { | |
| 128 | + | t.Fatalf("resumed badge = %q", interactive.goalStatus) | |
| 129 | + | } | |
| 130 | + | interactive.runSlash(context.Background(), "/goal clear") | |
| 131 | + | if goal != nil { | |
| 132 | + | t.Fatalf("cleared goal = %#v", goal) | |
| 133 | + | } | |
| 134 | + | if interactive.goalStatus != "" { | |
| 135 | + | t.Fatalf("cleared badge = %q", interactive.goalStatus) | |
| 136 | + | } | |
| 137 | + | interactive.runSlash(context.Background(), "/goal pause deployment safely") | |
| 138 | + | if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" { | |
| 139 | + | t.Fatalf("multiword control-like objective = %#v", goal) | |
| 140 | + | } | |
| 141 | + | goal.Status = core.GoalBlocked | |
| 142 | + | goal.Reason = "waiting for input" | |
| 143 | + | interactive.runSlash(context.Background(), "/goal") | |
| 144 | + | if !strings.Contains(interactive.statusOK, "waiting for input") { | |
| 145 | + | t.Fatalf("goal status = %q", interactive.statusOK) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestGoalControlRequiresSessionPersistence(t *testing.T) { | |
| 150 | + | interactive := NewInteractive(InteractiveConfig{}) | |
| 151 | + | interactive.runSlash(context.Background(), "/goal pause") | |
| 152 | + | if !strings.Contains(interactive.statusErr, "session persistence is unavailable") { | |
| 153 | + | t.Fatalf("status error = %q", interactive.statusErr) | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) { | |
| 158 | + | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | |
| 159 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 160 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |
| 161 | + | CurrentGoal: func() *core.SessionGoal { return goal }, | |
| 162 | + | PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") }, | |
| 163 | + | }) | |
| 164 | + | interactive.busy = true | |
| 165 | + | ||
| 166 | + | interactive.runSlash(context.Background(), "/goal pause") | |
| 167 | + | if goal.Status != core.GoalActive { | |
| 168 | + | t.Fatalf("live goal status = %q, want active", goal.Status) | |
| 169 | + | } | |
| 170 | + | if interactive.goalStatus != core.GoalActive { | |
| 171 | + | t.Fatalf("goal badge = %q, want active", interactive.goalStatus) | |
| 172 | + | } | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) { | |
| 176 | + | var persisted bool | |
| 177 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 178 | + | Agent: core.NewAgent(nil, "model", "system", nil), | |
| 179 | + | CurrentGoal: func() *core.SessionGoal { return nil }, | |
| 180 | + | PersistGoal: func(*core.SessionGoal) error { | |
| 181 | + | persisted = true | |
| 182 | + | return nil | |
| 183 | + | }, | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |
| 187 | + | if persisted { | |
| 188 | + | t.Fatal("goal was persisted without the update_goal tool") | |
| 189 | + | } | |
| 190 | + | if !strings.Contains(interactive.statusErr, "update_goal is unavailable") { | |
| 191 | + | t.Fatalf("status error = %q", interactive.statusErr) | |
| 192 | + | } | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) { | |
| 196 | + | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | |
| 197 | + | agent := core.NewAgent(nil, "model", "system", goalToolRegistry()) | |
| 198 | + | interactive := &Interactive{ | |
| 199 | + | agent: agent, | |
| 200 | + | cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }}, | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | message, ok := interactive.goalContinuationIfIdle() | |
| 204 | + | if !ok { | |
| 205 | + | t.Fatal("active idle goal did not request continuation") | |
| 206 | + | } | |
| 207 | + | if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser { | |
| 208 | + | t.Fatalf("continuation message = %#v", message) | |
| 209 | + | } | |
| 210 | + | if !isHiddenTranscriptMessage(message) { | |
| 211 | + | t.Fatal("goal continuation was rendered as a user message") | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | agent.QueueMessage("user follow-up") | |
| 215 | + | if _, ok := interactive.goalContinuationIfIdle(); ok { | |
| 216 | + | t.Fatal("goal continuation bypassed queued user work") | |
| 217 | + | } | |
| 218 | + | agent.DrainQueuedMessages() | |
| 219 | + | goal.Status = core.GoalPaused | |
| 220 | + | if _, ok := interactive.goalContinuationIfIdle(); ok { | |
| 221 | + | t.Fatal("paused goal requested continuation") | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal { | |
| 226 | + | if goal == nil { | |
| 227 | + | return nil | |
| 228 | + | } | |
| 229 | + | clone := *goal | |
| 230 | + | return &clone | |
| 231 | + | } | |
| @@ -0,0 +1,231 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | agenttools "github.com/bnema/zut/packages/agent/tools" | |||
| 12 | + | "github.com/bnema/zut/packages/core" | |||
| 13 | + | "github.com/bnema/zut/packages/provider" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func goalToolRegistry() core.Registry { | |||
| 17 | + | return core.Registry{agenttools.UpdateGoalToolName: &agenttools.UpdateGoalTool{}} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | type goalContinuationClient struct { | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | requests []provider.Request | |||
| 23 | + | onSecond func() | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (c *goalContinuationClient) Name() string { return "goal-continuation" } | |||
| 27 | + | ||||
| 28 | + | func (c *goalContinuationClient) Stream(_ context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 29 | + | c.mu.Lock() | |||
| 30 | + | c.requests = append(c.requests, req) | |||
| 31 | + | call := len(c.requests) | |||
| 32 | + | onSecond := c.onSecond | |||
| 33 | + | c.mu.Unlock() | |||
| 34 | + | if call == 2 && onSecond != nil { | |||
| 35 | + | onSecond() | |||
| 36 | + | } | |||
| 37 | + | out := make(chan provider.Event, 1) | |||
| 38 | + | out <- provider.EventDone{ | |||
| 39 | + | Stop: provider.StopEnd, | |||
| 40 | + | Message: provider.Message{ | |||
| 41 | + | Role: provider.RoleAssistant, | |||
| 42 | + | Content: []provider.Content{provider.TextBlock{Text: "working"}}, | |||
| 43 | + | }, | |||
| 44 | + | } | |||
| 45 | + | close(out) | |||
| 46 | + | return out, nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestActiveGoalStartsAnotherTurnWhenThreadBecomesIdle(t *testing.T) { | |||
| 50 | + | var mu sync.Mutex | |||
| 51 | + | var goal *core.SessionGoal | |||
| 52 | + | currentGoal := func() *core.SessionGoal { | |||
| 53 | + | mu.Lock() | |||
| 54 | + | defer mu.Unlock() | |||
| 55 | + | return cloneSessionGoal(goal) | |||
| 56 | + | } | |||
| 57 | + | persistGoal := func(next *core.SessionGoal) error { | |||
| 58 | + | mu.Lock() | |||
| 59 | + | goal = cloneSessionGoal(next) | |||
| 60 | + | mu.Unlock() | |||
| 61 | + | return nil | |||
| 62 | + | } | |||
| 63 | + | client := &goalContinuationClient{onSecond: func() { | |||
| 64 | + | _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused}) | |||
| 65 | + | }} | |||
| 66 | + | turnFlushed := make(chan struct{}, 2) | |||
| 67 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 68 | + | Agent: core.NewAgent(client, "model", "system", goalToolRegistry()), | |||
| 69 | + | CurrentGoal: currentGoal, | |||
| 70 | + | PersistGoal: persistGoal, | |||
| 71 | + | FlushSession: func() { | |||
| 72 | + | turnFlushed <- struct{}{} | |||
| 73 | + | }, | |||
| 74 | + | }) | |||
| 75 | + | interactive.runCtx = context.Background() | |||
| 76 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |||
| 77 | + | for turn := 1; turn <= 2; turn++ { | |||
| 78 | + | select { | |||
| 79 | + | case <-turnFlushed: | |||
| 80 | + | case <-time.After(2 * time.Second): | |||
| 81 | + | t.Fatalf("turn %d did not finish", turn) | |||
| 82 | + | } | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | client.mu.Lock() | |||
| 86 | + | defer client.mu.Unlock() | |||
| 87 | + | if len(client.requests) != 2 { | |||
| 88 | + | t.Fatalf("provider requests = %d, want 2", len(client.requests)) | |||
| 89 | + | } | |||
| 90 | + | messages := client.requests[1].Messages | |||
| 91 | + | if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" { | |||
| 92 | + | t.Fatalf("continuation request tail = %#v", messages) | |||
| 93 | + | } | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) { | |||
| 97 | + | var goal *core.SessionGoal | |||
| 98 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 99 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |||
| 100 | + | CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }, | |||
| 101 | + | PersistGoal: func(next *core.SessionGoal) error { | |||
| 102 | + | goal = cloneSessionGoal(next) | |||
| 103 | + | return nil | |||
| 104 | + | }, | |||
| 105 | + | }) | |||
| 106 | + | interactive.busy = true // exercise controls without launching a provider turn | |||
| 107 | + | ||||
| 108 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |||
| 109 | + | if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" { | |||
| 110 | + | t.Fatalf("started goal = %#v", goal) | |||
| 111 | + | } | |||
| 112 | + | if interactive.goalStatus != core.GoalActive { | |||
| 113 | + | t.Fatalf("started badge = %q", interactive.goalStatus) | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | interactive.runSlash(context.Background(), "/goal pause") | |||
| 117 | + | if goal.Status != core.GoalPaused { | |||
| 118 | + | t.Fatalf("paused goal = %#v", goal) | |||
| 119 | + | } | |||
| 120 | + | if interactive.goalStatus != core.GoalPaused { | |||
| 121 | + | t.Fatalf("paused badge = %q", interactive.goalStatus) | |||
| 122 | + | } | |||
| 123 | + | interactive.runSlash(context.Background(), "/goal resume") | |||
| 124 | + | if goal.Status != core.GoalActive { | |||
| 125 | + | t.Fatalf("resumed goal = %#v", goal) | |||
| 126 | + | } | |||
| 127 | + | if interactive.goalStatus != core.GoalActive { | |||
| 128 | + | t.Fatalf("resumed badge = %q", interactive.goalStatus) | |||
| 129 | + | } | |||
| 130 | + | interactive.runSlash(context.Background(), "/goal clear") | |||
| 131 | + | if goal != nil { | |||
| 132 | + | t.Fatalf("cleared goal = %#v", goal) | |||
| 133 | + | } | |||
| 134 | + | if interactive.goalStatus != "" { | |||
| 135 | + | t.Fatalf("cleared badge = %q", interactive.goalStatus) | |||
| 136 | + | } | |||
| 137 | + | interactive.runSlash(context.Background(), "/goal pause deployment safely") | |||
| 138 | + | if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" { | |||
| 139 | + | t.Fatalf("multiword control-like objective = %#v", goal) | |||
| 140 | + | } | |||
| 141 | + | goal.Status = core.GoalBlocked | |||
| 142 | + | goal.Reason = "waiting for input" | |||
| 143 | + | interactive.runSlash(context.Background(), "/goal") | |||
| 144 | + | if !strings.Contains(interactive.statusOK, "waiting for input") { | |||
| 145 | + | t.Fatalf("goal status = %q", interactive.statusOK) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestGoalControlRequiresSessionPersistence(t *testing.T) { | |||
| 150 | + | interactive := NewInteractive(InteractiveConfig{}) | |||
| 151 | + | interactive.runSlash(context.Background(), "/goal pause") | |||
| 152 | + | if !strings.Contains(interactive.statusErr, "session persistence is unavailable") { | |||
| 153 | + | t.Fatalf("status error = %q", interactive.statusErr) | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) { | |||
| 158 | + | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | |||
| 159 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 160 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |||
| 161 | + | CurrentGoal: func() *core.SessionGoal { return goal }, | |||
| 162 | + | PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") }, | |||
| 163 | + | }) | |||
| 164 | + | interactive.busy = true | |||
| 165 | + | ||||
| 166 | + | interactive.runSlash(context.Background(), "/goal pause") | |||
| 167 | + | if goal.Status != core.GoalActive { | |||
| 168 | + | t.Fatalf("live goal status = %q, want active", goal.Status) | |||
| 169 | + | } | |||
| 170 | + | if interactive.goalStatus != core.GoalActive { | |||
| 171 | + | t.Fatalf("goal badge = %q, want active", interactive.goalStatus) | |||
| 172 | + | } | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) { | |||
| 176 | + | var persisted bool | |||
| 177 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 178 | + | Agent: core.NewAgent(nil, "model", "system", nil), | |||
| 179 | + | CurrentGoal: func() *core.SessionGoal { return nil }, | |||
| 180 | + | PersistGoal: func(*core.SessionGoal) error { | |||
| 181 | + | persisted = true | |||
| 182 | + | return nil | |||
| 183 | + | }, | |||
| 184 | + | }) | |||
| 185 | + | ||||
| 186 | + | interactive.runSlash(context.Background(), "/goal finish issue 97") | |||
| 187 | + | if persisted { | |||
| 188 | + | t.Fatal("goal was persisted without the update_goal tool") | |||
| 189 | + | } | |||
| 190 | + | if !strings.Contains(interactive.statusErr, "update_goal is unavailable") { | |||
| 191 | + | t.Fatalf("status error = %q", interactive.statusErr) | |||
| 192 | + | } | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) { | |||
| 196 | + | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | |||
| 197 | + | agent := core.NewAgent(nil, "model", "system", goalToolRegistry()) | |||
| 198 | + | interactive := &Interactive{ | |||
| 199 | + | agent: agent, | |||
| 200 | + | cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }}, | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | message, ok := interactive.goalContinuationIfIdle() | |||
| 204 | + | if !ok { | |||
| 205 | + | t.Fatal("active idle goal did not request continuation") | |||
| 206 | + | } | |||
| 207 | + | if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser { | |||
| 208 | + | t.Fatalf("continuation message = %#v", message) | |||
| 209 | + | } | |||
| 210 | + | if !isHiddenTranscriptMessage(message) { | |||
| 211 | + | t.Fatal("goal continuation was rendered as a user message") | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | agent.QueueMessage("user follow-up") | |||
| 215 | + | if _, ok := interactive.goalContinuationIfIdle(); ok { | |||
| 216 | + | t.Fatal("goal continuation bypassed queued user work") | |||
| 217 | + | } | |||
| 218 | + | agent.DrainQueuedMessages() | |||
| 219 | + | goal.Status = core.GoalPaused | |||
| 220 | + | if _, ok := interactive.goalContinuationIfIdle(); ok { | |||
| 221 | + | t.Fatal("paused goal requested continuation") | |||
| 222 | + | } | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal { | |||
| 226 | + | if goal == nil { | |||
| 227 | + | return nil | |||
| 228 | + | } | |||
| 229 | + | clone := *goal | |||
| 230 | + | return &clone | |||
| 231 | + | } | |||
M packages/agent/modes/interactive.go +43 -5
| @@ -372,6 +372,13 @@ type InteractiveConfig struct { | |||
| 372 | 372 | // after a manual rename has already been written to disk. | |
| 373 | 373 | OnSessionTitleChanged func(title string) | |
| 374 | 374 | ||
| 375 | + | // CurrentGoal and PersistGoal expose the autonomous goal attached to the | |
| 376 | + | // current session. CurrentGoal must return a copy, not the live session | |
| 377 | + | // pointer, so failed persistence cannot mutate in-memory state. The callbacks | |
| 378 | + | // follow session switches without coupling the TUI to the session file owner. | |
| 379 | + | CurrentGoal func() *core.SessionGoal | |
| 380 | + | PersistGoal func(goal *core.SessionGoal) error | |
| 381 | + | ||
| 375 | 382 | OnAssistant func(m provider.Message) | |
| 376 | 383 | OnToolResult func(id string, r core.ToolResult) | |
| 377 | 384 | ||
| @@ -661,6 +668,7 @@ type Interactive struct { | |||
| 661 | 668 | toolGate map[string]int | |
| 662 | 669 | statusErr string | |
| 663 | 670 | statusOK string | |
| 671 | + | goalStatus core.GoalStatus | |
| 664 | 672 | reloadStatusSeq uint64 | |
| 665 | 673 | extStatuses map[string]map[string]extensionStatus | |
| 666 | 674 | extWidgets map[string]map[string]extensionWidget | |
| @@ -916,6 +924,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 916 | 924 | startupContextPaths := []string(nil) | |
| 917 | 925 | startupExtensionNames := []string(nil) | |
| 918 | 926 | startupSkillNames := []string(nil) | |
| 927 | + | initialGoalStatus := core.GoalStatus("") | |
| 928 | + | if cfg.CurrentGoal != nil { | |
| 929 | + | if goal := cfg.CurrentGoal(); goal != nil { | |
| 930 | + | initialGoalStatus = goal.Status | |
| 931 | + | } | |
| 932 | + | } | |
| 919 | 933 | if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup { | |
| 920 | 934 | startupAgentName = cfg.StartupAgentName | |
| 921 | 935 | startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...) | |
| @@ -969,6 +983,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 969 | 983 | spin: newSpinner(cfg.Theme), | |
| 970 | 984 | inputHistoryIndex: -1, | |
| 971 | 985 | clock: time.Now, | |
| 986 | + | goalStatus: initialGoalStatus, | |
| 972 | 987 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | |
| 973 | 988 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | |
| 974 | 989 | } | |
| @@ -2066,6 +2081,7 @@ func (i *Interactive) redraw() { | |||
| 2066 | 2081 | if workingWithStatus { | |
| 2067 | 2082 | statusBusyPrefix = busyPrefix | |
| 2068 | 2083 | } | |
| 2084 | + | goalStatus := string(i.goalStatus) | |
| 2069 | 2085 | statusLines := tui.StatusBar(tui.StatusBarParams{ | |
| 2070 | 2086 | Theme: i.cfg.Theme, | |
| 2071 | 2087 | Provider: i.cfg.Provider, | |
| @@ -2077,6 +2093,7 @@ func (i *Interactive) redraw() { | |||
| 2077 | 2093 | CWD: i.cfg.CWD, | |
| 2078 | 2094 | Locked: i.cfg.Sandbox.Locked(), | |
| 2079 | 2095 | NoYolo: i.cfg.NoYolo, | |
| 2096 | + | GoalStatus: goalStatus, | |
| 2080 | 2097 | Usage: i.cumUsage, | |
| 2081 | 2098 | Subscription: i.cfg.AuthMethod == "oauth", | |
| 2082 | 2099 | ContextUsed: i.lastCtxInput, | |
| @@ -2544,7 +2561,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||
| 2544 | 2561 | } | |
| 2545 | 2562 | ||
| 2546 | 2563 | func isHiddenTranscriptMessage(m provider.Message) bool { | |
| 2547 | - | if m.Meta[autoCompactContinueMetaKey] == "true" { | |
| 2564 | + | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" { | |
| 2548 | 2565 | return true | |
| 2549 | 2566 | } | |
| 2550 | 2567 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | |
| @@ -3290,6 +3307,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 3290 | 3307 | } | |
| 3291 | 3308 | i.mu.Unlock() | |
| 3292 | 3309 | if busyCancel { | |
| 3310 | + | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |
| 3293 | 3311 | cancelTurn() | |
| 3294 | 3312 | // If a confirm dialog is pending, refuse it so the agent | |
| 3295 | 3313 | // goroutine unblocks and the context cancellation can | |
| @@ -5908,6 +5926,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 5908 | 5926 | } | |
| 5909 | 5927 | ||
| 5910 | 5928 | switch strings.ToLower(parts[0]) { | |
| 5929 | + | case "/goal": | |
| 5930 | + | i.runGoalCommand(ctx, cmd, parts) | |
| 5911 | 5931 | case "/exit": | |
| 5912 | 5932 | return true | |
| 5913 | 5933 | case "/clear": | |
| @@ -7293,6 +7313,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7293 | 7313 | } | |
| 7294 | 7314 | summary, err := i.agent.Compact(ctx, keepTail, sink) | |
| 7295 | 7315 | _ = summary | |
| 7316 | + | goalMessage, goalActive := i.goalContinuationMessage() | |
| 7296 | 7317 | i.mu.Lock() | |
| 7297 | 7318 | // Keep busy/compacting asserted while cleanup and queue selection run. | |
| 7298 | 7319 | // Completion updates can arrive in this window; clearing busy before | |
| @@ -7311,6 +7332,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7311 | 7332 | var hasNext bool | |
| 7312 | 7333 | var continueExisting bool | |
| 7313 | 7334 | var continueAutomatically bool | |
| 7335 | + | var continueGoal bool | |
| 7314 | 7336 | var handoff json.RawMessage | |
| 7315 | 7337 | var persistHandoff bool | |
| 7316 | 7338 | ||
| @@ -7398,6 +7420,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7398 | 7420 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | |
| 7399 | 7421 | } | |
| 7400 | 7422 | continueAutomatically = true | |
| 7423 | + | case auto && goalActive: | |
| 7424 | + | continueGoal = true | |
| 7425 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7401 | 7426 | default: | |
| 7402 | 7427 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7403 | 7428 | } | |
| @@ -7406,7 +7431,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7406 | 7431 | // the mutex. A completion update arriving after the compaction result | |
| 7407 | 7432 | // but before this assignment is then queued for the selected follow-up | |
| 7408 | 7433 | // instead of starting a competing turn. | |
| 7409 | - | i.busy = hasNext || continueExisting || continueAutomatically | |
| 7434 | + | i.busy = hasNext || continueExisting || continueAutomatically || continueGoal | |
| 7410 | 7435 | i.compacting = false | |
| 7411 | 7436 | i.autoCompacting = false | |
| 7412 | 7437 | i.mu.Unlock() | |
| @@ -7416,7 +7441,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7416 | 7441 | runPendingIdleWork(pendingIdleWork) | |
| 7417 | 7442 | i.invalidate() | |
| 7418 | 7443 | ||
| 7419 | - | if hasNext || continueExisting || continueAutomatically { | |
| 7444 | + | if hasNext || continueExisting || continueAutomatically || continueGoal { | |
| 7420 | 7445 | p := i.runCtx | |
| 7421 | 7446 | if p == nil { | |
| 7422 | 7447 | p = context.Background() | |
| @@ -7428,6 +7453,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||
| 7428 | 7453 | i.startTurnWithImages(p, next, nextImages) | |
| 7429 | 7454 | case continueAutomatically: | |
| 7430 | 7455 | i.startAutoCompactContinuation(p) | |
| 7456 | + | case continueGoal: | |
| 7457 | + | i.startGoalContinuation(p, goalMessage) | |
| 7431 | 7458 | } | |
| 7432 | 7459 | } | |
| 7433 | 7460 | }() | |
| @@ -7762,6 +7789,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7762 | 7789 | if flush != nil { | |
| 7763 | 7790 | flush() | |
| 7764 | 7791 | } | |
| 7792 | + | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | |
| 7793 | + | if terminalGoalError { | |
| 7794 | + | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |
| 7795 | + | } | |
| 7796 | + | goalMessage, goalActive := i.goalContinuationMessage() | |
| 7765 | 7797 | i.mu.Lock() | |
| 7766 | 7798 | awaitingPre := i.awaitingStartupPre | |
| 7767 | 7799 | // A newer explicit prompt may have cleared the handoff while the | |
| @@ -7812,6 +7844,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7812 | 7844 | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | |
| 7813 | 7845 | handoff, persistHandoff = i.resetCompactContinuationLocked() | |
| 7814 | 7846 | } | |
| 7847 | + | continueGoal := goalActive && !awaitingPre && !hasNext && !continueQueued && !continueStatusRescue && !offer && !recoverContextOverflow && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil | |
| 7815 | 7848 | // The agent run can finish before the paced final text reaches the | |
| 7816 | 7849 | // transcript. A compaction replaces that transcript, so it must never | |
| 7817 | 7850 | // race the still-live stream frame; otherwise stale deltas can repaint | |
| @@ -7819,8 +7852,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7819 | 7852 | if recoverContextOverflow || shouldAutoCompact { | |
| 7820 | 7853 | i.resetStreamingStateLocked() | |
| 7821 | 7854 | } | |
| 7822 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | |
| 7823 | - | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | |
| 7855 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) | |
| 7856 | + | i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact | |
| 7824 | 7857 | i.mu.Unlock() | |
| 7825 | 7858 | if persistHandoff { | |
| 7826 | 7859 | i.persistCompactHandoff(handoff) | |
| @@ -7844,6 +7877,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 7844 | 7877 | i.startTurnRequest(parent, "", nil, true, false) | |
| 7845 | 7878 | case continueStatusRescue: | |
| 7846 | 7879 | i.startAutoCompactContinuation(parent) | |
| 7880 | + | case continueGoal: | |
| 7881 | + | i.startGoalContinuation(parent, goalMessage) | |
| 7847 | 7882 | case offer: | |
| 7848 | 7883 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | |
| 7849 | 7884 | case recoverContextOverflow: | |
| @@ -8269,6 +8304,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8269 | 8304 | if i.cfg.OnToolResult != nil { | |
| 8270 | 8305 | i.cfg.OnToolResult(e.ID, e.Result) | |
| 8271 | 8306 | } | |
| 8307 | + | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | |
| 8308 | + | i.goalStatus = update.Status | |
| 8309 | + | } | |
| 8272 | 8310 | case core.EvUsage: | |
| 8273 | 8311 | i.cumUsage = e.Cumulative | |
| 8274 | 8312 | if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { | |
| @@ -372,6 +372,13 @@ type InteractiveConfig struct { | |||||
| 372 | // after a manual rename has already been written to disk. | 372 | // after a manual rename has already been written to disk. | ||
| 373 | OnSessionTitleChanged func(title string) | 373 | OnSessionTitleChanged func(title string) | ||
| 374 | 374 | ||||
| 375 | + | // CurrentGoal and PersistGoal expose the autonomous goal attached to the | |||
| 376 | + | // current session. CurrentGoal must return a copy, not the live session | |||
| 377 | + | // pointer, so failed persistence cannot mutate in-memory state. The callbacks | |||
| 378 | + | // follow session switches without coupling the TUI to the session file owner. | |||
| 379 | + | CurrentGoal func() *core.SessionGoal | |||
| 380 | + | PersistGoal func(goal *core.SessionGoal) error | |||
| 381 | + | ||||
| 375 | OnAssistant func(m provider.Message) | 382 | OnAssistant func(m provider.Message) | ||
| 376 | OnToolResult func(id string, r core.ToolResult) | 383 | OnToolResult func(id string, r core.ToolResult) | ||
| 377 | 384 | ||||
| @@ -661,6 +668,7 @@ type Interactive struct { | |||||
| 661 | toolGate map[string]int | 668 | toolGate map[string]int | ||
| 662 | statusErr string | 669 | statusErr string | ||
| 663 | statusOK string | 670 | statusOK string | ||
| 671 | + | goalStatus core.GoalStatus | |||
| 664 | reloadStatusSeq uint64 | 672 | reloadStatusSeq uint64 | ||
| 665 | extStatuses map[string]map[string]extensionStatus | 673 | extStatuses map[string]map[string]extensionStatus | ||
| 666 | extWidgets map[string]map[string]extensionWidget | 674 | extWidgets map[string]map[string]extensionWidget | ||
| @@ -916,6 +924,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 916 | startupContextPaths := []string(nil) | 924 | startupContextPaths := []string(nil) | ||
| 917 | startupExtensionNames := []string(nil) | 925 | startupExtensionNames := []string(nil) | ||
| 918 | startupSkillNames := []string(nil) | 926 | startupSkillNames := []string(nil) | ||
| 927 | + | initialGoalStatus := core.GoalStatus("") | |||
| 928 | + | if cfg.CurrentGoal != nil { | |||
| 929 | + | if goal := cfg.CurrentGoal(); goal != nil { | |||
| 930 | + | initialGoalStatus = goal.Status | |||
| 931 | + | } | |||
| 932 | + | } | |||
| 919 | if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup { | 933 | if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup { | ||
| 920 | startupAgentName = cfg.StartupAgentName | 934 | startupAgentName = cfg.StartupAgentName | ||
| 921 | startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...) | 935 | startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...) | ||
| @@ -969,6 +983,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 969 | spin: newSpinner(cfg.Theme), | 983 | spin: newSpinner(cfg.Theme), | ||
| 970 | inputHistoryIndex: -1, | 984 | inputHistoryIndex: -1, | ||
| 971 | clock: time.Now, | 985 | clock: time.Now, | ||
| 986 | + | goalStatus: initialGoalStatus, | |||
| 972 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | 987 | reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...), | ||
| 973 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | 988 | compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff), | ||
| 974 | } | 989 | } | ||
| @@ -2066,6 +2081,7 @@ func (i *Interactive) redraw() { | |||||
| 2066 | if workingWithStatus { | 2081 | if workingWithStatus { | ||
| 2067 | statusBusyPrefix = busyPrefix | 2082 | statusBusyPrefix = busyPrefix | ||
| 2068 | } | 2083 | } | ||
| 2084 | + | goalStatus := string(i.goalStatus) | |||
| 2069 | statusLines := tui.StatusBar(tui.StatusBarParams{ | 2085 | statusLines := tui.StatusBar(tui.StatusBarParams{ | ||
| 2070 | Theme: i.cfg.Theme, | 2086 | Theme: i.cfg.Theme, | ||
| 2071 | Provider: i.cfg.Provider, | 2087 | Provider: i.cfg.Provider, | ||
| @@ -2077,6 +2093,7 @@ func (i *Interactive) redraw() { | |||||
| 2077 | CWD: i.cfg.CWD, | 2093 | CWD: i.cfg.CWD, | ||
| 2078 | Locked: i.cfg.Sandbox.Locked(), | 2094 | Locked: i.cfg.Sandbox.Locked(), | ||
| 2079 | NoYolo: i.cfg.NoYolo, | 2095 | NoYolo: i.cfg.NoYolo, | ||
| 2096 | + | GoalStatus: goalStatus, | |||
| 2080 | Usage: i.cumUsage, | 2097 | Usage: i.cumUsage, | ||
| 2081 | Subscription: i.cfg.AuthMethod == "oauth", | 2098 | Subscription: i.cfg.AuthMethod == "oauth", | ||
| 2082 | ContextUsed: i.lastCtxInput, | 2099 | ContextUsed: i.lastCtxInput, | ||
| @@ -2544,7 +2561,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message | |||||
| 2544 | } | 2561 | } | ||
| 2545 | 2562 | ||||
| 2546 | func isHiddenTranscriptMessage(m provider.Message) bool { | 2563 | func isHiddenTranscriptMessage(m provider.Message) bool { | ||
| 2547 | - | if m.Meta[autoCompactContinueMetaKey] == "true" { | 2564 | + | if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" { |
| 2548 | return true | 2565 | return true | ||
| 2549 | } | 2566 | } | ||
| 2550 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | 2567 | if m.Role != provider.RoleUser || len(m.Content) == 0 { | ||
| @@ -3290,6 +3307,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 3290 | } | 3307 | } | ||
| 3291 | i.mu.Unlock() | 3308 | i.mu.Unlock() | ||
| 3292 | if busyCancel { | 3309 | if busyCancel { | ||
| 3310 | + | i.updateActiveGoal(core.GoalPaused, "interrupted by user") | |||
| 3293 | cancelTurn() | 3311 | cancelTurn() | ||
| 3294 | // If a confirm dialog is pending, refuse it so the agent | 3312 | // If a confirm dialog is pending, refuse it so the agent | ||
| 3295 | // goroutine unblocks and the context cancellation can | 3313 | // goroutine unblocks and the context cancellation can | ||
| @@ -5908,6 +5926,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 5908 | } | 5926 | } | ||
| 5909 | 5927 | ||||
| 5910 | switch strings.ToLower(parts[0]) { | 5928 | switch strings.ToLower(parts[0]) { | ||
| 5929 | + | case "/goal": | |||
| 5930 | + | i.runGoalCommand(ctx, cmd, parts) | |||
| 5911 | case "/exit": | 5931 | case "/exit": | ||
| 5912 | return true | 5932 | return true | ||
| 5913 | case "/clear": | 5933 | case "/clear": | ||
| @@ -7293,6 +7313,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7293 | } | 7313 | } | ||
| 7294 | summary, err := i.agent.Compact(ctx, keepTail, sink) | 7314 | summary, err := i.agent.Compact(ctx, keepTail, sink) | ||
| 7295 | _ = summary | 7315 | _ = summary | ||
| 7316 | + | goalMessage, goalActive := i.goalContinuationMessage() | |||
| 7296 | i.mu.Lock() | 7317 | i.mu.Lock() | ||
| 7297 | // Keep busy/compacting asserted while cleanup and queue selection run. | 7318 | // Keep busy/compacting asserted while cleanup and queue selection run. | ||
| 7298 | // Completion updates can arrive in this window; clearing busy before | 7319 | // Completion updates can arrive in this window; clearing busy before | ||
| @@ -7311,6 +7332,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7311 | var hasNext bool | 7332 | var hasNext bool | ||
| 7312 | var continueExisting bool | 7333 | var continueExisting bool | ||
| 7313 | var continueAutomatically bool | 7334 | var continueAutomatically bool | ||
| 7335 | + | var continueGoal bool | |||
| 7314 | var handoff json.RawMessage | 7336 | var handoff json.RawMessage | ||
| 7315 | var persistHandoff bool | 7337 | var persistHandoff bool | ||
| 7316 | 7338 | ||||
| @@ -7398,6 +7420,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7398 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | 7420 | handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason}) | ||
| 7399 | } | 7421 | } | ||
| 7400 | continueAutomatically = true | 7422 | continueAutomatically = true | ||
| 7423 | + | case auto && goalActive: | |||
| 7424 | + | continueGoal = true | |||
| 7425 | + | handoff, persistHandoff = i.resetCompactContinuationLocked() | |||
| 7401 | default: | 7426 | default: | ||
| 7402 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 7427 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 7403 | } | 7428 | } | ||
| @@ -7406,7 +7431,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7406 | // the mutex. A completion update arriving after the compaction result | 7431 | // the mutex. A completion update arriving after the compaction result | ||
| 7407 | // but before this assignment is then queued for the selected follow-up | 7432 | // but before this assignment is then queued for the selected follow-up | ||
| 7408 | // instead of starting a competing turn. | 7433 | // instead of starting a competing turn. | ||
| 7409 | - | i.busy = hasNext || continueExisting || continueAutomatically | 7434 | + | i.busy = hasNext || continueExisting || continueAutomatically || continueGoal |
| 7410 | i.compacting = false | 7435 | i.compacting = false | ||
| 7411 | i.autoCompacting = false | 7436 | i.autoCompacting = false | ||
| 7412 | i.mu.Unlock() | 7437 | i.mu.Unlock() | ||
| @@ -7416,7 +7441,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7416 | runPendingIdleWork(pendingIdleWork) | 7441 | runPendingIdleWork(pendingIdleWork) | ||
| 7417 | i.invalidate() | 7442 | i.invalidate() | ||
| 7418 | 7443 | ||||
| 7419 | - | if hasNext || continueExisting || continueAutomatically { | 7444 | + | if hasNext || continueExisting || continueAutomatically || continueGoal { |
| 7420 | p := i.runCtx | 7445 | p := i.runCtx | ||
| 7421 | if p == nil { | 7446 | if p == nil { | ||
| 7422 | p = context.Background() | 7447 | p = context.Background() | ||
| @@ -7428,6 +7453,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua | |||||
| 7428 | i.startTurnWithImages(p, next, nextImages) | 7453 | i.startTurnWithImages(p, next, nextImages) | ||
| 7429 | case continueAutomatically: | 7454 | case continueAutomatically: | ||
| 7430 | i.startAutoCompactContinuation(p) | 7455 | i.startAutoCompactContinuation(p) | ||
| 7456 | + | case continueGoal: | |||
| 7457 | + | i.startGoalContinuation(p, goalMessage) | |||
| 7431 | } | 7458 | } | ||
| 7432 | } | 7459 | } | ||
| 7433 | }() | 7460 | }() | ||
| @@ -7762,6 +7789,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7762 | if flush != nil { | 7789 | if flush != nil { | ||
| 7763 | flush() | 7790 | flush() | ||
| 7764 | } | 7791 | } | ||
| 7792 | + | terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError) | |||
| 7793 | + | if terminalGoalError { | |||
| 7794 | + | i.updateActiveGoal(core.GoalBlocked, "turn ended with an error") | |||
| 7795 | + | } | |||
| 7796 | + | goalMessage, goalActive := i.goalContinuationMessage() | |||
| 7765 | i.mu.Lock() | 7797 | i.mu.Lock() | ||
| 7766 | awaitingPre := i.awaitingStartupPre | 7798 | awaitingPre := i.awaitingStartupPre | ||
| 7767 | // A newer explicit prompt may have cleared the handoff while the | 7799 | // A newer explicit prompt may have cleared the handoff while the | ||
| @@ -7812,6 +7844,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7812 | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | 7844 | if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) { | ||
| 7813 | handoff, persistHandoff = i.resetCompactContinuationLocked() | 7845 | handoff, persistHandoff = i.resetCompactContinuationLocked() | ||
| 7814 | } | 7846 | } | ||
| 7847 | + | continueGoal := goalActive && !awaitingPre && !hasNext && !continueQueued && !continueStatusRescue && !offer && !recoverContextOverflow && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil | |||
| 7815 | // The agent run can finish before the paced final text reaches the | 7848 | // The agent run can finish before the paced final text reaches the | ||
| 7816 | // transcript. A compaction replaces that transcript, so it must never | 7849 | // transcript. A compaction replaces that transcript, so it must never | ||
| 7817 | // race the still-live stream frame; otherwise stale deltas can repaint | 7850 | // race the still-live stream frame; otherwise stale deltas can repaint | ||
| @@ -7819,8 +7852,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7819 | if recoverContextOverflow || shouldAutoCompact { | 7852 | if recoverContextOverflow || shouldAutoCompact { | ||
| 7820 | i.resetStreamingStateLocked() | 7853 | i.resetStreamingStateLocked() | ||
| 7821 | } | 7854 | } | ||
| 7822 | - | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact) | 7855 | + | alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact) |
| 7823 | - | i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact | 7856 | + | i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact |
| 7824 | i.mu.Unlock() | 7857 | i.mu.Unlock() | ||
| 7825 | if persistHandoff { | 7858 | if persistHandoff { | ||
| 7826 | i.persistCompactHandoff(handoff) | 7859 | i.persistCompactHandoff(handoff) | ||
| @@ -7844,6 +7877,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 7844 | i.startTurnRequest(parent, "", nil, true, false) | 7877 | i.startTurnRequest(parent, "", nil, true, false) | ||
| 7845 | case continueStatusRescue: | 7878 | case continueStatusRescue: | ||
| 7846 | i.startAutoCompactContinuation(parent) | 7879 | i.startAutoCompactContinuation(parent) | ||
| 7880 | + | case continueGoal: | |||
| 7881 | + | i.startGoalContinuation(parent, goalMessage) | |||
| 7847 | case offer: | 7882 | case offer: | ||
| 7848 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | 7883 | i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs) | ||
| 7849 | case recoverContextOverflow: | 7884 | case recoverContextOverflow: | ||
| @@ -8269,6 +8304,9 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8269 | if i.cfg.OnToolResult != nil { | 8304 | if i.cfg.OnToolResult != nil { | ||
| 8270 | i.cfg.OnToolResult(e.ID, e.Result) | 8305 | i.cfg.OnToolResult(e.ID, e.Result) | ||
| 8271 | } | 8306 | } | ||
| 8307 | + | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | |||
| 8308 | + | i.goalStatus = update.Status | |||
| 8309 | + | } | |||
| 8272 | case core.EvUsage: | 8310 | case core.EvUsage: | ||
| 8273 | i.cumUsage = e.Cumulative | 8311 | i.cumUsage = e.Cumulative | ||
| 8274 | if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { | 8312 | if contextUsed := e.Usage.InputTokens + e.Usage.CacheReadTokens + e.Usage.CacheWriteTokens; contextUsed > 0 { | ||
M packages/agent/modes/slash_suggest.go +1 -0
| @@ -53,6 +53,7 @@ var slashCatalog = []slashCommand{ | |||
| 53 | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 54 | 54 | {Name: "/fast", Desc: "toggle fast mode"}, | |
| 55 | 55 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | |
| 56 | + | {Name: "/goal", Desc: "start or control an autonomous goal"}, | |
| 56 | 57 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | |
| 57 | 58 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | |
| 58 | 59 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | |
| @@ -53,6 +53,7 @@ var slashCatalog = []slashCommand{ | |||||
| 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 53 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 54 | {Name: "/fast", Desc: "toggle fast mode"}, | 54 | {Name: "/fast", Desc: "toggle fast mode"}, | ||
| 55 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | 55 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | ||
| 56 | + | {Name: "/goal", Desc: "start or control an autonomous goal"}, | |||
| 56 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | 57 | {Name: "/llama", Desc: "manage llama.cpp router models"}, | ||
| 57 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | 58 | {Name: "/sessions", Desc: "resume a previous session for this directory"}, | ||
| 58 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | 59 | {Name: "/fork", Desc: "pick a user message and fork the current session"}, | ||
M packages/agent/tools/read.go +2 -1
| @@ -1,4 +1,5 @@ | |||
| 1 | - | // Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search. | |
| 1 | + | // Package tools implements zut's built-in tools: read, write, edit, bash, | |
| 2 | + | // create_worktree, lsp, web search, and autonomous goal updates. | |
| 2 | 3 | package tools | |
| 3 | 4 | ||
| 4 | 5 | import ( | |
| @@ -1,4 +1,5 @@ | |||||
| 1 | - | // Package tools implements zut's built-in tools: read, write, edit, bash, create_worktree, lsp, and web search. | 1 | + | // Package tools implements zut's built-in tools: read, write, edit, bash, |
| 2 | + | // create_worktree, lsp, web search, and autonomous goal updates. | |||
| 2 | package tools | 3 | package tools | ||
| 3 | 4 | ||||
| 4 | import ( | 5 | import ( | ||
A packages/agent/tools/update_goal.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | const ( | |
| 15 | + | UpdateGoalToolName = "update_goal" | |
| 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // UpdateGoalTool lets the model stop an explicitly user-started autonomous | |
| 20 | + | // goal. Starting, pausing, and resuming remain user-controlled. | |
| 21 | + | type UpdateGoalTool struct{} | |
| 22 | + | ||
| 23 | + | type updateGoalArgs struct { | |
| 24 | + | Status string `json:"status"` | |
| 25 | + | Reason string `json:"reason,omitempty"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // GoalUpdate is trusted metadata returned only by UpdateGoalTool. | |
| 29 | + | type GoalUpdate struct { | |
| 30 | + | Status core.GoalStatus | |
| 31 | + | Reason string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |
| 35 | + | ||
| 36 | + | func (t *UpdateGoalTool) Description() string { | |
| 37 | + | return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal." | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | |
| 41 | + | ||
| 42 | + | func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |
| 43 | + | var args updateGoalArgs | |
| 44 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |
| 45 | + | decoder.DisallowUnknownFields() | |
| 46 | + | if err := decoder.Decode(&args); err != nil { | |
| 47 | + | return goalToolError("invalid arguments"), nil | |
| 48 | + | } | |
| 49 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { | |
| 50 | + | return goalToolError("invalid arguments"), nil | |
| 51 | + | } | |
| 52 | + | args.Status = strings.TrimSpace(args.Status) | |
| 53 | + | args.Reason = strings.TrimSpace(args.Reason) | |
| 54 | + | ||
| 55 | + | var update GoalUpdate | |
| 56 | + | switch args.Status { | |
| 57 | + | case "complete": | |
| 58 | + | update.Status = core.GoalDone | |
| 59 | + | case "blocked": | |
| 60 | + | if args.Reason == "" { | |
| 61 | + | return goalToolError("reason is required when blocking a goal"), nil | |
| 62 | + | } | |
| 63 | + | update.Status = core.GoalBlocked | |
| 64 | + | update.Reason = args.Reason | |
| 65 | + | default: | |
| 66 | + | return goalToolError("status must be complete or blocked"), nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | text := "autonomous goal marked " + string(update.Status) | |
| 70 | + | if update.Reason != "" { | |
| 71 | + | text += ": " + update.Reason | |
| 72 | + | } | |
| 73 | + | return core.ToolResult{ | |
| 74 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| 75 | + | Details: update, | |
| 76 | + | }, nil | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // GoalUpdateFromResult extracts a valid update_goal state transition. | |
| 80 | + | func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |
| 81 | + | if result.IsError { | |
| 82 | + | return GoalUpdate{}, false | |
| 83 | + | } | |
| 84 | + | update, ok := result.Details.(GoalUpdate) | |
| 85 | + | if !ok { | |
| 86 | + | return GoalUpdate{}, false | |
| 87 | + | } | |
| 88 | + | if update.Status != core.GoalDone && update.Status != core.GoalBlocked { | |
| 89 | + | return GoalUpdate{}, false | |
| 90 | + | } | |
| 91 | + | return update, true | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func goalToolError(message string) core.ToolResult { | |
| 95 | + | return core.ToolResult{ | |
| 96 | + | Content: []provider.Content{provider.TextBlock{Text: message}}, | |
| 97 | + | IsError: true, | |
| 98 | + | } | |
| 99 | + | } | |
| @@ -0,0 +1,99 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/json" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | const ( | |||
| 15 | + | UpdateGoalToolName = "update_goal" | |||
| 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["complete","blocked"],"description":"Mark the active autonomous goal complete or blocked."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // UpdateGoalTool lets the model stop an explicitly user-started autonomous | |||
| 20 | + | // goal. Starting, pausing, and resuming remain user-controlled. | |||
| 21 | + | type UpdateGoalTool struct{} | |||
| 22 | + | ||||
| 23 | + | type updateGoalArgs struct { | |||
| 24 | + | Status string `json:"status"` | |||
| 25 | + | Reason string `json:"reason,omitempty"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // GoalUpdate is trusted metadata returned only by UpdateGoalTool. | |||
| 29 | + | type GoalUpdate struct { | |||
| 30 | + | Status core.GoalStatus | |||
| 31 | + | Reason string | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |||
| 35 | + | ||||
| 36 | + | func (t *UpdateGoalTool) Description() string { | |||
| 37 | + | return "Mark the active autonomous goal complete or blocked. Only use this for a goal explicitly started by the user with /goal." | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | |||
| 41 | + | ||||
| 42 | + | func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) { | |||
| 43 | + | var args updateGoalArgs | |||
| 44 | + | decoder := json.NewDecoder(bytes.NewReader(raw)) | |||
| 45 | + | decoder.DisallowUnknownFields() | |||
| 46 | + | if err := decoder.Decode(&args); err != nil { | |||
| 47 | + | return goalToolError("invalid arguments"), nil | |||
| 48 | + | } | |||
| 49 | + | if err := decoder.Decode(&struct{}{}); err != io.EOF { | |||
| 50 | + | return goalToolError("invalid arguments"), nil | |||
| 51 | + | } | |||
| 52 | + | args.Status = strings.TrimSpace(args.Status) | |||
| 53 | + | args.Reason = strings.TrimSpace(args.Reason) | |||
| 54 | + | ||||
| 55 | + | var update GoalUpdate | |||
| 56 | + | switch args.Status { | |||
| 57 | + | case "complete": | |||
| 58 | + | update.Status = core.GoalDone | |||
| 59 | + | case "blocked": | |||
| 60 | + | if args.Reason == "" { | |||
| 61 | + | return goalToolError("reason is required when blocking a goal"), nil | |||
| 62 | + | } | |||
| 63 | + | update.Status = core.GoalBlocked | |||
| 64 | + | update.Reason = args.Reason | |||
| 65 | + | default: | |||
| 66 | + | return goalToolError("status must be complete or blocked"), nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | text := "autonomous goal marked " + string(update.Status) | |||
| 70 | + | if update.Reason != "" { | |||
| 71 | + | text += ": " + update.Reason | |||
| 72 | + | } | |||
| 73 | + | return core.ToolResult{ | |||
| 74 | + | Content: []provider.Content{provider.TextBlock{Text: text}}, | |||
| 75 | + | Details: update, | |||
| 76 | + | }, nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | // GoalUpdateFromResult extracts a valid update_goal state transition. | |||
| 80 | + | func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||
| 81 | + | if result.IsError { | |||
| 82 | + | return GoalUpdate{}, false | |||
| 83 | + | } | |||
| 84 | + | update, ok := result.Details.(GoalUpdate) | |||
| 85 | + | if !ok { | |||
| 86 | + | return GoalUpdate{}, false | |||
| 87 | + | } | |||
| 88 | + | if update.Status != core.GoalDone && update.Status != core.GoalBlocked { | |||
| 89 | + | return GoalUpdate{}, false | |||
| 90 | + | } | |||
| 91 | + | return update, true | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func goalToolError(message string) core.ToolResult { | |||
| 95 | + | return core.ToolResult{ | |||
| 96 | + | Content: []provider.Content{provider.TextBlock{Text: message}}, | |||
| 97 | + | IsError: true, | |||
| 98 | + | } | |||
| 99 | + | } | |||
A packages/agent/tools/update_goal_test.go +57 -0
| @@ -0,0 +1,57 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |
| 12 | + | tool := &UpdateGoalTool{} | |
| 13 | + | raw, err := json.Marshal(map[string]string{"status": "complete"}) | |
| 14 | + | if err != nil { | |
| 15 | + | t.Fatal(err) | |
| 16 | + | } | |
| 17 | + | result, err := tool.Execute(context.Background(), raw, nil) | |
| 18 | + | if err != nil { | |
| 19 | + | t.Fatal(err) | |
| 20 | + | } | |
| 21 | + | if result.IsError { | |
| 22 | + | t.Fatalf("result = %#v, want success", result) | |
| 23 | + | } | |
| 24 | + | update, ok := GoalUpdateFromResult(result) | |
| 25 | + | if !ok { | |
| 26 | + | t.Fatalf("details = %#v, want goal update", result.Details) | |
| 27 | + | } | |
| 28 | + | if update.Status != core.GoalDone || update.Reason != "" { | |
| 29 | + | t.Fatalf("update = %#v", update) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | |
| 34 | + | tool := &UpdateGoalTool{} | |
| 35 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil) | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if !result.IsError { | |
| 40 | + | t.Fatalf("result = %#v, want tool error", result) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | |
| 45 | + | tool := &UpdateGoalTool{} | |
| 46 | + | raw := json.RawMessage(`{"status":"active"}`) | |
| 47 | + | result, err := tool.Execute(context.Background(), raw, nil) | |
| 48 | + | if err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | if !result.IsError { | |
| 52 | + | t.Fatalf("result = %#v, want tool error", result) | |
| 53 | + | } | |
| 54 | + | if _, ok := GoalUpdateFromResult(result); ok { | |
| 55 | + | t.Fatal("invalid result exposed a goal update") | |
| 56 | + | } | |
| 57 | + | } | |
| @@ -0,0 +1,57 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |||
| 12 | + | tool := &UpdateGoalTool{} | |||
| 13 | + | raw, err := json.Marshal(map[string]string{"status": "complete"}) | |||
| 14 | + | if err != nil { | |||
| 15 | + | t.Fatal(err) | |||
| 16 | + | } | |||
| 17 | + | result, err := tool.Execute(context.Background(), raw, nil) | |||
| 18 | + | if err != nil { | |||
| 19 | + | t.Fatal(err) | |||
| 20 | + | } | |||
| 21 | + | if result.IsError { | |||
| 22 | + | t.Fatalf("result = %#v, want success", result) | |||
| 23 | + | } | |||
| 24 | + | update, ok := GoalUpdateFromResult(result) | |||
| 25 | + | if !ok { | |||
| 26 | + | t.Fatalf("details = %#v, want goal update", result.Details) | |||
| 27 | + | } | |||
| 28 | + | if update.Status != core.GoalDone || update.Reason != "" { | |||
| 29 | + | t.Fatalf("update = %#v", update) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | |||
| 34 | + | tool := &UpdateGoalTool{} | |||
| 35 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | if !result.IsError { | |||
| 40 | + | t.Fatalf("result = %#v, want tool error", result) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) { | |||
| 45 | + | tool := &UpdateGoalTool{} | |||
| 46 | + | raw := json.RawMessage(`{"status":"active"}`) | |||
| 47 | + | result, err := tool.Execute(context.Background(), raw, nil) | |||
| 48 | + | if err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | if !result.IsError { | |||
| 52 | + | t.Fatalf("result = %#v, want tool error", result) | |||
| 53 | + | } | |||
| 54 | + | if _, ok := GoalUpdateFromResult(result); ok { | |||
| 55 | + | t.Fatal("invalid result exposed a goal update") | |||
| 56 | + | } | |||
| 57 | + | } | |||
M packages/core/core_test.go +40 -0
| @@ -296,6 +296,46 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) { | |||
| 296 | 296 | } | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | + | func TestSessionGoalRoundTripAndClear(t *testing.T) { | |
| 300 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 301 | + | if err != nil { | |
| 302 | + | t.Fatal(err) | |
| 303 | + | } | |
| 304 | + | goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive} | |
| 305 | + | if err := sess.UpdateGoal(goal); err != nil { | |
| 306 | + | t.Fatal(err) | |
| 307 | + | } | |
| 308 | + | if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal { | |
| 309 | + | t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal) | |
| 310 | + | } | |
| 311 | + | if err := sess.Close(); err != nil { | |
| 312 | + | t.Fatal(err) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | reopened, _, err := OpenSession(sess.Path) | |
| 316 | + | if err != nil { | |
| 317 | + | t.Fatal(err) | |
| 318 | + | } | |
| 319 | + | if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal { | |
| 320 | + | t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal) | |
| 321 | + | } | |
| 322 | + | if err := reopened.UpdateGoal(nil); err != nil { | |
| 323 | + | t.Fatal(err) | |
| 324 | + | } | |
| 325 | + | if err := reopened.Close(); err != nil { | |
| 326 | + | t.Fatal(err) | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | reopened, _, err = OpenSession(sess.Path) | |
| 330 | + | if err != nil { | |
| 331 | + | t.Fatal(err) | |
| 332 | + | } | |
| 333 | + | defer reopened.Close() | |
| 334 | + | if reopened.Meta.Goal != nil { | |
| 335 | + | t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 299 | 339 | func TestCostAdd(t *testing.T) { | |
| 300 | 340 | var c CostTracker | |
| 301 | 341 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | |
| @@ -296,6 +296,46 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) { | |||||
| 296 | } | 296 | } | ||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | + | func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||
| 300 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 301 | + | if err != nil { | |||
| 302 | + | t.Fatal(err) | |||
| 303 | + | } | |||
| 304 | + | goal := &SessionGoal{Objective: "finish the requested change", Status: GoalActive} | |||
| 305 | + | if err := sess.UpdateGoal(goal); err != nil { | |||
| 306 | + | t.Fatal(err) | |||
| 307 | + | } | |||
| 308 | + | if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal { | |||
| 309 | + | t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal) | |||
| 310 | + | } | |||
| 311 | + | if err := sess.Close(); err != nil { | |||
| 312 | + | t.Fatal(err) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | reopened, _, err := OpenSession(sess.Path) | |||
| 316 | + | if err != nil { | |||
| 317 | + | t.Fatal(err) | |||
| 318 | + | } | |||
| 319 | + | if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal { | |||
| 320 | + | t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal) | |||
| 321 | + | } | |||
| 322 | + | if err := reopened.UpdateGoal(nil); err != nil { | |||
| 323 | + | t.Fatal(err) | |||
| 324 | + | } | |||
| 325 | + | if err := reopened.Close(); err != nil { | |||
| 326 | + | t.Fatal(err) | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | reopened, _, err = OpenSession(sess.Path) | |||
| 330 | + | if err != nil { | |||
| 331 | + | t.Fatal(err) | |||
| 332 | + | } | |||
| 333 | + | defer reopened.Close() | |||
| 334 | + | if reopened.Meta.Goal != nil { | |||
| 335 | + | t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | ||||
| 299 | func TestCostAdd(t *testing.T) { | 339 | func TestCostAdd(t *testing.T) { | ||
| 300 | var c CostTracker | 340 | var c CostTracker | ||
| 301 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | 341 | c.Add(provider.Usage{InputTokens: 100, OutputTokens: 50, ReasoningTokens: 20, ReasoningTokensKnown: true, CostUSD: 0.01}) | ||
M packages/core/session.go +40 -1
| @@ -47,6 +47,23 @@ type Session struct { | |||
| 47 | 47 | messagesAppended int | |
| 48 | 48 | } | |
| 49 | 49 | ||
| 50 | + | // GoalStatus is the persisted lifecycle state of an autonomous session goal. | |
| 51 | + | type GoalStatus string | |
| 52 | + | ||
| 53 | + | const ( | |
| 54 | + | GoalActive GoalStatus = "active" | |
| 55 | + | GoalPaused GoalStatus = "paused" | |
| 56 | + | GoalBlocked GoalStatus = "blocked" | |
| 57 | + | GoalDone GoalStatus = "done" | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | // SessionGoal is the concise autonomous objective attached to one session. | |
| 61 | + | type SessionGoal struct { | |
| 62 | + | Objective string `json:"objective"` | |
| 63 | + | Status GoalStatus `json:"status"` | |
| 64 | + | Reason string `json:"reason,omitempty"` | |
| 65 | + | } | |
| 66 | + | ||
| 50 | 67 | // SessionMeta is written as the first line of every session file. | |
| 51 | 68 | type SessionMeta struct { | |
| 52 | 69 | ID string `json:"id"` | |
| @@ -65,6 +82,9 @@ type SessionMeta struct { | |||
| 65 | 82 | // continuation. It is session metadata, never provider context. | |
| 66 | 83 | // Missing or invalid values are handled by the owning host as no handoff. | |
| 67 | 84 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | |
| 85 | + | // Goal is the current autonomous objective for this session. Completed | |
| 86 | + | // goals remain inspectable until the user clears or replaces them. | |
| 87 | + | Goal *SessionGoal `json:"goal,omitempty"` | |
| 68 | 88 | ||
| 69 | 89 | // Parent is the ID of the session this one was forked from, or | |
| 70 | 90 | // empty for top-level sessions. The tree picker walks parents | |
| @@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error { | |||
| 1295 | 1315 | return nil | |
| 1296 | 1316 | } | |
| 1297 | 1317 | ||
| 1318 | + | // UpdateGoal records or clears the current autonomous session goal. | |
| 1319 | + | func (s *Session) UpdateGoal(goal *SessionGoal) error { | |
| 1320 | + | if s == nil { | |
| 1321 | + | return nil | |
| 1322 | + | } | |
| 1323 | + | previous := s.Meta.Goal | |
| 1324 | + | if goal == nil { | |
| 1325 | + | s.Meta.Goal = nil | |
| 1326 | + | } else { | |
| 1327 | + | copyGoal := *goal | |
| 1328 | + | s.Meta.Goal = ©Goal | |
| 1329 | + | } | |
| 1330 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 1331 | + | s.Meta.Goal = previous | |
| 1332 | + | return fmt.Errorf("update goal: %w", err) | |
| 1333 | + | } | |
| 1334 | + | return nil | |
| 1335 | + | } | |
| 1336 | + | ||
| 1298 | 1337 | // UpdateTitle records a session title without adding anything to the | |
| 1299 | 1338 | // conversation transcript. The title is kept in memory as well so the live | |
| 1300 | 1339 | // session can restore it when the user switches sessions in the TUI. | |
| @@ -1374,7 +1413,7 @@ func (s *Session) Close() error { | |||
| 1374 | 1413 | } | |
| 1375 | 1414 | flushErr := s.Flush() | |
| 1376 | 1415 | closeErr := s.writer.Close() | |
| 1377 | - | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 { | |
| 1416 | + | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil { | |
| 1378 | 1417 | // Best-effort cleanup. We deliberately don't propagate the | |
| 1379 | 1418 | // remove error: if it fails (file already gone, perms changed) | |
| 1380 | 1419 | // the worst case is one stale empty file in the listing. | |
| @@ -47,6 +47,23 @@ type Session struct { | |||||
| 47 | messagesAppended int | 47 | messagesAppended int | ||
| 48 | } | 48 | } | ||
| 49 | 49 | ||||
| 50 | + | // GoalStatus is the persisted lifecycle state of an autonomous session goal. | |||
| 51 | + | type GoalStatus string | |||
| 52 | + | ||||
| 53 | + | const ( | |||
| 54 | + | GoalActive GoalStatus = "active" | |||
| 55 | + | GoalPaused GoalStatus = "paused" | |||
| 56 | + | GoalBlocked GoalStatus = "blocked" | |||
| 57 | + | GoalDone GoalStatus = "done" | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | // SessionGoal is the concise autonomous objective attached to one session. | |||
| 61 | + | type SessionGoal struct { | |||
| 62 | + | Objective string `json:"objective"` | |||
| 63 | + | Status GoalStatus `json:"status"` | |||
| 64 | + | Reason string `json:"reason,omitempty"` | |||
| 65 | + | } | |||
| 66 | + | ||||
| 50 | // SessionMeta is written as the first line of every session file. | 67 | // SessionMeta is written as the first line of every session file. | ||
| 51 | type SessionMeta struct { | 68 | type SessionMeta struct { | ||
| 52 | ID string `json:"id"` | 69 | ID string `json:"id"` | ||
| @@ -65,6 +82,9 @@ type SessionMeta struct { | |||||
| 65 | // continuation. It is session metadata, never provider context. | 82 | // continuation. It is session metadata, never provider context. | ||
| 66 | // Missing or invalid values are handled by the owning host as no handoff. | 83 | // Missing or invalid values are handled by the owning host as no handoff. | ||
| 67 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | 84 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | ||
| 85 | + | // Goal is the current autonomous objective for this session. Completed | |||
| 86 | + | // goals remain inspectable until the user clears or replaces them. | |||
| 87 | + | Goal *SessionGoal `json:"goal,omitempty"` | |||
| 68 | 88 | ||||
| 69 | // Parent is the ID of the session this one was forked from, or | 89 | // Parent is the ID of the session this one was forked from, or | ||
| 70 | // empty for top-level sessions. The tree picker walks parents | 90 | // empty for top-level sessions. The tree picker walks parents | ||
| @@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error { | |||||
| 1295 | return nil | 1315 | return nil | ||
| 1296 | } | 1316 | } | ||
| 1297 | 1317 | ||||
| 1318 | + | // UpdateGoal records or clears the current autonomous session goal. | |||
| 1319 | + | func (s *Session) UpdateGoal(goal *SessionGoal) error { | |||
| 1320 | + | if s == nil { | |||
| 1321 | + | return nil | |||
| 1322 | + | } | |||
| 1323 | + | previous := s.Meta.Goal | |||
| 1324 | + | if goal == nil { | |||
| 1325 | + | s.Meta.Goal = nil | |||
| 1326 | + | } else { | |||
| 1327 | + | copyGoal := *goal | |||
| 1328 | + | s.Meta.Goal = ©Goal | |||
| 1329 | + | } | |||
| 1330 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |||
| 1331 | + | s.Meta.Goal = previous | |||
| 1332 | + | return fmt.Errorf("update goal: %w", err) | |||
| 1333 | + | } | |||
| 1334 | + | return nil | |||
| 1335 | + | } | |||
| 1336 | + | ||||
| 1298 | // UpdateTitle records a session title without adding anything to the | 1337 | // UpdateTitle records a session title without adding anything to the | ||
| 1299 | // conversation transcript. The title is kept in memory as well so the live | 1338 | // conversation transcript. The title is kept in memory as well so the live | ||
| 1300 | // session can restore it when the user switches sessions in the TUI. | 1339 | // session can restore it when the user switches sessions in the TUI. | ||
| @@ -1374,7 +1413,7 @@ func (s *Session) Close() error { | |||||
| 1374 | } | 1413 | } | ||
| 1375 | flushErr := s.Flush() | 1414 | flushErr := s.Flush() | ||
| 1376 | closeErr := s.writer.Close() | 1415 | closeErr := s.writer.Close() | ||
| 1377 | - | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 { | 1416 | + | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil { |
| 1378 | // Best-effort cleanup. We deliberately don't propagate the | 1417 | // Best-effort cleanup. We deliberately don't propagate the | ||
| 1379 | // remove error: if it fails (file already gone, perms changed) | 1418 | // remove error: if it fails (file already gone, perms changed) | ||
| 1380 | // the worst case is one stale empty file in the listing. | 1419 | // the worst case is one stale empty file in the listing. | ||
M packages/core/session_portable.go +21 -3
| @@ -228,6 +228,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 228 | 228 | Timezone: timezone, | |
| 229 | 229 | TimezoneOffset: timezoneOffset, | |
| 230 | 230 | CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...), | |
| 231 | + | Goal: cloneSessionGoal(snapshot.Meta.Goal), | |
| 231 | 232 | } | |
| 232 | 233 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | |
| 233 | 234 | if err != nil { | |
| @@ -320,7 +321,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||
| 320 | 321 | return "", err | |
| 321 | 322 | } | |
| 322 | 323 | compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit) | |
| 323 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff) | |
| 324 | + | goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit) | |
| 325 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal) | |
| 324 | 326 | } | |
| 325 | 327 | ||
| 326 | 328 | // BranchSessionHiddenFromHistory creates a hidden tree branch from a | |
| @@ -350,7 +352,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme | |||
| 350 | 352 | if err != nil { | |
| 351 | 353 | return "", err | |
| 352 | 354 | } | |
| 353 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil) | |
| 355 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil) | |
| 354 | 356 | } | |
| 355 | 357 | ||
| 356 | 358 | // readExtensionStateAtFork reconstructs the latest extension snapshot that was | |
| @@ -437,7 +439,22 @@ func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, lim | |||
| 437 | 439 | return append(json.RawMessage(nil), parent.CompactHandoff...) | |
| 438 | 440 | } | |
| 439 | 441 | ||
| 440 | - | 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) (string, error) { | |
| 442 | + | func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal { | |
| 443 | + | if limit != len(messages) { | |
| 444 | + | return nil | |
| 445 | + | } | |
| 446 | + | return cloneSessionGoal(parent.Goal) | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |
| 450 | + | if goal == nil { | |
| 451 | + | return nil | |
| 452 | + | } | |
| 453 | + | clone := *goal | |
| 454 | + | return &clone | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | 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) { | |
| 441 | 458 | dir := SessionsDir(root, cwd) | |
| 442 | 459 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| 443 | 460 | return "", err | |
| @@ -479,6 +496,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 479 | 496 | ForkPoint: limit, | |
| 480 | 497 | HideFromSessions: hideFromSessions, | |
| 481 | 498 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | |
| 499 | + | Goal: cloneSessionGoal(goal), | |
| 482 | 500 | } | |
| 483 | 501 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| 484 | 502 | if err != nil { | |
| @@ -228,6 +228,7 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||||
| 228 | Timezone: timezone, | 228 | Timezone: timezone, | ||
| 229 | TimezoneOffset: timezoneOffset, | 229 | TimezoneOffset: timezoneOffset, | ||
| 230 | CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...), | 230 | CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...), | ||
| 231 | + | Goal: cloneSessionGoal(snapshot.Meta.Goal), | |||
| 231 | } | 232 | } | ||
| 232 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | 233 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | ||
| 233 | if err != nil { | 234 | if err != nil { | ||
| @@ -320,7 +321,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi | |||||
| 320 | return "", err | 321 | return "", err | ||
| 321 | } | 322 | } | ||
| 322 | compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit) | 323 | compactHandoff := compactHandoffAtBranch(snapshot.Meta, snapshot.Messages, limit) | ||
| 323 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff) | 324 | + | goal := goalAtBranch(snapshot.Meta, snapshot.Messages, limit) |
| 325 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, snapshot.Messages, snapshot.UsageCheckpoints, limit, hideFromSessions, extensionState, compactHandoff, goal) | |||
| 324 | } | 326 | } | ||
| 325 | 327 | ||||
| 326 | // BranchSessionHiddenFromHistory creates a hidden tree branch from a | 328 | // BranchSessionHiddenFromHistory creates a hidden tree branch from a | ||
| @@ -350,7 +352,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme | |||||
| 350 | if err != nil { | 352 | if err != nil { | ||
| 351 | return "", err | 353 | return "", err | ||
| 352 | } | 354 | } | ||
| 353 | - | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil) | 355 | + | return writeBranchSession(root, cwd, version, snapshot.Meta, segment.Messages, segment.UsageCheckpoints, limit, true, extensionState, nil, nil) |
| 354 | } | 356 | } | ||
| 355 | 357 | ||||
| 356 | // readExtensionStateAtFork reconstructs the latest extension snapshot that was | 358 | // readExtensionStateAtFork reconstructs the latest extension snapshot that was | ||
| @@ -437,7 +439,22 @@ func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, lim | |||||
| 437 | return append(json.RawMessage(nil), parent.CompactHandoff...) | 439 | return append(json.RawMessage(nil), parent.CompactHandoff...) | ||
| 438 | } | 440 | } | ||
| 439 | 441 | ||||
| 440 | - | 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) (string, error) { | 442 | + | func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionGoal { |
| 443 | + | if limit != len(messages) { | |||
| 444 | + | return nil | |||
| 445 | + | } | |||
| 446 | + | return cloneSessionGoal(parent.Goal) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |||
| 450 | + | if goal == nil { | |||
| 451 | + | return nil | |||
| 452 | + | } | |||
| 453 | + | clone := *goal | |||
| 454 | + | return &clone | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | 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) { | |||
| 441 | dir := SessionsDir(root, cwd) | 458 | dir := SessionsDir(root, cwd) | ||
| 442 | if err := os.MkdirAll(dir, 0o755); err != nil { | 459 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| 443 | return "", err | 460 | return "", err | ||
| @@ -479,6 +496,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||||
| 479 | ForkPoint: limit, | 496 | ForkPoint: limit, | ||
| 480 | HideFromSessions: hideFromSessions, | 497 | HideFromSessions: hideFromSessions, | ||
| 481 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | 498 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | ||
| 499 | + | Goal: cloneSessionGoal(goal), | |||
| 482 | } | 500 | } | ||
| 483 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | 501 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | ||
| 484 | if err != nil { | 502 | if err != nil { | ||
M packages/core/session_portable_test.go +17 -0
| @@ -80,6 +80,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||
| 80 | 80 | if err := sess.UpdateCompactHandoff(handoff); err != nil { | |
| 81 | 81 | t.Fatal(err) | |
| 82 | 82 | } | |
| 83 | + | goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused} | |
| 84 | + | if err := sess.UpdateGoal(goal); err != nil { | |
| 85 | + | t.Fatal(err) | |
| 86 | + | } | |
| 83 | 87 | _ = sess.Close() | |
| 84 | 88 | ||
| 85 | 89 | // Export to a directory — helper should build a name inside it. | |
| @@ -124,6 +128,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||
| 124 | 128 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | |
| 125 | 129 | t.Errorf("compact handoff = %q, want %q", got, handoff) | |
| 126 | 130 | } | |
| 131 | + | if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal { | |
| 132 | + | t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal) | |
| 133 | + | } | |
| 127 | 134 | if len(msgs) != 2 { | |
| 128 | 135 | t.Fatalf("want 2 messages, got %d", len(msgs)) | |
| 129 | 136 | } | |
| @@ -564,6 +571,10 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||
| 564 | 571 | if err := parent.UpdateCompactHandoff(handoff); err != nil { | |
| 565 | 572 | t.Fatal(err) | |
| 566 | 573 | } | |
| 574 | + | goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive} | |
| 575 | + | if err := parent.UpdateGoal(goal); err != nil { | |
| 576 | + | t.Fatal(err) | |
| 577 | + | } | |
| 567 | 578 | if err := parent.Close(); err != nil { | |
| 568 | 579 | t.Fatal(err) | |
| 569 | 580 | } | |
| @@ -580,6 +591,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||
| 580 | 591 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | |
| 581 | 592 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | |
| 582 | 593 | } | |
| 594 | + | if full.Meta.Goal == nil || *full.Meta.Goal != *goal { | |
| 595 | + | t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal) | |
| 596 | + | } | |
| 583 | 597 | ||
| 584 | 598 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | |
| 585 | 599 | if err != nil { | |
| @@ -593,6 +607,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||
| 593 | 607 | if len(prefix.Meta.CompactHandoff) != 0 { | |
| 594 | 608 | t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff) | |
| 595 | 609 | } | |
| 610 | + | if prefix.Meta.Goal != nil { | |
| 611 | + | t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal) | |
| 612 | + | } | |
| 596 | 613 | } | |
| 597 | 614 | ||
| 598 | 615 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | |
| @@ -80,6 +80,10 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||||
| 80 | if err := sess.UpdateCompactHandoff(handoff); err != nil { | 80 | if err := sess.UpdateCompactHandoff(handoff); err != nil { | ||
| 81 | t.Fatal(err) | 81 | t.Fatal(err) | ||
| 82 | } | 82 | } | ||
| 83 | + | goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused} | |||
| 84 | + | if err := sess.UpdateGoal(goal); err != nil { | |||
| 85 | + | t.Fatal(err) | |||
| 86 | + | } | |||
| 83 | _ = sess.Close() | 87 | _ = sess.Close() | ||
| 84 | 88 | ||||
| 85 | // Export to a directory — helper should build a name inside it. | 89 | // Export to a directory — helper should build a name inside it. | ||
| @@ -124,6 +128,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||||
| 124 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | 128 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | ||
| 125 | t.Errorf("compact handoff = %q, want %q", got, handoff) | 129 | t.Errorf("compact handoff = %q, want %q", got, handoff) | ||
| 126 | } | 130 | } | ||
| 131 | + | if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal { | |||
| 132 | + | t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal) | |||
| 133 | + | } | |||
| 127 | if len(msgs) != 2 { | 134 | if len(msgs) != 2 { | ||
| 128 | t.Fatalf("want 2 messages, got %d", len(msgs)) | 135 | t.Fatalf("want 2 messages, got %d", len(msgs)) | ||
| 129 | } | 136 | } | ||
| @@ -564,6 +571,10 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||||
| 564 | if err := parent.UpdateCompactHandoff(handoff); err != nil { | 571 | if err := parent.UpdateCompactHandoff(handoff); err != nil { | ||
| 565 | t.Fatal(err) | 572 | t.Fatal(err) | ||
| 566 | } | 573 | } | ||
| 574 | + | goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive} | |||
| 575 | + | if err := parent.UpdateGoal(goal); err != nil { | |||
| 576 | + | t.Fatal(err) | |||
| 577 | + | } | |||
| 567 | if err := parent.Close(); err != nil { | 578 | if err := parent.Close(); err != nil { | ||
| 568 | t.Fatal(err) | 579 | t.Fatal(err) | ||
| 569 | } | 580 | } | ||
| @@ -580,6 +591,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||||
| 580 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | 591 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | ||
| 581 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | 592 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | ||
| 582 | } | 593 | } | ||
| 594 | + | if full.Meta.Goal == nil || *full.Meta.Goal != *goal { | |||
| 595 | + | t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal) | |||
| 596 | + | } | |||
| 583 | 597 | ||||
| 584 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | 598 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | ||
| 585 | if err != nil { | 599 | if err != nil { | ||
| @@ -593,6 +607,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||||
| 593 | if len(prefix.Meta.CompactHandoff) != 0 { | 607 | if len(prefix.Meta.CompactHandoff) != 0 { | ||
| 594 | t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff) | 608 | t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff) | ||
| 595 | } | 609 | } | ||
| 610 | + | if prefix.Meta.Goal != nil { | |||
| 611 | + | t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal) | |||
| 612 | + | } | |||
| 596 | } | 613 | } | ||
| 597 | 614 | ||||
| 598 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | 615 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | ||
M packages/tui/statusbar_test.go +24 -0
| @@ -7,6 +7,30 @@ import ( | |||
| 7 | 7 | "github.com/bnema/zut/packages/provider" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | func TestStatusBarShowsActiveGoal(t *testing.T) { | |
| 11 | + | params := StatusBarParams{ | |
| 12 | + | Theme: Dark, | |
| 13 | + | Provider: "openai", | |
| 14 | + | Model: "gpt-test", | |
| 15 | + | GoalStatus: "active", | |
| 16 | + | CWD: "/tmp/project", | |
| 17 | + | Cols: 100, | |
| 18 | + | } | |
| 19 | + | lines := StatusBar(params) | |
| 20 | + | if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") { | |
| 21 | + | t.Fatalf("status bar = %q, want active goal", lines) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | params.Cols = 35 | |
| 25 | + | lines = StatusBar(params) | |
| 26 | + | for _, line := range lines { | |
| 27 | + | if strings.Contains(line, "goal:active") { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | t.Fatalf("narrow status bar = %q, want active goal", lines) | |
| 32 | + | } | |
| 33 | + | ||
| 10 | 34 | // TestStatusBarAlwaysTwoLines verifies the status bar always emits | |
| 11 | 35 | // two lines when a cwd is present, regardless of terminal width, and | |
| 12 | 36 | // that the cwd is indented with the 2-space pad so it lines up under | |
| @@ -7,6 +7,30 @@ import ( | |||||
| 7 | "github.com/bnema/zut/packages/provider" | 7 | "github.com/bnema/zut/packages/provider" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | func TestStatusBarShowsActiveGoal(t *testing.T) { | |||
| 11 | + | params := StatusBarParams{ | |||
| 12 | + | Theme: Dark, | |||
| 13 | + | Provider: "openai", | |||
| 14 | + | Model: "gpt-test", | |||
| 15 | + | GoalStatus: "active", | |||
| 16 | + | CWD: "/tmp/project", | |||
| 17 | + | Cols: 100, | |||
| 18 | + | } | |||
| 19 | + | lines := StatusBar(params) | |||
| 20 | + | if len(lines) == 0 || !strings.Contains(lines[0], "goal:active") { | |||
| 21 | + | t.Fatalf("status bar = %q, want active goal", lines) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | params.Cols = 35 | |||
| 25 | + | lines = StatusBar(params) | |||
| 26 | + | for _, line := range lines { | |||
| 27 | + | if strings.Contains(line, "goal:active") { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | t.Fatalf("narrow status bar = %q, want active goal", lines) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 10 | // TestStatusBarAlwaysTwoLines verifies the status bar always emits | 34 | // TestStatusBarAlwaysTwoLines verifies the status bar always emits | ||
| 11 | // two lines when a cwd is present, regardless of terminal width, and | 35 | // two lines when a cwd is present, regardless of terminal width, and | ||
| 12 | // that the cwd is indented with the 2-space pad so it lines up under | 36 | // that the cwd is indented with the 2-space pad so it lines up under | ||
M packages/tui/view.go +6 -2
| @@ -2828,8 +2828,9 @@ type StatusBarParams struct { | |||
| 2828 | 2828 | Busy bool | |
| 2829 | 2829 | BusyPrefix string // spinner + funny line when busy | |
| 2830 | 2830 | CWD string | |
| 2831 | - | Locked bool // sandbox on? | |
| 2832 | - | NoYolo bool // confirmation mode enabled? | |
| 2831 | + | Locked bool // sandbox on? | |
| 2832 | + | NoYolo bool // confirmation mode enabled? | |
| 2833 | + | GoalStatus string // autonomous goal lifecycle, empty when none | |
| 2833 | 2834 | ||
| 2834 | 2835 | // Cumulative session usage and cost. | |
| 2835 | 2836 | Usage provider.Usage | |
| @@ -2904,6 +2905,9 @@ func StatusBar(p StatusBarParams) []string { | |||
| 2904 | 2905 | if costStr != "" { | |
| 2905 | 2906 | stats = append(stats, costStr) | |
| 2906 | 2907 | } | |
| 2908 | + | if p.GoalStatus != "" { | |
| 2909 | + | stats = append(stats, "goal:"+p.GoalStatus) | |
| 2910 | + | } | |
| 2907 | 2911 | ||
| 2908 | 2912 | // Context %. Color-coded: yellow >70, red >90. | |
| 2909 | 2913 | ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax) | |
| @@ -2828,8 +2828,9 @@ type StatusBarParams struct { | |||||
| 2828 | Busy bool | 2828 | Busy bool | ||
| 2829 | BusyPrefix string // spinner + funny line when busy | 2829 | BusyPrefix string // spinner + funny line when busy | ||
| 2830 | CWD string | 2830 | CWD string | ||
| 2831 | - | Locked bool // sandbox on? | 2831 | + | Locked bool // sandbox on? |
| 2832 | - | NoYolo bool // confirmation mode enabled? | 2832 | + | NoYolo bool // confirmation mode enabled? |
| 2833 | + | GoalStatus string // autonomous goal lifecycle, empty when none | |||
| 2833 | 2834 | ||||
| 2834 | // Cumulative session usage and cost. | 2835 | // Cumulative session usage and cost. | ||
| 2835 | Usage provider.Usage | 2836 | Usage provider.Usage | ||
| @@ -2904,6 +2905,9 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 2904 | if costStr != "" { | 2905 | if costStr != "" { | ||
| 2905 | stats = append(stats, costStr) | 2906 | stats = append(stats, costStr) | ||
| 2906 | } | 2907 | } | ||
| 2908 | + | if p.GoalStatus != "" { | |||
| 2909 | + | stats = append(stats, "goal:"+p.GoalStatus) | |||
| 2910 | + | } | |||
| 2907 | 2911 | ||||
| 2908 | // Context %. Color-coded: yellow >70, red >90. | 2912 | // Context %. Color-coded: yellow >70, red >90. | ||
| 2909 | ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax) | 2913 | ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax) | ||