fix(agent): allow implicit current goal mission (#168)
- commit
- cce76491d0e121acb6fa80cf5e713d84fa75befa
- author
- bnema · · 2026-09-03 15:31 UTC
- committer
- GitHub · · 2026-09-03 15:31 UTC
- parent
- ebbb70f
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
4 files · +26 -4
M README.md +1 -1
| @@ -338,7 +338,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 338 | 338 | ||
| 339 | 339 | ### `/goal` | |
| 340 | 340 | ||
| 341 | - | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | |
| 341 | + | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | |
| 342 | 342 | ||
| 343 | 343 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | |
| 344 | 344 | ||
| @@ -338,7 +338,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 338 | 338 | ||||
| 339 | ### `/goal` | 339 | ### `/goal` | ||
| 340 | 340 | ||||
| 341 | - | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. | 341 | + | In interactive mode, the first user request establishes a durable user-owned mission. `/goal <objective>` explicitly starts or replaces its current user-owned goal and begins work immediately. While a goal is active, zut starts one leased hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. When no mission is active, the main agent can use `update_goal` to start one of its own; within an active mission, it can settle the current goal or set a bounded next goal. An omitted `mission_id` targets the current mission; when supplied, it must match that mission. Subagents cannot start or update missions. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist with the session. |
| 342 | 342 | ||||
| 343 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | 343 | Goals are unlimited by default. Zut records token use for each goal but only enforces a budget when it is explicitly configured in `config.json`: | ||
| 344 | 344 | ||||
M packages/agent/cli.go +1 -1
| @@ -761,7 +761,7 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||
| 761 | 761 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |
| 762 | 762 | return errGoalActiveReplacement | |
| 763 | 763 | } | |
| 764 | - | if sess.Meta.Mission != nil && update.MissionID != sess.Meta.Mission.ID { | |
| 764 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |
| 765 | 765 | return errGoalMissionMismatch | |
| 766 | 766 | } | |
| 767 | 767 | goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager} | |
| @@ -761,7 +761,7 @@ func persistGoalToolResult(sess *core.Session, result core.ToolResult, goalMaxTo | |||||
| 761 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | 761 | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | ||
| 762 | return errGoalActiveReplacement | 762 | return errGoalActiveReplacement | ||
| 763 | } | 763 | } | ||
| 764 | - | if sess.Meta.Mission != nil && update.MissionID != sess.Meta.Mission.ID { | 764 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { |
| 765 | return errGoalMissionMismatch | 765 | return errGoalMissionMismatch | ||
| 766 | } | 766 | } | ||
| 767 | goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager} | 767 | goal := &core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager} | ||
M packages/agent/cli_session_test.go +21 -0
| @@ -262,6 +262,27 @@ func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) { | |||
| 262 | 262 | } | |
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | + | func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T) { | |
| 266 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 267 | + | if err != nil { | |
| 268 | + | t.Fatal(err) | |
| 269 | + | } | |
| 270 | + | defer sess.Close() | |
| 271 | + | if err := sess.EnsureMission("fix the reported failure", core.MissionSourceUser); err != nil { | |
| 272 | + | t.Fatal(err) | |
| 273 | + | } | |
| 274 | + | missionID := sess.Meta.Mission.ID | |
| 275 | + | ||
| 276 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "inspect the failure"}} | |
| 277 | + | if err := persistGoalToolResult(sess, result); err != nil { | |
| 278 | + | t.Fatal(err) | |
| 279 | + | } | |
| 280 | + | goal := sess.Meta.Goal | |
| 281 | + | if goal == nil || goal.Status != core.GoalActive || goal.MissionID != missionID { | |
| 282 | + | t.Fatalf("goal = %#v, want active goal in mission %q", goal, missionID) | |
| 283 | + | } | |
| 284 | + | } | |
| 285 | + | ||
| 265 | 286 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | |
| 266 | 287 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 267 | 288 | if err != nil { | |
| @@ -262,6 +262,27 @@ func TestPersistGoalToolResultAppliesConfiguredBudget(t *testing.T) { | |||||
| 262 | } | 262 | } | ||
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | + | func TestPersistGoalToolResultStartsGoalInCurrentMissionWithoutID(t *testing.T) { | |||
| 266 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 267 | + | if err != nil { | |||
| 268 | + | t.Fatal(err) | |||
| 269 | + | } | |||
| 270 | + | defer sess.Close() | |||
| 271 | + | if err := sess.EnsureMission("fix the reported failure", core.MissionSourceUser); err != nil { | |||
| 272 | + | t.Fatal(err) | |||
| 273 | + | } | |||
| 274 | + | missionID := sess.Meta.Mission.ID | |||
| 275 | + | ||||
| 276 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "inspect the failure"}} | |||
| 277 | + | if err := persistGoalToolResult(sess, result); err != nil { | |||
| 278 | + | t.Fatal(err) | |||
| 279 | + | } | |||
| 280 | + | goal := sess.Meta.Goal | |||
| 281 | + | if goal == nil || goal.Status != core.GoalActive || goal.MissionID != missionID { | |||
| 282 | + | t.Fatalf("goal = %#v, want active goal in mission %q", goal, missionID) | |||
| 283 | + | } | |||
| 284 | + | } | |||
| 285 | + | ||||
| 265 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | 286 | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | ||
| 266 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | 287 | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | ||
| 267 | if err != nil { | 288 | if err != nil { | ||
M packages/agent/tools/update_goal.go +3 -2
| @@ -13,12 +13,13 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | 15 | UpdateGoalToolName = "update_goal" | |
| 16 | - | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Required identifier from the active-goal context when setting a next goal in an existing mission. Omit it when starting a mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | |
| 20 | 20 | // active goal, or settle the current goal. Host-side persistence validates | |
| 21 | - | // transitions within an existing session mission. | |
| 21 | + | // transitions within an existing session mission. A supplied mission ID is | |
| 22 | + | // checked against the current mission; an omitted ID targets that mission. | |
| 22 | 23 | type UpdateGoalTool struct{} | |
| 23 | 24 | ||
| 24 | 25 | type updateGoalArgs struct { | |
| @@ -13,12 +13,13 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | UpdateGoalToolName = "update_goal" | 15 | UpdateGoalToolName = "update_goal" | ||
| 16 | - | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Required identifier from the active-goal context when setting a next goal in an existing mission. Omit it when starting a mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Start a mission or set its next active goal, or mark the active goal complete or blocked."},"objective":{"type":"string","description":"Concrete objective for status active."},"mission_id":{"type":"string","description":"Optional identifier from the active-goal context. When supplied, it must match the current mission; omit it when starting or continuing the current mission."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | 19 | // UpdateGoalTool lets the main agent start a mission, set its next concrete | ||
| 20 | // active goal, or settle the current goal. Host-side persistence validates | 20 | // active goal, or settle the current goal. Host-side persistence validates | ||
| 21 | - | // transitions within an existing session mission. | 21 | + | // transitions within an existing session mission. A supplied mission ID is |
| 22 | + | // checked against the current mission; an omitted ID targets that mission. | |||
| 22 | type UpdateGoalTool struct{} | 23 | type UpdateGoalTool struct{} | ||
| 23 | 24 | ||||
| 24 | type updateGoalArgs struct { | 25 | type updateGoalArgs struct { | ||