| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "path/filepath" |
| 9 | "strings" |
| 10 | "sync/atomic" |
| 11 | "testing" |
| 12 | |
| 13 | "reasonix/internal/agent" |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/evidence" |
| 16 | "reasonix/internal/provider" |
| 17 | "reasonix/internal/store" |
| 18 | "reasonix/internal/tool" |
| 19 | |
| 20 | _ "reasonix/internal/tool/builtin" |
| 21 | ) |
| 22 | |
| 23 | // goalRegistry returns a registry carrying the update_goal builtin so scripted |
| 24 | // goal turns can report dispositions through the structured tool. |
| 25 | func goalRegistry() *tool.Registry { |
| 26 | reg := tool.NewRegistry() |
| 27 | if t, ok := tool.LookupBuiltin("update_goal"); ok { |
| 28 | reg.Add(t) |
| 29 | } |
| 30 | return reg |
| 31 | } |
| 32 | |
| 33 | // goalWireStatus maps FSM status values to the update_goal wire enum. |
| 34 | func goalWireStatus(status string) string { |
| 35 | switch status { |
| 36 | case GoalStatusComplete: |
| 37 | return "complete" |
| 38 | case GoalStatusBlocked: |
| 39 | return "blocked" |
| 40 | default: |
| 41 | return "continue" |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | // goalToolTurn models one goal turn's provider sequence: the model calls |
| 46 | // update_goal with the given disposition, then answers with text. Call IDs are |
| 47 | // unique per call so recycled scripted turns never collide in the transcript. |
| 48 | func goalToolTurn(status, reason, nextAction string) [][]provider.Chunk { |
| 49 | args, err := json.Marshal(map[string]string{"status": goalWireStatus(status), "reason": reason, "next_action": nextAction}) |
| 50 | if err != nil { |
| 51 | panic(err) |
| 52 | } |
| 53 | id := fmt.Sprintf("ug-%d", goalToolCallSeq.Add(1)) |
| 54 | return [][]provider.Chunk{ |
| 55 | {toolCallChunk(id, "update_goal", string(args)), {Type: provider.ChunkDone}}, |
| 56 | textTurn("worked on the goal"), |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | var goalToolCallSeq atomic.Uint64 |
| 61 | |
| 62 | // fakeGoalEvaluator is a scripted bounded Goal evaluator for tests. |
| 63 | type legacyEvaluatorVerdict struct { |
| 64 | Outcome string |
| 65 | Reason string |
| 66 | } |
| 67 | |
| 68 | type fakeGoalEvaluator struct { |
| 69 | outcome string |
| 70 | reason string |
| 71 | err error |
| 72 | calls int |
| 73 | } |
| 74 | |
| 75 | func (f *fakeGoalEvaluator) Evaluate(_ context.Context, _ struct{}) (legacyEvaluatorVerdict, error) { |
| 76 | f.calls++ |
| 77 | if f.err != nil { |
| 78 | return legacyEvaluatorVerdict{}, f.err |
| 79 | } |
| 80 | return legacyEvaluatorVerdict{Outcome: f.outcome, Reason: f.reason}, nil |
| 81 | } |
| 82 | |
| 83 | // flattenTurns concatenates per-goal-turn provider sequences into one flat |
| 84 | // scripted provider stream. |
| 85 | func flattenTurns(groups ...[][]provider.Chunk) [][]provider.Chunk { |
| 86 | var out [][]provider.Chunk |
| 87 | for _, g := range groups { |
| 88 | out = append(out, g...) |
| 89 | } |
| 90 | return out |
| 91 | } |
| 92 | |
| 93 | // toolCallChunk builds a provider turn carrying one tool call. |
| 94 | func toolCallChunk(id, name, args string) provider.Chunk { |
| 95 | return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}} |
| 96 | } |
| 97 | |
| 98 | func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) { |
| 99 | block := activeGoalBlock("fix the parser") |
| 100 | for _, want := range []string{ |
| 101 | "Treat the user's goal as a task contract", |
| 102 | "Context, Request, Output format, Constraints", |
| 103 | "Pause policy", |
| 104 | "irreversible or externally visible operation", |
| 105 | "the requested scope has changed", |
| 106 | "information only the user can provide", |
| 107 | "output format and constraints are satisfied", |
| 108 | } { |
| 109 | if !strings.Contains(block, want) { |
| 110 | t.Fatalf("active goal block missing %q:\n%s", want, block) |
| 111 | } |
| 112 | } |
| 113 | if strings.Contains(block, "AutoResearch protocol") { |
| 114 | t.Fatalf("simple goal should not include AutoResearch protocol:\n%s", block) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestPlainInputWithStrongResearchSignalStaysNormal(t *testing.T) { |
| 119 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 120 | textTurn("Here is the normal response."), |
| 121 | }} |
| 122 | ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 123 | events := make(chan event.Event, 8) |
| 124 | c := newOwnedTestController(t, Options{ |
| 125 | Runner: ag, |
| 126 | Executor: ag, |
| 127 | Sink: event.FuncSink(func(e event.Event) { |
| 128 | if e.Kind == event.TurnDone || e.Kind == event.Notice { |
| 129 | events <- e |
| 130 | } |
| 131 | }), |
| 132 | }) |
| 133 | |
| 134 | c.Submit("持续排查这个线上卡顿直到根因明确,并验证修复") |
| 135 | waitForTurnDone(t, events) |
| 136 | |
| 137 | if prov.call != 1 { |
| 138 | t.Fatalf("provider calls = %d, want 1", prov.call) |
| 139 | } |
| 140 | first := agent.StripTransientUserBlocks(firstUserMessage(ag.Session().Messages)) |
| 141 | if !strings.HasSuffix(first, "持续排查这个线上卡顿直到根因明确,并验证修复") { |
| 142 | t.Fatalf("ordinary turn should preserve the original prompt suffix: %q", first) |
| 143 | } |
| 144 | if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") { |
| 145 | t.Fatalf("ordinary prompt should not enter Goal or AutoResearch:\n%s", first) |
| 146 | } |
| 147 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 148 | t.Fatalf("GoalStatus() = %q, want stopped", got) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | func TestPlainInputWithStrongResearchSignalPreservesRefsWithoutStartingGoal(t *testing.T) { |
| 153 | root := t.TempDir() |
| 154 | if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("important referenced evidence"), 0o644); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 158 | textTurn("Referenced normal response."), |
| 159 | }} |
| 160 | ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 161 | events := make(chan event.Event, 8) |
| 162 | c := newOwnedTestController(t, Options{ |
| 163 | WorkspaceRoot: root, |
| 164 | Runner: ag, |
| 165 | Executor: ag, |
| 166 | Sink: event.FuncSink(func(e event.Event) { |
| 167 | if e.Kind == event.TurnDone || e.Kind == event.Notice { |
| 168 | events <- e |
| 169 | } |
| 170 | }), |
| 171 | }) |
| 172 | |
| 173 | c.Submit("持续排查直到根因明确,并验证 @notes.txt") |
| 174 | waitForTurnDone(t, events) |
| 175 | |
| 176 | first := firstUserMessage(ag.Session().Messages) |
| 177 | for _, want := range []string{ |
| 178 | "important referenced evidence", |
| 179 | } { |
| 180 | if !strings.Contains(first, want) { |
| 181 | t.Fatalf("ordinary turn with refs missing %q:\n%s", want, first) |
| 182 | } |
| 183 | } |
| 184 | if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") { |
| 185 | t.Fatalf("ordinary prompt with refs should not enter Goal or AutoResearch:\n%s", first) |
| 186 | } |
| 187 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 188 | t.Fatalf("GoalStatus() = %q, want stopped", got) |
| 189 | } |
| 190 | if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { |
| 191 | t.Fatalf("ordinary prompt created AutoResearch state: err=%v", err) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | func TestResearchGoalUsesContinuousRuntimeWithoutArchive(t *testing.T) { |
| 196 | root := t.TempDir() |
| 197 | sessionPath := filepath.Join(root, "sessions", "s.jsonl") |
| 198 | sess := agent.NewSession("sys") |
| 199 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 200 | c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) |
| 201 | c.Resume(sess, sessionPath) |
| 202 | c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn) |
| 203 | defer c.Close() |
| 204 | if got := c.GoalRuntime().TurnsLimit; got != 0 { |
| 205 | t.Fatalf("research Goal turns limit = %d, want unlimited", got) |
| 206 | } |
| 207 | // The class still drives behaviour; it just no longer mints a turn quota. |
| 208 | if got := c.goals.budgetClass; got != budgetClassResearch { |
| 209 | t.Fatalf("research Goal budget class = %q, want %q", got, budgetClassResearch) |
| 210 | } |
| 211 | if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { |
| 212 | t.Fatalf("research Goal created legacy archive: %v", err) |
| 213 | } |
| 214 | if composed := c.Compose("continue"); strings.Contains(composed, "AutoResearch") || strings.Contains(composed, "autoresearch") { |
| 215 | t.Fatalf("Goal prompt exposes removed AutoResearch protocol:\n%s", composed) |
| 216 | } |
| 217 | } |
| 218 | |
| 219 | func TestLegacyGoalSidecarMigratesToContinuousRuntimeWithoutTaskID(t *testing.T) { |
| 220 | root := t.TempDir() |
| 221 | sessionPath := filepath.Join(root, "sessions", "s.jsonl") |
| 222 | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | writeLegacyGoalArchive(t, root, "old-task", "archive fallback should not replace sidecar goal") |
| 226 | if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"goal":"investigate runtime","status":"running","researchMode":1,"autoResearchTaskID":"old-task"}`), 0o644); err != nil { |
| 227 | t.Fatal(err) |
| 228 | } |
| 229 | sess := agent.NewSession("sys") |
| 230 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 231 | c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) |
| 232 | c.Resume(sess, sessionPath) |
| 233 | defer c.Close() |
| 234 | if got := c.GoalRuntime().TurnsLimit; got != 0 { |
| 235 | t.Fatalf("migrated Goal turns limit = %d, want unlimited", got) |
| 236 | } |
| 237 | if got := c.goals.budgetClass; got != budgetClassResearch { |
| 238 | t.Fatalf("migrated Goal budget class = %q, want %q", got, budgetClassResearch) |
| 239 | } |
| 240 | if got := c.Goal(); got != "investigate runtime" { |
| 241 | t.Fatalf("migrated Goal = %q, want sidecar goal", got) |
| 242 | } |
| 243 | raw, err := os.ReadFile(goalStatePath(sessionPath)) |
| 244 | if err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | var state goalState |
| 248 | if err := json.Unmarshal(raw, &state); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | if state.AutoResearchTaskID != "old-task" { |
| 252 | t.Fatalf("read-only restore rewrote the original sidecar: %q", state.AutoResearchTaskID) |
| 253 | } |
| 254 | } |
| 255 | |
| 256 | func TestMissingExplicitLegacyTaskBlocksWithoutCreatingArchive(t *testing.T) { |
| 257 | root := t.TempDir() |
| 258 | c := newOwnedTestController(t, Options{WorkspaceRoot: root}) |
| 259 | defer c.Close() |
| 260 | c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn) |
| 261 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 262 | t.Fatalf("GoalStatus = %q, want blocked", got) |
| 263 | } |
| 264 | if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) { |
| 265 | t.Fatalf("missing legacy task created archive: %v", err) |
| 266 | } |
| 267 | |
| 268 | c.SetGoal("resume .reasonix/autoresearch/missing-task/../../escape") |
| 269 | if got := c.GoalStatus(); got != GoalStatusBlocked { |
| 270 | t.Fatalf("unsafe legacy path status = %q, want blocked", got) |
| 271 | } |
| 272 | if got := c.Goal(); got != "resume .reasonix/autoresearch/missing-task/../../escape" { |
| 273 | t.Fatalf("unsafe legacy path silently resumed a truncated task: %q", got) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | func TestExplicitLegacyTaskPathRestoresOriginalGoal(t *testing.T) { |
| 278 | root := t.TempDir() |
| 279 | if resolved, err := filepath.EvalSymlinks(root); err == nil { |
| 280 | root = resolved |
| 281 | } |
| 282 | taskID := "20260630-original-goal" |
| 283 | taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) |
| 284 | if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | spec := `{"task_id":"` + taskID + `","goal":"find the original root cause","allowed_operations":{"write":true},"success_criteria":[]}` |
| 291 | progress := `{"status":"running","iteration":2,"updated_at":"2026-06-30T10:00:00Z"}` |
| 292 | for name, body := range map[string]string{ |
| 293 | "state/task_spec.json": spec, |
| 294 | "state/progress.json": progress, |
| 295 | "state/directions_tried.json": "[]\n", |
| 296 | "state/findings.jsonl": "", |
| 297 | "state/iteration_log.jsonl": "", |
| 298 | "logs/heartbeat.jsonl": "", |
| 299 | } { |
| 300 | if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | } |
| 304 | before, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) |
| 305 | if err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | c := newOwnedTestController(t, Options{WorkspaceRoot: root}) |
| 309 | defer c.Close() |
| 310 | c.SetGoalWithResearchMode("resume .reasonix/autoresearch/"+taskID+"/", GoalResearchAuto) |
| 311 | if got := c.Goal(); got != "find the original root cause" { |
| 312 | t.Fatalf("Goal() = %q, want original archive goal", got) |
| 313 | } |
| 314 | if got := c.GoalRuntime().TurnsLimit; got != 0 { |
| 315 | t.Fatalf("turns limit = %d, want unlimited", got) |
| 316 | } |
| 317 | if got := c.goals.budgetClass; got != budgetClassResearch { |
| 318 | t.Fatalf("budget class = %q, want %q", got, budgetClassResearch) |
| 319 | } |
| 320 | if got := c.GoalStatus(); got != GoalStatusRunning { |
| 321 | t.Fatalf("status = %q", got) |
| 322 | } |
| 323 | after, err := os.ReadFile(filepath.Join(taskRoot, "state", "task_spec.json")) |
| 324 | if err != nil { |
| 325 | t.Fatal(err) |
| 326 | } |
| 327 | if string(before) != string(after) { |
| 328 | t.Fatal("archive task_spec mutated during resume") |
| 329 | } |
| 330 | } |
| 331 | |
| 332 | func TestLegacySidecarEmptyGoalFilledFromArchive(t *testing.T) { |
| 333 | root := t.TempDir() |
| 334 | if resolved, err := filepath.EvalSymlinks(root); err == nil { |
| 335 | root = resolved |
| 336 | } |
| 337 | sessionPath := filepath.Join(root, "sessions", "s.jsonl") |
| 338 | if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil { |
| 339 | t.Fatal(err) |
| 340 | } |
| 341 | taskID := "fill-from-archive" |
| 342 | taskRoot := filepath.Join(root, ".reasonix", "autoresearch", taskID) |
| 343 | if err := os.MkdirAll(filepath.Join(taskRoot, "state"), 0o755); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | if err := os.MkdirAll(filepath.Join(taskRoot, "logs"), 0o755); err != nil { |
| 347 | t.Fatal(err) |
| 348 | } |
| 349 | for name, body := range map[string]string{ |
| 350 | "state/task_spec.json": `{"task_id":"` + taskID + `","goal":"recover me from archive","allowed_operations":{"write":true},"success_criteria":[]}`, |
| 351 | "state/progress.json": `{"status":"running","updated_at":"2026-06-30T10:00:00Z"}`, |
| 352 | "state/directions_tried.json": "[]\n", |
| 353 | "state/findings.jsonl": "", |
| 354 | "state/iteration_log.jsonl": "", |
| 355 | "logs/heartbeat.jsonl": "", |
| 356 | } { |
| 357 | if err := os.WriteFile(filepath.Join(taskRoot, name), []byte(body), 0o644); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | } |
| 361 | if err := os.WriteFile(goalStatePath(sessionPath), []byte(`{"status":"running","researchMode":1,"autoResearchTaskID":"`+taskID+`","turnsUsed":3,"turnsLimit":40}`), 0o644); err != nil { |
| 362 | t.Fatal(err) |
| 363 | } |
| 364 | sess := agent.NewSession("sys") |
| 365 | exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard) |
| 366 | c := newOwnedTestController(t, Options{WorkspaceRoot: root, SessionDir: root, Executor: exec}) |
| 367 | c.Resume(sess, sessionPath) |
| 368 | defer c.Close() |
| 369 | if got := c.Goal(); got != "recover me from archive" { |
| 370 | t.Fatalf("Goal() = %q", got) |
| 371 | } |
| 372 | if got := c.GoalRuntime().TurnsUsed; got != 3 { |
| 373 | t.Fatalf("turns used = %d, want preserved 3", got) |
| 374 | } |
| 375 | raw, err := os.ReadFile(goalStatePath(sessionPath)) |
| 376 | if err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | if strings.Contains(string(raw), "autoResearchTaskID") { |
| 380 | t.Fatalf("sidecar retained task id: %s", raw) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestPlainInputWithWeakResearchSignalStaysNormal(t *testing.T) { |
| 385 | prov := &scriptedTurns{turns: [][]provider.Chunk{ |
| 386 | textTurn("Here is a normal answer."), |
| 387 | }} |
| 388 | ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard) |
| 389 | events := make(chan event.Event, 4) |
| 390 | c := newOwnedTestController(t, Options{ |
| 391 | Runner: ag, |
| 392 | Executor: ag, |
| 393 | Sink: event.FuncSink(func(e event.Event) { |
| 394 | if e.Kind == event.TurnDone { |
| 395 | events <- e |
| 396 | } |
| 397 | }), |
| 398 | }) |
| 399 | |
| 400 | c.Submit("长期来看这个模块怎么优化?") |
| 401 | waitForTurnDone(t, events) |
| 402 | |
| 403 | first := firstUserMessage(ag.Session().Messages) |
| 404 | if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") { |
| 405 | t.Fatalf("ordinary prompt should stay outside Goal and AutoResearch:\n%s", first) |
| 406 | } |
| 407 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 408 | t.Fatalf("GoalStatus() = %q, want stopped", got) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | func TestCancelStopsIdleGoalWithIncompleteTodos(t *testing.T) { |
| 413 | ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard) |
| 414 | ag.SeedTodoState([]evidence.TodoItem{{Content: "finish the migration", Status: "in_progress"}}) |
| 415 | c := newOwnedTestController(t, Options{Executor: ag, Sink: event.Discard}) |
| 416 | c.SetGoalWithResearchMode("finish the migration", GoalResearchOn) |
| 417 | |
| 418 | c.Cancel() |
| 419 | |
| 420 | if got := c.GoalStatus(); got != GoalStatusStopped { |
| 421 | t.Fatalf("GoalStatus() = %q, want stopped", got) |
| 422 | } |
| 423 | if got := c.Goal(); got != "finish the migration" { |
| 424 | t.Fatalf("Goal() = %q, want stopped goal text to remain for display/persistence", got) |
| 425 | } |
| 426 | if todos := c.Todos(); len(todos) != 0 { |
| 427 | t.Fatalf("Todos() after stopping idle goal = %+v, want executor seed ignored", todos) |
| 428 | } |
| 429 | } |
| 430 | |
| 431 | // TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics: |
| 432 | // a fresh session starts with no active goal (so the old goal's text stops |
| 433 | // injecting into its first turns), while the OLD session's persisted |
| 434 | // goal-state sidecar keeps the running goal so resuming it restores the goal. |
| 435 | func TestSessionRotationClearsActiveGoal(t *testing.T) { |
| 436 | dir := t.TempDir() |
| 437 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 438 | oldPath := filepath.Join(dir, "session.jsonl") |
| 439 | c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test"}) |
| 440 | |
| 441 | c.SetGoal("ship the release checklist") |
| 442 | if got := c.Goal(); got != "ship the release checklist" { |
| 443 | t.Fatalf("Goal() = %q after SetGoal", got) |
| 444 | } |
| 445 | if composed := c.Compose("hello"); !strings.Contains(composed, "<active-goal>") { |
| 446 | t.Fatalf("running goal should inject into turns, composed = %q", composed) |
| 447 | } |
| 448 | |
| 449 | if err := c.NewSession(); err != nil { |
| 450 | t.Fatalf("NewSession: %v", err) |
| 451 | } |
| 452 | if got := c.Goal(); got != "" { |
| 453 | t.Fatalf("Goal() after /new = %q, want empty", got) |
| 454 | } |
| 455 | if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") { |
| 456 | t.Fatalf("old goal leaked into the fresh session's turn: %q", composed) |
| 457 | } |
| 458 | // The old session keeps its running goal on disk for /resume. |
| 459 | oldState, err := os.ReadFile(store.SessionGoalState(oldPath)) |
| 460 | if err != nil { |
| 461 | t.Fatalf("read old goal state: %v", err) |
| 462 | } |
| 463 | if !strings.Contains(string(oldState), "ship the release checklist") || !strings.Contains(string(oldState), GoalStatusRunning) { |
| 464 | t.Fatalf("old session's goal state was disturbed by /new: %s", oldState) |
| 465 | } |
| 466 | // The new session's sidecar records the cleared (stopped) state, so |
| 467 | // profile restores read it as "no running goal". |
| 468 | newState, err := os.ReadFile(store.SessionGoalState(c.SessionPath())) |
| 469 | if err != nil { |
| 470 | t.Fatalf("read new goal state: %v", err) |
| 471 | } |
| 472 | if strings.Contains(string(newState), "ship the release checklist") { |
| 473 | t.Fatalf("new session's goal state carries the old goal: %s", newState) |
| 474 | } |
| 475 | |
| 476 | // Same contract for /clear. |
| 477 | c.SetGoal("another goal") |
| 478 | if err := c.ClearSession(); err != nil { |
| 479 | t.Fatalf("ClearSession: %v", err) |
| 480 | } |
| 481 | if got := c.Goal(); got != "" { |
| 482 | t.Fatalf("Goal() after /clear = %q, want empty", got) |
| 483 | } |
| 484 | if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") { |
| 485 | t.Fatalf("old goal leaked into the cleared session's turn: %q", composed) |
| 486 | } |
| 487 | } |
| 488 | |
| 489 | func TestGoalSidecarRoundTripPreservesBlockedDeliveryCheckpoint(t *testing.T) { |
| 490 | dir := t.TempDir() |
| 491 | path := filepath.Join(dir, "session.jsonl") |
| 492 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 493 | c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"}) |
| 494 | c.SetGoal("finish the delivery") |
| 495 | scopeID, _, ok := c.goals.deliveryScope() |
| 496 | if !ok || scopeID == "" { |
| 497 | t.Fatal("Goal did not allocate a delivery scope") |
| 498 | } |
| 499 | cp := evidence.DeliveryCheckpoint{ |
| 500 | ScopeID: scopeID, |
| 501 | CriteriaEstablished: true, |
| 502 | WorkObserved: true, |
| 503 | MutationObserved: true, |
| 504 | PendingMutation: true, |
| 505 | } |
| 506 | statePath, data, persist := c.goals.setDeliveryCheckpoint(cp) |
| 507 | c.persistGoalState(statePath, data, persist) |
| 508 | c.stopGoal(GoalStatusBlocked) |
| 509 | |
| 510 | freshExec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 511 | fresh := newOwnedTestController(t, Options{Executor: freshExec, SessionDir: dir, Label: "fresh"}) |
| 512 | fresh.Resume(agent.NewSession("sys"), path) |
| 513 | if fresh.Goal() != "finish the delivery" || fresh.GoalStatus() != GoalStatusBlocked { |
| 514 | t.Fatalf("restored Goal = (%q, %q), want blocked Goal", fresh.Goal(), fresh.GoalStatus()) |
| 515 | } |
| 516 | if got := freshExec.DeliveryCheckpoint(); got != cp { |
| 517 | t.Fatalf("restored checkpoint = %+v, want %+v", got, cp) |
| 518 | } |
| 519 | if !fresh.ResumeGoal() { |
| 520 | t.Fatal("ResumeGoal rejected a restored blocked Goal") |
| 521 | } |
| 522 | id, _, ok := fresh.goals.deliveryScope() |
| 523 | if !ok || id != scopeID { |
| 524 | t.Fatalf("resumed scope = %q, want %q", id, scopeID) |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | func TestLegacyRunningGoalSidecarAllocatesScope(t *testing.T) { |
| 529 | dir := t.TempDir() |
| 530 | path := filepath.Join(dir, "legacy.jsonl") |
| 531 | data := []byte(`{"goal":"legacy goal","status":"running"}`) |
| 532 | if err := os.WriteFile(store.SessionGoalState(path), data, 0o600); err != nil { |
| 533 | t.Fatal(err) |
| 534 | } |
| 535 | exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard) |
| 536 | c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"}) |
| 537 | c.Resume(agent.NewSession("sys"), path) |
| 538 | if c.goals.active() { |
| 539 | t.Fatal("restored goal automatically active") |
| 540 | } |
| 541 | if !c.ResumeGoal() { |
| 542 | t.Fatal("explicit resume failed") |
| 543 | } |
| 544 | id, task, ok := c.goals.deliveryScope() |
| 545 | if !ok || id == "" || task != "legacy goal" { |
| 546 | t.Fatalf("legacy delivery scope = (%q, %q, %v)", id, task, ok) |
| 547 | } |
| 548 | if got := exec.DeliveryCheckpoint(); got.ScopeID != id { |
| 549 | t.Fatalf("legacy checkpoint scope = %q, want %q", got.ScopeID, id) |
| 550 | } |
| 551 | } |
| 552 |