feat(agent): persist mission-scoped goals (#113)
- commit
- 59c252457014454f9b5f6b6bc7447547ae76623b
- author
- bnema · · 2026-08-11 06:09 UTC
- committer
- GitHub · · 2026-08-11 06:09 UTC
- parent
- c63670e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
12 files · +609 -31
M README.md +3 -3
| @@ -239,7 +239,7 @@ Print-mode stats contain `provider`, `model`, `prompt_tokens`, `reasoning_tokens | |||
| 239 | 239 | - `create_worktree`: create a new branch from the current `HEAD` and check it out persistently. A repository with no configured root and no `.worktrees` directory first returns bootstrap guidance without making changes; the agent asks whether to use the repository default or an external root, then retries with `bootstrap_root`. The choice is saved privately in local Git config. Choosing `.worktrees` creates `<repository-root>/.worktrees/<branch>` and adds `/.worktrees/` to the root `.gitignore`; an absolute external root leaves tracked repository files and the root `.gitignore` unchanged. The tool never copies uncommitted or ignored files, and it refuses an existing branch or worktree path. | |
| 240 | 240 | - `lsp`: query configured language servers and linters for diagnostics, definitions, references, hover information, symbols, renames, code actions, capabilities, and raw protocol requests. LSP servers use stdio JSON-RPC; project `lsp.json` files can add or override servers and CLI linters. Diagnostics are bounded, sorted, deduplicated by path/severity/code/start position, and repeated issues are grouped before they reach the model. See [docs/lsp.md](docs/lsp.md). | |
| 241 | 241 | - `web_search`: search the public web through DuckDuckGo HTML and return bounded source titles, URLs, and snippets. Enabled by default for normal CLI sessions; it is unavailable to bot, default SDK, and packaged-agent runs. See [docs/web-search.md](docs/web-search.md). | |
| 242 | - | - `update_goal`: mark an explicitly started autonomous session goal `done` or `blocked`. Starting, pausing, resuming, and clearing goals remain user-controlled through `/goal`. | |
| 242 | + | - `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`. | |
| 243 | 243 | ||
| 244 | 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. | |
| 245 | 245 | ||
| @@ -296,7 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 296 | 296 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 297 | 297 | | `/fast` | Toggle fast mode for subsequent model calls. | | |
| 298 | 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. | | |
| 299 | + | | `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. | | |
| 300 | 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. | | |
| 301 | 301 | | `/sessions` | Resume a previous session for this directory. The picker omits branches created only for tree navigation. | | |
| 302 | 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. | | |
| @@ -319,7 +319,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||
| 319 | 319 | ||
| 320 | 320 | ### `/goal` | |
| 321 | 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. | |
| 322 | + | 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 a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist 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 | 323 | ||
| 324 | 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 | 325 | ||
| @@ -239,7 +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 | + | - `update_goal`: set the manager's next bounded goal within the active mission, or mark the active goal `done` or `blocked`. Starting, pausing, resuming, and clearing missions remain user-controlled through `/goal`. |
| 243 | 243 | ||||
| 244 | When the sandbox is on (see `/jail`), filesystem tools and LSP workspace edits refuse paths outside the session cwd. `create_worktree` also requires its repository root, configured worktree destination, optional `.gitignore`, and Git metadata to remain inside the jail. Jail does not sandbox web-search network egress. | 244 | 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. | ||
| 245 | 245 | ||||
| @@ -296,7 +296,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 296 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 296 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 297 | | `/fast` | Toggle fast mode for subsequent model calls. | | 297 | | `/fast` | Toggle fast mode for subsequent model calls. | | ||
| 298 | | `/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. | | 299 | + | | `/goal <objective>` | Start an autonomous interactive-session mission. Use `/goal` for status, `/goal history` for prior goals, and `/goal pause`, `resume`, or `clear` for control. | |
| 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. | | 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. | | ||
| 301 | | `/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. | | ||
| 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. | | 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. | | ||
| @@ -319,7 +319,7 @@ Extension-registered commands appear under a divider at the bottom of the popup, | |||||
| 319 | 319 | ||||
| 320 | ### `/goal` | 320 | ### `/goal` | ||
| 321 | 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. | 322 | + | 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 a hidden follow-up whenever the interactive thread becomes idle; queued user input always runs first. The manager can use `update_goal` to settle the current goal or set a bounded next goal within the same mission. The current state appears in the status bar and prior transitions are available through `/goal history`; both persist 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 | 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. | 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 | 325 | ||||
M packages/agent/cli.go +33 -1
| @@ -707,13 +707,25 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result | |||
| 707 | 707 | } | |
| 708 | 708 | ||
| 709 | 709 | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | |
| 710 | - | if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |
| 710 | + | if sess == nil { | |
| 711 | 711 | return nil | |
| 712 | 712 | } | |
| 713 | 713 | update, ok := tools.GoalUpdateFromResult(result) | |
| 714 | 714 | if !ok { | |
| 715 | 715 | return nil | |
| 716 | 716 | } | |
| 717 | + | if update.Status == core.GoalActive { | |
| 718 | + | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |
| 719 | + | return fmt.Errorf("cannot replace an active goal") | |
| 720 | + | } | |
| 721 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |
| 722 | + | return fmt.Errorf("goal update does not belong to the active mission") | |
| 723 | + | } | |
| 724 | + | return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}) | |
| 725 | + | } | |
| 726 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |
| 727 | + | return nil | |
| 728 | + | } | |
| 717 | 729 | goal := *sess.Meta.Goal | |
| 718 | 730 | goal.Status = update.Status | |
| 719 | 731 | goal.Reason = update.Reason | |
| @@ -1852,6 +1864,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1852 | 1864 | } | |
| 1853 | 1865 | return sess.UpdateGoal(goal) | |
| 1854 | 1866 | } | |
| 1867 | + | ensureMission := func(objective string) error { | |
| 1868 | + | sessionTransitionMu.RLock() | |
| 1869 | + | defer sessionTransitionMu.RUnlock() | |
| 1870 | + | persistMu.Lock() | |
| 1871 | + | defer persistMu.Unlock() | |
| 1872 | + | if sess == nil { | |
| 1873 | + | return errors.New("session persistence is disabled") | |
| 1874 | + | } | |
| 1875 | + | return sess.EnsureMission(objective, core.MissionSourceUser) | |
| 1876 | + | } | |
| 1855 | 1877 | currentGoal := func() *core.SessionGoal { | |
| 1856 | 1878 | persistMu.Lock() | |
| 1857 | 1879 | defer persistMu.Unlock() | |
| @@ -1861,6 +1883,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1861 | 1883 | goal := *sess.Meta.Goal | |
| 1862 | 1884 | return &goal | |
| 1863 | 1885 | } | |
| 1886 | + | currentGoalHistory := func() []core.SessionGoal { | |
| 1887 | + | persistMu.Lock() | |
| 1888 | + | defer persistMu.Unlock() | |
| 1889 | + | if sess == nil { | |
| 1890 | + | return nil | |
| 1891 | + | } | |
| 1892 | + | return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...) | |
| 1893 | + | } | |
| 1864 | 1894 | bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) { | |
| 1865 | 1895 | if a == nil { | |
| 1866 | 1896 | return | |
| @@ -2474,6 +2504,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2474 | 2504 | CurrentCompactHandoff: currentCompactHandoff, | |
| 2475 | 2505 | PersistGoal: persistGoal, | |
| 2476 | 2506 | CurrentGoal: currentGoal, | |
| 2507 | + | CurrentGoalHistory: currentGoalHistory, | |
| 2508 | + | EnsureMission: ensureMission, | |
| 2477 | 2509 | CurrentSessionPath: func() string { | |
| 2478 | 2510 | persistMu.Lock() | |
| 2479 | 2511 | defer persistMu.Unlock() | |
| @@ -707,13 +707,25 @@ func persistToolResultState(mgr *extensions.Manager, sess *core.Session, result | |||||
| 707 | } | 707 | } | ||
| 708 | 708 | ||||
| 709 | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | 709 | func persistGoalToolResult(sess *core.Session, result core.ToolResult) error { | ||
| 710 | - | if sess == nil || sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | 710 | + | if sess == nil { |
| 711 | return nil | 711 | return nil | ||
| 712 | } | 712 | } | ||
| 713 | update, ok := tools.GoalUpdateFromResult(result) | 713 | update, ok := tools.GoalUpdateFromResult(result) | ||
| 714 | if !ok { | 714 | if !ok { | ||
| 715 | return nil | 715 | return nil | ||
| 716 | } | 716 | } | ||
| 717 | + | if update.Status == core.GoalActive { | |||
| 718 | + | if sess.Meta.Goal != nil && sess.Meta.Goal.Status == core.GoalActive { | |||
| 719 | + | return fmt.Errorf("cannot replace an active goal") | |||
| 720 | + | } | |||
| 721 | + | if sess.Meta.Mission != nil && update.MissionID != "" && update.MissionID != sess.Meta.Mission.ID { | |||
| 722 | + | return fmt.Errorf("goal update does not belong to the active mission") | |||
| 723 | + | } | |||
| 724 | + | return sess.UpdateGoal(&core.SessionGoal{Objective: update.Objective, Status: core.GoalActive, Owner: core.GoalOwnerManager}) | |||
| 725 | + | } | |||
| 726 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Status != core.GoalActive { | |||
| 727 | + | return nil | |||
| 728 | + | } | |||
| 717 | goal := *sess.Meta.Goal | 729 | goal := *sess.Meta.Goal | ||
| 718 | goal.Status = update.Status | 730 | goal.Status = update.Status | ||
| 719 | goal.Reason = update.Reason | 731 | goal.Reason = update.Reason | ||
| @@ -1852,6 +1864,16 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1852 | } | 1864 | } | ||
| 1853 | return sess.UpdateGoal(goal) | 1865 | return sess.UpdateGoal(goal) | ||
| 1854 | } | 1866 | } | ||
| 1867 | + | ensureMission := func(objective string) error { | |||
| 1868 | + | sessionTransitionMu.RLock() | |||
| 1869 | + | defer sessionTransitionMu.RUnlock() | |||
| 1870 | + | persistMu.Lock() | |||
| 1871 | + | defer persistMu.Unlock() | |||
| 1872 | + | if sess == nil { | |||
| 1873 | + | return errors.New("session persistence is disabled") | |||
| 1874 | + | } | |||
| 1875 | + | return sess.EnsureMission(objective, core.MissionSourceUser) | |||
| 1876 | + | } | |||
| 1855 | currentGoal := func() *core.SessionGoal { | 1877 | currentGoal := func() *core.SessionGoal { | ||
| 1856 | persistMu.Lock() | 1878 | persistMu.Lock() | ||
| 1857 | defer persistMu.Unlock() | 1879 | defer persistMu.Unlock() | ||
| @@ -1861,6 +1883,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1861 | goal := *sess.Meta.Goal | 1883 | goal := *sess.Meta.Goal | ||
| 1862 | return &goal | 1884 | return &goal | ||
| 1863 | } | 1885 | } | ||
| 1886 | + | currentGoalHistory := func() []core.SessionGoal { | |||
| 1887 | + | persistMu.Lock() | |||
| 1888 | + | defer persistMu.Unlock() | |||
| 1889 | + | if sess == nil { | |||
| 1890 | + | return nil | |||
| 1891 | + | } | |||
| 1892 | + | return append([]core.SessionGoal(nil), sess.Meta.GoalHistory...) | |||
| 1893 | + | } | |||
| 1864 | bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) { | 1894 | bindAgentToolResultSession := func(a *core.Agent, boundSession *core.Session) { | ||
| 1865 | if a == nil { | 1895 | if a == nil { | ||
| 1866 | return | 1896 | return | ||
| @@ -2474,6 +2504,8 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2474 | CurrentCompactHandoff: currentCompactHandoff, | 2504 | CurrentCompactHandoff: currentCompactHandoff, | ||
| 2475 | PersistGoal: persistGoal, | 2505 | PersistGoal: persistGoal, | ||
| 2476 | CurrentGoal: currentGoal, | 2506 | CurrentGoal: currentGoal, | ||
| 2507 | + | CurrentGoalHistory: currentGoalHistory, | |||
| 2508 | + | EnsureMission: ensureMission, | |||
| 2477 | CurrentSessionPath: func() string { | 2509 | CurrentSessionPath: func() string { | ||
| 2478 | persistMu.Lock() | 2510 | persistMu.Lock() | ||
| 2479 | defer persistMu.Unlock() | 2511 | defer persistMu.Unlock() | ||
M packages/agent/cli_session_test.go +73 -0
| @@ -111,6 +111,79 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) { | |||
| 111 | 111 | } | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | |
| 115 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 116 | + | if err != nil { | |
| 117 | + | t.Fatal(err) | |
| 118 | + | } | |
| 119 | + | defer sess.Close() | |
| 120 | + | ||
| 121 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}} | |
| 122 | + | if err := persistGoalToolResult(sess, result); err != nil { | |
| 123 | + | t.Fatal(err) | |
| 124 | + | } | |
| 125 | + | if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager { | |
| 126 | + | t.Fatalf("persisted goal = %#v", goal) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | |
| 131 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 132 | + | if err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | defer sess.Close() | |
| 136 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |
| 137 | + | t.Fatal(err) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}}) | |
| 141 | + | if err == nil { | |
| 142 | + | t.Fatal("active goal replacement succeeded") | |
| 143 | + | } | |
| 144 | + | if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive { | |
| 145 | + | t.Fatalf("persisted goal = %#v", got) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) { | |
| 150 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatal(err) | |
| 153 | + | } | |
| 154 | + | defer sess.Close() | |
| 155 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil { | |
| 156 | + | t.Fatal(err) | |
| 157 | + | } | |
| 158 | + | missionID := sess.Meta.Mission.ID | |
| 159 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil { | |
| 163 | + | t.Fatal(err) | |
| 164 | + | } | |
| 165 | + | goal := sess.Meta.Goal | |
| 166 | + | if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 { | |
| 167 | + | t.Fatalf("next goal = %#v", goal) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) { | |
| 172 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |
| 173 | + | if err != nil { | |
| 174 | + | t.Fatal(err) | |
| 175 | + | } | |
| 176 | + | defer sess.Close() | |
| 177 | + | if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil { | |
| 178 | + | t.Fatal(err) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}}) | |
| 182 | + | if err == nil { | |
| 183 | + | t.Fatal("mismatched mission was accepted") | |
| 184 | + | } | |
| 185 | + | } | |
| 186 | + | ||
| 114 | 187 | func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) { | |
| 115 | 188 | sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | |
| 116 | 189 | if err != nil { | |
| @@ -111,6 +111,79 @@ func TestToolResultPersistenceUsesBoundSession(t *testing.T) { | |||||
| 111 | } | 111 | } | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | + | func TestPersistGoalToolResultCreatesActiveGoal(t *testing.T) { | |||
| 115 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 116 | + | if err != nil { | |||
| 117 | + | t.Fatal(err) | |||
| 118 | + | } | |||
| 119 | + | defer sess.Close() | |||
| 120 | + | ||||
| 121 | + | result := core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "reproduce the reported failure"}} | |||
| 122 | + | if err := persistGoalToolResult(sess, result); err != nil { | |||
| 123 | + | t.Fatal(err) | |||
| 124 | + | } | |||
| 125 | + | if goal := sess.Meta.Goal; goal == nil || goal.Status != core.GoalActive || goal.Objective != "reproduce the reported failure" || goal.Owner != core.GoalOwnerManager { | |||
| 126 | + | t.Fatalf("persisted goal = %#v", goal) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestPersistGoalToolResultRejectsActiveGoalReplacement(t *testing.T) { | |||
| 131 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 132 | + | if err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | defer sess.Close() | |||
| 136 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "current work", Status: core.GoalActive}); err != nil { | |||
| 137 | + | t.Fatal(err) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "unrelated work"}}) | |||
| 141 | + | if err == nil { | |||
| 142 | + | t.Fatal("active goal replacement succeeded") | |||
| 143 | + | } | |||
| 144 | + | if got := sess.Meta.Goal; got == nil || got.Objective != "current work" || got.Status != core.GoalActive { | |||
| 145 | + | t.Fatalf("persisted goal = %#v", got) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func TestPersistGoalToolResultCreatesNextGoalInMission(t *testing.T) { | |||
| 150 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 151 | + | if err != nil { | |||
| 152 | + | t.Fatal(err) | |||
| 153 | + | } | |||
| 154 | + | defer sess.Close() | |||
| 155 | + | if err := sess.UpdateGoal(&core.SessionGoal{Objective: "reproduce", Status: core.GoalActive}); err != nil { | |||
| 156 | + | t.Fatal(err) | |||
| 157 | + | } | |||
| 158 | + | missionID := sess.Meta.Mission.ID | |||
| 159 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalDone}}); err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | if err := persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "implement fix", MissionID: missionID}}); err != nil { | |||
| 163 | + | t.Fatal(err) | |||
| 164 | + | } | |||
| 165 | + | goal := sess.Meta.Goal | |||
| 166 | + | if goal == nil || goal.Objective != "implement fix" || goal.MissionID != missionID || goal.Owner != core.GoalOwnerManager || goal.Ordinal != 3 { | |||
| 167 | + | t.Fatalf("next goal = %#v", goal) | |||
| 168 | + | } | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func TestPersistGoalToolResultRejectsMismatchedMission(t *testing.T) { | |||
| 172 | + | sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "version") | |||
| 173 | + | if err != nil { | |||
| 174 | + | t.Fatal(err) | |||
| 175 | + | } | |||
| 176 | + | defer sess.Close() | |||
| 177 | + | if err := sess.EnsureMission("current work", core.MissionSourceUser); err != nil { | |||
| 178 | + | t.Fatal(err) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | err = persistGoalToolResult(sess, core.ToolResult{Details: tools.GoalUpdate{Status: core.GoalActive, Objective: "next work", MissionID: "other-mission"}}) | |||
| 182 | + | if err == nil { | |||
| 183 | + | t.Fatal("mismatched mission was accepted") | |||
| 184 | + | } | |||
| 185 | + | } | |||
| 186 | + | ||||
| 114 | func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) { | 187 | func TestPersistGoalToolResultRejectsBlockedWithoutReason(t *testing.T) { | ||
| 115 | sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | 188 | sess, err := core.NewSession(t.TempDir(), "cwd", "provider", "model", "test") | ||
| 116 | if err != nil { | 189 | if err != nil { | ||
M packages/agent/modes/goal.go +35 -3
| @@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||
| 31 | 31 | } | |
| 32 | 32 | text := goal.Objective + | |
| 33 | 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 | + | if goal.MissionID != "" { | |
| 35 | + | text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." | |
| 36 | + | } | |
| 34 | 37 | return provider.Message{ | |
| 35 | 38 | Role: provider.RoleUser, | |
| 36 | 39 | Content: []provider.Content{provider.TextBlock{Text: text}}, | |
| @@ -157,12 +160,41 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 157 | 160 | } | |
| 158 | 161 | ||
| 159 | 162 | current := copySessionGoal(i.cfg.CurrentGoal()) | |
| 163 | + | if len(parts) == 2 && strings.EqualFold(parts[1], "history") { | |
| 164 | + | if i.cfg.CurrentGoalHistory == nil { | |
| 165 | + | i.setGoalCommandError("goal: history is unavailable") | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | history := i.cfg.CurrentGoalHistory() | |
| 169 | + | if len(history) == 0 { | |
| 170 | + | i.setGoalCommandStatus("no goal history") | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | entries := make([]string, 0, len(history)) | |
| 174 | + | for _, goal := range history { | |
| 175 | + | owner := goal.Owner | |
| 176 | + | if owner == "" { | |
| 177 | + | owner = core.GoalOwnerUser | |
| 178 | + | } | |
| 179 | + | entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective) | |
| 180 | + | if goal.Reason != "" { | |
| 181 | + | entry += " (" + goal.Reason + ")" | |
| 182 | + | } | |
| 183 | + | entries = append(entries, entry) | |
| 184 | + | } | |
| 185 | + | i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → ")) | |
| 186 | + | return | |
| 187 | + | } | |
| 160 | 188 | if len(parts) == 1 { | |
| 161 | 189 | if current == nil { | |
| 162 | 190 | i.setGoalCommandStatus("no autonomous goal") | |
| 163 | 191 | return | |
| 164 | 192 | } | |
| 165 | - | status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective) | |
| 193 | + | owner := current.Owner | |
| 194 | + | if owner == "" { | |
| 195 | + | owner = core.GoalOwnerUser | |
| 196 | + | } | |
| 197 | + | status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) | |
| 166 | 198 | if current.Reason != "" { | |
| 167 | 199 | status += " (" + current.Reason + ")" | |
| 168 | 200 | } | |
| @@ -203,10 +235,10 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||
| 203 | 235 | default: | |
| 204 | 236 | objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) | |
| 205 | 237 | if objective == "" { | |
| 206 | - | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear") | |
| 238 | + | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history") | |
| 207 | 239 | return | |
| 208 | 240 | } | |
| 209 | - | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive} | |
| 241 | + | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} | |
| 210 | 242 | startIfIdle = true | |
| 211 | 243 | startTitle = true | |
| 212 | 244 | } | |
| @@ -31,6 +31,9 @@ func (i *Interactive) goalContinuationMessage() (provider.Message, bool) { | |||||
| 31 | } | 31 | } | ||
| 32 | text := goal.Objective + | 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." | 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 | + | if goal.MissionID != "" { | |||
| 35 | + | text += " When setting a next goal, use mission_id " + goal.MissionID + " and keep it within the same user mission." | |||
| 36 | + | } | |||
| 34 | return provider.Message{ | 37 | return provider.Message{ | ||
| 35 | Role: provider.RoleUser, | 38 | Role: provider.RoleUser, | ||
| 36 | Content: []provider.Content{provider.TextBlock{Text: text}}, | 39 | Content: []provider.Content{provider.TextBlock{Text: text}}, | ||
| @@ -157,12 +160,41 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||||
| 157 | } | 160 | } | ||
| 158 | 161 | ||||
| 159 | current := copySessionGoal(i.cfg.CurrentGoal()) | 162 | current := copySessionGoal(i.cfg.CurrentGoal()) | ||
| 163 | + | if len(parts) == 2 && strings.EqualFold(parts[1], "history") { | |||
| 164 | + | if i.cfg.CurrentGoalHistory == nil { | |||
| 165 | + | i.setGoalCommandError("goal: history is unavailable") | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | history := i.cfg.CurrentGoalHistory() | |||
| 169 | + | if len(history) == 0 { | |||
| 170 | + | i.setGoalCommandStatus("no goal history") | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 173 | + | entries := make([]string, 0, len(history)) | |||
| 174 | + | for _, goal := range history { | |||
| 175 | + | owner := goal.Owner | |||
| 176 | + | if owner == "" { | |||
| 177 | + | owner = core.GoalOwnerUser | |||
| 178 | + | } | |||
| 179 | + | entry := fmt.Sprintf("%s (%s): %s", goal.Status, owner, goal.Objective) | |||
| 180 | + | if goal.Reason != "" { | |||
| 181 | + | entry += " (" + goal.Reason + ")" | |||
| 182 | + | } | |||
| 183 | + | entries = append(entries, entry) | |||
| 184 | + | } | |||
| 185 | + | i.setGoalCommandStatus("goal history: " + strings.Join(entries, " → ")) | |||
| 186 | + | return | |||
| 187 | + | } | |||
| 160 | if len(parts) == 1 { | 188 | if len(parts) == 1 { | ||
| 161 | if current == nil { | 189 | if current == nil { | ||
| 162 | i.setGoalCommandStatus("no autonomous goal") | 190 | i.setGoalCommandStatus("no autonomous goal") | ||
| 163 | return | 191 | return | ||
| 164 | } | 192 | } | ||
| 165 | - | status := fmt.Sprintf("goal %s: %s", current.Status, current.Objective) | 193 | + | owner := current.Owner |
| 194 | + | if owner == "" { | |||
| 195 | + | owner = core.GoalOwnerUser | |||
| 196 | + | } | |||
| 197 | + | status := fmt.Sprintf("goal %s (%s): %s", current.Status, owner, current.Objective) | |||
| 166 | if current.Reason != "" { | 198 | if current.Reason != "" { | ||
| 167 | status += " (" + current.Reason + ")" | 199 | status += " (" + current.Reason + ")" | ||
| 168 | } | 200 | } | ||
| @@ -203,10 +235,10 @@ func (i *Interactive) runGoalCommand(ctx context.Context, cmd string, parts []st | |||||
| 203 | default: | 235 | default: | ||
| 204 | objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) | 236 | objective := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(cmd), parts[0])) | ||
| 205 | if objective == "" { | 237 | if objective == "" { | ||
| 206 | - | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear") | 238 | + | i.setGoalCommandError("usage: /goal <objective>|pause|resume|clear|history") |
| 207 | return | 239 | return | ||
| 208 | } | 240 | } | ||
| 209 | - | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive} | 241 | + | current = &core.SessionGoal{Objective: objective, Status: core.GoalActive, Owner: core.GoalOwnerUser} |
| 210 | startIfIdle = true | 242 | startIfIdle = true | ||
| 211 | startTitle = true | 243 | startTitle = true | ||
| 212 | } | 244 | } | ||
M packages/agent/modes/goal_test.go +39 -0
| @@ -214,6 +214,27 @@ func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) { | |||
| 214 | 214 | } | |
| 215 | 215 | } | |
| 216 | 216 | ||
| 217 | + | func TestGoalBadgeAcceptsNextPersistedManagerGoal(t *testing.T) { | |
| 218 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 219 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |
| 220 | + | CurrentGoal: func() *core.SessionGoal { | |
| 221 | + | return &core.SessionGoal{Objective: "implement the fix", Status: core.GoalActive, Owner: core.GoalOwnerManager} | |
| 222 | + | }, | |
| 223 | + | }) | |
| 224 | + | interactive.mu.Lock() | |
| 225 | + | interactive.goalStatus = core.GoalDone | |
| 226 | + | interactive.mu.Unlock() | |
| 227 | + | ||
| 228 | + | interactive.handleEvent(core.EvToolResult{ID: "goal", Result: core.ToolResult{ | |
| 229 | + | Details: agenttools.GoalUpdate{Status: core.GoalActive, Objective: "implement the fix"}, | |
| 230 | + | }}) | |
| 231 | + | interactive.mu.Lock() | |
| 232 | + | defer interactive.mu.Unlock() | |
| 233 | + | if interactive.goalStatus != core.GoalActive { | |
| 234 | + | t.Fatalf("goal badge = %q, want active", interactive.goalStatus) | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 217 | 238 | func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) { | |
| 218 | 239 | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | |
| 219 | 240 | interactive := NewInteractive(InteractiveConfig{ | |
| @@ -288,6 +309,24 @@ func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) { | |||
| 288 | 309 | } | |
| 289 | 310 | } | |
| 290 | 311 | ||
| 312 | + | func TestGoalHistoryShowsOwnerAndReason(t *testing.T) { | |
| 313 | + | interactive := NewInteractive(InteractiveConfig{ | |
| 314 | + | CurrentGoal: func() *core.SessionGoal { return nil }, | |
| 315 | + | PersistGoal: func(*core.SessionGoal) error { return nil }, | |
| 316 | + | CurrentGoalHistory: func() []core.SessionGoal { | |
| 317 | + | return []core.SessionGoal{ | |
| 318 | + | {Objective: "reproduce", Status: core.GoalDone, Owner: core.GoalOwnerManager}, | |
| 319 | + | {Objective: "await input", Status: core.GoalBlocked, Owner: core.GoalOwnerUser, Reason: "missing logs"}, | |
| 320 | + | } | |
| 321 | + | }, | |
| 322 | + | }) | |
| 323 | + | ||
| 324 | + | interactive.runSlash(context.Background(), "/goal history") | |
| 325 | + | if !strings.Contains(interactive.statusOK, "done (manager): reproduce") || !strings.Contains(interactive.statusOK, "blocked (user): await input (missing logs)") { | |
| 326 | + | t.Fatalf("goal history = %q", interactive.statusOK) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 291 | 330 | func TestGoalControlRequiresSessionPersistence(t *testing.T) { | |
| 292 | 331 | interactive := NewInteractive(InteractiveConfig{}) | |
| 293 | 332 | interactive.runSlash(context.Background(), "/goal pause") | |
| @@ -214,6 +214,27 @@ func TestToolResultPersistenceRunsOutsideTUILock(t *testing.T) { | |||||
| 214 | } | 214 | } | ||
| 215 | } | 215 | } | ||
| 216 | 216 | ||||
| 217 | + | func TestGoalBadgeAcceptsNextPersistedManagerGoal(t *testing.T) { | |||
| 218 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 219 | + | Agent: core.NewAgent(nil, "model", "system", goalToolRegistry()), | |||
| 220 | + | CurrentGoal: func() *core.SessionGoal { | |||
| 221 | + | return &core.SessionGoal{Objective: "implement the fix", Status: core.GoalActive, Owner: core.GoalOwnerManager} | |||
| 222 | + | }, | |||
| 223 | + | }) | |||
| 224 | + | interactive.mu.Lock() | |||
| 225 | + | interactive.goalStatus = core.GoalDone | |||
| 226 | + | interactive.mu.Unlock() | |||
| 227 | + | ||||
| 228 | + | interactive.handleEvent(core.EvToolResult{ID: "goal", Result: core.ToolResult{ | |||
| 229 | + | Details: agenttools.GoalUpdate{Status: core.GoalActive, Objective: "implement the fix"}, | |||
| 230 | + | }}) | |||
| 231 | + | interactive.mu.Lock() | |||
| 232 | + | defer interactive.mu.Unlock() | |||
| 233 | + | if interactive.goalStatus != core.GoalActive { | |||
| 234 | + | t.Fatalf("goal badge = %q, want active", interactive.goalStatus) | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 217 | func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) { | 238 | func TestGoalBadgeUsesDurableStatusAfterPersistenceFailure(t *testing.T) { | ||
| 218 | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | 239 | goal := &core.SessionGoal{Objective: "finish issue 97", Status: core.GoalActive} | ||
| 219 | interactive := NewInteractive(InteractiveConfig{ | 240 | interactive := NewInteractive(InteractiveConfig{ | ||
| @@ -288,6 +309,24 @@ func TestGoalSlashCommandStartsAndControlsGoal(t *testing.T) { | |||||
| 288 | } | 309 | } | ||
| 289 | } | 310 | } | ||
| 290 | 311 | ||||
| 312 | + | func TestGoalHistoryShowsOwnerAndReason(t *testing.T) { | |||
| 313 | + | interactive := NewInteractive(InteractiveConfig{ | |||
| 314 | + | CurrentGoal: func() *core.SessionGoal { return nil }, | |||
| 315 | + | PersistGoal: func(*core.SessionGoal) error { return nil }, | |||
| 316 | + | CurrentGoalHistory: func() []core.SessionGoal { | |||
| 317 | + | return []core.SessionGoal{ | |||
| 318 | + | {Objective: "reproduce", Status: core.GoalDone, Owner: core.GoalOwnerManager}, | |||
| 319 | + | {Objective: "await input", Status: core.GoalBlocked, Owner: core.GoalOwnerUser, Reason: "missing logs"}, | |||
| 320 | + | } | |||
| 321 | + | }, | |||
| 322 | + | }) | |||
| 323 | + | ||||
| 324 | + | interactive.runSlash(context.Background(), "/goal history") | |||
| 325 | + | if !strings.Contains(interactive.statusOK, "done (manager): reproduce") || !strings.Contains(interactive.statusOK, "blocked (user): await input (missing logs)") { | |||
| 326 | + | t.Fatalf("goal history = %q", interactive.statusOK) | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 291 | func TestGoalControlRequiresSessionPersistence(t *testing.T) { | 330 | func TestGoalControlRequiresSessionPersistence(t *testing.T) { | ||
| 292 | interactive := NewInteractive(InteractiveConfig{}) | 331 | interactive := NewInteractive(InteractiveConfig{}) | ||
| 293 | interactive.runSlash(context.Background(), "/goal pause") | 332 | interactive.runSlash(context.Background(), "/goal pause") | ||
M packages/agent/modes/interactive.go +21 -4
| @@ -377,8 +377,10 @@ type InteractiveConfig struct { | |||
| 377 | 377 | // current session. CurrentGoal must return a copy, not the live session | |
| 378 | 378 | // pointer, so failed persistence cannot mutate in-memory state. The callbacks | |
| 379 | 379 | // follow session switches without coupling the TUI to the session file owner. | |
| 380 | - | CurrentGoal func() *core.SessionGoal | |
| 381 | - | PersistGoal func(goal *core.SessionGoal) error | |
| 380 | + | CurrentGoal func() *core.SessionGoal | |
| 381 | + | CurrentGoalHistory func() []core.SessionGoal | |
| 382 | + | EnsureMission func(objective string) error | |
| 383 | + | PersistGoal func(goal *core.SessionGoal) error | |
| 382 | 384 | ||
| 383 | 385 | OnAssistant func(m provider.Message) | |
| 384 | 386 | ||
| @@ -4176,6 +4178,12 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||
| 4176 | 4178 | return | |
| 4177 | 4179 | } | |
| 4178 | 4180 | i.mu.Unlock() | |
| 4181 | + | if userInput && i.cfg.EnsureMission != nil { | |
| 4182 | + | if err := i.cfg.EnsureMission(text); err != nil { | |
| 4183 | + | i.ReportError(fmt.Errorf("persist mission: %w", err)) | |
| 4184 | + | return | |
| 4185 | + | } | |
| 4186 | + | } | |
| 4179 | 4187 | // User input is an orchestration event, not merely a queue append. When | |
| 4180 | 4188 | // the manager is dormant behind a sealed worker wave, the coordinator makes | |
| 4181 | 4189 | // this the next manager turn and carries any completed worker summary with it. | |
| @@ -8362,8 +8370,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||
| 8362 | 8370 | tc.Result = text.String() | |
| 8363 | 8371 | i.bumpToolRevisionLocked(tc) | |
| 8364 | 8372 | } | |
| 8365 | - | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | |
| 8366 | - | i.goalStatus = update.Status | |
| 8373 | + | if update, ok := tools.GoalUpdateFromResult(e.Result); ok { | |
| 8374 | + | if update.Status == core.GoalActive { | |
| 8375 | + | // A manager may advance a terminal goal to the next persisted | |
| 8376 | + | // goal in the same mission. Do not let a late tool result resume | |
| 8377 | + | // a goal explicitly paused by the user. | |
| 8378 | + | if i.goalStatus != core.GoalPaused { | |
| 8379 | + | i.goalStatus = core.GoalActive | |
| 8380 | + | } | |
| 8381 | + | } else if i.goalStatus == core.GoalActive { | |
| 8382 | + | i.goalStatus = update.Status | |
| 8383 | + | } | |
| 8367 | 8384 | } | |
| 8368 | 8385 | case core.EvUsage: | |
| 8369 | 8386 | i.cumUsage = e.Cumulative | |
| @@ -377,8 +377,10 @@ type InteractiveConfig struct { | |||||
| 377 | // current session. CurrentGoal must return a copy, not the live session | 377 | // current session. CurrentGoal must return a copy, not the live session | ||
| 378 | // pointer, so failed persistence cannot mutate in-memory state. The callbacks | 378 | // pointer, so failed persistence cannot mutate in-memory state. The callbacks | ||
| 379 | // follow session switches without coupling the TUI to the session file owner. | 379 | // follow session switches without coupling the TUI to the session file owner. | ||
| 380 | - | CurrentGoal func() *core.SessionGoal | 380 | + | CurrentGoal func() *core.SessionGoal |
| 381 | - | PersistGoal func(goal *core.SessionGoal) error | 381 | + | CurrentGoalHistory func() []core.SessionGoal |
| 382 | + | EnsureMission func(objective string) error | |||
| 383 | + | PersistGoal func(goal *core.SessionGoal) error | |||
| 382 | 384 | ||||
| 383 | OnAssistant func(m provider.Message) | 385 | OnAssistant func(m provider.Message) | ||
| 384 | 386 | ||||
| @@ -4176,6 +4178,12 @@ func (i *Interactive) submitOrQueue(text string, images []provider.ImageBlock, u | |||||
| 4176 | return | 4178 | return | ||
| 4177 | } | 4179 | } | ||
| 4178 | i.mu.Unlock() | 4180 | i.mu.Unlock() | ||
| 4181 | + | if userInput && i.cfg.EnsureMission != nil { | |||
| 4182 | + | if err := i.cfg.EnsureMission(text); err != nil { | |||
| 4183 | + | i.ReportError(fmt.Errorf("persist mission: %w", err)) | |||
| 4184 | + | return | |||
| 4185 | + | } | |||
| 4186 | + | } | |||
| 4179 | // User input is an orchestration event, not merely a queue append. When | 4187 | // User input is an orchestration event, not merely a queue append. When | ||
| 4180 | // the manager is dormant behind a sealed worker wave, the coordinator makes | 4188 | // the manager is dormant behind a sealed worker wave, the coordinator makes | ||
| 4181 | // this the next manager turn and carries any completed worker summary with it. | 4189 | // this the next manager turn and carries any completed worker summary with it. | ||
| @@ -8362,8 +8370,17 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) { | |||||
| 8362 | tc.Result = text.String() | 8370 | tc.Result = text.String() | ||
| 8363 | i.bumpToolRevisionLocked(tc) | 8371 | i.bumpToolRevisionLocked(tc) | ||
| 8364 | } | 8372 | } | ||
| 8365 | - | if update, ok := tools.GoalUpdateFromResult(e.Result); ok && i.goalStatus == core.GoalActive { | 8373 | + | if update, ok := tools.GoalUpdateFromResult(e.Result); ok { |
| 8366 | - | i.goalStatus = update.Status | 8374 | + | if update.Status == core.GoalActive { |
| 8375 | + | // A manager may advance a terminal goal to the next persisted | |||
| 8376 | + | // goal in the same mission. Do not let a late tool result resume | |||
| 8377 | + | // a goal explicitly paused by the user. | |||
| 8378 | + | if i.goalStatus != core.GoalPaused { | |||
| 8379 | + | i.goalStatus = core.GoalActive | |||
| 8380 | + | } | |||
| 8381 | + | } else if i.goalStatus == core.GoalActive { | |||
| 8382 | + | i.goalStatus = update.Status | |||
| 8383 | + | } | |||
| 8367 | } | 8384 | } | ||
| 8368 | case core.EvUsage: | 8385 | case core.EvUsage: | ||
| 8369 | i.cumUsage = e.Cumulative | 8386 | i.cumUsage = e.Cumulative | ||
M packages/agent/tools/update_goal.go +27 -10
| @@ -13,28 +13,33 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | const ( | |
| 15 | 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}` | |
| 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Set the 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":"Mission identifier supplied in the active-goal context when setting a next goal."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` | |
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | - | // UpdateGoalTool lets the model stop an explicitly user-started autonomous | |
| 20 | - | // goal. Starting, pausing, and resuming remain user-controlled. | |
| 19 | + | // UpdateGoalTool lets the manager set the next concrete active goal or settle | |
| 20 | + | // the current goal. Host-side persistence validates that a transition remains | |
| 21 | + | // within the session's active mission. | |
| 21 | 22 | type UpdateGoalTool struct{} | |
| 22 | 23 | ||
| 23 | 24 | type updateGoalArgs struct { | |
| 24 | - | Status string `json:"status"` | |
| 25 | - | Reason string `json:"reason,omitempty"` | |
| 25 | + | Status string `json:"status"` | |
| 26 | + | Objective string `json:"objective,omitempty"` | |
| 27 | + | MissionID string `json:"mission_id,omitempty"` | |
| 28 | + | Reason string `json:"reason,omitempty"` | |
| 26 | 29 | } | |
| 27 | 30 | ||
| 28 | 31 | // GoalUpdate is trusted metadata returned only by UpdateGoalTool. | |
| 29 | 32 | type GoalUpdate struct { | |
| 30 | - | Status core.GoalStatus | |
| 31 | - | Reason string | |
| 33 | + | Status core.GoalStatus | |
| 34 | + | Objective string | |
| 35 | + | MissionID string | |
| 36 | + | Reason string | |
| 32 | 37 | } | |
| 33 | 38 | ||
| 34 | 39 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | |
| 35 | 40 | ||
| 36 | 41 | 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." | |
| 42 | + | return "Set the next concrete goal needed for the active user mission, or mark the active goal complete or blocked. Do not broaden the user's mission." | |
| 38 | 43 | } | |
| 39 | 44 | ||
| 40 | 45 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | |
| @@ -50,10 +55,19 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||
| 50 | 55 | return goalToolError("invalid arguments"), nil | |
| 51 | 56 | } | |
| 52 | 57 | args.Status = strings.TrimSpace(args.Status) | |
| 58 | + | args.Objective = strings.TrimSpace(args.Objective) | |
| 59 | + | args.MissionID = strings.TrimSpace(args.MissionID) | |
| 53 | 60 | args.Reason = strings.TrimSpace(args.Reason) | |
| 54 | 61 | ||
| 55 | 62 | var update GoalUpdate | |
| 56 | 63 | switch args.Status { | |
| 64 | + | case "active": | |
| 65 | + | if args.Objective == "" { | |
| 66 | + | return goalToolError("objective is required when setting a goal"), nil | |
| 67 | + | } | |
| 68 | + | update.Status = core.GoalActive | |
| 69 | + | update.Objective = args.Objective | |
| 70 | + | update.MissionID = args.MissionID | |
| 57 | 71 | case "complete": | |
| 58 | 72 | update.Status = core.GoalDone | |
| 59 | 73 | case "blocked": | |
| @@ -63,7 +77,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||
| 63 | 77 | update.Status = core.GoalBlocked | |
| 64 | 78 | update.Reason = args.Reason | |
| 65 | 79 | default: | |
| 66 | - | return goalToolError("status must be complete or blocked"), nil | |
| 80 | + | return goalToolError("status must be active, complete, or blocked"), nil | |
| 67 | 81 | } | |
| 68 | 82 | ||
| 69 | 83 | text := "autonomous goal marked " + string(update.Status) | |
| @@ -85,7 +99,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||
| 85 | 99 | if !ok { | |
| 86 | 100 | return GoalUpdate{}, false | |
| 87 | 101 | } | |
| 88 | - | if update.Status != core.GoalDone && update.Status != core.GoalBlocked { | |
| 102 | + | if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked { | |
| 103 | + | return GoalUpdate{}, false | |
| 104 | + | } | |
| 105 | + | if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" { | |
| 89 | 106 | return GoalUpdate{}, false | |
| 90 | 107 | } | |
| 91 | 108 | if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" { | |
| @@ -13,28 +13,33 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | const ( | 14 | const ( | ||
| 15 | UpdateGoalToolName = "update_goal" | 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}` | 16 | + | updateGoalSchema = `{"type":"object","properties":{"status":{"type":"string","enum":["active","complete","blocked"],"description":"Set the 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":"Mission identifier supplied in the active-goal context when setting a next goal."},"reason":{"type":"string","description":"Concise reason when the goal is blocked."}},"required":["status"],"additionalProperties":false}` |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | - | // UpdateGoalTool lets the model stop an explicitly user-started autonomous | 19 | + | // UpdateGoalTool lets the manager set the next concrete active goal or settle |
| 20 | - | // goal. Starting, pausing, and resuming remain user-controlled. | 20 | + | // the current goal. Host-side persistence validates that a transition remains |
| 21 | + | // within the session's active mission. | |||
| 21 | type UpdateGoalTool struct{} | 22 | type UpdateGoalTool struct{} | ||
| 22 | 23 | ||||
| 23 | type updateGoalArgs struct { | 24 | type updateGoalArgs struct { | ||
| 24 | - | Status string `json:"status"` | 25 | + | Status string `json:"status"` |
| 25 | - | Reason string `json:"reason,omitempty"` | 26 | + | Objective string `json:"objective,omitempty"` |
| 27 | + | MissionID string `json:"mission_id,omitempty"` | |||
| 28 | + | Reason string `json:"reason,omitempty"` | |||
| 26 | } | 29 | } | ||
| 27 | 30 | ||||
| 28 | // GoalUpdate is trusted metadata returned only by UpdateGoalTool. | 31 | // GoalUpdate is trusted metadata returned only by UpdateGoalTool. | ||
| 29 | type GoalUpdate struct { | 32 | type GoalUpdate struct { | ||
| 30 | - | Status core.GoalStatus | 33 | + | Status core.GoalStatus |
| 31 | - | Reason string | 34 | + | Objective string |
| 35 | + | MissionID string | |||
| 36 | + | Reason string | |||
| 32 | } | 37 | } | ||
| 33 | 38 | ||||
| 34 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | 39 | func (t *UpdateGoalTool) Name() string { return UpdateGoalToolName } | ||
| 35 | 40 | ||||
| 36 | func (t *UpdateGoalTool) Description() string { | 41 | 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." | 42 | + | return "Set the next concrete goal needed for the active user mission, or mark the active goal complete or blocked. Do not broaden the user's mission." |
| 38 | } | 43 | } | ||
| 39 | 44 | ||||
| 40 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | 45 | func (t *UpdateGoalTool) Schema() json.RawMessage { return json.RawMessage(updateGoalSchema) } | ||
| @@ -50,10 +55,19 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||||
| 50 | return goalToolError("invalid arguments"), nil | 55 | return goalToolError("invalid arguments"), nil | ||
| 51 | } | 56 | } | ||
| 52 | args.Status = strings.TrimSpace(args.Status) | 57 | args.Status = strings.TrimSpace(args.Status) | ||
| 58 | + | args.Objective = strings.TrimSpace(args.Objective) | |||
| 59 | + | args.MissionID = strings.TrimSpace(args.MissionID) | |||
| 53 | args.Reason = strings.TrimSpace(args.Reason) | 60 | args.Reason = strings.TrimSpace(args.Reason) | ||
| 54 | 61 | ||||
| 55 | var update GoalUpdate | 62 | var update GoalUpdate | ||
| 56 | switch args.Status { | 63 | switch args.Status { | ||
| 64 | + | case "active": | |||
| 65 | + | if args.Objective == "" { | |||
| 66 | + | return goalToolError("objective is required when setting a goal"), nil | |||
| 67 | + | } | |||
| 68 | + | update.Status = core.GoalActive | |||
| 69 | + | update.Objective = args.Objective | |||
| 70 | + | update.MissionID = args.MissionID | |||
| 57 | case "complete": | 71 | case "complete": | ||
| 58 | update.Status = core.GoalDone | 72 | update.Status = core.GoalDone | ||
| 59 | case "blocked": | 73 | case "blocked": | ||
| @@ -63,7 +77,7 @@ func (t *UpdateGoalTool) Execute(_ context.Context, raw json.RawMessage, _ func( | |||||
| 63 | update.Status = core.GoalBlocked | 77 | update.Status = core.GoalBlocked | ||
| 64 | update.Reason = args.Reason | 78 | update.Reason = args.Reason | ||
| 65 | default: | 79 | default: | ||
| 66 | - | return goalToolError("status must be complete or blocked"), nil | 80 | + | return goalToolError("status must be active, complete, or blocked"), nil |
| 67 | } | 81 | } | ||
| 68 | 82 | ||||
| 69 | text := "autonomous goal marked " + string(update.Status) | 83 | text := "autonomous goal marked " + string(update.Status) | ||
| @@ -85,7 +99,10 @@ func GoalUpdateFromResult(result core.ToolResult) (GoalUpdate, bool) { | |||||
| 85 | if !ok { | 99 | if !ok { | ||
| 86 | return GoalUpdate{}, false | 100 | return GoalUpdate{}, false | ||
| 87 | } | 101 | } | ||
| 88 | - | if update.Status != core.GoalDone && update.Status != core.GoalBlocked { | 102 | + | if update.Status != core.GoalActive && update.Status != core.GoalDone && update.Status != core.GoalBlocked { |
| 103 | + | return GoalUpdate{}, false | |||
| 104 | + | } | |||
| 105 | + | if update.Status == core.GoalActive && strings.TrimSpace(update.Objective) == "" { | |||
| 89 | return GoalUpdate{}, false | 106 | return GoalUpdate{}, false | ||
| 90 | } | 107 | } | ||
| 91 | if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" { | 108 | if update.Status == core.GoalBlocked && strings.TrimSpace(update.Reason) == "" { | ||
M packages/agent/tools/update_goal_test.go +15 -0
| @@ -30,6 +30,21 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) { | |
| 34 | + | tool := &UpdateGoalTool{} | |
| 35 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil) | |
| 36 | + | if err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | update, ok := GoalUpdateFromResult(result) | |
| 40 | + | if !ok { | |
| 41 | + | t.Fatalf("details = %#v, want goal update", result.Details) | |
| 42 | + | } | |
| 43 | + | if update.Status != core.GoalActive || update.Objective != "reproduce the reported failure" { | |
| 44 | + | t.Fatalf("update = %#v", update) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 33 | 48 | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | |
| 34 | 49 | tool := &UpdateGoalTool{} | |
| 35 | 50 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil) | |
| @@ -30,6 +30,21 @@ func TestUpdateGoalToolReturnsPersistableStatus(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestUpdateGoalToolReturnsPersistableManagerGoal(t *testing.T) { | |||
| 34 | + | tool := &UpdateGoalTool{} | |||
| 35 | + | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"active","objective":"reproduce the reported failure"}`), nil) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | update, ok := GoalUpdateFromResult(result) | |||
| 40 | + | if !ok { | |||
| 41 | + | t.Fatalf("details = %#v, want goal update", result.Details) | |||
| 42 | + | } | |||
| 43 | + | if update.Status != core.GoalActive || update.Objective != "reproduce the reported failure" { | |||
| 44 | + | t.Fatalf("update = %#v", update) | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 33 | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | 48 | func TestUpdateGoalToolRejectsUnknownFields(t *testing.T) { | ||
| 34 | tool := &UpdateGoalTool{} | 49 | tool := &UpdateGoalTool{} | ||
| 35 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil) | 50 | result, err := tool.Execute(context.Background(), json.RawMessage(`{"status":"complete","unexpected":true}`), nil) | ||
M packages/core/core_test.go +159 -4
| @@ -296,6 +296,155 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) { | |||
| 296 | 296 | } | |
| 297 | 297 | } | |
| 298 | 298 | ||
| 299 | + | func TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) { | |
| 300 | + | meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}} | |
| 301 | + | normalizeSessionGoalMeta(&meta) | |
| 302 | + | if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" { | |
| 303 | + | t.Fatalf("mission = %#v", meta.Mission) | |
| 304 | + | } | |
| 305 | + | if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 { | |
| 306 | + | t.Fatalf("goal = %#v", meta.Goal) | |
| 307 | + | } | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) { | |
| 311 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 312 | + | if err != nil { | |
| 313 | + | t.Fatal(err) | |
| 314 | + | } | |
| 315 | + | defer sess.Close() | |
| 316 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil { | |
| 317 | + | t.Fatal(err) | |
| 318 | + | } | |
| 319 | + | for transition := 0; transition < maxMissionGoalTransitions; transition++ { | |
| 320 | + | done := *sess.Meta.Goal | |
| 321 | + | done.Status = GoalDone | |
| 322 | + | if err := sess.UpdateGoal(&done); err != nil { | |
| 323 | + | t.Fatalf("settle transition %d: %v", transition, err) | |
| 324 | + | } | |
| 325 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil { | |
| 326 | + | t.Fatalf("start transition %d: %v", transition, err) | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | done := *sess.Meta.Goal | |
| 330 | + | done.Status = GoalDone | |
| 331 | + | if err := sess.UpdateGoal(&done); err != nil { | |
| 332 | + | t.Fatal(err) | |
| 333 | + | } | |
| 334 | + | previousMission := *sess.Meta.Mission | |
| 335 | + | previousGoal := *sess.Meta.Goal | |
| 336 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil { | |
| 337 | + | t.Fatal("transition limit was not enforced") | |
| 338 | + | } | |
| 339 | + | if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal { | |
| 340 | + | t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal) | |
| 341 | + | } | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) { | |
| 345 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 346 | + | if err != nil { | |
| 347 | + | t.Fatal(err) | |
| 348 | + | } | |
| 349 | + | defer sess.Close() | |
| 350 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil { | |
| 351 | + | t.Fatal(err) | |
| 352 | + | } | |
| 353 | + | done := *sess.Meta.Goal | |
| 354 | + | done.Status = GoalDone | |
| 355 | + | if err := sess.UpdateGoal(&done); err != nil { | |
| 356 | + | t.Fatal(err) | |
| 357 | + | } | |
| 358 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil { | |
| 359 | + | t.Fatal(err) | |
| 360 | + | } | |
| 361 | + | for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ { | |
| 362 | + | paused := *sess.Meta.Goal | |
| 363 | + | paused.Status = GoalPaused | |
| 364 | + | if err := sess.UpdateGoal(&paused); err != nil { | |
| 365 | + | t.Fatalf("pause cycle %d: %v", cycle, err) | |
| 366 | + | } | |
| 367 | + | resumed := *sess.Meta.Goal | |
| 368 | + | resumed.Status = GoalActive | |
| 369 | + | if err := sess.UpdateGoal(&resumed); err != nil { | |
| 370 | + | t.Fatalf("resume cycle %d: %v", cycle, err) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | if got := sess.Meta.Mission.TransitionCount; got != 1 { | |
| 374 | + | t.Fatalf("transition count = %d, want 1", got) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) { | |
| 379 | + | path := t.TempDir() + "/legacy.jsonl" | |
| 380 | + | meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}} | |
| 381 | + | line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta}) | |
| 382 | + | if err != nil { | |
| 383 | + | t.Fatal(err) | |
| 384 | + | } | |
| 385 | + | if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil { | |
| 386 | + | t.Fatal(err) | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | sess, _, err := OpenSession(path) | |
| 390 | + | if err != nil { | |
| 391 | + | t.Fatal(err) | |
| 392 | + | } | |
| 393 | + | if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID { | |
| 394 | + | t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal) | |
| 395 | + | } | |
| 396 | + | done := *sess.Meta.Goal | |
| 397 | + | done.Status = GoalDone | |
| 398 | + | if err := sess.UpdateGoal(&done); err != nil { | |
| 399 | + | t.Fatal(err) | |
| 400 | + | } | |
| 401 | + | if err := sess.Close(); err != nil { | |
| 402 | + | t.Fatal(err) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | reopened, _, err := OpenSession(path) | |
| 406 | + | if err != nil { | |
| 407 | + | t.Fatal(err) | |
| 408 | + | } | |
| 409 | + | defer reopened.Close() | |
| 410 | + | if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID { | |
| 411 | + | t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) { | |
| 416 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 417 | + | if err != nil { | |
| 418 | + | t.Fatal(err) | |
| 419 | + | } | |
| 420 | + | if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil { | |
| 421 | + | t.Fatal(err) | |
| 422 | + | } | |
| 423 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser { | |
| 424 | + | t.Fatalf("mission = %#v", sess.Meta.Mission) | |
| 425 | + | } | |
| 426 | + | missionID := sess.Meta.Mission.ID | |
| 427 | + | if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil { | |
| 428 | + | t.Fatal(err) | |
| 429 | + | } | |
| 430 | + | if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" { | |
| 431 | + | t.Fatalf("mission was replaced: %#v", sess.Meta.Mission) | |
| 432 | + | } | |
| 433 | + | path := sess.Path | |
| 434 | + | if err := sess.Close(); err != nil { | |
| 435 | + | t.Fatal(err) | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | reopened, _, err := OpenSession(path) | |
| 439 | + | if err != nil { | |
| 440 | + | t.Fatal(err) | |
| 441 | + | } | |
| 442 | + | defer reopened.Close() | |
| 443 | + | if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID { | |
| 444 | + | t.Fatalf("reopened mission = %#v", reopened.Meta.Mission) | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | ||
| 299 | 448 | func TestSessionGoalRoundTripAndClear(t *testing.T) { | |
| 300 | 449 | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |
| 301 | 450 | if err != nil { | |
| @@ -305,8 +454,11 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||
| 305 | 454 | if err := sess.UpdateGoal(goal); err != nil { | |
| 306 | 455 | t.Fatal(err) | |
| 307 | 456 | } | |
| 308 | - | if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal { | |
| 309 | - | t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal) | |
| 457 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Objective != goal.Objective || sess.Meta.Goal.Status != goal.Status || sess.Meta.Goal.Owner != GoalOwnerUser || sess.Meta.Goal.ID == "" || sess.Meta.Goal.MissionID == "" || sess.Meta.Goal.Ordinal != 1 { | |
| 458 | + | t.Fatalf("live goal = %#v", sess.Meta.Goal) | |
| 459 | + | } | |
| 460 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.ID != sess.Meta.Goal.MissionID || sess.Meta.Mission.Objective != goal.Objective || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID { | |
| 461 | + | t.Fatalf("live mission = %#v", sess.Meta.Mission) | |
| 310 | 462 | } | |
| 311 | 463 | if err := sess.Close(); err != nil { | |
| 312 | 464 | t.Fatal(err) | |
| @@ -316,8 +468,8 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||
| 316 | 468 | if err != nil { | |
| 317 | 469 | t.Fatal(err) | |
| 318 | 470 | } | |
| 319 | - | if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal { | |
| 320 | - | t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal) | |
| 471 | + | if reopened.Meta.Goal == nil || reopened.Meta.Goal.Objective != goal.Objective || reopened.Meta.Goal.Status != goal.Status || reopened.Meta.Goal.MissionID != sess.Meta.Goal.MissionID { | |
| 472 | + | t.Fatalf("reopened goal = %#v", reopened.Meta.Goal) | |
| 321 | 473 | } | |
| 322 | 474 | if err := reopened.UpdateGoal(nil); err != nil { | |
| 323 | 475 | t.Fatal(err) | |
| @@ -334,6 +486,9 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||
| 334 | 486 | if reopened.Meta.Goal != nil { | |
| 335 | 487 | t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal) | |
| 336 | 488 | } | |
| 489 | + | if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive { | |
| 490 | + | t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory) | |
| 491 | + | } | |
| 337 | 492 | } | |
| 338 | 493 | ||
| 339 | 494 | func TestCostAdd(t *testing.T) { | |
| @@ -296,6 +296,155 @@ func TestSessionCompactHandoffRoundTripAndClear(t *testing.T) { | |||||
| 296 | } | 296 | } | ||
| 297 | } | 297 | } | ||
| 298 | 298 | ||||
| 299 | + | func TestNormalizeSessionGoalMetaMigratesLegacyGoal(t *testing.T) { | |||
| 300 | + | meta := SessionMeta{ID: "legacy-session", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}} | |||
| 301 | + | normalizeSessionGoalMeta(&meta) | |||
| 302 | + | if meta.Mission == nil || meta.Mission.ID != "legacy-legacy-session" || meta.Mission.Source != MissionSourceUser || meta.Mission.ActiveGoalID != "legacy-goal-legacy-session" { | |||
| 303 | + | t.Fatalf("mission = %#v", meta.Mission) | |||
| 304 | + | } | |||
| 305 | + | if meta.Goal.Owner != GoalOwnerUser || meta.Goal.ID != "legacy-goal-legacy-session" || meta.Goal.MissionID != meta.Mission.ID || meta.Goal.Ordinal != 1 { | |||
| 306 | + | t.Fatalf("goal = %#v", meta.Goal) | |||
| 307 | + | } | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func TestSessionGoalTransitionLimitRollsBackState(t *testing.T) { | |||
| 311 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 312 | + | if err != nil { | |||
| 313 | + | t.Fatal(err) | |||
| 314 | + | } | |||
| 315 | + | defer sess.Close() | |||
| 316 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil { | |||
| 317 | + | t.Fatal(err) | |||
| 318 | + | } | |||
| 319 | + | for transition := 0; transition < maxMissionGoalTransitions; transition++ { | |||
| 320 | + | done := *sess.Meta.Goal | |||
| 321 | + | done.Status = GoalDone | |||
| 322 | + | if err := sess.UpdateGoal(&done); err != nil { | |||
| 323 | + | t.Fatalf("settle transition %d: %v", transition, err) | |||
| 324 | + | } | |||
| 325 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "next", Status: GoalActive, Owner: GoalOwnerManager}); err != nil { | |||
| 326 | + | t.Fatalf("start transition %d: %v", transition, err) | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | done := *sess.Meta.Goal | |||
| 330 | + | done.Status = GoalDone | |||
| 331 | + | if err := sess.UpdateGoal(&done); err != nil { | |||
| 332 | + | t.Fatal(err) | |||
| 333 | + | } | |||
| 334 | + | previousMission := *sess.Meta.Mission | |||
| 335 | + | previousGoal := *sess.Meta.Goal | |||
| 336 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "one too many", Status: GoalActive, Owner: GoalOwnerManager}); err == nil { | |||
| 337 | + | t.Fatal("transition limit was not enforced") | |||
| 338 | + | } | |||
| 339 | + | if *sess.Meta.Mission != previousMission || *sess.Meta.Goal != previousGoal { | |||
| 340 | + | t.Fatalf("state changed after rejected transition: mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal) | |||
| 341 | + | } | |||
| 342 | + | } | |||
| 343 | + | ||||
| 344 | + | func TestSessionGoalResumeDoesNotConsumeTransitionLimit(t *testing.T) { | |||
| 345 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 346 | + | if err != nil { | |||
| 347 | + | t.Fatal(err) | |||
| 348 | + | } | |||
| 349 | + | defer sess.Close() | |||
| 350 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "initial", Status: GoalActive, Owner: GoalOwnerUser}); err != nil { | |||
| 351 | + | t.Fatal(err) | |||
| 352 | + | } | |||
| 353 | + | done := *sess.Meta.Goal | |||
| 354 | + | done.Status = GoalDone | |||
| 355 | + | if err := sess.UpdateGoal(&done); err != nil { | |||
| 356 | + | t.Fatal(err) | |||
| 357 | + | } | |||
| 358 | + | if err := sess.UpdateGoal(&SessionGoal{Objective: "manager work", Status: GoalActive, Owner: GoalOwnerManager}); err != nil { | |||
| 359 | + | t.Fatal(err) | |||
| 360 | + | } | |||
| 361 | + | for cycle := 0; cycle < maxMissionGoalTransitions*2; cycle++ { | |||
| 362 | + | paused := *sess.Meta.Goal | |||
| 363 | + | paused.Status = GoalPaused | |||
| 364 | + | if err := sess.UpdateGoal(&paused); err != nil { | |||
| 365 | + | t.Fatalf("pause cycle %d: %v", cycle, err) | |||
| 366 | + | } | |||
| 367 | + | resumed := *sess.Meta.Goal | |||
| 368 | + | resumed.Status = GoalActive | |||
| 369 | + | if err := sess.UpdateGoal(&resumed); err != nil { | |||
| 370 | + | t.Fatalf("resume cycle %d: %v", cycle, err) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | if got := sess.Meta.Mission.TransitionCount; got != 1 { | |||
| 374 | + | t.Fatalf("transition count = %d, want 1", got) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | func TestSessionLegacyGoalMigrationPersistsOnNextTransition(t *testing.T) { | |||
| 379 | + | path := t.TempDir() + "/legacy.jsonl" | |||
| 380 | + | meta := SessionMeta{ID: "legacy-session", CWD: "/tmp/project", Goal: &SessionGoal{Objective: "finish migration", Status: GoalActive}} | |||
| 381 | + | line, err := json.Marshal(sessionLine{Type: "meta", Meta: &meta}) | |||
| 382 | + | if err != nil { | |||
| 383 | + | t.Fatal(err) | |||
| 384 | + | } | |||
| 385 | + | if err := os.WriteFile(path, append(line, '\n'), 0o600); err != nil { | |||
| 386 | + | t.Fatal(err) | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | sess, _, err := OpenSession(path) | |||
| 390 | + | if err != nil { | |||
| 391 | + | t.Fatal(err) | |||
| 392 | + | } | |||
| 393 | + | if sess.Meta.Mission == nil || sess.Meta.Goal.MissionID != sess.Meta.Mission.ID { | |||
| 394 | + | t.Fatalf("legacy migration = mission %#v, goal %#v", sess.Meta.Mission, sess.Meta.Goal) | |||
| 395 | + | } | |||
| 396 | + | done := *sess.Meta.Goal | |||
| 397 | + | done.Status = GoalDone | |||
| 398 | + | if err := sess.UpdateGoal(&done); err != nil { | |||
| 399 | + | t.Fatal(err) | |||
| 400 | + | } | |||
| 401 | + | if err := sess.Close(); err != nil { | |||
| 402 | + | t.Fatal(err) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | reopened, _, err := OpenSession(path) | |||
| 406 | + | if err != nil { | |||
| 407 | + | t.Fatal(err) | |||
| 408 | + | } | |||
| 409 | + | defer reopened.Close() | |||
| 410 | + | if reopened.Meta.Mission == nil || reopened.Meta.Goal == nil || reopened.Meta.Goal.Status != GoalDone || reopened.Meta.Goal.MissionID != reopened.Meta.Mission.ID { | |||
| 411 | + | t.Fatalf("persisted migration = mission %#v, goal %#v", reopened.Meta.Mission, reopened.Meta.Goal) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func TestSessionEnsureMissionCreatesUserBoundaryOnce(t *testing.T) { | |||
| 416 | + | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | |||
| 417 | + | if err != nil { | |||
| 418 | + | t.Fatal(err) | |||
| 419 | + | } | |||
| 420 | + | if err := sess.EnsureMission("fix the reported failure", MissionSourceUser); err != nil { | |||
| 421 | + | t.Fatal(err) | |||
| 422 | + | } | |||
| 423 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.Objective != "fix the reported failure" || sess.Meta.Mission.Source != MissionSourceUser { | |||
| 424 | + | t.Fatalf("mission = %#v", sess.Meta.Mission) | |||
| 425 | + | } | |||
| 426 | + | missionID := sess.Meta.Mission.ID | |||
| 427 | + | if err := sess.EnsureMission("unrelated follow-up", MissionSourceUser); err != nil { | |||
| 428 | + | t.Fatal(err) | |||
| 429 | + | } | |||
| 430 | + | if sess.Meta.Mission.ID != missionID || sess.Meta.Mission.Objective != "fix the reported failure" { | |||
| 431 | + | t.Fatalf("mission was replaced: %#v", sess.Meta.Mission) | |||
| 432 | + | } | |||
| 433 | + | path := sess.Path | |||
| 434 | + | if err := sess.Close(); err != nil { | |||
| 435 | + | t.Fatal(err) | |||
| 436 | + | } | |||
| 437 | + | ||||
| 438 | + | reopened, _, err := OpenSession(path) | |||
| 439 | + | if err != nil { | |||
| 440 | + | t.Fatal(err) | |||
| 441 | + | } | |||
| 442 | + | defer reopened.Close() | |||
| 443 | + | if reopened.Meta.Mission == nil || reopened.Meta.Mission.ID != missionID { | |||
| 444 | + | t.Fatalf("reopened mission = %#v", reopened.Meta.Mission) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | ||||
| 299 | func TestSessionGoalRoundTripAndClear(t *testing.T) { | 448 | func TestSessionGoalRoundTripAndClear(t *testing.T) { | ||
| 300 | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | 449 | sess, err := NewSession(t.TempDir(), "/tmp/project", "anthropic", "claude", "test") | ||
| 301 | if err != nil { | 450 | if err != nil { | ||
| @@ -305,8 +454,11 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||||
| 305 | if err := sess.UpdateGoal(goal); err != nil { | 454 | if err := sess.UpdateGoal(goal); err != nil { | ||
| 306 | t.Fatal(err) | 455 | t.Fatal(err) | ||
| 307 | } | 456 | } | ||
| 308 | - | if sess.Meta.Goal == nil || *sess.Meta.Goal != *goal { | 457 | + | if sess.Meta.Goal == nil || sess.Meta.Goal.Objective != goal.Objective || sess.Meta.Goal.Status != goal.Status || sess.Meta.Goal.Owner != GoalOwnerUser || sess.Meta.Goal.ID == "" || sess.Meta.Goal.MissionID == "" || sess.Meta.Goal.Ordinal != 1 { |
| 309 | - | t.Fatalf("live goal = %#v, want %#v", sess.Meta.Goal, goal) | 458 | + | t.Fatalf("live goal = %#v", sess.Meta.Goal) |
| 459 | + | } | |||
| 460 | + | if sess.Meta.Mission == nil || sess.Meta.Mission.ID != sess.Meta.Goal.MissionID || sess.Meta.Mission.Objective != goal.Objective || sess.Meta.Mission.ActiveGoalID != sess.Meta.Goal.ID { | |||
| 461 | + | t.Fatalf("live mission = %#v", sess.Meta.Mission) | |||
| 310 | } | 462 | } | ||
| 311 | if err := sess.Close(); err != nil { | 463 | if err := sess.Close(); err != nil { | ||
| 312 | t.Fatal(err) | 464 | t.Fatal(err) | ||
| @@ -316,8 +468,8 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||||
| 316 | if err != nil { | 468 | if err != nil { | ||
| 317 | t.Fatal(err) | 469 | t.Fatal(err) | ||
| 318 | } | 470 | } | ||
| 319 | - | if reopened.Meta.Goal == nil || *reopened.Meta.Goal != *goal { | 471 | + | if reopened.Meta.Goal == nil || reopened.Meta.Goal.Objective != goal.Objective || reopened.Meta.Goal.Status != goal.Status || reopened.Meta.Goal.MissionID != sess.Meta.Goal.MissionID { |
| 320 | - | t.Fatalf("reopened goal = %#v, want %#v", reopened.Meta.Goal, goal) | 472 | + | t.Fatalf("reopened goal = %#v", reopened.Meta.Goal) |
| 321 | } | 473 | } | ||
| 322 | if err := reopened.UpdateGoal(nil); err != nil { | 474 | if err := reopened.UpdateGoal(nil); err != nil { | ||
| 323 | t.Fatal(err) | 475 | t.Fatal(err) | ||
| @@ -334,6 +486,9 @@ func TestSessionGoalRoundTripAndClear(t *testing.T) { | |||||
| 334 | if reopened.Meta.Goal != nil { | 486 | if reopened.Meta.Goal != nil { | ||
| 335 | t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal) | 487 | t.Fatalf("cleared goal = %#v, want nil", reopened.Meta.Goal) | ||
| 336 | } | 488 | } | ||
| 489 | + | if len(reopened.Meta.GoalHistory) != 1 || reopened.Meta.GoalHistory[0].Objective != goal.Objective || reopened.Meta.GoalHistory[0].Status != GoalActive { | |||
| 490 | + | t.Fatalf("goal history = %#v", reopened.Meta.GoalHistory) | |||
| 491 | + | } | |||
| 337 | } | 492 | } | ||
| 338 | 493 | ||||
| 339 | func TestCostAdd(t *testing.T) { | 494 | func TestCostAdd(t *testing.T) { | ||
M packages/core/session.go +169 -2
| @@ -61,10 +61,53 @@ const ( | |||
| 61 | 61 | GoalDone GoalStatus = "done" | |
| 62 | 62 | ) | |
| 63 | 63 | ||
| 64 | + | // GoalOwner identifies who established a session goal. Missing owners in | |
| 65 | + | // legacy session data are interpreted as user-owned. | |
| 66 | + | type GoalOwner string | |
| 67 | + | ||
| 68 | + | const ( | |
| 69 | + | GoalOwnerUser GoalOwner = "user" | |
| 70 | + | GoalOwnerManager GoalOwner = "manager" | |
| 71 | + | ) | |
| 72 | + | ||
| 73 | + | // MissionStatus is the durable lifecycle state of the user intent that owns | |
| 74 | + | // a linear sequence of manager and user goals. | |
| 75 | + | type MissionStatus string | |
| 76 | + | ||
| 77 | + | const ( | |
| 78 | + | MissionActive MissionStatus = "active" | |
| 79 | + | MissionPaused MissionStatus = "paused" | |
| 80 | + | MissionCompleted MissionStatus = "completed" | |
| 81 | + | MissionBlocked MissionStatus = "blocked" | |
| 82 | + | ) | |
| 83 | + | ||
| 84 | + | // MissionSource identifies who established the mission objective. | |
| 85 | + | type MissionSource string | |
| 86 | + | ||
| 87 | + | const ( | |
| 88 | + | MissionSourceUser MissionSource = "user" | |
| 89 | + | MissionSourceManager MissionSource = "manager" | |
| 90 | + | ) | |
| 91 | + | ||
| 92 | + | // SessionMission is the bounded, linear container for session goals. | |
| 93 | + | type SessionMission struct { | |
| 94 | + | ID string `json:"id"` | |
| 95 | + | Objective string `json:"objective"` | |
| 96 | + | Status MissionStatus `json:"status"` | |
| 97 | + | Source MissionSource `json:"source"` | |
| 98 | + | ActiveGoalID string `json:"active_goal_id,omitempty"` | |
| 99 | + | TransitionCount int `json:"transition_count,omitempty"` | |
| 100 | + | Reason string `json:"reason,omitempty"` | |
| 101 | + | } | |
| 102 | + | ||
| 64 | 103 | // SessionGoal is the concise autonomous objective attached to one session. | |
| 65 | 104 | type SessionGoal struct { | |
| 105 | + | ID string `json:"id,omitempty"` | |
| 106 | + | MissionID string `json:"mission_id,omitempty"` | |
| 66 | 107 | Objective string `json:"objective"` | |
| 67 | 108 | Status GoalStatus `json:"status"` | |
| 109 | + | Owner GoalOwner `json:"owner,omitempty"` | |
| 110 | + | Ordinal int `json:"ordinal,omitempty"` | |
| 68 | 111 | Reason string `json:"reason,omitempty"` | |
| 69 | 112 | } | |
| 70 | 113 | ||
| @@ -86,9 +129,15 @@ type SessionMeta struct { | |||
| 86 | 129 | // continuation. It is session metadata, never provider context. | |
| 87 | 130 | // Missing or invalid values are handled by the owning host as no handoff. | |
| 88 | 131 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | |
| 132 | + | // Mission owns the linear history of autonomous goals. Legacy sessions with | |
| 133 | + | // only Goal are normalized on read. | |
| 134 | + | Mission *SessionMission `json:"mission,omitempty"` | |
| 89 | 135 | // Goal is the current autonomous objective for this session. Completed | |
| 90 | 136 | // goals remain inspectable until the user clears or replaces them. | |
| 91 | 137 | Goal *SessionGoal `json:"goal,omitempty"` | |
| 138 | + | // GoalHistory records prior durable goal states in chronological order. | |
| 139 | + | // It stays linear: there is only one current goal at a time. | |
| 140 | + | GoalHistory []SessionGoal `json:"goal_history,omitempty"` | |
| 92 | 141 | ||
| 93 | 142 | // Parent is the ID of the session this one was forked from, or | |
| 94 | 143 | // empty for top-level sessions. The tree picker walks parents | |
| @@ -466,6 +515,7 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err | |||
| 466 | 515 | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path) | |
| 467 | 516 | } | |
| 468 | 517 | ||
| 518 | + | normalizeSessionGoalMeta(&snapshot.Meta) | |
| 469 | 519 | snapshot.CompactionGeneration = generation | |
| 470 | 520 | snapshot.ExtensionState = extensionState | |
| 471 | 521 | ||
| @@ -1335,24 +1385,141 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error { | |||
| 1335 | 1385 | } | |
| 1336 | 1386 | ||
| 1337 | 1387 | // UpdateGoal records or clears the current autonomous session goal. | |
| 1388 | + | const maxMissionGoalTransitions = 16 | |
| 1389 | + | ||
| 1390 | + | // EnsureMission creates the durable user-intent boundary when this session has | |
| 1391 | + | // none. It never replaces an existing mission; explicit user controls own | |
| 1392 | + | // replacement and cancellation semantics. | |
| 1393 | + | func (s *Session) EnsureMission(objective string, source MissionSource) error { | |
| 1394 | + | if s == nil || strings.TrimSpace(objective) == "" || s.Meta.Mission != nil { | |
| 1395 | + | return nil | |
| 1396 | + | } | |
| 1397 | + | previous := s.Meta.Mission | |
| 1398 | + | s.Meta.Mission = &SessionMission{ | |
| 1399 | + | ID: uuid.NewString(), | |
| 1400 | + | Objective: strings.TrimSpace(objective), | |
| 1401 | + | Status: MissionActive, | |
| 1402 | + | Source: source, | |
| 1403 | + | } | |
| 1404 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 1405 | + | s.Meta.Mission = previous | |
| 1406 | + | return fmt.Errorf("ensure mission: %w", err) | |
| 1407 | + | } | |
| 1408 | + | return nil | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | // UpdateGoal persists one linear goal transition. It lazily migrates legacy | |
| 1412 | + | // goal-only sessions into a mission and rejects unbounded manager progression. | |
| 1338 | 1413 | func (s *Session) UpdateGoal(goal *SessionGoal) error { | |
| 1339 | 1414 | if s == nil { | |
| 1340 | 1415 | return nil | |
| 1341 | 1416 | } | |
| 1342 | - | previous := s.Meta.Goal | |
| 1417 | + | previous := cloneSessionGoal(s.Meta.Goal) | |
| 1418 | + | previousMission := cloneSessionMission(s.Meta.Mission) | |
| 1419 | + | previousHistory := append([]SessionGoal(nil), s.Meta.GoalHistory...) | |
| 1420 | + | if previous != nil { | |
| 1421 | + | s.Meta.GoalHistory = append(s.Meta.GoalHistory, *previous) | |
| 1422 | + | } | |
| 1343 | 1423 | if goal == nil { | |
| 1344 | 1424 | s.Meta.Goal = nil | |
| 1425 | + | s.Meta.Mission = nil | |
| 1345 | 1426 | } else { | |
| 1346 | 1427 | copyGoal := *goal | |
| 1428 | + | if err := s.assignGoalToMission(©Goal, previous); err != nil { | |
| 1429 | + | s.Meta.Mission = previousMission | |
| 1430 | + | s.Meta.GoalHistory = previousHistory | |
| 1431 | + | return err | |
| 1432 | + | } | |
| 1347 | 1433 | s.Meta.Goal = ©Goal | |
| 1348 | 1434 | } | |
| 1349 | 1435 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |
| 1350 | 1436 | s.Meta.Goal = previous | |
| 1437 | + | s.Meta.Mission = previousMission | |
| 1438 | + | s.Meta.GoalHistory = previousHistory | |
| 1351 | 1439 | return fmt.Errorf("update goal: %w", err) | |
| 1352 | 1440 | } | |
| 1353 | 1441 | return nil | |
| 1354 | 1442 | } | |
| 1355 | 1443 | ||
| 1444 | + | func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |
| 1445 | + | if s.Meta.Mission == nil { | |
| 1446 | + | source := MissionSourceUser | |
| 1447 | + | if goal.Owner == GoalOwnerManager { | |
| 1448 | + | source = MissionSourceManager | |
| 1449 | + | } | |
| 1450 | + | s.Meta.Mission = &SessionMission{ | |
| 1451 | + | ID: uuid.NewString(), | |
| 1452 | + | Objective: goal.Objective, | |
| 1453 | + | Status: MissionActive, | |
| 1454 | + | Source: source, | |
| 1455 | + | } | |
| 1456 | + | } | |
| 1457 | + | mission := s.Meta.Mission | |
| 1458 | + | if goal.ID == "" { | |
| 1459 | + | goal.ID = uuid.NewString() | |
| 1460 | + | } | |
| 1461 | + | isNewManagerGoal := goal.Status == GoalActive && goal.Owner == GoalOwnerManager && (previous == nil || goal.ID != previous.ID) | |
| 1462 | + | if isNewManagerGoal { | |
| 1463 | + | mission.TransitionCount++ | |
| 1464 | + | if mission.TransitionCount > maxMissionGoalTransitions { | |
| 1465 | + | return fmt.Errorf("mission goal transition limit reached") | |
| 1466 | + | } | |
| 1467 | + | } | |
| 1468 | + | goal.MissionID = mission.ID | |
| 1469 | + | if goal.Ordinal == 0 { | |
| 1470 | + | goal.Ordinal = len(s.Meta.GoalHistory) + 1 | |
| 1471 | + | } | |
| 1472 | + | if goal.Owner == "" { | |
| 1473 | + | goal.Owner = GoalOwnerUser | |
| 1474 | + | } | |
| 1475 | + | if goal.Status == GoalActive { | |
| 1476 | + | mission.ActiveGoalID = goal.ID | |
| 1477 | + | mission.Status = MissionActive | |
| 1478 | + | mission.Reason = "" | |
| 1479 | + | } else if mission.ActiveGoalID == goal.ID { | |
| 1480 | + | mission.ActiveGoalID = "" | |
| 1481 | + | } | |
| 1482 | + | return nil | |
| 1483 | + | } | |
| 1484 | + | ||
| 1485 | + | func normalizeSessionGoalMeta(meta *SessionMeta) { | |
| 1486 | + | if meta == nil || meta.Goal == nil { | |
| 1487 | + | return | |
| 1488 | + | } | |
| 1489 | + | goal := meta.Goal | |
| 1490 | + | if goal.Owner == "" { | |
| 1491 | + | goal.Owner = GoalOwnerUser | |
| 1492 | + | } | |
| 1493 | + | if meta.Mission == nil { | |
| 1494 | + | meta.Mission = &SessionMission{ | |
| 1495 | + | ID: "legacy-" + meta.ID, | |
| 1496 | + | Objective: goal.Objective, | |
| 1497 | + | Status: MissionActive, | |
| 1498 | + | Source: MissionSourceUser, | |
| 1499 | + | } | |
| 1500 | + | } | |
| 1501 | + | if goal.ID == "" { | |
| 1502 | + | goal.ID = "legacy-goal-" + meta.ID | |
| 1503 | + | } | |
| 1504 | + | if goal.MissionID == "" { | |
| 1505 | + | goal.MissionID = meta.Mission.ID | |
| 1506 | + | } | |
| 1507 | + | if goal.Ordinal == 0 { | |
| 1508 | + | goal.Ordinal = len(meta.GoalHistory) + 1 | |
| 1509 | + | } | |
| 1510 | + | if goal.Status == GoalActive { | |
| 1511 | + | meta.Mission.ActiveGoalID = goal.ID | |
| 1512 | + | } | |
| 1513 | + | } | |
| 1514 | + | ||
| 1515 | + | func cloneSessionMission(mission *SessionMission) *SessionMission { | |
| 1516 | + | if mission == nil { | |
| 1517 | + | return nil | |
| 1518 | + | } | |
| 1519 | + | clone := *mission | |
| 1520 | + | return &clone | |
| 1521 | + | } | |
| 1522 | + | ||
| 1356 | 1523 | // UpdateTitle records a session title without adding anything to the | |
| 1357 | 1524 | // conversation transcript. The title is kept in memory as well so the live | |
| 1358 | 1525 | // session can restore it when the user switches sessions in the TUI. | |
| @@ -1512,7 +1679,7 @@ func (s *Session) Close() error { | |||
| 1512 | 1679 | } | |
| 1513 | 1680 | flushErr := s.Flush() | |
| 1514 | 1681 | closeErr := s.writer.Close() | |
| 1515 | - | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil { | |
| 1682 | + | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 { | |
| 1516 | 1683 | // Best-effort cleanup. We deliberately don't propagate the | |
| 1517 | 1684 | // remove error: if it fails (file already gone, perms changed) | |
| 1518 | 1685 | // the worst case is one stale empty file in the listing. | |
| @@ -61,10 +61,53 @@ const ( | |||||
| 61 | GoalDone GoalStatus = "done" | 61 | GoalDone GoalStatus = "done" | ||
| 62 | ) | 62 | ) | ||
| 63 | 63 | ||||
| 64 | + | // GoalOwner identifies who established a session goal. Missing owners in | |||
| 65 | + | // legacy session data are interpreted as user-owned. | |||
| 66 | + | type GoalOwner string | |||
| 67 | + | ||||
| 68 | + | const ( | |||
| 69 | + | GoalOwnerUser GoalOwner = "user" | |||
| 70 | + | GoalOwnerManager GoalOwner = "manager" | |||
| 71 | + | ) | |||
| 72 | + | ||||
| 73 | + | // MissionStatus is the durable lifecycle state of the user intent that owns | |||
| 74 | + | // a linear sequence of manager and user goals. | |||
| 75 | + | type MissionStatus string | |||
| 76 | + | ||||
| 77 | + | const ( | |||
| 78 | + | MissionActive MissionStatus = "active" | |||
| 79 | + | MissionPaused MissionStatus = "paused" | |||
| 80 | + | MissionCompleted MissionStatus = "completed" | |||
| 81 | + | MissionBlocked MissionStatus = "blocked" | |||
| 82 | + | ) | |||
| 83 | + | ||||
| 84 | + | // MissionSource identifies who established the mission objective. | |||
| 85 | + | type MissionSource string | |||
| 86 | + | ||||
| 87 | + | const ( | |||
| 88 | + | MissionSourceUser MissionSource = "user" | |||
| 89 | + | MissionSourceManager MissionSource = "manager" | |||
| 90 | + | ) | |||
| 91 | + | ||||
| 92 | + | // SessionMission is the bounded, linear container for session goals. | |||
| 93 | + | type SessionMission struct { | |||
| 94 | + | ID string `json:"id"` | |||
| 95 | + | Objective string `json:"objective"` | |||
| 96 | + | Status MissionStatus `json:"status"` | |||
| 97 | + | Source MissionSource `json:"source"` | |||
| 98 | + | ActiveGoalID string `json:"active_goal_id,omitempty"` | |||
| 99 | + | TransitionCount int `json:"transition_count,omitempty"` | |||
| 100 | + | Reason string `json:"reason,omitempty"` | |||
| 101 | + | } | |||
| 102 | + | ||||
| 64 | // SessionGoal is the concise autonomous objective attached to one session. | 103 | // SessionGoal is the concise autonomous objective attached to one session. | ||
| 65 | type SessionGoal struct { | 104 | type SessionGoal struct { | ||
| 105 | + | ID string `json:"id,omitempty"` | |||
| 106 | + | MissionID string `json:"mission_id,omitempty"` | |||
| 66 | Objective string `json:"objective"` | 107 | Objective string `json:"objective"` | ||
| 67 | Status GoalStatus `json:"status"` | 108 | Status GoalStatus `json:"status"` | ||
| 109 | + | Owner GoalOwner `json:"owner,omitempty"` | |||
| 110 | + | Ordinal int `json:"ordinal,omitempty"` | |||
| 68 | Reason string `json:"reason,omitempty"` | 111 | Reason string `json:"reason,omitempty"` | ||
| 69 | } | 112 | } | ||
| 70 | 113 | ||||
| @@ -86,9 +129,15 @@ type SessionMeta struct { | |||||
| 86 | // continuation. It is session metadata, never provider context. | 129 | // continuation. It is session metadata, never provider context. | ||
| 87 | // Missing or invalid values are handled by the owning host as no handoff. | 130 | // Missing or invalid values are handled by the owning host as no handoff. | ||
| 88 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | 131 | CompactHandoff json.RawMessage `json:"compact_handoff,omitempty"` | ||
| 132 | + | // Mission owns the linear history of autonomous goals. Legacy sessions with | |||
| 133 | + | // only Goal are normalized on read. | |||
| 134 | + | Mission *SessionMission `json:"mission,omitempty"` | |||
| 89 | // Goal is the current autonomous objective for this session. Completed | 135 | // Goal is the current autonomous objective for this session. Completed | ||
| 90 | // goals remain inspectable until the user clears or replaces them. | 136 | // goals remain inspectable until the user clears or replaces them. | ||
| 91 | Goal *SessionGoal `json:"goal,omitempty"` | 137 | Goal *SessionGoal `json:"goal,omitempty"` | ||
| 138 | + | // GoalHistory records prior durable goal states in chronological order. | |||
| 139 | + | // It stays linear: there is only one current goal at a time. | |||
| 140 | + | GoalHistory []SessionGoal `json:"goal_history,omitempty"` | |||
| 92 | 141 | ||||
| 93 | // Parent is the ID of the session this one was forked from, or | 142 | // Parent is the ID of the session this one was forked from, or | ||
| 94 | // empty for top-level sessions. The tree picker walks parents | 143 | // empty for top-level sessions. The tree picker walks parents | ||
| @@ -466,6 +515,7 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err | |||||
| 466 | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path) | 515 | return SessionSnapshot{}, fmt.Errorf("session snapshot %q: file is empty", path) | ||
| 467 | } | 516 | } | ||
| 468 | 517 | ||||
| 518 | + | normalizeSessionGoalMeta(&snapshot.Meta) | |||
| 469 | snapshot.CompactionGeneration = generation | 519 | snapshot.CompactionGeneration = generation | ||
| 470 | snapshot.ExtensionState = extensionState | 520 | snapshot.ExtensionState = extensionState | ||
| 471 | 521 | ||||
| @@ -1335,24 +1385,141 @@ func (s *Session) UpdateCompactHandoff(state json.RawMessage) error { | |||||
| 1335 | } | 1385 | } | ||
| 1336 | 1386 | ||||
| 1337 | // UpdateGoal records or clears the current autonomous session goal. | 1387 | // UpdateGoal records or clears the current autonomous session goal. | ||
| 1388 | + | const maxMissionGoalTransitions = 16 | |||
| 1389 | + | ||||
| 1390 | + | // EnsureMission creates the durable user-intent boundary when this session has | |||
| 1391 | + | // none. It never replaces an existing mission; explicit user controls own | |||
| 1392 | + | // replacement and cancellation semantics. | |||
| 1393 | + | func (s *Session) EnsureMission(objective string, source MissionSource) error { | |||
| 1394 | + | if s == nil || strings.TrimSpace(objective) == "" || s.Meta.Mission != nil { | |||
| 1395 | + | return nil | |||
| 1396 | + | } | |||
| 1397 | + | previous := s.Meta.Mission | |||
| 1398 | + | s.Meta.Mission = &SessionMission{ | |||
| 1399 | + | ID: uuid.NewString(), | |||
| 1400 | + | Objective: strings.TrimSpace(objective), | |||
| 1401 | + | Status: MissionActive, | |||
| 1402 | + | Source: source, | |||
| 1403 | + | } | |||
| 1404 | + | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | |||
| 1405 | + | s.Meta.Mission = previous | |||
| 1406 | + | return fmt.Errorf("ensure mission: %w", err) | |||
| 1407 | + | } | |||
| 1408 | + | return nil | |||
| 1409 | + | } | |||
| 1410 | + | ||||
| 1411 | + | // UpdateGoal persists one linear goal transition. It lazily migrates legacy | |||
| 1412 | + | // goal-only sessions into a mission and rejects unbounded manager progression. | |||
| 1338 | func (s *Session) UpdateGoal(goal *SessionGoal) error { | 1413 | func (s *Session) UpdateGoal(goal *SessionGoal) error { | ||
| 1339 | if s == nil { | 1414 | if s == nil { | ||
| 1340 | return nil | 1415 | return nil | ||
| 1341 | } | 1416 | } | ||
| 1342 | - | previous := s.Meta.Goal | 1417 | + | previous := cloneSessionGoal(s.Meta.Goal) |
| 1418 | + | previousMission := cloneSessionMission(s.Meta.Mission) | |||
| 1419 | + | previousHistory := append([]SessionGoal(nil), s.Meta.GoalHistory...) | |||
| 1420 | + | if previous != nil { | |||
| 1421 | + | s.Meta.GoalHistory = append(s.Meta.GoalHistory, *previous) | |||
| 1422 | + | } | |||
| 1343 | if goal == nil { | 1423 | if goal == nil { | ||
| 1344 | s.Meta.Goal = nil | 1424 | s.Meta.Goal = nil | ||
| 1425 | + | s.Meta.Mission = nil | |||
| 1345 | } else { | 1426 | } else { | ||
| 1346 | copyGoal := *goal | 1427 | copyGoal := *goal | ||
| 1428 | + | if err := s.assignGoalToMission(©Goal, previous); err != nil { | |||
| 1429 | + | s.Meta.Mission = previousMission | |||
| 1430 | + | s.Meta.GoalHistory = previousHistory | |||
| 1431 | + | return err | |||
| 1432 | + | } | |||
| 1347 | s.Meta.Goal = ©Goal | 1433 | s.Meta.Goal = ©Goal | ||
| 1348 | } | 1434 | } | ||
| 1349 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | 1435 | if err := s.writeLine(sessionLine{Type: "meta", Meta: &s.Meta}); err != nil { | ||
| 1350 | s.Meta.Goal = previous | 1436 | s.Meta.Goal = previous | ||
| 1437 | + | s.Meta.Mission = previousMission | |||
| 1438 | + | s.Meta.GoalHistory = previousHistory | |||
| 1351 | return fmt.Errorf("update goal: %w", err) | 1439 | return fmt.Errorf("update goal: %w", err) | ||
| 1352 | } | 1440 | } | ||
| 1353 | return nil | 1441 | return nil | ||
| 1354 | } | 1442 | } | ||
| 1355 | 1443 | ||||
| 1444 | + | func (s *Session) assignGoalToMission(goal, previous *SessionGoal) error { | |||
| 1445 | + | if s.Meta.Mission == nil { | |||
| 1446 | + | source := MissionSourceUser | |||
| 1447 | + | if goal.Owner == GoalOwnerManager { | |||
| 1448 | + | source = MissionSourceManager | |||
| 1449 | + | } | |||
| 1450 | + | s.Meta.Mission = &SessionMission{ | |||
| 1451 | + | ID: uuid.NewString(), | |||
| 1452 | + | Objective: goal.Objective, | |||
| 1453 | + | Status: MissionActive, | |||
| 1454 | + | Source: source, | |||
| 1455 | + | } | |||
| 1456 | + | } | |||
| 1457 | + | mission := s.Meta.Mission | |||
| 1458 | + | if goal.ID == "" { | |||
| 1459 | + | goal.ID = uuid.NewString() | |||
| 1460 | + | } | |||
| 1461 | + | isNewManagerGoal := goal.Status == GoalActive && goal.Owner == GoalOwnerManager && (previous == nil || goal.ID != previous.ID) | |||
| 1462 | + | if isNewManagerGoal { | |||
| 1463 | + | mission.TransitionCount++ | |||
| 1464 | + | if mission.TransitionCount > maxMissionGoalTransitions { | |||
| 1465 | + | return fmt.Errorf("mission goal transition limit reached") | |||
| 1466 | + | } | |||
| 1467 | + | } | |||
| 1468 | + | goal.MissionID = mission.ID | |||
| 1469 | + | if goal.Ordinal == 0 { | |||
| 1470 | + | goal.Ordinal = len(s.Meta.GoalHistory) + 1 | |||
| 1471 | + | } | |||
| 1472 | + | if goal.Owner == "" { | |||
| 1473 | + | goal.Owner = GoalOwnerUser | |||
| 1474 | + | } | |||
| 1475 | + | if goal.Status == GoalActive { | |||
| 1476 | + | mission.ActiveGoalID = goal.ID | |||
| 1477 | + | mission.Status = MissionActive | |||
| 1478 | + | mission.Reason = "" | |||
| 1479 | + | } else if mission.ActiveGoalID == goal.ID { | |||
| 1480 | + | mission.ActiveGoalID = "" | |||
| 1481 | + | } | |||
| 1482 | + | return nil | |||
| 1483 | + | } | |||
| 1484 | + | ||||
| 1485 | + | func normalizeSessionGoalMeta(meta *SessionMeta) { | |||
| 1486 | + | if meta == nil || meta.Goal == nil { | |||
| 1487 | + | return | |||
| 1488 | + | } | |||
| 1489 | + | goal := meta.Goal | |||
| 1490 | + | if goal.Owner == "" { | |||
| 1491 | + | goal.Owner = GoalOwnerUser | |||
| 1492 | + | } | |||
| 1493 | + | if meta.Mission == nil { | |||
| 1494 | + | meta.Mission = &SessionMission{ | |||
| 1495 | + | ID: "legacy-" + meta.ID, | |||
| 1496 | + | Objective: goal.Objective, | |||
| 1497 | + | Status: MissionActive, | |||
| 1498 | + | Source: MissionSourceUser, | |||
| 1499 | + | } | |||
| 1500 | + | } | |||
| 1501 | + | if goal.ID == "" { | |||
| 1502 | + | goal.ID = "legacy-goal-" + meta.ID | |||
| 1503 | + | } | |||
| 1504 | + | if goal.MissionID == "" { | |||
| 1505 | + | goal.MissionID = meta.Mission.ID | |||
| 1506 | + | } | |||
| 1507 | + | if goal.Ordinal == 0 { | |||
| 1508 | + | goal.Ordinal = len(meta.GoalHistory) + 1 | |||
| 1509 | + | } | |||
| 1510 | + | if goal.Status == GoalActive { | |||
| 1511 | + | meta.Mission.ActiveGoalID = goal.ID | |||
| 1512 | + | } | |||
| 1513 | + | } | |||
| 1514 | + | ||||
| 1515 | + | func cloneSessionMission(mission *SessionMission) *SessionMission { | |||
| 1516 | + | if mission == nil { | |||
| 1517 | + | return nil | |||
| 1518 | + | } | |||
| 1519 | + | clone := *mission | |||
| 1520 | + | return &clone | |||
| 1521 | + | } | |||
| 1522 | + | ||||
| 1356 | // UpdateTitle records a session title without adding anything to the | 1523 | // UpdateTitle records a session title without adding anything to the | ||
| 1357 | // conversation transcript. The title is kept in memory as well so the live | 1524 | // conversation transcript. The title is kept in memory as well so the live | ||
| 1358 | // session can restore it when the user switches sessions in the TUI. | 1525 | // session can restore it when the user switches sessions in the TUI. | ||
| @@ -1512,7 +1679,7 @@ func (s *Session) Close() error { | |||||
| 1512 | } | 1679 | } | ||
| 1513 | flushErr := s.Flush() | 1680 | flushErr := s.Flush() | ||
| 1514 | closeErr := s.writer.Close() | 1681 | closeErr := s.writer.Close() | ||
| 1515 | - | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Goal == nil { | 1682 | + | if s.freshFile && s.messagesAppended == 0 && len(s.ExtensionState) == 0 && len(s.Meta.CompactHandoff) == 0 && s.Meta.Mission == nil && s.Meta.Goal == nil && len(s.Meta.GoalHistory) == 0 { |
| 1516 | // Best-effort cleanup. We deliberately don't propagate the | 1683 | // Best-effort cleanup. We deliberately don't propagate the | ||
| 1517 | // remove error: if it fails (file already gone, perms changed) | 1684 | // remove error: if it fails (file already gone, perms changed) | ||
| 1518 | // the worst case is one stale empty file in the listing. | 1685 | // the worst case is one stale empty file in the listing. | ||
M packages/core/session_portable.go +22 -0
| @@ -228,7 +228,9 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) { | |||
| 228 | 228 | Timezone: timezone, | |
| 229 | 229 | TimezoneOffset: timezoneOffset, | |
| 230 | 230 | CompactHandoff: append(json.RawMessage(nil), snapshot.Meta.CompactHandoff...), | |
| 231 | + | Mission: cloneSessionMission(snapshot.Meta.Mission), | |
| 231 | 232 | Goal: cloneSessionGoal(snapshot.Meta.Goal), | |
| 233 | + | GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory), | |
| 232 | 234 | } | |
| 233 | 235 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | |
| 234 | 236 | if err != nil { | |
| @@ -446,6 +448,13 @@ func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *S | |||
| 446 | 448 | return cloneSessionGoal(parent.Goal) | |
| 447 | 449 | } | |
| 448 | 450 | ||
| 451 | + | func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission { | |
| 452 | + | if limit != len(messages) { | |
| 453 | + | return nil | |
| 454 | + | } | |
| 455 | + | return cloneSessionMission(parent.Mission) | |
| 456 | + | } | |
| 457 | + | ||
| 449 | 458 | func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |
| 450 | 459 | if goal == nil { | |
| 451 | 460 | return nil | |
| @@ -454,6 +463,17 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |||
| 454 | 463 | return &clone | |
| 455 | 464 | } | |
| 456 | 465 | ||
| 466 | + | func cloneGoalHistory(history []SessionGoal) []SessionGoal { | |
| 467 | + | return append([]SessionGoal(nil), history...) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal { | |
| 471 | + | if limit != len(messages) { | |
| 472 | + | return nil | |
| 473 | + | } | |
| 474 | + | return cloneGoalHistory(parent.GoalHistory) | |
| 475 | + | } | |
| 476 | + | ||
| 457 | 477 | 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) { | |
| 458 | 478 | dir := SessionsDir(root, cwd) | |
| 459 | 479 | if err := os.MkdirAll(dir, 0o755); err != nil { | |
| @@ -496,7 +516,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||
| 496 | 516 | ForkPoint: limit, | |
| 497 | 517 | HideFromSessions: hideFromSessions, | |
| 498 | 518 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | |
| 519 | + | Mission: missionAtBranch(parent, messages, limit), | |
| 499 | 520 | Goal: cloneSessionGoal(goal), | |
| 521 | + | GoalHistory: goalHistoryAtBranch(parent, messages, limit), | |
| 500 | 522 | } | |
| 501 | 523 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | |
| 502 | 524 | if err != nil { | |
| @@ -228,7 +228,9 @@ 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 | + | Mission: cloneSessionMission(snapshot.Meta.Mission), | |||
| 231 | Goal: cloneSessionGoal(snapshot.Meta.Goal), | 232 | Goal: cloneSessionGoal(snapshot.Meta.Goal), | ||
| 233 | + | GoalHistory: cloneGoalHistory(snapshot.Meta.GoalHistory), | |||
| 232 | } | 234 | } | ||
| 233 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | 235 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &importMeta}) | ||
| 234 | if err != nil { | 236 | if err != nil { | ||
| @@ -446,6 +448,13 @@ func goalAtBranch(parent SessionMeta, messages []provider.Message, limit int) *S | |||||
| 446 | return cloneSessionGoal(parent.Goal) | 448 | return cloneSessionGoal(parent.Goal) | ||
| 447 | } | 449 | } | ||
| 448 | 450 | ||||
| 451 | + | func missionAtBranch(parent SessionMeta, messages []provider.Message, limit int) *SessionMission { | |||
| 452 | + | if limit != len(messages) { | |||
| 453 | + | return nil | |||
| 454 | + | } | |||
| 455 | + | return cloneSessionMission(parent.Mission) | |||
| 456 | + | } | |||
| 457 | + | ||||
| 449 | func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | 458 | func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | ||
| 450 | if goal == nil { | 459 | if goal == nil { | ||
| 451 | return nil | 460 | return nil | ||
| @@ -454,6 +463,17 @@ func cloneSessionGoal(goal *SessionGoal) *SessionGoal { | |||||
| 454 | return &clone | 463 | return &clone | ||
| 455 | } | 464 | } | ||
| 456 | 465 | ||||
| 466 | + | func cloneGoalHistory(history []SessionGoal) []SessionGoal { | |||
| 467 | + | return append([]SessionGoal(nil), history...) | |||
| 468 | + | } | |||
| 469 | + | ||||
| 470 | + | func goalHistoryAtBranch(parent SessionMeta, messages []provider.Message, limit int) []SessionGoal { | |||
| 471 | + | if limit != len(messages) { | |||
| 472 | + | return nil | |||
| 473 | + | } | |||
| 474 | + | return cloneGoalHistory(parent.GoalHistory) | |||
| 475 | + | } | |||
| 476 | + | ||||
| 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) { | 477 | 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) { | ||
| 458 | dir := SessionsDir(root, cwd) | 478 | dir := SessionsDir(root, cwd) | ||
| 459 | if err := os.MkdirAll(dir, 0o755); err != nil { | 479 | if err := os.MkdirAll(dir, 0o755); err != nil { | ||
| @@ -496,7 +516,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages | |||||
| 496 | ForkPoint: limit, | 516 | ForkPoint: limit, | ||
| 497 | HideFromSessions: hideFromSessions, | 517 | HideFromSessions: hideFromSessions, | ||
| 498 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | 518 | CompactHandoff: append(json.RawMessage(nil), compactHandoff...), | ||
| 519 | + | Mission: missionAtBranch(parent, messages, limit), | |||
| 499 | Goal: cloneSessionGoal(goal), | 520 | Goal: cloneSessionGoal(goal), | ||
| 521 | + | GoalHistory: goalHistoryAtBranch(parent, messages, limit), | |||
| 500 | } | 522 | } | ||
| 501 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | 523 | metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta}) | ||
| 502 | if err != nil { | 524 | if err != nil { | ||
M packages/core/session_portable_test.go +13 -4
| @@ -128,8 +128,11 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||
| 128 | 128 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | |
| 129 | 129 | t.Errorf("compact handoff = %q, want %q", got, handoff) | |
| 130 | 130 | } | |
| 131 | - | if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal { | |
| 132 | - | t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal) | |
| 131 | + | if imported.Meta.Goal == nil || imported.Meta.Goal.Objective != goal.Objective || imported.Meta.Goal.Status != goal.Status || imported.Meta.Goal.MissionID == "" { | |
| 132 | + | t.Errorf("goal = %#v", imported.Meta.Goal) | |
| 133 | + | } | |
| 134 | + | if imported.Meta.Mission == nil || imported.Meta.Mission.ID != imported.Meta.Goal.MissionID || imported.Meta.Mission.Objective != goal.Objective { | |
| 135 | + | t.Errorf("mission = %#v", imported.Meta.Mission) | |
| 133 | 136 | } | |
| 134 | 137 | if len(msgs) != 2 { | |
| 135 | 138 | t.Fatalf("want 2 messages, got %d", len(msgs)) | |
| @@ -591,8 +594,11 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||
| 591 | 594 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | |
| 592 | 595 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | |
| 593 | 596 | } | |
| 594 | - | if full.Meta.Goal == nil || *full.Meta.Goal != *goal { | |
| 595 | - | t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal) | |
| 597 | + | if full.Meta.Goal == nil || full.Meta.Goal.Objective != goal.Objective || full.Meta.Goal.Status != goal.Status || full.Meta.Goal.MissionID == "" { | |
| 598 | + | t.Fatalf("full branch goal = %#v", full.Meta.Goal) | |
| 599 | + | } | |
| 600 | + | if full.Meta.Mission == nil || full.Meta.Mission.ID != full.Meta.Goal.MissionID { | |
| 601 | + | t.Fatalf("full branch mission = %#v", full.Meta.Mission) | |
| 596 | 602 | } | |
| 597 | 603 | ||
| 598 | 604 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | |
| @@ -610,6 +616,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||
| 610 | 616 | if prefix.Meta.Goal != nil { | |
| 611 | 617 | t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal) | |
| 612 | 618 | } | |
| 619 | + | if prefix.Meta.Mission != nil || len(prefix.Meta.GoalHistory) != 0 { | |
| 620 | + | t.Fatalf("prefix branch mission/history = %#v / %#v, want nil", prefix.Meta.Mission, prefix.Meta.GoalHistory) | |
| 621 | + | } | |
| 613 | 622 | } | |
| 614 | 623 | ||
| 615 | 624 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | |
| @@ -128,8 +128,11 @@ func TestSessionExportImportRoundTrip(t *testing.T) { | |||||
| 128 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | 128 | if got := string(imported.Meta.CompactHandoff); got != string(handoff) { | ||
| 129 | t.Errorf("compact handoff = %q, want %q", got, handoff) | 129 | t.Errorf("compact handoff = %q, want %q", got, handoff) | ||
| 130 | } | 130 | } | ||
| 131 | - | if imported.Meta.Goal == nil || *imported.Meta.Goal != *goal { | 131 | + | if imported.Meta.Goal == nil || imported.Meta.Goal.Objective != goal.Objective || imported.Meta.Goal.Status != goal.Status || imported.Meta.Goal.MissionID == "" { |
| 132 | - | t.Errorf("goal = %#v, want %#v", imported.Meta.Goal, goal) | 132 | + | t.Errorf("goal = %#v", imported.Meta.Goal) |
| 133 | + | } | |||
| 134 | + | if imported.Meta.Mission == nil || imported.Meta.Mission.ID != imported.Meta.Goal.MissionID || imported.Meta.Mission.Objective != goal.Objective { | |||
| 135 | + | t.Errorf("mission = %#v", imported.Meta.Mission) | |||
| 133 | } | 136 | } | ||
| 134 | if len(msgs) != 2 { | 137 | if len(msgs) != 2 { | ||
| 135 | t.Fatalf("want 2 messages, got %d", len(msgs)) | 138 | t.Fatalf("want 2 messages, got %d", len(msgs)) | ||
| @@ -591,8 +594,11 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||||
| 591 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | 594 | if got := string(full.Meta.CompactHandoff); got != string(handoff) { | ||
| 592 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | 595 | t.Fatalf("full branch compact handoff = %q, want %q", got, handoff) | ||
| 593 | } | 596 | } | ||
| 594 | - | if full.Meta.Goal == nil || *full.Meta.Goal != *goal { | 597 | + | if full.Meta.Goal == nil || full.Meta.Goal.Objective != goal.Objective || full.Meta.Goal.Status != goal.Status || full.Meta.Goal.MissionID == "" { |
| 595 | - | t.Fatalf("full branch goal = %#v, want %#v", full.Meta.Goal, goal) | 598 | + | t.Fatalf("full branch goal = %#v", full.Meta.Goal) |
| 599 | + | } | |||
| 600 | + | if full.Meta.Mission == nil || full.Meta.Mission.ID != full.Meta.Goal.MissionID { | |||
| 601 | + | t.Fatalf("full branch mission = %#v", full.Meta.Mission) | |||
| 596 | } | 602 | } | ||
| 597 | 603 | ||||
| 598 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | 604 | prefixPath, err := BranchSession(parent.Path, root, cwd, "test", 1) | ||
| @@ -610,6 +616,9 @@ func TestBranchSessionCompactHandoffFollowsCopiedEffectiveTail(t *testing.T) { | |||||
| 610 | if prefix.Meta.Goal != nil { | 616 | if prefix.Meta.Goal != nil { | ||
| 611 | t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal) | 617 | t.Fatalf("prefix branch goal = %#v, want nil", prefix.Meta.Goal) | ||
| 612 | } | 618 | } | ||
| 619 | + | if prefix.Meta.Mission != nil || len(prefix.Meta.GoalHistory) != 0 { | |||
| 620 | + | t.Fatalf("prefix branch mission/history = %#v / %#v, want nil", prefix.Meta.Mission, prefix.Meta.GoalHistory) | |||
| 621 | + | } | |||
| 613 | } | 622 | } | ||
| 614 | 623 | ||||
| 615 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | 624 | func TestBranchSessionHiddenFromHistoryClearsCompactHandoff(t *testing.T) { | ||