detached 352349c

Merge pull request #98 from bnema/feat/persistent-goals

feat(agent): add persistent autonomous goals
commit
352349c2b73b965e62dfac840b5333d502e76325
author
bnema · · 2026-08-09 21:23 UTC
committer
GitHub · · 2026-08-09 21:23 UTC
merge of
e5afabc + 5e914f4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
23 files · +1326 -50
M README.md +10 -2
@@ -9,7 +9,7 @@ Yet another coding agent harness, lightweight and written (vibe-slopped) in go.
99
1010 - one static binary.
1111 - built-in providers for Anthropic, OpenAI/Codex/Responses, Kimi, DeepSeek, Google Gemini/Vertex, GitHub Copilot, Bedrock, Azure OpenAI, OpenRouter, Groq, Cerebras, xAI, Together, Hugging Face, Mistral, Moonshot, Z.AI, Xiaomi, MiniMax, Fireworks, Vercel AI Gateway, OpenCode, Cloudflare AI, and Ollama/local models.
12-- 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.
1313 - three run modes (interactive tui, print, json).
1414 - built-in telegram bot.
1515 - extensions in any language via subprocess + json-rpc. None installed by default; opt in with `zut ext install` or `zut --ext`. See [docs/extensions.md](docs/extensions.md).
@@ -239,6 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens
239239 - `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.
240240 - `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).
241241 - `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`.
242243
243244 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.
244245
@@ -295,6 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
295296 | `/reasoning` | Set the reasoning level for subsequent model calls. |
296297 | `/fast` | Toggle fast mode for subsequent model calls. |
297298 | `/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. |
298300 | `/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. |
299301 | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. |
300302 | `/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
315317
316318 Extension-registered commands appear under a divider at the bottom of the popup, sorted by name.
317319
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+
318326 ### Shell escape (`!command`)
319327
320328 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
10841092 packages/agent/modes/ interactive tui, print, json, dialogs
10851093 packages/agent/modes/bot/ protocol-agnostic bot runner (BotAdapter interface)
10861094 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
10881096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
10891097 packages/agent/subagents/ named profiles, supervisor, and background runtime
10901098 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, dialogs1092 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, daemon1094 packages/agent/modes/telegram/ telegram adapter, api client, daemon
1087-packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, sandbox1095+packages/agent/tools/ read, write, edit, bash, create_worktree, lsp, web search, goals, sandbox
1088 packages/agent/skills/ skill discovery, frontmatter parser, skill tool1096 packages/agent/skills/ skill discovery, frontmatter parser, skill tool
1089 packages/agent/subagents/ named profiles, supervisor, and background runtime1097 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) {
101101 // capability policy excludes the current session. An extension must
102102 // not turn a normal CLI opt-out into a differently implemented search
103103 // capability with the same model-visible name.
104- if info.Name == "web_search" {
104+ if info.Name == "web_search" || info.Name == "update_goal" {
105105 continue
106106 }
107107 if _, exists := r.ToolRegistry[info.Name]; exists {
@@ -1291,6 +1291,7 @@ func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, lspEnabled
12911291 "edit": &tools.EditTool{CWD: cwd, Sandbox: sandbox, LSP: manager, LSPDiagnostics: diagnosticsOnEdit},
12921292 "bash": &tools.BashTool{CWD: cwd, Sandbox: sandbox},
12931293 "create_worktree": &tools.CreateWorktreeTool{CWD: cwd, Sandbox: sandbox},
1294+ "update_goal": &tools.UpdateGoalTool{},
12941295 }
12951296 if webSearchAllowedForRegistry(args) {
12961297 all["web_search"] = tools.NewWebSearchTool()
@@ -1377,7 +1378,7 @@ func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13771378 return false
13781379 }
13791380
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"}
13811382
13821383 func toolSummaries(reg core.Registry, args Args) []ToolSummary {
13831384 var out []ToolSummary
@@ -101,7 +101,7 @@ func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
101 // capability policy excludes the current session. An extension must101 // capability policy excludes the current session. An extension must
102 // not turn a normal CLI opt-out into a differently implemented search102 // 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 continue105 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 false1378 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 []ToolSummary1384 var out []ToolSummary
M packages/agent/build_test.go +23 -8
@@ -36,6 +36,15 @@ func (webSearchConflictSource) NewExtensionTool(ExtensionToolInfo) core.Tool {
3636 return &tools.ReadTool{}
3737 }
3838
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+
3948 type unorderedExtensionSource struct{}
4049
4150 func (unorderedExtensionSource) Tools() []ExtensionToolInfo {
@@ -84,11 +93,14 @@ func TestMergeExtensionToolsRespectsDisabledSkillDiscovery(t *testing.T) {
8493 }
8594 }
8695
87-func TestMergeExtensionToolsReservesWebSearchName(t *testing.T) {
96+func TestMergeExtensionToolsReservesNativeControlNames(t *testing.T) {
8897 r := Resolved{ToolRegistry: make(core.Registry)}
8998 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+ }
92104 }
93105 }
94106
@@ -103,7 +115,7 @@ func TestMergeExtensionToolsKeepsDeterministicNativeSummaryOrder(t *testing.T) {
103115 for _, summary := range r.ToolSummary {
104116 names = append(names, summary.Name)
105117 }
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"
107119 if got := strings.Join(names, ","); got != want {
108120 t.Fatalf("merged tool summary order = %q, want %q", got, want)
109121 }
@@ -153,6 +165,9 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
153165 if _, ok := registry["web_search"].(*tools.WebSearchTool); !ok {
154166 t.Fatalf("default registry web_search = %#v", registry["web_search"])
155167 }
168+ if _, ok := registry["update_goal"].(*tools.UpdateGoalTool); !ok {
169+ t.Fatalf("default registry update_goal = %#v", registry["update_goal"])
170+ }
156171 write, ok := registry["write"].(*tools.WriteTool)
157172 if !ok || write.LSP == nil || !write.LSPDiagnostics {
158173 t.Fatalf("write tool LSP wiring = %#v", write)
@@ -161,8 +176,8 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
161176 if !ok || edit.LSP == nil || edit.LSPDiagnostics {
162177 t.Fatalf("edit tool LSP wiring = %#v", edit)
163178 }
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))
166181 }
167182 readOnly := buildToolRegistry(Args{Tools: []string{"read"}}, root, tools.NewSandbox(root), true, true, true)
168183 if read, ok := readOnly["read"].(*tools.ReadTool); !ok || read == nil {
@@ -193,12 +208,12 @@ func TestBuildToolRegistryIncludesLSPAndWriteDiagnostics(t *testing.T) {
193208 }
194209 }
195210
196-func TestToolSummariesIncludeCreateWorktreeAndWebSearch(t *testing.T) {
211+func TestToolSummariesIncludeGoalUpdates(t *testing.T) {
197212 root := t.TempDir()
198213 registry := buildToolRegistry(Args{}, root, tools.NewSandbox(root), false, false, false)
199214
200215 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"}
202217 if len(summaries) != len(want) {
203218 t.Fatalf("tool summaries = %#v, want %v", summaries, want)
204219 }
@@ -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 +63 -16
@@ -688,21 +688,36 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
688688 return out
689689 }
690690
691-func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {
691+func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {
692692 if sess == nil {
693- return
693+ return nil
694694 }
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+ return fmt.Errorf("extension state: %w", err)
698+ }
699+ if mgr != nil {
700+ mgr.UpdateSessionState(details.Extension, details.State)
701+ }
698702 }
699- if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {
700- fmt.Fprintf(os.Stderr, "extension state: %v\n", err)
701- return
703+ if err := persistGoalToolResult(sess, result); err != nil {
704+ return fmt.Errorf("goal state: %w", err)
702705 }
703- if mgr != nil {
704- mgr.UpdateSessionState(details.Extension, details.State)
706+ return nil
707+}
708+
709+func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710+ if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
711+ return nil
712+ }
713+ update, ok := tools.GoalUpdateFromResult(result)
714+ if !ok {
715+ return nil
705716 }
717+ goal := *sess.Meta.Goal
718+ goal.Status = update.Status
719+ goal.Reason = update.Reason
720+ return sess.UpdateGoal(&goal)
706721 }
707722
708723 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -969,7 +984,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
969984 return err
970985 }
971986 r.Provider, r.Model = providerName, model
972- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
987+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
973988 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
974989 }
975990 announceSession(extMgr, sess)
@@ -1050,7 +1065,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
10501065 return err
10511066 }
10521067 r.Provider, r.Model = providerName, model
1053- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1068+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
10541069 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
10551070 }
10561071 announceSession(extMgr, sess)
@@ -1170,7 +1185,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
11701185 return err
11711186 }
11721187 r.Provider, r.Model = providerName, model
1173- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
1188+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
11741189 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
11751190 }
11761191 announceSession(extMgr, sess)
@@ -1802,10 +1817,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18021817 }
18031818 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
18041819 }
1805- persistToolResult := func(_ string, result core.ToolResult) {
1820+ persistGoal := func(goal *core.SessionGoal) error {
1821+ sessionTransitionMu.RLock()
1822+ defer sessionTransitionMu.RUnlock()
1823+ persistMu.Lock()
1824+ defer persistMu.Unlock()
1825+ if sess == nil {
1826+ return errors.New("session persistence is disabled")
1827+ }
1828+ return sess.UpdateGoal(goal)
1829+ }
1830+ currentGoal := func() *core.SessionGoal {
18061831 persistMu.Lock()
18071832 defer persistMu.Unlock()
1808- persistExtensionToolResult(extMgr, sess, result)
1833+ if sess == nil || sess.Meta.Goal == nil {
1834+ return nil
1835+ }
1836+ goal := *sess.Meta.Goal
1837+ return &goal
1838+ }
1839+ bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1840+ if a == nil {
1841+ return
1842+ }
1843+ a.CommitToolResult = func(_ string, result core.ToolResult) error {
1844+ persistMu.Lock()
1845+ defer persistMu.Unlock()
1846+ return persistToolResultState(extMgr, boundSession, result)
1847+ }
18091848 }
18101849 wireAgentPersist := func(a *core.Agent) *core.Agent {
18111850 if a == nil {
@@ -1820,7 +1859,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
18201859 a.OnMessageAppended = persistMessage
18211860 a.OnUsage = persistUsage
18221861 a.OnTranscriptCompacted = persistCompaction
1823- a.OnToolResult = persistToolResult
1862+ bindAgentToolResultSession(a, currentSession)
18241863 return a
18251864 }
18261865 wireAgentPersist(ag)
@@ -1929,6 +1968,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19291968 oldCloseErr = oldSess.Close()
19301969 }
19311970 sess = candidate.session
1971+ bindAgentToolResultSession(candidate.agent, candidate.session)
19321972 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
19331973 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
19341974 // The live agent only receives a compact resume window, but the
@@ -1964,6 +2004,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
19642004 }
19652005 committed = true
19662006 persistMu.Unlock()
2007+ if iv != nil {
2008+ iv.RefreshGoal()
2009+ }
19672010 if candidate.session.Meta.Parent != "" {
19682011 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
19692012 }
@@ -2128,6 +2171,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21282171 oldCloseErr = sess.Close()
21292172 }
21302173 sess = newSess
2174+ bindAgentToolResultSession(newAg, newSess)
21312175 if newSess != nil {
21322176 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
21332177 newStates = copyExtensionStates(newSess.ExtensionState)
@@ -2144,6 +2188,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
21442188 if iv != nil {
21452189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
21462190 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2191+ iv.RefreshGoal()
21472192 }
21482193
21492194 // Re-scope the subagent dashboard to the new session.
@@ -2401,6 +2446,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
24012446 ChangeCWD: changeCWD,
24022447 PersistCompactHandoff: persistCompactHandoff,
24032448 CurrentCompactHandoff: currentCompactHandoff,
2449+ PersistGoal: persistGoal,
2450+ CurrentGoal: currentGoal,
24042451 CurrentSessionPath: func() string {
24052452 persistMu.Lock()
24062453 defer persistMu.Unlock()
@@ -688,21 +688,36 @@ func copyExtensionStates(states map[string]json.RawMessage) map[string]json.RawM
688 return out688 return out
689 }689 }
690 690
691-func persistExtensionToolResult(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) {691+func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result core.ToolResult) error {
692 if sess == nil {692 if sess == nil {
693- return693+ return nil
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- return697+ return fmt.Errorf("extension state: %w", err)
698+ }
699+ if mgr != nil {
700+ mgr.UpdateSessionState(details.Extension, details.State)
701+ }
698 }702 }
699- if err := sess.AppendExtensionState(details.Extension, details.State); err != nil {703+ if err := persistGoalToolResult(sess, result); err != nil {
700- fmt.Fprintf(os.Stderr, "extension state: %v\n", err)704+ return fmt.Errorf("goal state: %w", err)
701- return
702 }705 }
703- if mgr != nil {706+ return nil
704- mgr.UpdateSessionState(details.Extension, details.State)707+}
708+
709+func persistGoalToolResult(sess *core.Session, result core.ToolResult) error {
710+ if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive {
711+ return nil
712+ }
713+ update, ok := tools.GoalUpdateFromResult(result)
714+ if !ok {
715+ return nil
705 }716 }
717+ goal := *sess.Meta.Goal
718+ goal.Status = update.Status
719+ goal.Reason = update.Reason
720+ return sess.UpdateGoal(&goal)
706 }721 }
707 722
708 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {723 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -969,7 +984,7 @@ func runPrintMode(ctx context.Context, args Args, version string) (runErr error)
969 return err984 return err
970 }985 }
971 r.Provider, r.Model = providerName, model986 r.Provider, r.Model = providerName, model
972- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }987+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
973 defer func() { runErr = joinSessionCloseError(runErr, sess) }()988 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
974 }989 }
975 announceSession(extMgr, sess)990 announceSession(extMgr, sess)
@@ -1050,7 +1065,7 @@ func runStreamMode(ctx context.Context, args Args, version string) (runErr error
1050 return err1065 return err
1051 }1066 }
1052 r.Provider, r.Model = providerName, model1067 r.Provider, r.Model = providerName, model
1053- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }1068+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1054 defer func() { runErr = joinSessionCloseError(runErr, sess) }()1069 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1055 }1070 }
1056 announceSession(extMgr, sess)1071 announceSession(extMgr, sess)
@@ -1170,7 +1185,7 @@ func runJSONMode(ctx context.Context, args Args, version string) (runErr error)
1170 return err1185 return err
1171 }1186 }
1172 r.Provider, r.Model = providerName, model1187 r.Provider, r.Model = providerName, model
1173- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }1188+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
1174 defer func() { runErr = joinSessionCloseError(runErr, sess) }()1189 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
1175 }1190 }
1176 announceSession(extMgr, sess)1191 announceSession(extMgr, sess)
@@ -1802,10 +1817,34 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1802 }1817 }
1803 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)1818 return append(json.RawMessage(nil), sess.Meta.CompactHandoff...)
1804 }1819 }
1805- persistToolResult := func(_ string, result core.ToolResult) {1820+ persistGoal := func(goal *core.SessionGoal) error {
1821+ sessionTransitionMu.RLock()
1822+ defer sessionTransitionMu.RUnlock()
1823+ persistMu.Lock()
1824+ defer persistMu.Unlock()
1825+ if sess == nil {
1826+ return errors.New("session persistence is disabled")
1827+ }
1828+ return sess.UpdateGoal(goal)
1829+ }
1830+ currentGoal := func() *core.SessionGoal {
1806 persistMu.Lock()1831 persistMu.Lock()
1807 defer persistMu.Unlock()1832 defer persistMu.Unlock()
1808- persistExtensionToolResult(extMgr, sess, result)1833+ if sess == nil || sess.Meta.Goal == nil {
1834+ return nil
1835+ }
1836+ goal := *sess.Meta.Goal
1837+ return &goal
1838+ }
1839+ bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) {
1840+ if a == nil {
1841+ return
1842+ }
1843+ a.CommitToolResult = func(_ string, result core.ToolResult) error {
1844+ persistMu.Lock()
1845+ defer persistMu.Unlock()
1846+ return persistToolResultState(extMgr, boundSession, result)
1847+ }
1809 }1848 }
1810 wireAgentPersist := func(a *core.Agent) *core.Agent {1849 wireAgentPersist := func(a *core.Agent) *core.Agent {
1811 if a == nil {1850 if a == nil {
@@ -1820,7 +1859,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1820 a.OnMessageAppended = persistMessage1859 a.OnMessageAppended = persistMessage
1821 a.OnUsage = persistUsage1860 a.OnUsage = persistUsage
1822 a.OnTranscriptCompacted = persistCompaction1861 a.OnTranscriptCompacted = persistCompaction
1823- a.OnToolResult = persistToolResult1862+ bindAgentToolResultSession(a, currentSession)
1824 return a1863 return a
1825 }1864 }
1826 wireAgentPersist(ag)1865 wireAgentPersist(ag)
@@ -1929,6 +1968,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1929 oldCloseErr = oldSess.Close()1968 oldCloseErr = oldSess.Close()
1930 }1969 }
1931 sess = candidate.session1970 sess = candidate.session
1971+ bindAgentToolResultSession(candidate.agent, candidate.session)
1932 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)1972 candidate.agent.SetSessionTimeContext(candidate.session.Meta.Started, candidate.session.Meta.Timezone, candidate.session.Meta.TimezoneOffset)
1933 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint1973 sessionTitlePending = candidate.session != nil && candidate.session.Meta.Parent != "" && candidate.session.Title == "" && candidate.fullMessageCount <= candidate.session.Meta.ForkPoint
1934 // The live agent only receives a compact resume window, but the1974 // The live agent only receives a compact resume window, but the
@@ -1964,6 +2004,9 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
1964 }2004 }
1965 committed = true2005 committed = true
1966 persistMu.Unlock()2006 persistMu.Unlock()
2007+ if iv != nil {
2008+ iv.RefreshGoal()
2009+ }
1967 if candidate.session.Meta.Parent != "" {2010 if candidate.session.Meta.Parent != "" {
1968 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)2011 extMgr.EmitSessionEvent("session_forked", sessionContext(candidate.session), newStates)
1969 }2012 }
@@ -2128,6 +2171,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2128 oldCloseErr = sess.Close()2171 oldCloseErr = sess.Close()
2129 }2172 }
2130 sess = newSess2173 sess = newSess
2174+ bindAgentToolResultSession(newAg, newSess)
2131 if newSess != nil {2175 if newSess != nil {
2132 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)2176 newAg.SetSessionTimeContext(newSess.Meta.Started, newSess.Meta.Timezone, newSess.Meta.TimezoneOffset)
2133 newStates = copyExtensionStates(newSess.ExtensionState)2177 newStates = copyExtensionStates(newSess.ExtensionState)
@@ -2144,6 +2188,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2144 if iv != nil {2188 if iv != nil {
2145 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))2189 startupPaths := instructionContextPaths(loadAgentsContext(absPath, ZutHome()))
2146 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)2190 iv.ApplyChangedCWDWithStartupContext(newAg, newProvider, newModel, absPath, startupPaths)
2191+ iv.RefreshGoal()
2147 }2192 }
2148 2193
2149 // Re-scope the subagent dashboard to the new session.2194 // Re-scope the subagent dashboard to the new session.
@@ -2401,6 +2446,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
2401 ChangeCWD: changeCWD,2446 ChangeCWD: changeCWD,
2402 PersistCompactHandoff: persistCompactHandoff,2447 PersistCompactHandoff: persistCompactHandoff,
2403 CurrentCompactHandoff: currentCompactHandoff,2448 CurrentCompactHandoff: currentCompactHandoff,
2449+ PersistGoal: persistGoal,
2450+ CurrentGoal: currentGoal,
2404 CurrentSessionPath: func() string {2451 CurrentSessionPath: func() string {
2405 persistMu.Lock()2452 persistMu.Lock()
2406 defer persistMu.Unlock()2453 defer persistMu.Unlock()
M packages/agent/cli_session_test.go +72 -0
@@ -13,11 +13,83 @@ import (
1313 "time"
1414
1515 "github.com/bnema/zut/packages/agent/modes"
16+ "github.com/bnema/zut/packages/agent/tools"
1617 "github.com/bnema/zut/packages/core"
1718 "github.com/bnema/zut/packages/provider"
1819 "github.com/google/uuid"
1920 )
2021
22+func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
23+ root := t.TempDir()
24+ original, err := core.NewSession(root, "original", "provider", "model", "test")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ defer original.Close()
29+ next, err := core.NewSession(root, "next", "provider", "model", "test")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer next.Close()
34+ for _, sess := range []*core.Session{original, next} {
35+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil {
36+ t.Fatal(err)
37+ }
38+ }
39+
40+ boundSession := original
41+ currentSession := next // Simulate a completed session switch before callback delivery.
42+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}
43+ if err := persistToolResultState(nil, boundSession, result); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone {
48+ t.Fatalf("original goal = %#v, want done", original.Meta.Goal)
49+ }
50+ if currentSession.Meta.Goal == nil || currentSession.Meta.Goal.Status != core.GoalActive {
51+ t.Fatalf("current goal = %#v, want active", currentSession.Meta.Goal)
52+ }
53+}
54+
55+func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
56+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ defer sess.Close()
61+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
62+ t.Fatal(err)
63+ }
64+
65+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked}}
66+ if err := persistGoalToolResult(sess, result); err != nil {
67+ t.Fatal(err)
68+ }
69+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
70+ t.Fatalf("persisted goal = %#v, want active", got)
71+ }
72+}
73+
74+func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
75+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer sess.Close()
80+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
81+ t.Fatal(err)
82+ }
83+
84+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
85+ if err := persistGoalToolResult(sess, result); err != nil {
86+ t.Fatal(err)
87+ }
88+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
89+ t.Fatalf("persisted goal = %#v", got)
90+ }
91+}
92+
2193 type failingStdinReader struct{}
2294
2395 func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }
@@ -13,11 +13,83 @@ import (
13 "time"13 "time"
14 14
15 "github.com/bnema/zut/packages/agent/modes"15 "github.com/bnema/zut/packages/agent/modes"
16+ "github.com/bnema/zut/packages/agent/tools"
16 "github.com/bnema/zut/packages/core"17 "github.com/bnema/zut/packages/core"
17 "github.com/bnema/zut/packages/provider"18 "github.com/bnema/zut/packages/provider"
18 "github.com/google/uuid"19 "github.com/google/uuid"
19 )20 )
20 21
22+func TestToolResultPersistenceUsesBoundSession(t *testing.T) {
23+ root := t.TempDir()
24+ original, err := core.NewSession(root, "original", "provider", "model", "test")
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ defer original.Close()
29+ next, err := core.NewSession(root, "next", "provider", "model", "test")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ defer next.Close()
34+ for _, sess := range []*core.Session{original, next} {
35+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish", Status: core.GoalActive}); err != nil {
36+ t.Fatal(err)
37+ }
38+ }
39+
40+ boundSession := original
41+ currentSession := next // Simulate a completed session switch before callback delivery.
42+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}
43+ if err := persistToolResultState(nil, boundSession, result); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ if original.Meta.Goal == nil || original.Meta.Goal.Status != core.GoalDone {
48+ t.Fatalf("original goal = %#v, want done", original.Meta.Goal)
49+ }
50+ if currentSession.Meta.Goal == nil || currentSession.Meta.Goal.Status != core.GoalActive {
51+ t.Fatalf("current goal = %#v, want active", currentSession.Meta.Goal)
52+ }
53+}
54+
55+func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) {
56+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
57+ if err != nil {
58+ t.Fatal(err)
59+ }
60+ defer sess.Close()
61+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
62+ t.Fatal(err)
63+ }
64+
65+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked}}
66+ if err := persistGoalToolResult(sess, result); err != nil {
67+ t.Fatal(err)
68+ }
69+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalActive {
70+ t.Fatalf("persisted goal = %#v, want active", got)
71+ }
72+}
73+
74+func TestPersistGoalToolResultUpdatesActiveGoal(t *testing.T) {
75+ sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test")
76+ if err != nil {
77+ t.Fatal(err)
78+ }
79+ defer sess.Close()
80+ if err := sess.UpdateGoal(&core.SessionGoal{Objective: "finish the change", Status: core.GoalActive}); err != nil {
81+ t.Fatal(err)
82+ }
83+
84+ result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalBlocked, Reason: "need user input"}}
85+ if err := persistGoalToolResult(sess, result); err != nil {
86+ t.Fatal(err)
87+ }
88+ if got := sess.Meta.Goal; got == nil || got.Status != core.GoalBlocked || got.Reason != "need user input" {
89+ t.Fatalf("persisted goal = %#v", got)
90+ }
91+}
92+
21 type failingStdinReader struct{}93 type failingStdinReader struct{}
22 94
23 func (failingStdinReader) Read([]byte) (int, error) { return 0, errors.New("stdin failed") }95 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 {
1717 compactionStarted chan struct{}
1818 releaseCompaction chan struct{}
1919 followUpRequest chan provider.Request
20+ onFollowUp func()
2021
2122 mu sync.Mutex
2223 calls int
@@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (
4546 return
4647 }
4748
49+ if c.onFollowUp != nil {
50+ c.onFollowUp()
51+ }
4852 c.followUpRequest <- req
4953 out <- provider.EventDone{
5054 Stop: provider.StopEnd,
@@ -137,6 +141,93 @@ func TestAutoCompactionSettlesFinalStreamingStateBeforeCompacting(t *testing.T)
137141 close(client.releaseCompaction)
138142 }
139143
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+
140231 func TestPromptSubmittedDuringCompactionStartsFollowUpTurn(t *testing.T) {
141232 client := &compactQueueClient{
142233 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.Request19 followUpRequest chan provider.Request
20+ onFollowUp func()
20 21
21 mu sync.Mutex22 mu sync.Mutex
22 calls int23 calls int
@@ -45,6 +46,9 @@ func (c *compactQueueClient) Stream(ctx context.Context, req provider.Request) (
45 return46 return
46 }47 }
47 48
49+ if c.onFollowUp != nil {
50+ c.onFollowUp()
51+ }
48 c.followUpRequest <- req52 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 +266 -0
@@ -0,0 +1,266 @@
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+ // The objective may have been replaced while the prior turn was
104+ // finishing. Re-evaluate it now instead of waiting for another event;
105+ // the idle gate still gives queued user work priority.
106+ i.requestGoalContinuationIfIdle(parent)
107+ i.invalidate()
108+ return
109+ }
110+ i.mu.Unlock()
111+ ag.AppendUserContext(userMessageText(message), message.Meta)
112+
113+ // Persistence callbacks run synchronously from AppendUserContext, so never
114+ // hold the TUI mutex across it. Re-check user work that may have arrived
115+ // while the hidden continuation was being persisted.
116+ i.mu.Lock()
117+ if len(i.queued) > 0 {
118+ next := i.queued[0]
119+ i.queued = i.queued[1:]
120+ i.mu.Unlock()
121+ i.startTurn(parent, next)
122+ return
123+ }
124+ i.mu.Unlock()
125+ i.startTurnRequest(parent, "", nil, true, false)
126+}
127+
128+func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
129+ if goal == nil {
130+ return nil
131+ }
132+ copyGoal := *goal
133+ return &copyGoal
134+}
135+
136+func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
137+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
138+ return
139+ }
140+ goal := copySessionGoal(i.cfg.CurrentGoal())
141+ if goal == nil || goal.Status != core.GoalActive {
142+ return
143+ }
144+ goal.Status = status
145+ goal.Reason = reason
146+ if err := i.cfg.PersistGoal(goal); err != nil {
147+ i.ReportError(fmt.Errorf("persist goal: %w", err))
148+ return
149+ }
150+ i.setGoalStatus(goal)
151+}
152+
153+func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
154+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
155+ i.setGoalCommandError("goal: session persistence is unavailable")
156+ return
157+ }
158+
159+ current := copySessionGoal(i.cfg.CurrentGoal())
160+ if len(parts) == 1 {
161+ if current == nil {
162+ i.setGoalCommandStatus("no autonomous goal")
163+ return
164+ }
165+ status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)
166+ if current.Reason != "" {
167+ status += " (" + current.Reason + ")"
168+ }
169+ i.setGoalCommandStatus(status)
170+ return
171+ }
172+
173+ startIfIdle := false
174+ startTitle := false
175+ arg := ""
176+ if len(parts) == 2 {
177+ arg = strings.ToLower(parts[1])
178+ }
179+ switch arg {
180+ case "pause":
181+ if current == nil {
182+ i.setGoalCommandError("goal: no goal to pause")
183+ return
184+ }
185+ current.Status = core.GoalPaused
186+ current.Reason = ""
187+ case "resume":
188+ if current == nil {
189+ i.setGoalCommandError("goal: no goal to resume")
190+ return
191+ }
192+ current.Status = core.GoalActive
193+ current.Reason = ""
194+ startIfIdle = true
195+ case "clear":
196+ if err := i.cfg.PersistGoal(nil); err != nil {
197+ i.setGoalCommandError("goal: " + err.Error())
198+ return
199+ }
200+ i.setGoalStatus(nil)
201+ i.setGoalCommandStatus("autonomous goal cleared")
202+ return
203+ default:
204+ objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
205+ if objective == "" {
206+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")
207+ return
208+ }
209+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}
210+ startIfIdle = true
211+ startTitle = true
212+ }
213+
214+ if startIfIdle && !i.goalUpdatesAvailable() {
215+ i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
216+ return
217+ }
218+ if err := i.cfg.PersistGoal(current); err != nil {
219+ i.setGoalCommandError("goal: " + err.Error())
220+ return
221+ }
222+ i.setGoalStatus(current)
223+ i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
224+ if startTitle {
225+ i.maybeStartSessionTitle(ctx, current.Objective)
226+ }
227+ if startIfIdle {
228+ i.requestGoalContinuationIfIdle(ctx)
229+ }
230+}
231+
232+// RefreshGoal reloads the status badge after the CLI commits a session switch.
233+func (i *Interactive) RefreshGoal() {
234+ if i.cfg.CurrentGoal == nil {
235+ i.setGoalStatus(nil)
236+ return
237+ }
238+ i.setGoalStatus(i.cfg.CurrentGoal())
239+}
240+
241+func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
242+ status := core.GoalStatus("")
243+ if goal != nil {
244+ status = goal.Status
245+ }
246+ i.mu.Lock()
247+ i.goalStatus = status
248+ i.mu.Unlock()
249+ i.invalidate()
250+}
251+
252+func (i *Interactive) setGoalCommandStatus(message string) {
253+ i.mu.Lock()
254+ i.statusOK = message
255+ i.statusErr = ""
256+ i.mu.Unlock()
257+ i.invalidate()
258+}
259+
260+func (i *Interactive) setGoalCommandError(message string) {
261+ i.mu.Lock()
262+ i.statusOK = ""
263+ i.statusErr = message
264+ i.mu.Unlock()
265+ i.invalidate()
266+}
@@ -0,0 +1,266 @@
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+ // The objective may have been replaced while the prior turn was
104+ // finishing. Re-evaluate it now instead of waiting for another event;
105+ // the idle gate still gives queued user work priority.
106+ i.requestGoalContinuationIfIdle(parent)
107+ i.invalidate()
108+ return
109+ }
110+ i.mu.Unlock()
111+ ag.AppendUserContext(userMessageText(message), message.Meta)
112+
113+ // Persistence callbacks run synchronously from AppendUserContext, so never
114+ // hold the TUI mutex across it. Re-check user work that may have arrived
115+ // while the hidden continuation was being persisted.
116+ i.mu.Lock()
117+ if len(i.queued) > 0 {
118+ next := i.queued[0]
119+ i.queued = i.queued[1:]
120+ i.mu.Unlock()
121+ i.startTurn(parent, next)
122+ return
123+ }
124+ i.mu.Unlock()
125+ i.startTurnRequest(parent, "", nil, true, false)
126+}
127+
128+func copySessionGoal(goal *core.SessionGoal) *core.SessionGoal {
129+ if goal == nil {
130+ return nil
131+ }
132+ copyGoal := *goal
133+ return &copyGoal
134+}
135+
136+func (i *Interactive) updateActiveGoal(status core.GoalStatus, reason string) {
137+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
138+ return
139+ }
140+ goal := copySessionGoal(i.cfg.CurrentGoal())
141+ if goal == nil || goal.Status != core.GoalActive {
142+ return
143+ }
144+ goal.Status = status
145+ goal.Reason = reason
146+ if err := i.cfg.PersistGoal(goal); err != nil {
147+ i.ReportError(fmt.Errorf("persist goal: %w", err))
148+ return
149+ }
150+ i.setGoalStatus(goal)
151+}
152+
153+func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []string) {
154+ if i.cfg.CurrentGoal == nil || i.cfg.PersistGoal == nil {
155+ i.setGoalCommandError("goal: session persistence is unavailable")
156+ return
157+ }
158+
159+ current := copySessionGoal(i.cfg.CurrentGoal())
160+ if len(parts) == 1 {
161+ if current == nil {
162+ i.setGoalCommandStatus("no autonomous goal")
163+ return
164+ }
165+ status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective)
166+ if current.Reason != "" {
167+ status += " (" + current.Reason + ")"
168+ }
169+ i.setGoalCommandStatus(status)
170+ return
171+ }
172+
173+ startIfIdle := false
174+ startTitle := false
175+ arg := ""
176+ if len(parts) == 2 {
177+ arg = strings.ToLower(parts[1])
178+ }
179+ switch arg {
180+ case "pause":
181+ if current == nil {
182+ i.setGoalCommandError("goal: no goal to pause")
183+ return
184+ }
185+ current.Status = core.GoalPaused
186+ current.Reason = ""
187+ case "resume":
188+ if current == nil {
189+ i.setGoalCommandError("goal: no goal to resume")
190+ return
191+ }
192+ current.Status = core.GoalActive
193+ current.Reason = ""
194+ startIfIdle = true
195+ case "clear":
196+ if err := i.cfg.PersistGoal(nil); err != nil {
197+ i.setGoalCommandError("goal: " + err.Error())
198+ return
199+ }
200+ i.setGoalStatus(nil)
201+ i.setGoalCommandStatus("autonomous goal cleared")
202+ return
203+ default:
204+ objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0]))
205+ if objective == "" {
206+ i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear")
207+ return
208+ }
209+ current = &core.SessionGoal{Objective: objective, Status: core.GoalActive}
210+ startIfIdle = true
211+ startTitle = true
212+ }
213+
214+ if startIfIdle && !i.goalUpdatesAvailable() {
215+ i.setGoalCommandError("goal: update_goal is unavailable; enable the default tools")
216+ return
217+ }
218+ if err := i.cfg.PersistGoal(current); err != nil {
219+ i.setGoalCommandError("goal: " + err.Error())
220+ return
221+ }
222+ i.setGoalStatus(current)
223+ i.setGoalCommandStatus(fmt.Sprintf("autonomous goal %s: %s", current.Status, current.Objective))
224+ if startTitle {
225+ i.maybeStartSessionTitle(ctx, current.Objective)
226+ }
227+ if startIfIdle {
228+ i.requestGoalContinuationIfIdle(ctx)
229+ }
230+}
231+
232+// RefreshGoal reloads the status badge after the CLI commits a session switch.
233+func (i *Interactive) RefreshGoal() {
234+ if i.cfg.CurrentGoal == nil {
235+ i.setGoalStatus(nil)
236+ return
237+ }
238+ i.setGoalStatus(i.cfg.CurrentGoal())
239+}
240+
241+func (i *Interactive) setGoalStatus(goal *core.SessionGoal) {
242+ status := core.GoalStatus("")
243+ if goal != nil {
244+ status = goal.Status
245+ }
246+ i.mu.Lock()
247+ i.goalStatus = status
248+ i.mu.Unlock()
249+ i.invalidate()
250+}
251+
252+func (i *Interactive) setGoalCommandStatus(message string) {
253+ i.mu.Lock()
254+ i.statusOK = message
255+ i.statusErr = ""
256+ i.mu.Unlock()
257+ i.invalidate()
258+}
259+
260+func (i *Interactive) setGoalCommandError(message string) {
261+ i.mu.Lock()
262+ i.statusOK = ""
263+ i.statusErr = message
264+ i.mu.Unlock()
265+ i.invalidate()
266+}
A packages/agent/modes/goal_test.go +373 -0
@@ -0,0 +1,373 @@
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+ onCall func(call int)
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+ onCall := c.onCall
33+ c.mu.Unlock()
34+ if onCall != nil {
35+ onCall(call)
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{onCall: func(call int) {
64+ if call == 2 {
65+ _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
66+ }
67+ }}
68+ turnFlushed := make(chan struct{}, 2)
69+ interactive := NewInteractive(InteractiveConfig{
70+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
71+ CurrentGoal: currentGoal,
72+ PersistGoal: persistGoal,
73+ FlushSession: func() {
74+ turnFlushed <- struct{}{}
75+ },
76+ })
77+ interactive.runCtx = context.Background()
78+ interactive.runSlash(context.Background(), "/goal finish issue 97")
79+ for turn := 1; turn <= 2; turn++ {
80+ select {
81+ case <-turnFlushed:
82+ case <-time.After(2 * time.Second):
83+ t.Fatalf("turn %d did not finish", turn)
84+ }
85+ }
86+
87+ client.mu.Lock()
88+ defer client.mu.Unlock()
89+ if len(client.requests) != 2 {
90+ t.Fatalf("provider requests = %d, want 2", len(client.requests))
91+ }
92+ messages := client.requests[1].Messages
93+ if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
94+ t.Fatalf("continuation request tail = %#v", messages)
95+ }
96+}
97+
98+func TestReplacementGoalContinuesAfterStaleTurn(t *testing.T) {
99+ var mu sync.Mutex
100+ goal := &core.SessionGoal{Objective: "replacement objective", Status: core.GoalActive}
101+ currentGoal := func() *core.SessionGoal {
102+ mu.Lock()
103+ defer mu.Unlock()
104+ return cloneSessionGoal(goal)
105+ }
106+ persistGoal := func(next *core.SessionGoal) error {
107+ mu.Lock()
108+ goal = cloneSessionGoal(next)
109+ mu.Unlock()
110+ return nil
111+ }
112+ client := &goalContinuationClient{onCall: func(call int) {
113+ if call == 1 {
114+ _ = persistGoal(&core.SessionGoal{Objective: "replacement objective", Status: core.GoalPaused})
115+ }
116+ }}
117+ turnFlushed := make(chan struct{}, 1)
118+ interactive := NewInteractive(InteractiveConfig{
119+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
120+ CurrentGoal: currentGoal,
121+ PersistGoal: persistGoal,
122+ FlushSession: func() {
123+ turnFlushed <- struct{}{}
124+ },
125+ })
126+ interactive.runCtx = context.Background()
127+ interactive.mu.Lock()
128+ interactive.busy = true
129+ interactive.mu.Unlock()
130+ stale := provider.Message{
131+ Role: provider.RoleUser,
132+ Content: []provider.Content{provider.TextBlock{Text: "original objective"}},
133+ Meta: map[string]string{goalContinueMetaKey: "true"},
134+ }
135+
136+ interactive.startGoalContinuation(context.Background(), stale)
137+ select {
138+ case <-turnFlushed:
139+ case <-time.After(2 * time.Second):
140+ t.Fatal("replacement goal did not start")
141+ }
142+
143+ client.mu.Lock()
144+ defer client.mu.Unlock()
145+ if len(client.requests) != 1 {
146+ t.Fatalf("provider requests = %d, want 1", len(client.requests))
147+ }
148+ tail := client.requests[0].Messages[len(client.requests[0].Messages)-1]
149+ if got := userMessageText(tail); !strings.Contains(got, "replacement objective") || strings.Contains(got, "original objective") {
150+ t.Fatalf("continuation message = %q", got)
151+ }
152+}
153+
154+func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
155+ var transitionMu sync.RWMutex
156+ transitionCompleted := false
157+ callbackStarted := make(chan struct{})
158+ allowPersistence := make(chan struct{})
159+ transitionHeld := make(chan struct{})
160+ transitionDone := make(chan struct{})
161+ handled := make(chan struct{})
162+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
163+ agent.CommitToolResult = func(_ string, _ core.ToolResult) error {
164+ close(callbackStarted)
165+ <-allowPersistence
166+ transitionMu.RLock()
167+ completed := transitionCompleted
168+ transitionMu.RUnlock()
169+ if !completed {
170+ return errors.New("session transition did not complete")
171+ }
172+ return nil
173+ }
174+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
175+ commitErr := make(chan error, 1)
176+
177+ go func() {
178+ commitErr <- agent.CommitToolResult("tool", core.ToolResult{})
179+ interactive.handleEvent(core.EvToolResult{ID: "tool", Result: core.ToolResult{}})
180+ close(handled)
181+ }()
182+ select {
183+ case <-callbackStarted:
184+ case <-time.After(2 * time.Second):
185+ t.Fatal("tool-result callback did not start")
186+ }
187+ go func() {
188+ transitionMu.Lock()
189+ close(transitionHeld)
190+ interactive.ApplySessionAgent(core.NewAgent(nil, "model", "system", goalToolRegistry()), "provider", "model")
191+ transitionCompleted = true
192+ transitionMu.Unlock()
193+ close(transitionDone)
194+ }()
195+ select {
196+ case <-transitionHeld:
197+ case <-time.After(2 * time.Second):
198+ t.Fatal("session transition did not acquire its lock")
199+ }
200+ select {
201+ case <-transitionDone:
202+ case <-time.After(2 * time.Second):
203+ close(allowPersistence)
204+ t.Fatal("session transition deadlocked with tool-result persistence")
205+ }
206+ close(allowPersistence)
207+ select {
208+ case <-handled:
209+ case <-time.After(2 * time.Second):
210+ t.Fatal("tool-result callback did not finish")
211+ }
212+ if err := <-commitErr; err != nil {
213+ t.Fatalf("commit tool result: %v", err)
214+ }
215+}
216+
217+func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219+ interactive := NewInteractive(InteractiveConfig{
220+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
221+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
222+ })
223+ // Core converts a goal update to an error result when durable persistence
224+ // fails, so the presentation layer must leave the active badge unchanged.
225+ result := core.ToolResult{
226+ Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
227+ IsError: true,
228+ }
229+
230+ interactive.handleEvent(core.EvToolResult{ID: "goal", Result: result})
231+ interactive.mu.Lock()
232+ defer interactive.mu.Unlock()
233+ if interactive.goalStatus != core.GoalActive {
234+ t.Fatalf("goal badge = %q, want durable active status", interactive.goalStatus)
235+ }
236+}
237+
238+func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
239+ var goal *core.SessionGoal
240+ interactive := NewInteractive(InteractiveConfig{
241+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
242+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
243+ PersistGoal: func(next *core.SessionGoal) error {
244+ goal = cloneSessionGoal(next)
245+ return nil
246+ },
247+ })
248+ interactive.busy = true // exercise controls without launching a provider turn
249+
250+ interactive.runSlash(context.Background(), "/goal finish issue 97")
251+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
252+ t.Fatalf("started goal = %#v", goal)
253+ }
254+ if interactive.goalStatus != core.GoalActive {
255+ t.Fatalf("started badge = %q", interactive.goalStatus)
256+ }
257+
258+ interactive.runSlash(context.Background(), "/goal pause")
259+ if goal.Status != core.GoalPaused {
260+ t.Fatalf("paused goal = %#v", goal)
261+ }
262+ if interactive.goalStatus != core.GoalPaused {
263+ t.Fatalf("paused badge = %q", interactive.goalStatus)
264+ }
265+ interactive.runSlash(context.Background(), "/goal resume")
266+ if goal.Status != core.GoalActive {
267+ t.Fatalf("resumed goal = %#v", goal)
268+ }
269+ if interactive.goalStatus != core.GoalActive {
270+ t.Fatalf("resumed badge = %q", interactive.goalStatus)
271+ }
272+ interactive.runSlash(context.Background(), "/goal clear")
273+ if goal != nil {
274+ t.Fatalf("cleared goal = %#v", goal)
275+ }
276+ if interactive.goalStatus != "" {
277+ t.Fatalf("cleared badge = %q", interactive.goalStatus)
278+ }
279+ interactive.runSlash(context.Background(), "/goal pause deployment safely")
280+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
281+ t.Fatalf("multiword control-like objective = %#v", goal)
282+ }
283+ goal.Status = core.GoalBlocked
284+ goal.Reason = "waiting for input"
285+ interactive.runSlash(context.Background(), "/goal")
286+ if !strings.Contains(interactive.statusOK, "waiting for input") {
287+ t.Fatalf("goal status = %q", interactive.statusOK)
288+ }
289+}
290+
291+func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292+ interactive := NewInteractive(InteractiveConfig{})
293+ interactive.runSlash(context.Background(), "/goal pause")
294+ if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
295+ t.Fatalf("status error = %q", interactive.statusErr)
296+ }
297+}
298+
299+func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
300+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
301+ interactive := NewInteractive(InteractiveConfig{
302+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
303+ CurrentGoal: func() *core.SessionGoal { return goal },
304+ PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
305+ })
306+ interactive.busy = true
307+
308+ interactive.runSlash(context.Background(), "/goal pause")
309+ if goal.Status != core.GoalActive {
310+ t.Fatalf("live goal status = %q, want active", goal.Status)
311+ }
312+ if interactive.goalStatus != core.GoalActive {
313+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
314+ }
315+}
316+
317+func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
318+ var persisted bool
319+ interactive := NewInteractive(InteractiveConfig{
320+ Agent: core.NewAgent(nil, "model", "system", nil),
321+ CurrentGoal: func() *core.SessionGoal { return nil },
322+ PersistGoal: func(*core.SessionGoal) error {
323+ persisted = true
324+ return nil
325+ },
326+ })
327+
328+ interactive.runSlash(context.Background(), "/goal finish issue 97")
329+ if persisted {
330+ t.Fatal("goal was persisted without the update_goal tool")
331+ }
332+ if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
333+ t.Fatalf("status error = %q", interactive.statusErr)
334+ }
335+}
336+
337+func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
338+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
339+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
340+ interactive := &Interactive{
341+ agent: agent,
342+ cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
343+ }
344+
345+ message, ok := interactive.goalContinuationIfIdle()
346+ if !ok {
347+ t.Fatal("active idle goal did not request continuation")
348+ }
349+ if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
350+ t.Fatalf("continuation message = %#v", message)
351+ }
352+ if !isHiddenTranscriptMessage(message) {
353+ t.Fatal("goal continuation was rendered as a user message")
354+ }
355+
356+ agent.QueueMessage("user follow-up")
357+ if _, ok := interactive.goalContinuationIfIdle(); ok {
358+ t.Fatal("goal continuation bypassed queued user work")
359+ }
360+ agent.DrainQueuedMessages()
361+ goal.Status = core.GoalPaused
362+ if _, ok := interactive.goalContinuationIfIdle(); ok {
363+ t.Fatal("paused goal requested continuation")
364+ }
365+}
366+
367+func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
368+ if goal == nil {
369+ return nil
370+ }
371+ clone := *goal
372+ return &clone
373+}
@@ -0,0 +1,373 @@
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+ onCall func(call int)
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+ onCall := c.onCall
33+ c.mu.Unlock()
34+ if onCall != nil {
35+ onCall(call)
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{onCall: func(call int) {
64+ if call == 2 {
65+ _ = persistGoal(&core.SessionGoal{Objective: "finish issue 97", Status: core.GoalPaused})
66+ }
67+ }}
68+ turnFlushed := make(chan struct{}, 2)
69+ interactive := NewInteractive(InteractiveConfig{
70+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
71+ CurrentGoal: currentGoal,
72+ PersistGoal: persistGoal,
73+ FlushSession: func() {
74+ turnFlushed <- struct{}{}
75+ },
76+ })
77+ interactive.runCtx = context.Background()
78+ interactive.runSlash(context.Background(), "/goal finish issue 97")
79+ for turn := 1; turn <= 2; turn++ {
80+ select {
81+ case <-turnFlushed:
82+ case <-time.After(2 * time.Second):
83+ t.Fatalf("turn %d did not finish", turn)
84+ }
85+ }
86+
87+ client.mu.Lock()
88+ defer client.mu.Unlock()
89+ if len(client.requests) != 2 {
90+ t.Fatalf("provider requests = %d, want 2", len(client.requests))
91+ }
92+ messages := client.requests[1].Messages
93+ if len(messages) == 0 || messages[len(messages)-1].Meta[goalContinueMetaKey] != "true" {
94+ t.Fatalf("continuation request tail = %#v", messages)
95+ }
96+}
97+
98+func TestReplacementGoalContinuesAfterStaleTurn(t *testing.T) {
99+ var mu sync.Mutex
100+ goal := &core.SessionGoal{Objective: "replacement objective", Status: core.GoalActive}
101+ currentGoal := func() *core.SessionGoal {
102+ mu.Lock()
103+ defer mu.Unlock()
104+ return cloneSessionGoal(goal)
105+ }
106+ persistGoal := func(next *core.SessionGoal) error {
107+ mu.Lock()
108+ goal = cloneSessionGoal(next)
109+ mu.Unlock()
110+ return nil
111+ }
112+ client := &goalContinuationClient{onCall: func(call int) {
113+ if call == 1 {
114+ _ = persistGoal(&core.SessionGoal{Objective: "replacement objective", Status: core.GoalPaused})
115+ }
116+ }}
117+ turnFlushed := make(chan struct{}, 1)
118+ interactive := NewInteractive(InteractiveConfig{
119+ Agent: core.NewAgent(client, "model", "system", goalToolRegistry()),
120+ CurrentGoal: currentGoal,
121+ PersistGoal: persistGoal,
122+ FlushSession: func() {
123+ turnFlushed <- struct{}{}
124+ },
125+ })
126+ interactive.runCtx = context.Background()
127+ interactive.mu.Lock()
128+ interactive.busy = true
129+ interactive.mu.Unlock()
130+ stale := provider.Message{
131+ Role: provider.RoleUser,
132+ Content: []provider.Content{provider.TextBlock{Text: "original objective"}},
133+ Meta: map[string]string{goalContinueMetaKey: "true"},
134+ }
135+
136+ interactive.startGoalContinuation(context.Background(), stale)
137+ select {
138+ case <-turnFlushed:
139+ case <-time.After(2 * time.Second):
140+ t.Fatal("replacement goal did not start")
141+ }
142+
143+ client.mu.Lock()
144+ defer client.mu.Unlock()
145+ if len(client.requests) != 1 {
146+ t.Fatalf("provider requests = %d, want 1", len(client.requests))
147+ }
148+ tail := client.requests[0].Messages[len(client.requests[0].Messages)-1]
149+ if got := userMessageText(tail); !strings.Contains(got, "replacement objective") || strings.Contains(got, "original objective") {
150+ t.Fatalf("continuation message = %q", got)
151+ }
152+}
153+
154+func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) {
155+ var transitionMu sync.RWMutex
156+ transitionCompleted := false
157+ callbackStarted := make(chan struct{})
158+ allowPersistence := make(chan struct{})
159+ transitionHeld := make(chan struct{})
160+ transitionDone := make(chan struct{})
161+ handled := make(chan struct{})
162+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
163+ agent.CommitToolResult = func(_ string, _ core.ToolResult) error {
164+ close(callbackStarted)
165+ <-allowPersistence
166+ transitionMu.RLock()
167+ completed := transitionCompleted
168+ transitionMu.RUnlock()
169+ if !completed {
170+ return errors.New("session transition did not complete")
171+ }
172+ return nil
173+ }
174+ interactive := NewInteractive(InteractiveConfig{Agent: agent})
175+ commitErr := make(chan error, 1)
176+
177+ go func() {
178+ commitErr <- agent.CommitToolResult("tool", core.ToolResult{})
179+ interactive.handleEvent(core.EvToolResult{ID: "tool", Result: core.ToolResult{}})
180+ close(handled)
181+ }()
182+ select {
183+ case <-callbackStarted:
184+ case <-time.After(2 * time.Second):
185+ t.Fatal("tool-result callback did not start")
186+ }
187+ go func() {
188+ transitionMu.Lock()
189+ close(transitionHeld)
190+ interactive.ApplySessionAgent(core.NewAgent(nil, "model", "system", goalToolRegistry()), "provider", "model")
191+ transitionCompleted = true
192+ transitionMu.Unlock()
193+ close(transitionDone)
194+ }()
195+ select {
196+ case <-transitionHeld:
197+ case <-time.After(2 * time.Second):
198+ t.Fatal("session transition did not acquire its lock")
199+ }
200+ select {
201+ case <-transitionDone:
202+ case <-time.After(2 * time.Second):
203+ close(allowPersistence)
204+ t.Fatal("session transition deadlocked with tool-result persistence")
205+ }
206+ close(allowPersistence)
207+ select {
208+ case <-handled:
209+ case <-time.After(2 * time.Second):
210+ t.Fatal("tool-result callback did not finish")
211+ }
212+ if err := <-commitErr; err != nil {
213+ t.Fatalf("commit tool result: %v", err)
214+ }
215+}
216+
217+func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) {
218+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
219+ interactive := NewInteractive(InteractiveConfig{
220+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
221+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
222+ })
223+ // Core converts a goal update to an error result when durable persistence
224+ // fails, so the presentation layer must leave the active badge unchanged.
225+ result := core.ToolResult{
226+ Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
227+ IsError: true,
228+ }
229+
230+ interactive.handleEvent(core.EvToolResult{ID: "goal", Result: result})
231+ interactive.mu.Lock()
232+ defer interactive.mu.Unlock()
233+ if interactive.goalStatus != core.GoalActive {
234+ t.Fatalf("goal badge = %q, want durable active status", interactive.goalStatus)
235+ }
236+}
237+
238+func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) {
239+ var goal *core.SessionGoal
240+ interactive := NewInteractive(InteractiveConfig{
241+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
242+ CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) },
243+ PersistGoal: func(next *core.SessionGoal) error {
244+ goal = cloneSessionGoal(next)
245+ return nil
246+ },
247+ })
248+ interactive.busy = true // exercise controls without launching a provider turn
249+
250+ interactive.runSlash(context.Background(), "/goal finish issue 97")
251+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "finish issue 97" {
252+ t.Fatalf("started goal = %#v", goal)
253+ }
254+ if interactive.goalStatus != core.GoalActive {
255+ t.Fatalf("started badge = %q", interactive.goalStatus)
256+ }
257+
258+ interactive.runSlash(context.Background(), "/goal pause")
259+ if goal.Status != core.GoalPaused {
260+ t.Fatalf("paused goal = %#v", goal)
261+ }
262+ if interactive.goalStatus != core.GoalPaused {
263+ t.Fatalf("paused badge = %q", interactive.goalStatus)
264+ }
265+ interactive.runSlash(context.Background(), "/goal resume")
266+ if goal.Status != core.GoalActive {
267+ t.Fatalf("resumed goal = %#v", goal)
268+ }
269+ if interactive.goalStatus != core.GoalActive {
270+ t.Fatalf("resumed badge = %q", interactive.goalStatus)
271+ }
272+ interactive.runSlash(context.Background(), "/goal clear")
273+ if goal != nil {
274+ t.Fatalf("cleared goal = %#v", goal)
275+ }
276+ if interactive.goalStatus != "" {
277+ t.Fatalf("cleared badge = %q", interactive.goalStatus)
278+ }
279+ interactive.runSlash(context.Background(), "/goal pause deployment safely")
280+ if goal == nil || goal.Status != core.GoalActive || goal.Objective != "pause deployment safely" {
281+ t.Fatalf("multiword control-like objective = %#v", goal)
282+ }
283+ goal.Status = core.GoalBlocked
284+ goal.Reason = "waiting for input"
285+ interactive.runSlash(context.Background(), "/goal")
286+ if !strings.Contains(interactive.statusOK, "waiting for input") {
287+ t.Fatalf("goal status = %q", interactive.statusOK)
288+ }
289+}
290+
291+func TestGoalControlRequiresSessionPersistence(t *testing.T) {
292+ interactive := NewInteractive(InteractiveConfig{})
293+ interactive.runSlash(context.Background(), "/goal pause")
294+ if !strings.Contains(interactive.statusErr, "session persistence is unavailable") {
295+ t.Fatalf("status error = %q", interactive.statusErr)
296+ }
297+}
298+
299+func TestGoalCommandDoesNotMutateLiveStateWhenPersistenceFails(t *testing.T) {
300+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
301+ interactive := NewInteractive(InteractiveConfig{
302+ Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()),
303+ CurrentGoal: func() *core.SessionGoal { return goal },
304+ PersistGoal: func(*core.SessionGoal) error { return errors.New("disk full") },
305+ })
306+ interactive.busy = true
307+
308+ interactive.runSlash(context.Background(), "/goal pause")
309+ if goal.Status != core.GoalActive {
310+ t.Fatalf("live goal status = %q, want active", goal.Status)
311+ }
312+ if interactive.goalStatus != core.GoalActive {
313+ t.Fatalf("goal badge = %q, want active", interactive.goalStatus)
314+ }
315+}
316+
317+func TestGoalSlashCommandRequiresUpdateGoalTool(t *testing.T) {
318+ var persisted bool
319+ interactive := NewInteractive(InteractiveConfig{
320+ Agent: core.NewAgent(nil, "model", "system", nil),
321+ CurrentGoal: func() *core.SessionGoal { return nil },
322+ PersistGoal: func(*core.SessionGoal) error {
323+ persisted = true
324+ return nil
325+ },
326+ })
327+
328+ interactive.runSlash(context.Background(), "/goal finish issue 97")
329+ if persisted {
330+ t.Fatal("goal was persisted without the update_goal tool")
331+ }
332+ if !strings.Contains(interactive.statusErr, "update_goal is unavailable") {
333+ t.Fatalf("status error = %q", interactive.statusErr)
334+ }
335+}
336+
337+func TestGoalContinuationRequiresActiveIdleGoalAndYieldsToQueuedUser(t *testing.T) {
338+ goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive}
339+ agent := core.NewAgent(nil, "model", "system", goalToolRegistry())
340+ interactive := &Interactive{
341+ agent: agent,
342+ cfg: InteractiveConfig{CurrentGoal: func() *core.SessionGoal { return cloneSessionGoal(goal) }},
343+ }
344+
345+ message, ok := interactive.goalContinuationIfIdle()
346+ if !ok {
347+ t.Fatal("active idle goal did not request continuation")
348+ }
349+ if message.Meta[goalContinueMetaKey] != "true" || message.Role != provider.RoleUser {
350+ t.Fatalf("continuation message = %#v", message)
351+ }
352+ if !isHiddenTranscriptMessage(message) {
353+ t.Fatal("goal continuation was rendered as a user message")
354+ }
355+
356+ agent.QueueMessage("user follow-up")
357+ if _, ok := interactive.goalContinuationIfIdle(); ok {
358+ t.Fatal("goal continuation bypassed queued user work")
359+ }
360+ agent.DrainQueuedMessages()
361+ goal.Status = core.GoalPaused
362+ if _, ok := interactive.goalContinuationIfIdle(); ok {
363+ t.Fatal("paused goal requested continuation")
364+ }
365+}
366+
367+func cloneSessionGoal(goal *core.SessionGoal) *core.SessionGoal {
368+ if goal == nil {
369+ return nil
370+ }
371+ clone := *goal
372+ return &clone
373+}
M packages/agent/modes/interactive.go +43 -9
@@ -372,8 +372,14 @@ type InteractiveConfig struct {
372372 // after a manual rename has already been written to disk.
373373 OnSessionTitleChanged func(title string)
374374
375- OnAssistant func(m provider.Message)
376- OnToolResult func(id string, r core.ToolResult)
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+
382+ OnAssistant func(m provider.Message)
377383
378384 // Extensions, if non-nil, lets users invoke extension-registered
379385 // slash commands. Commands declared by extensions are looked up
@@ -661,6 +667,7 @@ type Interactive struct {
661667 toolGate map[string]int
662668 statusErr string
663669 statusOK string
670+ goalStatus core.GoalStatus
664671 reloadStatusSeq uint64
665672 extStatuses map[string]map[string]extensionStatus
666673 extWidgets map[string]map[string]extensionWidget
@@ -916,6 +923,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
916923 startupContextPaths := []string(nil)
917924 startupExtensionNames := []string(nil)
918925 startupSkillNames := []string(nil)
926+ initialGoalStatus := core.GoalStatus("")
927+ if cfg.CurrentGoal != nil {
928+ if goal := cfg.CurrentGoal(); goal != nil {
929+ initialGoalStatus = goal.Status
930+ }
931+ }
919932 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
920933 startupAgentName = cfg.StartupAgentName
921934 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
@@ -969,6 +982,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
969982 spin: newSpinner(cfg.Theme),
970983 inputHistoryIndex: -1,
971984 clock: time.Now,
985+ goalStatus: initialGoalStatus,
972986 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
973987 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
974988 }
@@ -2066,6 +2080,7 @@ func (i *Interactive) redraw() {
20662080 if workingWithStatus {
20672081 statusBusyPrefix = busyPrefix
20682082 }
2083+ goalStatus := string(i.goalStatus)
20692084 statusLines := tui.StatusBar(tui.StatusBarParams{
20702085 Theme: i.cfg.Theme,
20712086 Provider: i.cfg.Provider,
@@ -2077,6 +2092,7 @@ func (i *Interactive) redraw() {
20772092 CWD: i.cfg.CWD,
20782093 Locked: i.cfg.Sandbox.Locked(),
20792094 NoYolo: i.cfg.NoYolo,
2095+ GoalStatus: goalStatus,
20802096 Usage: i.cumUsage,
20812097 Subscription: i.cfg.AuthMethod == "oauth",
20822098 ContextUsed: i.lastCtxInput,
@@ -2544,7 +2560,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
25442560 }
25452561
25462562 func isHiddenTranscriptMessage(m provider.Message) bool {
2547- if m.Meta[autoCompactContinueMetaKey] == "true" {
2563+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
25482564 return true
25492565 }
25502566 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -3290,6 +3306,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
32903306 }
32913307 i.mu.Unlock()
32923308 if busyCancel {
3309+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
32933310 cancelTurn()
32943311 // If a confirm dialog is pending, refuse it so the agent
32953312 // goroutine unblocks and the context cancellation can
@@ -5908,6 +5925,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
59085925 }
59095926
59105927 switch strings.ToLower(parts[0]) {
5928+ case "/goal":
5929+ i.runGoalCommand(ctx, cmd, parts)
59115930 case "/exit":
59125931 return true
59135932 case "/clear":
@@ -7293,6 +7312,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
72937312 }
72947313 summary, err := i.agent.Compact(ctx, keepTail, sink)
72957314 _ = summary
7315+ goalMessage, goalActive := i.goalContinuationMessage()
72967316 i.mu.Lock()
72977317 // Keep busy/compacting asserted while cleanup and queue selection run.
72987318 // Completion updates can arrive in this window; clearing busy before
@@ -7311,6 +7331,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
73117331 var hasNext bool
73127332 var continueExisting bool
73137333 var continueAutomatically bool
7334+ var continueGoal bool
73147335 var handoff json.RawMessage
73157336 var persistHandoff bool
73167337
@@ -7398,6 +7419,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
73987419 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
73997420 }
74007421 continueAutomatically = true
7422+ case auto && goalActive:
7423+ continueGoal = true
7424+ handoff, persistHandoff = i.resetCompactContinuationLocked()
74017425 default:
74027426 handoff, persistHandoff = i.resetCompactContinuationLocked()
74037427 }
@@ -7406,7 +7430,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74067430 // the mutex. A completion update arriving after the compaction result
74077431 // but before this assignment is then queued for the selected follow-up
74087432 // instead of starting a competing turn.
7409- i.busy = hasNext || continueExisting || continueAutomatically
7433+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
74107434 i.compacting = false
74117435 i.autoCompacting = false
74127436 i.mu.Unlock()
@@ -7416,7 +7440,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74167440 runPendingIdleWork(pendingIdleWork)
74177441 i.invalidate()
74187442
7419- if hasNext || continueExisting || continueAutomatically {
7443+ if hasNext || continueExisting || continueAutomatically || continueGoal {
74207444 p := i.runCtx
74217445 if p == nil {
74227446 p = context.Background()
@@ -7428,6 +7452,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
74287452 i.startTurnWithImages(p, next, nextImages)
74297453 case continueAutomatically:
74307454 i.startAutoCompactContinuation(p)
7455+ case continueGoal:
7456+ i.startGoalContinuation(p, goalMessage)
74317457 }
74327458 }
74337459 }()
@@ -7762,6 +7788,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
77627788 if flush != nil {
77637789 flush()
77647790 }
7791+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7792+ if terminalGoalError {
7793+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7794+ }
7795+ goalMessage, goalActive := i.goalContinuationMessage()
77657796 i.mu.Lock()
77667797 awaitingPre := i.awaitingStartupPre
77677798 // A newer explicit prompt may have cleared the handoff while the
@@ -7812,6 +7843,7 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78127843 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
78137844 handoff, persistHandoff = i.resetCompactContinuationLocked()
78147845 }
7846+ continueGoal := goalActive && !awaitingPre && !hasNext && !continueQueued && !continueStatusRescue && !offer && !recoverContextOverflow && !shouldAutoCompact && err == nil && ctx.Err() == nil && lastStop == provider.StopEnd && lastTurnErr == nil
78157847 // The agent run can finish before the paced final text reaches the
78167848 // transcript. A compaction replaces that transcript, so it must never
78177849 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7819,8 +7851,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78197851 if recoverContextOverflow || shouldAutoCompact {
78207852 i.resetStreamingStateLocked()
78217853 }
7822- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)
7823- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact
7854+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
7855+ i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact
78247856 i.mu.Unlock()
78257857 if persistHandoff {
78267858 i.persistCompactHandoff(handoff)
@@ -7844,6 +7876,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
78447876 i.startTurnRequest(parent, "", nil, true, false)
78457877 case continueStatusRescue:
78467878 i.startAutoCompactContinuation(parent)
7879+ case continueGoal:
7880+ i.startGoalContinuation(parent, goalMessage)
78477881 case offer:
78487882 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
78497883 case recoverContextOverflow:
@@ -8266,8 +8300,8 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
82668300 tc.Result = text.String()
82678301 bumpToolRevision(tc)
82688302 }
8269- if i.cfg.OnToolResult != nil {
8270- i.cfg.OnToolResult(e.ID, e.Result)
8303+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
8304+ i.goalStatus = update.Status
82718305 }
82728306 case core.EvUsage:
82738307 i.cumUsage = e.Cumulative
@@ -372,8 +372,14 @@ 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- OnAssistant func(m provider.Message)375+ // CurrentGoal and PersistGoal expose the autonomous goal attached to the
376- OnToolResult func(id string, r core.ToolResult)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+
382+ OnAssistant func(m provider.Message)
377 383
378 // Extensions, if non-nil, lets users invoke extension-registered384 // Extensions, if non-nil, lets users invoke extension-registered
379 // slash commands. Commands declared by extensions are looked up385 // slash commands. Commands declared by extensions are looked up
@@ -661,6 +667,7 @@ type Interactive struct {
661 toolGate map[string]int667 toolGate map[string]int
662 statusErr string668 statusErr string
663 statusOK string669 statusOK string
670+ goalStatus core.GoalStatus
664 reloadStatusSeq uint64671 reloadStatusSeq uint64
665 extStatuses map[string]map[string]extensionStatus672 extStatuses map[string]map[string]extensionStatus
666 extWidgets map[string]map[string]extensionWidget673 extWidgets map[string]map[string]extensionWidget
@@ -916,6 +923,12 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
916 startupContextPaths := []string(nil)923 startupContextPaths := []string(nil)
917 startupExtensionNames := []string(nil)924 startupExtensionNames := []string(nil)
918 startupSkillNames := []string(nil)925 startupSkillNames := []string(nil)
926+ initialGoalStatus := core.GoalStatus("")
927+ if cfg.CurrentGoal != nil {
928+ if goal := cfg.CurrentGoal(); goal != nil {
929+ initialGoalStatus = goal.Status
930+ }
931+ }
919 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {932 if cfg.ShowInstructionsAtStartup != nil && *cfg.ShowInstructionsAtStartup {
920 startupAgentName = cfg.StartupAgentName933 startupAgentName = cfg.StartupAgentName
921 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)934 startupContextPaths = append(startupContextPaths, cfg.StartupContextPaths...)
@@ -969,6 +982,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
969 spin: newSpinner(cfg.Theme),982 spin: newSpinner(cfg.Theme),
970 inputHistoryIndex: -1,983 inputHistoryIndex: -1,
971 clock: time.Now,984 clock: time.Now,
985+ goalStatus: initialGoalStatus,
972 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),986 reloadErrors: append([]string(nil), cfg.StartupExtensionErrors...),
973 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),987 compactContinuation: decodeCompactHandoff(cfg.InitialCompactHandoff),
974 }988 }
@@ -2066,6 +2080,7 @@ func (i *Interactive) redraw() {
2066 if workingWithStatus {2080 if workingWithStatus {
2067 statusBusyPrefix = busyPrefix2081 statusBusyPrefix = busyPrefix
2068 }2082 }
2083+ goalStatus := string(i.goalStatus)
2069 statusLines := tui.StatusBar(tui.StatusBarParams{2084 statusLines := tui.StatusBar(tui.StatusBarParams{
2070 Theme: i.cfg.Theme,2085 Theme: i.cfg.Theme,
2071 Provider: i.cfg.Provider,2086 Provider: i.cfg.Provider,
@@ -2077,6 +2092,7 @@ func (i *Interactive) redraw() {
2077 CWD: i.cfg.CWD,2092 CWD: i.cfg.CWD,
2078 Locked: i.cfg.Sandbox.Locked(),2093 Locked: i.cfg.Sandbox.Locked(),
2079 NoYolo: i.cfg.NoYolo,2094 NoYolo: i.cfg.NoYolo,
2095+ GoalStatus: goalStatus,
2080 Usage: i.cumUsage,2096 Usage: i.cumUsage,
2081 Subscription: i.cfg.AuthMethod == "oauth",2097 Subscription: i.cfg.AuthMethod == "oauth",
2082 ContextUsed: i.lastCtxInput,2098 ContextUsed: i.lastCtxInput,
@@ -2544,7 +2560,7 @@ func filterHiddenTranscriptMessages(msgs []provider.Message) []provider.Message
2544 }2560 }
2545 2561
2546 func isHiddenTranscriptMessage(m provider.Message) bool {2562 func isHiddenTranscriptMessage(m provider.Message) bool {
2547- if m.Meta[autoCompactContinueMetaKey] == "true" {2563+ if m.Meta[autoCompactContinueMetaKey] == "true" || m.Meta[goalContinueMetaKey] == "true" {
2548 return true2564 return true
2549 }2565 }
2550 if m.Role != provider.RoleUser || len(m.Content) == 0 {2566 if m.Role != provider.RoleUser || len(m.Content) == 0 {
@@ -3290,6 +3306,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
3290 }3306 }
3291 i.mu.Unlock()3307 i.mu.Unlock()
3292 if busyCancel {3308 if busyCancel {
3309+ i.updateActiveGoal(core.GoalPaused, "interrupted by user")
3293 cancelTurn()3310 cancelTurn()
3294 // If a confirm dialog is pending, refuse it so the agent3311 // If a confirm dialog is pending, refuse it so the agent
3295 // goroutine unblocks and the context cancellation can3312 // goroutine unblocks and the context cancellation can
@@ -5908,6 +5925,8 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
5908 }5925 }
5909 5926
5910 switch strings.ToLower(parts[0]) {5927 switch strings.ToLower(parts[0]) {
5928+ case "/goal":
5929+ i.runGoalCommand(ctx, cmd, parts)
5911 case "/exit":5930 case "/exit":
5912 return true5931 return true
5913 case "/clear":5932 case "/clear":
@@ -7293,6 +7312,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7293 }7312 }
7294 summary, err := i.agent.Compact(ctx, keepTail, sink)7313 summary, err := i.agent.Compact(ctx, keepTail, sink)
7295 _ = summary7314 _ = summary
7315+ goalMessage, goalActive := i.goalContinuationMessage()
7296 i.mu.Lock()7316 i.mu.Lock()
7297 // Keep busy/compacting asserted while cleanup and queue selection run.7317 // Keep busy/compacting asserted while cleanup and queue selection run.
7298 // Completion updates can arrive in this window; clearing busy before7318 // Completion updates can arrive in this window; clearing busy before
@@ -7311,6 +7331,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7311 var hasNext bool7331 var hasNext bool
7312 var continueExisting bool7332 var continueExisting bool
7313 var continueAutomatically bool7333 var continueAutomatically bool
7334+ var continueGoal bool
7314 var handoff json.RawMessage7335 var handoff json.RawMessage
7315 var persistHandoff bool7336 var persistHandoff bool
7316 7337
@@ -7398,6 +7419,9 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7398 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})7419 handoff, persistHandoff = i.setCompactContinuationLocked(compactContinuationState{reason: continuationReason})
7399 }7420 }
7400 continueAutomatically = true7421 continueAutomatically = true
7422+ case auto && goalActive:
7423+ continueGoal = true
7424+ handoff, persistHandoff = i.resetCompactContinuationLocked()
7401 default:7425 default:
7402 handoff, persistHandoff = i.resetCompactContinuationLocked()7426 handoff, persistHandoff = i.resetCompactContinuationLocked()
7403 }7427 }
@@ -7406,7 +7430,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7406 // the mutex. A completion update arriving after the compaction result7430 // the mutex. A completion update arriving after the compaction result
7407 // but before this assignment is then queued for the selected follow-up7431 // but before this assignment is then queued for the selected follow-up
7408 // instead of starting a competing turn.7432 // instead of starting a competing turn.
7409- i.busy = hasNext || continueExisting || continueAutomatically7433+ i.busy = hasNext || continueExisting || continueAutomatically || continueGoal
7410 i.compacting = false7434 i.compacting = false
7411 i.autoCompacting = false7435 i.autoCompacting = false
7412 i.mu.Unlock()7436 i.mu.Unlock()
@@ -7416,7 +7440,7 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7416 runPendingIdleWork(pendingIdleWork)7440 runPendingIdleWork(pendingIdleWork)
7417 i.invalidate()7441 i.invalidate()
7418 7442
7419- if hasNext || continueExisting || continueAutomatically {7443+ if hasNext || continueExisting || continueAutomatically || continueGoal {
7420 p := i.runCtx7444 p := i.runCtx
7421 if p == nil {7445 if p == nil {
7422 p = context.Background()7446 p = context.Background()
@@ -7428,6 +7452,8 @@ func (i *Interactive) runCompact(parent context.Context, request compactContinua
7428 i.startTurnWithImages(p, next, nextImages)7452 i.startTurnWithImages(p, next, nextImages)
7429 case continueAutomatically:7453 case continueAutomatically:
7430 i.startAutoCompactContinuation(p)7454 i.startAutoCompactContinuation(p)
7455+ case continueGoal:
7456+ i.startGoalContinuation(p, goalMessage)
7431 }7457 }
7432 }7458 }
7433 }()7459 }()
@@ -7762,6 +7788,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7762 if flush != nil {7788 if flush != nil {
7763 flush()7789 flush()
7764 }7790 }
7791+ terminalGoalError := ctx.Err() == nil && !offer && !recoverContextOverflow && (err != nil || lastTurnErr != nil || lastStop == provider.StopError)
7792+ if terminalGoalError {
7793+ i.updateActiveGoal(core.GoalBlocked, "turn ended with an error")
7794+ }
7795+ goalMessage, goalActive := i.goalContinuationMessage()
7765 i.mu.Lock()7796 i.mu.Lock()
7766 awaitingPre := i.awaitingStartupPre7797 awaitingPre := i.awaitingStartupPre
7767 // A newer explicit prompt may have cleared the handoff while the7798 // A newer explicit prompt may have cleared the handoff while the
@@ -7812,6 +7843,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)) {7843 if !continueStatusRescue && (i.agent == nil || ctx.Err() != nil || err != nil || awaitingPre || hasNext || continueQueued || offer || recoverContextOverflow || (!shouldAutoCompact && !statusRescueActive)) {
7813 handoff, persistHandoff = i.resetCompactContinuationLocked()7844 handoff, persistHandoff = i.resetCompactContinuationLocked()
7814 }7845 }
7846+ 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 the7847 // The agent run can finish before the paced final text reaches the
7816 // transcript. A compaction replaces that transcript, so it must never7848 // transcript. A compaction replaces that transcript, so it must never
7817 // race the still-live stream frame; otherwise stale deltas can repaint7849 // race the still-live stream frame; otherwise stale deltas can repaint
@@ -7819,8 +7851,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7819 if recoverContextOverflow || shouldAutoCompact {7851 if recoverContextOverflow || shouldAutoCompact {
7820 i.resetStreamingStateLocked()7852 i.resetStreamingStateLocked()
7821 }7853 }
7822- alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0, offer, recoverContextOverflow, shouldAutoCompact)7854+ alertReason := mainAlertReason(ctx, err, lastTurnErr, lastStop, awaitingPre, hasNext || agentQueued > 0 || continueGoal, offer, recoverContextOverflow, shouldAutoCompact)
7823- i.busy = hasNext || continueQueued || continueStatusRescue || recoverContextOverflow || shouldAutoCompact7855+ i.busy = hasNext || continueQueued || continueStatusRescue || continueGoal || recoverContextOverflow || shouldAutoCompact
7824 i.mu.Unlock()7856 i.mu.Unlock()
7825 if persistHandoff {7857 if persistHandoff {
7826 i.persistCompactHandoff(handoff)7858 i.persistCompactHandoff(handoff)
@@ -7844,6 +7876,8 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
7844 i.startTurnRequest(parent, "", nil, true, false)7876 i.startTurnRequest(parent, "", nil, true, false)
7845 case continueStatusRescue:7877 case continueStatusRescue:
7846 i.startAutoCompactContinuation(parent)7878 i.startAutoCompactContinuation(parent)
7879+ case continueGoal:
7880+ i.startGoalContinuation(parent, goalMessage)
7847 case offer:7881 case offer:
7848 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)7882 i.openRescueDialog(rescueProv, rescueFprov, rescueModel, rescueWhy, prompt, rescueImgs)
7849 case recoverContextOverflow:7883 case recoverContextOverflow:
@@ -8266,8 +8300,8 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
8266 tc.Result = text.String()8300 tc.Result = text.String()
8267 bumpToolRevision(tc)8301 bumpToolRevision(tc)
8268 }8302 }
8269- if i.cfg.OnToolResult != nil {8303+ if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive {
8270- i.cfg.OnToolResult(e.ID, e.Result)8304+ i.goalStatus = update.Status
8271 }8305 }
8272 case core.EvUsage:8306 case core.EvUsage:
8273 i.cumUsage = e.Cumulative8307 i.cumUsage = e.Cumulative
M packages/agent/modes/slash_suggest.go +1 -0
@@ -53,6 +53,7 @@ var slashCatalog = []slashCommand{
5353 {Name: "/reasoning", Desc: "set the reasoning level"},
5454 {Name: "/fast", Desc: "toggle fast mode"},
5555 {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"},
56+ {Name: "/goal", Desc: "start or control an autonomous goal"},
5657 {Name: "/llama", Desc: "manage llama.cpp router models"},
5758 {Name: "/sessions", Desc: "resume a previous session for this directory"},
5859 {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/orchestration_modes.go +1 -1
@@ -98,7 +98,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
9898 return err
9999 }
100100 r.Provider, r.Model = providerName, model
101- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
101+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
102102 runtime.SetActiveSession(sess.ID)
103103 }
104104 announceSession(extMgr, sess)
@@ -98,7 +98,7 @@ func runOrchestratedMode(parentCtx context.Context, args Args, version string, h
98 return err98 return err
99 }99 }
100 r.Provider, r.Model = providerName, model100 r.Provider, r.Model = providerName, model
101- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }101+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
102 runtime.SetActiveSession(sess.ID)102 runtime.SetActiveSession(sess.ID)
103 }103 }
104 announceSession(extMgr, sess)104 announceSession(extMgr, sess)
M packages/agent/subagent_worker.go +1 -1
@@ -143,7 +143,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
143143 return err
144144 }
145145 r.Provider, r.Model = providerName, model
146- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
146+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
147147 // A prompt is acknowledged by the supervisor only after this callback
148148 // has appended and synced the matching user message. This closes the
149149 // crash window between accepting a follow-up and writing session.json.
@@ -143,7 +143,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
143 return err143 return err
144 }144 }
145 r.Provider, r.Model = providerName, model145 r.Provider, r.Model = providerName, model
146- ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }146+ ag.CommitToolResult = func(_ string, result core.ToolResult) error { return persistToolResultState(extMgr, sess, result) }
147 // A prompt is acknowledged by the supervisor only after this callback147 // A prompt is acknowledged by the supervisor only after this callback
148 // has appended and synced the matching user message. This closes the148 // has appended and synced the matching user message. This closes the
149 // crash window between accepting a follow-up and writing session.json.149 // crash window between accepting a follow-up and writing session.json.
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.
23 package tools
34
45 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 tools3 package tools
3 4
4 import (5 import (
A packages/agent/tools/update_goal.go +102 -0
@@ -0,0 +1,102 @@
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+ if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
92+ return GoalUpdate{}, false
93+ }
94+ return update, true
95+}
96+
97+func goalToolError(message string) core.ToolResult {
98+ return core.ToolResult{
99+ Content: []provider.Content{provider.TextBlock{Text: message}},
100+ IsError: true,
101+ }
102+}
@@ -0,0 +1,102 @@
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+ if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" {
92+ return GoalUpdate{}, false
93+ }
94+ return update, true
95+}
96+
97+func goalToolError(message string) core.ToolResult {
98+ return core.ToolResult{
99+ Content: []provider.Content{provider.TextBlock{Text: message}},
100+ IsError: true,
101+ }
102+}
A packages/agent/tools/update_goal_test.go +74 -0
@@ -0,0 +1,74 @@
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 TestGoalUpdateFromResultRejectsBlockedWithoutReason(t *testing.T) {
45+ result := core.ToolResult{Details: GoalUpdate{Status: core.GoalBlocked}}
46+ if _, ok := GoalUpdateFromResult(result); ok {
47+ t.Fatal("blank-reason blocked update was accepted")
48+ }
49+
50+ result.Details = GoalUpdate{Status: core.GoalBlocked, Reason: "needs user input"}
51+ if _, ok := GoalUpdateFromResult(result); !ok {
52+ t.Fatal("blocked update with reason was rejected")
53+ }
54+
55+ result.Details = GoalUpdate{Status: core.GoalDone}
56+ if _, ok := GoalUpdateFromResult(result); !ok {
57+ t.Fatal("completed update without reason was rejected")
58+ }
59+}
60+
61+func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
62+ tool := &UpdateGoalTool{}
63+ raw := json.RawMessage(`{"status":"active"}`)
64+ result, err := tool.Execute(context.Background(), raw, nil)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if !result.IsError {
69+ t.Fatalf("result = %#v, want tool error", result)
70+ }
71+ if _, ok := GoalUpdateFromResult(result); ok {
72+ t.Fatal("invalid result exposed a goal update")
73+ }
74+}
@@ -0,0 +1,74 @@
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 TestGoalUpdateFromResultRejectsBlockedWithoutReason(t *testing.T) {
45+ result := core.ToolResult{Details: GoalUpdate{Status: core.GoalBlocked}}
46+ if _, ok := GoalUpdateFromResult(result); ok {
47+ t.Fatal("blank-reason blocked update was accepted")
48+ }
49+
50+ result.Details = GoalUpdate{Status: core.GoalBlocked, Reason: "needs user input"}
51+ if _, ok := GoalUpdateFromResult(result); !ok {
52+ t.Fatal("blocked update with reason was rejected")
53+ }
54+
55+ result.Details = GoalUpdate{Status: core.GoalDone}
56+ if _, ok := GoalUpdateFromResult(result); !ok {
57+ t.Fatal("completed update without reason was rejected")
58+ }
59+}
60+
61+func TestUpdateGoalToolRejectsUnsupportedStatus(t *testing.T) {
62+ tool := &UpdateGoalTool{}
63+ raw := json.RawMessage(`{"status":"active"}`)
64+ result, err := tool.Execute(context.Background(), raw, nil)
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if !result.IsError {
69+ t.Fatalf("result = %#v, want tool error", result)
70+ }
71+ if _, ok := GoalUpdateFromResult(result); ok {
72+ t.Fatal("invalid result exposed a goal update")
73+ }
74+}
M packages/core/agent.go +15 -4
@@ -72,10 +72,12 @@ type Agent struct {
7272 // collection into one lifecycle round trip.
7373 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)
7474
75- // OnToolResult, if set, receives extension/tool metadata immediately
76- // after each tool call completes. Details are host-owned metadata and
77- // are never sent to the provider; hosts may persist selected details
78- // with the active session.
75+ // CommitToolResult, if set, commits extension/tool metadata immediately
76+ // after each tool call completes. Returning an error converts the result to
77+ // an error before it is appended to the transcript or sent to the provider.
78+ CommitToolResult func(id string, result ToolResult) error
79+ // OnToolResult observes the final result after CommitToolResult succeeds or
80+ // converts it to an error.
7981 OnToolResult func(id string, result ToolResult)
8082
8183 // BeforeAssistantMessage, if set, is called after the model's
@@ -861,6 +863,15 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
861863 continue
862864 }
863865 res := a.runOneTool(ctx, tc, tools, sink)
866+ if a.CommitToolResult != nil {
867+ if err := a.CommitToolResult(tc.ID, res); err != nil {
868+ res = ToolResult{
869+ Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
870+ IsError: true,
871+ Timing: res.Timing,
872+ }
873+ }
874+ }
864875 if res.IsError {
865876 hadError = true
866877 }
@@ -72,10 +72,12 @@ type Agent struct {
72 // collection into one lifecycle round trip.72 // collection into one lifecycle round trip.
73 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)73 BeforeTurnContext func(ctx context.Context, step int) (allowed bool, reason, context string)
74 74
75- // OnToolResult, if set, receives extension/tool metadata immediately75+ // CommitToolResult, if set, commits extension/tool metadata immediately
76- // after each tool call completes. Details are host-owned metadata and76+ // after each tool call completes. Returning an error converts the result to
77- // are never sent to the provider; hosts may persist selected details77+ // an error before it is appended to the transcript or sent to the provider.
78- // with the active session.78+ CommitToolResult func(id string, result ToolResult) error
79+ // OnToolResult observes the final result after CommitToolResult succeeds or
80+ // converts it to an error.
79 OnToolResult func(id string, result ToolResult)81 OnToolResult func(id string, result ToolResult)
80 82
81 // BeforeAssistantMessage, if set, is called after the model's83 // BeforeAssistantMessage, if set, is called after the model's
@@ -861,6 +863,15 @@ func (a *Agent) executeTools(ctx context.Context, msg provider.Message, sink fun
861 continue863 continue
862 }864 }
863 res := a.runOneTool(ctx, tc, tools, sink)865 res := a.runOneTool(ctx, tc, tools, sink)
866+ if a.CommitToolResult != nil {
867+ if err := a.CommitToolResult(tc.ID, res); err != nil {
868+ res = ToolResult{
869+ Content: []provider.Content{provider.TextBlock{Text: "tool result state could not be persisted"}},
870+ IsError: true,
871+ Timing: res.Timing,
872+ }
873+ }
874+ }
864 if res.IsError {875 if res.IsError {
865 hadError = true876 hadError = true
866 }877 }
M packages/core/core_test.go +40 -0
@@ -296,6 +296,46 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) {
296296 }
297297 }
298298
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+
299339 func TestCostAdd(t *testing.T) {
300340 var c CostTracker
301341 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 CostTracker340 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/intercept_test.go +38 -0
@@ -42,6 +42,44 @@ func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(
4242 }, nil
4343 }
4444
45+func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
46+ agent := NewAgent(nil, "test", "", Registry{
47+ "result": &resultTool{result: ToolResult{
48+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
49+ Details: "durable update",
50+ }},
51+ })
52+ agent.CommitToolResult = func(string, ToolResult) error { return errors.New("disk full") }
53+ message := provider.Message{
54+ Role: provider.RoleAssistant,
55+ Content: []provider.Content{provider.ToolCallBlock{
56+ ID: "call-1",
57+ Name: "result",
58+ Arguments: json.RawMessage(`{}`),
59+ }},
60+ }
61+ var event EvToolResult
62+ toolMessage, hadError := agent.executeTools(context.Background(), message, func(ev AgentEvent) {
63+ if result, ok := ev.(EvToolResult); ok {
64+ event = result
65+ }
66+ })
67+
68+ if !hadError {
69+ t.Fatal("persistence failure did not mark the tool batch failed")
70+ }
71+ if !event.Result.IsError || event.Result.Details != nil {
72+ t.Fatalf("tool event result = %#v", event.Result)
73+ }
74+ block, ok := toolMessage.Content[0].(provider.ToolResultBlock)
75+ if !ok || !block.IsError {
76+ t.Fatalf("tool transcript block = %#v", toolMessage.Content)
77+ }
78+ if got := block.Content[0].(provider.TextBlock).Text; got != "tool result state could not be persisted" {
79+ t.Fatalf("tool error text = %q", got)
80+ }
81+}
82+
4583 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
4684 // modifiedArgs returned from BeforeToolExecute is what the tool
4785 // actually sees.
@@ -42,6 +42,44 @@ func (r *recordingTool) Execute(_ context.Context, args json.RawMessage, _ func(
42 }, nil42 }, nil
43 }43 }
44 44
45+func TestToolResultPersistenceFailureBecomesToolError(t *testing.T) {
46+ agent := NewAgent(nil, "test", "", Registry{
47+ "result": &resultTool{result: ToolResult{
48+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
49+ Details: "durable update",
50+ }},
51+ })
52+ agent.CommitToolResult = func(string, ToolResult) error { return errors.New("disk full") }
53+ message := provider.Message{
54+ Role: provider.RoleAssistant,
55+ Content: []provider.Content{provider.ToolCallBlock{
56+ ID: "call-1",
57+ Name: "result",
58+ Arguments: json.RawMessage(`{}`),
59+ }},
60+ }
61+ var event EvToolResult
62+ toolMessage, hadError := agent.executeTools(context.Background(), message, func(ev AgentEvent) {
63+ if result, ok := ev.(EvToolResult); ok {
64+ event = result
65+ }
66+ })
67+
68+ if !hadError {
69+ t.Fatal("persistence failure did not mark the tool batch failed")
70+ }
71+ if !event.Result.IsError || event.Result.Details != nil {
72+ t.Fatalf("tool event result = %#v", event.Result)
73+ }
74+ block, ok := toolMessage.Content[0].(provider.ToolResultBlock)
75+ if !ok || !block.IsError {
76+ t.Fatalf("tool transcript block = %#v", toolMessage.Content)
77+ }
78+ if got := block.Content[0].(provider.TextBlock).Text; got != "tool result state could not be persisted" {
79+ t.Fatalf("tool error text = %q", got)
80+ }
81+}
82+
45 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil83 // TestBeforeToolExecuteModifiesArgs verifies that a non-nil
46 // modifiedArgs returned from BeforeToolExecute is what the tool84 // modifiedArgs returned from BeforeToolExecute is what the tool
47 // actually sees.85 // actually sees.
M packages/core/session.go +40 -1
@@ -47,6 +47,23 @@ type Session struct {
4747 messagesAppended int
4848 }
4949
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+
5067 // SessionMeta is written as the first line of every session file.
5168 type SessionMeta struct {
5269 ID string `json:"id"`
@@ -65,6 +82,9 @@ type SessionMeta struct {
6582 // continuation. It is session metadata, never provider context.
6683 // Missing or invalid values are handled by the owning host as no handoff.
6784 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"`
6888
6989 // Parent is the ID of the session this one was forked from, or
7090 // empty for top-level sessions. The tree picker walks parents
@@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
12951315 return nil
12961316 }
12971317
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 = &copyGoal
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+
12981337 // UpdateTitle records a session title without adding anything to the
12991338 // conversation transcript. The title is kept in memory as well so the live
13001339 // session can restore it when the user switches sessions in the TUI.
@@ -1374,7 +1413,7 @@ func (s *Session) Close() error {
13741413 }
13751414 flushErr := s.Flush()
13761415 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 {
13781417 // Best-effort cleanup. We deliberately don't propagate the
13791418 // remove error: if it fails (file already gone, perms changed)
13801419 // the worst case is one stale empty file in the listing.
@@ -47,6 +47,23 @@ type Session struct {
47 messagesAppended int47 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, or89 // Parent is the ID of the session this one was forked from, or
70 // empty for top-level sessions. The tree picker walks parents90 // empty for top-level sessions. The tree picker walks parents
@@ -1295,6 +1315,25 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error {
1295 return nil1315 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 = &copyGoal
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 the1337 // UpdateTitle records a session title without adding anything to the
1299 // conversation transcript. The title is kept in memory as well so the live1338 // 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 the1417 // 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) {
228228 Timezone: timezone,
229229 TimezoneOffset: timezoneOffset,
230230 CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...),
231+ Goal: cloneSessionGoal(snapshot.Meta.Goal),
231232 }
232233 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta})
233234 if err != nil {
@@ -320,7 +321,8 @@ func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hi
320321 return "", err
321322 }
322323 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)
324326 }
325327
326328 // BranchSessionHiddenFromHistory creates a hidden tree branch from a
@@ -350,7 +352,7 @@ func BranchSessionHiddenFromHistory(parentPath, root, cwd, version string, segme
350352 if err != nil {
351353 return "", err
352354 }
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)
354356 }
355357
356358 // readExtensionStateAtFork reconstructs the latest extension snapshot that was
@@ -437,7 +439,22 @@ func compactHandoffAtBranch(parent SessionMeta, messages []provider.Message, lim
437439 return append(json.RawMessage(nil), parent.CompactHandoff...)
438440 }
439441
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) {
441458 dir := SessionsDir(root, cwd)
442459 if err := os.MkdirAll(dir, 0o755); err != nil {
443460 return "", err
@@ -479,6 +496,7 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
479496 ForkPoint: limit,
480497 HideFromSessions: hideFromSessions,
481498 CompactHandoff: append(json.RawMessage(nil), compactHandoff...),
499+ Goal: cloneSessionGoal(goal),
482500 }
483501 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
484502 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 "", err321 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 a328 // 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 "", err353 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 was358 // 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 "", err460 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) {
8080 if err := sess.UpdateCompactHandoff(handoff); err != nil {
8181 t.Fatal(err)
8282 }
83+ goal := &SessionGoal{Objective: "finish the portable task", Status: GoalPaused}
84+ if err := sess.UpdateGoal(goal); err != nil {
85+ t.Fatal(err)
86+ }
8387 _ = sess.Close()
8488
8589 // Export to a directory — helper should build a name inside it.
@@ -124,6 +128,9 @@ func TestSessionExportImportRoundTrip(t *testing.T) {
124128 if got := string(imported.Meta.CompactHandoff); got != string(handoff) {
125129 t.Errorf("compact handoff = %q, want %q", got, handoff)
126130 }
131+ if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal {
132+ t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal)
133+ }
127134 if len(msgs) != 2 {
128135 t.Fatalf("want 2 messages, got %d", len(msgs))
129136 }
@@ -564,6 +571,10 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
564571 if err := parent.UpdateCompactHandoff(handoff); err != nil {
565572 t.Fatal(err)
566573 }
574+ goal := &SessionGoal{Objective: "finish the branch", Status: GoalActive}
575+ if err := parent.UpdateGoal(goal); err != nil {
576+ t.Fatal(err)
577+ }
567578 if err := parent.Close(); err != nil {
568579 t.Fatal(err)
569580 }
@@ -580,6 +591,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
580591 if got := string(full.Meta.CompactHandoff); got != string(handoff) {
581592 t.Fatalf("full branch compact handoff = %q, want %q", got, handoff)
582593 }
594+ if full.Meta.Goal == nil || *full.Meta.Goal != *goal {
595+ t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal)
596+ }
583597
584598 prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1)
585599 if err != nil {
@@ -593,6 +607,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) {
593607 if len(prefix.Meta.CompactHandoff) != 0 {
594608 t.Fatalf("prefix branch compact handoff = %q, want empty", prefix.Meta.CompactHandoff)
595609 }
610+ if prefix.Meta.Goal != nil {
611+ t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal)
612+ }
596613 }
597614
598615 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 (
77 "github.com/bnema/zut/packages/provider"
88 )
99
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+
1034 // TestStatusBarAlwaysTwoLines verifies the status bar always emits
1135 // two lines when a cwd is present, regardless of terminal width, and
1236 // 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 emits34 // TestStatusBarAlwaysTwoLines verifies the status bar always emits
11 // two lines when a cwd is present, regardless of terminal width, and35 // 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 under36 // 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 {
28282828 Busy bool
28292829 BusyPrefix string // spinner + funny line when busy
28302830 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
28332834
28342835 // Cumulative session usage and cost.
28352836 Usage provider.Usage
@@ -2904,6 +2905,9 @@ func StatusBar(p StatusBarParams) []string {
29042905 if costStr != "" {
29052906 stats = append(stats, costStr)
29062907 }
2908+ if p.GoalStatus != "" {
2909+ stats = append(stats, "goal:"+p.GoalStatus)
2910+ }
29072911
29082912 // Context %. Color-coded: yellow >70, red >90.
29092913 ctx, ctxColor := contextUsage(th, p.ContextUsed, p.ContextMax)
@@ -2828,8 +2828,9 @@ type StatusBarParams struct {
2828 Busy bool2828 Busy bool
2829 BusyPrefix string // spinner + funny line when busy2829 BusyPrefix string // spinner + funny line when busy
2830 CWD string2830 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.Usage2836 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)

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close