返回 DeepSeek-Reasonix
goal_test.go
根目录 / internal / control / goal_test.go
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/goaleval"
17 "reasonix/internal/provider"
18 "reasonix/internal/store"
19 "reasonix/internal/tool"
20
21 _ "reasonix/internal/tool/builtin"
22 )
23
24 // goalRegistry returns a registry carrying the update_goal builtin so scripted
25 // goal turns can report dispositions through the structured tool.
26 func goalRegistry() *tool.Registry {
27 reg := tool.NewRegistry()
28 if t, ok := tool.LookupBuiltin("update_goal"); ok {
29 reg.Add(t)
30 }
31 return reg
32 }
33
34 // goalWireStatus maps FSM status values to the update_goal wire enum.
35 func goalWireStatus(status string) string {
36 switch status {
37 case GoalStatusComplete:
38 return "complete"
39 case GoalStatusBlocked:
40 return "blocked"
41 default:
42 return "continue"
43 }
44 }
45
46 // goalToolTurn models one goal turn's provider sequence: the model calls
47 // update_goal with the given disposition, then answers with text. Call IDs are
48 // unique per call so recycled scripted turns never collide in the transcript.
49 func goalToolTurn(status, reason, nextAction string) [][]provider.Chunk {
50 args, err := json.Marshal(map[string]string{"status": goalWireStatus(status), "reason": reason, "next_action": nextAction})
51 if err != nil {
52 panic(err)
53 }
54 id := fmt.Sprintf("ug-%d", goalToolCallSeq.Add(1))
55 return [][]provider.Chunk{
56 {toolCallChunk(id, "update_goal", string(args)), {Type: provider.ChunkDone}},
57 textTurn("worked on the goal"),
58 }
59 }
60
61 var goalToolCallSeq atomic.Uint64
62
63 // fakeGoalEvaluator is a scripted bounded Goal evaluator for tests.
64 type fakeGoalEvaluator struct {
65 outcome goaleval.Outcome
66 reason string
67 err error
68 calls int
69 }
70
71 func (f *fakeGoalEvaluator) Evaluate(_ context.Context, _ goaleval.GoalEvidence) (goaleval.Verdict, error) {
72 f.calls++
73 if f.err != nil {
74 return goaleval.Verdict{}, f.err
75 }
76 return goaleval.Verdict{Outcome: f.outcome, Reason: f.reason}, nil
77 }
78
79 func TestGoalCommandAutoContinuesUntilComplete(t *testing.T) {
80 prov := &scriptedTurns{turns: flattenTurns(
81 goalToolTurn(GoalStatusRunning, "work in progress", "next step"),
82 goalToolTurn(GoalStatusComplete, "", ""),
83 )}
84 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
85 events := make(chan event.Event, 8)
86 c := New(Options{
87 Runner: ag,
88 Executor: ag,
89 Sink: event.FuncSink(func(e event.Event) {
90 if e.Kind == event.TurnDone || e.Kind == event.Notice {
91 events <- e
92 }
93 }),
94 })
95
96 c.Submit("/goal ship the redesign")
97 waitForTurnDone(t, events)
98
99 if prov.call != 4 {
100 t.Fatalf("provider calls = %d, want 4 (continue report + continuation complete report)", prov.call)
101 }
102 if got := c.Goal(); got != "" {
103 t.Fatalf("completed goal should be cleared, got %q", got)
104 }
105 if got := c.GoalStatus(); got != GoalStatusComplete {
106 t.Fatalf("GoalStatus() = %q, want complete", got)
107 }
108 first := firstUserMessage(ag.Session().Messages)
109 if !strings.Contains(first, "<active-goal>\nship the redesign") {
110 t.Fatalf("first goal turn should include active goal block, got %q", first)
111 }
112 if strings.HasPrefix(first, PlanModeMarker) {
113 t.Fatalf("goal mode should not enter plan mode, got %q", first)
114 }
115 }
116
117 // flattenTurns concatenates per-goal-turn provider sequences into one flat
118 // scripted provider stream.
119 func flattenTurns(groups ...[][]provider.Chunk) [][]provider.Chunk {
120 var out [][]provider.Chunk
121 for _, g := range groups {
122 out = append(out, g...)
123 }
124 return out
125 }
126
127 // toolCallChunk builds a provider turn carrying one tool call.
128 func toolCallChunk(id, name, args string) provider.Chunk {
129 return provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: args}}
130 }
131
132 func TestActiveGoalBlockCarriesTaskContractAndPausePolicy(t *testing.T) {
133 block := activeGoalBlock("fix the parser", GoalResearchOff)
134 for _, want := range []string{
135 "Treat the user's goal as a task contract",
136 "Context, Request, Output format, Constraints",
137 "Pause policy",
138 "irreversible or externally visible operation",
139 "the requested scope has changed",
140 "information only the user can provide",
141 "output format and constraints are satisfied",
142 } {
143 if !strings.Contains(block, want) {
144 t.Fatalf("active goal block missing %q:\n%s", want, block)
145 }
146 }
147 if strings.Contains(block, "AutoResearch protocol") {
148 t.Fatalf("simple goal should not include AutoResearch protocol:\n%s", block)
149 }
150 }
151
152 func TestPlainInputWithStrongResearchSignalStaysNormal(t *testing.T) {
153 prov := &scriptedTurns{turns: [][]provider.Chunk{
154 textTurn("Here is the normal response."),
155 }}
156 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
157 events := make(chan event.Event, 8)
158 c := New(Options{
159 Runner: ag,
160 Executor: ag,
161 Sink: event.FuncSink(func(e event.Event) {
162 if e.Kind == event.TurnDone || e.Kind == event.Notice {
163 events <- e
164 }
165 }),
166 })
167
168 c.Submit("持续排查这个线上卡顿直到根因明确,并验证修复")
169 waitForTurnDone(t, events)
170
171 if prov.call != 1 {
172 t.Fatalf("provider calls = %d, want 1", prov.call)
173 }
174 first := firstUserMessage(ag.Session().Messages)
175 if !strings.HasSuffix(first, "持续排查这个线上卡顿直到根因明确,并验证修复") {
176 t.Fatalf("ordinary turn should preserve the original prompt suffix: %q", first)
177 }
178 if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") {
179 t.Fatalf("ordinary prompt should not enter Goal or AutoResearch:\n%s", first)
180 }
181 if got := c.GoalStatus(); got != GoalStatusStopped {
182 t.Fatalf("GoalStatus() = %q, want stopped", got)
183 }
184 }
185
186 func TestPlainInputWithStrongResearchSignalPreservesRefsWithoutStartingGoal(t *testing.T) {
187 root := t.TempDir()
188 if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("important referenced evidence"), 0o644); err != nil {
189 t.Fatal(err)
190 }
191 prov := &scriptedTurns{turns: [][]provider.Chunk{
192 textTurn("Referenced normal response."),
193 }}
194 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
195 events := make(chan event.Event, 8)
196 c := New(Options{
197 WorkspaceRoot: root,
198 Runner: ag,
199 Executor: ag,
200 Sink: event.FuncSink(func(e event.Event) {
201 if e.Kind == event.TurnDone || e.Kind == event.Notice {
202 events <- e
203 }
204 }),
205 })
206
207 c.Submit("持续排查直到根因明确,并验证 @notes.txt")
208 waitForTurnDone(t, events)
209
210 first := firstUserMessage(ag.Session().Messages)
211 for _, want := range []string{
212 "important referenced evidence",
213 } {
214 if !strings.Contains(first, want) {
215 t.Fatalf("ordinary turn with refs missing %q:\n%s", want, first)
216 }
217 }
218 if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") {
219 t.Fatalf("ordinary prompt with refs should not enter Goal or AutoResearch:\n%s", first)
220 }
221 if got := c.GoalStatus(); got != GoalStatusStopped {
222 t.Fatalf("GoalStatus() = %q, want stopped", got)
223 }
224 if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
225 t.Fatalf("ordinary prompt created AutoResearch state: err=%v", err)
226 }
227 }
228
229 func TestPlainAutoResearchTaskPathDoesNotResumeGoal(t *testing.T) {
230 root := t.TempDir()
231 prov := &scriptedTurns{turns: [][]provider.Chunk{
232 textTurn("Handled as an ordinary turn."),
233 }}
234 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
235 events := make(chan event.Event, 8)
236 c := New(Options{
237 WorkspaceRoot: root,
238 Runner: ag,
239 Executor: ag,
240 Sink: event.FuncSink(func(e event.Event) {
241 if e.Kind == event.TurnDone || e.Kind == event.Notice {
242 events <- e
243 }
244 }),
245 })
246 defer c.Close()
247 c.SetGoalWithResearchMode("seed resumable task", GoalResearchOn)
248 taskID := c.goals.currentAutoResearchTaskID()
249 if taskID == "" {
250 t.Fatal("expected seeded AutoResearch task")
251 }
252 c.ClearGoal()
253
254 input := "继续 .reasonix/autoresearch/" + taskID + "/ 这个任务"
255 c.Submit(input)
256 waitForTurnDone(t, events)
257
258 if prov.call != 1 {
259 t.Fatalf("provider calls = %d, want 1", prov.call)
260 }
261 first := firstUserMessage(ag.Session().Messages)
262 if !strings.HasSuffix(first, input) {
263 t.Fatalf("ordinary task path should preserve the original prompt suffix: %q", first)
264 }
265 if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") {
266 t.Fatalf("ordinary task path should not enter Goal or AutoResearch:\n%s", first)
267 }
268 if got := c.Goal(); got != "" {
269 t.Fatalf("ordinary task path should not resume Goal, got %q", got)
270 }
271 if got := c.GoalStatus(); got != GoalStatusStopped {
272 t.Fatalf("GoalStatus() = %q, want stopped", got)
273 }
274 }
275
276 func TestResearchGoalCreatesHostManagedAutoResearchTask(t *testing.T) {
277 root := t.TempDir()
278 if resolved, err := filepath.EvalSymlinks(root); err == nil {
279 root = resolved
280 }
281 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
282 ag := agent.New(&scriptedTurns{}, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
283 c := New(Options{WorkspaceRoot: root, SessionPath: sessionPath, Runner: ag, Executor: ag})
284
285 c.SetGoalWithResearchMode("fix the typo and add a test", GoalResearchOn)
286
287 data, err := os.ReadFile(goalStatePath(sessionPath))
288 if err != nil {
289 t.Fatalf("read goal state: %v", err)
290 }
291 var state goalState
292 if err := json.Unmarshal(data, &state); err != nil {
293 t.Fatalf("unmarshal goal state: %v", err)
294 }
295 if state.AutoResearchTaskID == "" {
296 t.Fatalf("AutoResearchTaskID was empty in persisted goal state: %+v", state)
297 }
298 for _, rel := range []string{
299 "state/task_spec.json",
300 "state/progress.json",
301 "state/findings.jsonl",
302 "logs/heartbeat.jsonl",
303 } {
304 path := filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, rel)
305 if _, err := os.Stat(path); err != nil {
306 t.Fatalf("expected autoresearch file %s: %v", rel, err)
307 }
308 }
309 var spec struct {
310 SuccessCriteria []struct {
311 ID string `json:"id"`
312 Required bool `json:"required"`
313 } `json:"success_criteria"`
314 }
315 readJSONFileForTest(t, filepath.Join(root, ".reasonix", "autoresearch", state.AutoResearchTaskID, "state", "task_spec.json"), &spec)
316 if len(spec.SuccessCriteria) != 2 || spec.SuccessCriteria[0].ID != "objective_evidence" || spec.SuccessCriteria[1].ID != "verification" {
317 t.Fatalf("default success criteria = %+v, want objective_evidence and verification", spec.SuccessCriteria)
318 }
319 for _, criterion := range spec.SuccessCriteria {
320 if !criterion.Required {
321 t.Fatalf("default criterion %+v was not required", criterion)
322 }
323 }
324
325 composed := c.Compose("continue")
326 if !strings.Contains(composed, "<autoresearch-runtime>") || !strings.Contains(composed, "task_id: "+state.AutoResearchTaskID) || !strings.Contains(composed, "objective_evidence") {
327 t.Fatalf("Compose missing runtime summary for task %q:\n%s", state.AutoResearchTaskID, composed)
328 }
329 }
330
331 func TestResearchGoalCreatedEmitsLifecycleNotice(t *testing.T) {
332 root := t.TempDir()
333 if resolved, err := filepath.EvalSymlinks(root); err == nil {
334 root = resolved
335 }
336 events := make(chan event.Event, 4)
337 c := New(Options{
338 WorkspaceRoot: root,
339 Sink: event.FuncSink(func(e event.Event) {
340 if e.Kind == event.Notice {
341 events <- e
342 }
343 }),
344 })
345
346 c.SetGoalWithResearchMode("investigate lifecycle notice", GoalResearchOn)
347
348 select {
349 case e := <-events:
350 if !strings.Contains(e.Text, "autoresearch task created") {
351 t.Fatalf("notice = %q, want autoresearch task created", e.Text)
352 }
353 default:
354 t.Fatal("expected autoresearch lifecycle notice")
355 }
356 }
357
358 func TestResearchGoalRepeatedSetReusesAutoResearchTask(t *testing.T) {
359 root := t.TempDir()
360 if resolved, err := filepath.EvalSymlinks(root); err == nil {
361 root = resolved
362 }
363 events := make(chan event.Event, 8)
364 c := New(Options{
365 WorkspaceRoot: root,
366 Sink: event.FuncSink(func(e event.Event) {
367 if e.Kind == event.Notice {
368 events <- e
369 }
370 }),
371 })
372
373 goal := "请持续研究当前项目的 AutoResearch 状态栏展示链路,验证任务创建、状态刷新、右侧 Context 面板展示、状态栏 chip 展示是否一致。不要只看表面现象,需要找到根因、记录 evidence,并在完成前确认所有验证步骤通过。不要修改文件"
374 c.SetGoalWithResearchMode(goal, GoalResearchOn)
375 _, _, _, firstTaskID := c.goals.snapshot()
376 if firstTaskID == "" {
377 t.Fatal("first AutoResearch task id was empty")
378 }
379
380 c.SetGoalWithResearchMode(goal, GoalResearchOn)
381 c.SetGoal(goal)
382 _, _, _, repeatedTaskID := c.goals.snapshot()
383 if repeatedTaskID != firstTaskID {
384 t.Fatalf("repeated SetGoal created a new task: got %q, want %q", repeatedTaskID, firstTaskID)
385 }
386
387 entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
388 if err != nil {
389 t.Fatalf("read autoresearch dir: %v", err)
390 }
391 if len(entries) != 1 {
392 t.Fatalf("autoresearch task count = %d, want 1", len(entries))
393 }
394
395 createdNotices := 0
396 for {
397 select {
398 case e := <-events:
399 if strings.Contains(e.Text, "autoresearch task created") {
400 createdNotices++
401 }
402 default:
403 if createdNotices != 1 {
404 t.Fatalf("created notices = %d, want 1", createdNotices)
405 }
406 return
407 }
408 }
409 }
410
411 func TestResearchGoalMissingExplicitTaskBlocksInsteadOfCreatingNewTask(t *testing.T) {
412 root := t.TempDir()
413 if resolved, err := filepath.EvalSymlinks(root); err == nil {
414 root = resolved
415 }
416 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
417 var notices []string
418 c := New(Options{
419 WorkspaceRoot: root,
420 SessionPath: sessionPath,
421 Sink: event.FuncSink(func(e event.Event) {
422 if e.Kind == event.Notice {
423 notices = append(notices, e.Text)
424 }
425 }),
426 })
427
428 c.SetGoalWithResearchMode("resume .reasonix/autoresearch/missing-task/", GoalResearchOn)
429
430 if got := c.GoalStatus(); got != GoalStatusBlocked {
431 t.Fatalf("GoalStatus() = %q, want blocked for missing explicit AutoResearch task", got)
432 }
433 if got := c.goals.currentAutoResearchTaskID(); got != "" {
434 t.Fatalf("current AutoResearch task id = %q, want none for missing explicit task", got)
435 }
436 entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
437 if err != nil && !os.IsNotExist(err) {
438 t.Fatalf("ReadDir autoresearch root: %v", err)
439 }
440 if len(entries) != 0 {
441 t.Fatalf("created tasks for missing explicit resume: %+v", entries)
442 }
443 if !containsNotice(notices, "autoresearch resume failed") || !containsNotice(notices, "missing-task") {
444 t.Fatalf("notices = %+v, want explicit resume failure", notices)
445 }
446 }
447
448 func TestResearchGoalTurnAppendsAutoResearchHeartbeats(t *testing.T) {
449 root := t.TempDir()
450 if resolved, err := filepath.EvalSymlinks(root); err == nil {
451 root = resolved
452 }
453 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
454 prov := &scriptedTurns{turns: flattenTurns(
455 goalToolTurn(GoalStatusComplete, "", ""),
456 goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""),
457 )}
458 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
459 events := make(chan event.Event, 4)
460 c := New(Options{
461 WorkspaceRoot: root,
462 SessionPath: sessionPath,
463 Runner: ag,
464 Executor: ag,
465 Sink: event.FuncSink(func(e event.Event) {
466 if e.Kind == event.TurnDone || e.Kind == event.Notice {
467 events <- e
468 }
469 }),
470 })
471
472 c.Submit("/goal --research fix the typo and add a test")
473 waitForTurnDone(t, events)
474
475 data, err := os.ReadFile(goalStatePath(sessionPath))
476 if err != nil {
477 t.Fatalf("read goal state: %v", err)
478 }
479 var state goalState
480 if err := json.Unmarshal(data, &state); err != nil {
481 t.Fatalf("unmarshal goal state: %v", err)
482 }
483 heartbeats, err := c.autoResearch.Heartbeats(state.AutoResearchTaskID, 10)
484 if err != nil {
485 t.Fatalf("Heartbeats: %v", err)
486 }
487 if len(heartbeats) < 2 {
488 t.Fatalf("heartbeats = %+v, want at least starting and done", heartbeats)
489 }
490 if heartbeats[0].Status != "starting_turn" || heartbeats[len(heartbeats)-1].Status != "turn_done" {
491 t.Fatalf("heartbeats = %+v, want starting_turn then turn_done", heartbeats)
492 }
493 }
494
495 func TestResearchGoalTurnUpdatesAutoResearchStaleProgress(t *testing.T) {
496 root := t.TempDir()
497 if resolved, err := filepath.EvalSymlinks(root); err == nil {
498 root = resolved
499 }
500 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
501 prov := &scriptedTurns{turns: flattenTurns(
502 goalToolTurn(GoalStatusRunning, "still investigating", ""),
503 goalToolTurn(GoalStatusBlocked, "needs a repro trace", ""),
504 )}
505 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
506 events := make(chan event.Event, 8)
507 c := New(Options{
508 WorkspaceRoot: root,
509 SessionPath: sessionPath,
510 Runner: ag,
511 Executor: ag,
512 Sink: event.FuncSink(func(e event.Event) {
513 if e.Kind == event.TurnDone || e.Kind == event.Notice {
514 events <- e
515 }
516 }),
517 })
518
519 c.Submit("/goal --research investigate stale progress")
520 waitForTurnDone(t, events)
521
522 data, err := os.ReadFile(goalStatePath(sessionPath))
523 if err != nil {
524 t.Fatalf("read goal state: %v", err)
525 }
526 var state goalState
527 if err := json.Unmarshal(data, &state); err != nil {
528 t.Fatalf("unmarshal goal state: %v", err)
529 }
530 summary, err := c.autoResearch.Summary(state.AutoResearchTaskID)
531 if err != nil {
532 t.Fatalf("Summary: %v", err)
533 }
534 if summary.Iteration < 2 || summary.StaleCount != summary.Iteration || !summary.PivotRequired || summary.PivotCount != 1 {
535 t.Fatalf("summary = %+v, want stale progress for every no-evidence turn and pivot required", summary)
536 }
537 }
538
539 func TestResearchGoalCompletionIsInterceptedWhenReadinessFails(t *testing.T) {
540 root := t.TempDir()
541 if resolved, err := filepath.EvalSymlinks(root); err == nil {
542 root = resolved
543 }
544 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
545 prov := &scriptedTurns{turns: flattenTurns(
546 goalToolTurn(GoalStatusComplete, "", ""),
547 goalToolTurn(GoalStatusBlocked, "missing evidence", ""),
548 )}
549 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
550 events := make(chan event.Event, 8)
551 var notices []string
552 c := New(Options{
553 WorkspaceRoot: root,
554 SessionPath: sessionPath,
555 Runner: ag,
556 Executor: ag,
557 Sink: event.FuncSink(func(e event.Event) {
558 if e.Kind == event.Notice {
559 notices = append(notices, e.Text)
560 }
561 if e.Kind == event.TurnDone || e.Kind == event.Notice {
562 events <- e
563 }
564 }),
565 })
566
567 c.SetGoalWithResearchMode("identify the root cause", GoalResearchOn)
568
569 if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
570 t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
571 }
572
573 if got := c.GoalStatus(); got != GoalStatusBlocked {
574 t.Fatalf("GoalStatus() = %q, want blocked after the readiness intercept and a blocked report", got)
575 }
576 if prov.call != 4 {
577 t.Fatalf("provider calls = %d, want complete-intercepted + blocked turns (2 provider calls each)", prov.call)
578 }
579 if !sessionContainsUserText(ag.Session().Messages, "AutoResearch readiness check failed", "objective_evidence", "verification") {
580 t.Fatalf("transcript missing readiness intercept; last user:\n%s", lastUserMessage(ag.Session().Messages))
581 }
582 if !containsNotice(notices, "Goal is not ready to complete yet; continuing the remaining work.") {
583 t.Fatalf("notices = %+v, want readiness continuation notice", notices)
584 }
585 }
586
587 func TestControllerRecordsAutoResearchEvidence(t *testing.T) {
588 root := t.TempDir()
589 if resolved, err := filepath.EvalSymlinks(root); err == nil {
590 root = resolved
591 }
592 c := New(Options{WorkspaceRoot: root})
593 c.SetGoalWithResearchMode("verify the fix", GoalResearchOn)
594 taskID := c.goals.currentAutoResearchTaskID()
595 if taskID == "" {
596 t.Fatal("expected autoresearch task id")
597 }
598
599 err := c.RecordAutoResearchEvidence("objective_evidence", AutoResearchEvidenceInput{
600 ID: "f-objective",
601 Kind: "file",
602 Summary: "implementation inspected",
603 Source: "file",
604 Paths: []string{"internal/control/controller.go"},
605 Accepted: true,
606 })
607 if err != nil {
608 t.Fatalf("RecordAutoResearchEvidence objective_evidence: %v", err)
609 }
610 err = c.RecordAutoResearchEvidence("verification", AutoResearchEvidenceInput{
611 ID: "f-verification",
612 Kind: "test",
613 Summary: "go test passed",
614 Source: "command",
615 Command: "go test ./internal/control",
616 Accepted: true,
617 })
618 if err != nil {
619 t.Fatalf("RecordAutoResearchEvidence verification: %v", err)
620 }
621
622 report, err := c.autoResearch.Readiness(taskID)
623 if err != nil {
624 t.Fatalf("Readiness: %v", err)
625 }
626 if !report.Ready {
627 t.Fatalf("readiness = %+v, want ready", report)
628 }
629 }
630
631 func TestAutoResearchEvidenceDoesNotChangeDefaultToolSurface(t *testing.T) {
632 root := t.TempDir()
633 if resolved, err := filepath.EvalSymlinks(root); err == nil {
634 root = resolved
635 }
636 reg := tool.NewRegistry()
637 New(Options{WorkspaceRoot: root, Registry: reg})
638 if _, ok := reg.Get("autoresearch_record_evidence"); ok {
639 t.Fatalf("autoresearch_record_evidence should not be registered in the default provider-visible tool surface; tools=%v", reg.Names())
640 }
641 for _, schema := range reg.Schemas() {
642 if schema.Name == "autoresearch_record_evidence" {
643 t.Fatalf("autoresearch_record_evidence should not appear in provider schemas: %+v", reg.Schemas())
644 }
645 }
646 }
647
648 func TestResearchGoalCompletionMarksAutoResearchTaskComplete(t *testing.T) {
649 root := t.TempDir()
650 if resolved, err := filepath.EvalSymlinks(root); err == nil {
651 root = resolved
652 }
653 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
654 prov := &scriptedTurns{turns: flattenTurns(
655 [][]provider.Chunk{
656 {toolCallChunk("ug1", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}},
657 textTurn(`Done.
658
659 <autoresearch-evidence>
660 {"criterion_id":"objective_evidence","id":"f-objective","kind":"file","summary":"The implementation state was inspected directly.","source":"file","paths":["internal/control/controller.go"],"accepted":true}
661 </autoresearch-evidence>
662 <autoresearch-evidence>
663 {"criterion_id":"verification","id":"f-verification","kind":"test","summary":"The focused AutoResearch tests passed.","source":"command","command":"go test ./internal/control","accepted":true}
664 </autoresearch-evidence>`),
665 },
666 )}
667 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
668 var notices []string
669 c := New(Options{
670 WorkspaceRoot: root,
671 SessionPath: sessionPath,
672 Runner: ag,
673 Executor: ag,
674 Sink: event.FuncSink(func(e event.Event) {
675 if e.Kind == event.Notice {
676 notices = append(notices, e.Text)
677 }
678 }),
679 })
680 c.SetGoalWithResearchMode("verify completion lifecycle", GoalResearchOn)
681 taskID := c.goals.currentAutoResearchTaskID()
682 if taskID == "" {
683 t.Fatal("expected autoresearch task id")
684 }
685
686 if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
687 t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
688 }
689
690 summary, err := c.autoResearch.Summary(taskID)
691 if err != nil {
692 t.Fatalf("Summary: %v", err)
693 }
694 if summary.Status != "complete" {
695 t.Fatalf("AutoResearch status = %q, want complete", summary.Status)
696 }
697 if summary.StaleCount != 0 {
698 t.Fatalf("AutoResearch stale_count = %d, want 0 after accepted evidence", summary.StaleCount)
699 }
700 findings, err := c.autoResearch.Findings(taskID, 0)
701 if err != nil {
702 t.Fatalf("Findings: %v", err)
703 }
704 if len(findings) != 2 {
705 t.Fatalf("findings = %+v, want two assistant evidence records", findings)
706 }
707 if !containsNotice(notices, "autoresearch task completed") {
708 t.Fatalf("notices = %+v, want autoresearch task completed", notices)
709 }
710 }
711
712 func TestResearchGoalBlockedMarksAutoResearchTaskBlocked(t *testing.T) {
713 root := t.TempDir()
714 if resolved, err := filepath.EvalSymlinks(root); err == nil {
715 root = resolved
716 }
717 sessionPath := filepath.Join(root, "sessions", "s.jsonl")
718 prov := &scriptedTurns{turns: flattenTurns(
719 goalToolTurn(GoalStatusBlocked, "needs credentials", ""),
720 )}
721 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
722 var notices []string
723 c := New(Options{
724 WorkspaceRoot: root,
725 SessionPath: sessionPath,
726 Runner: ag,
727 Executor: ag,
728 Sink: event.FuncSink(func(e event.Event) {
729 if e.Kind == event.Notice {
730 notices = append(notices, e.Text)
731 }
732 }),
733 })
734 c.SetGoalWithResearchMode("verify blocked lifecycle", GoalResearchOn)
735 taskID := c.goals.currentAutoResearchTaskID()
736 if taskID == "" {
737 t.Fatal("expected autoresearch task id")
738 }
739
740 if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "start"); err != nil {
741 t.Fatalf("runGoalLoopWithRawDisplay: %v", err)
742 }
743
744 summary, err := c.autoResearch.Summary(taskID)
745 if err != nil {
746 t.Fatalf("Summary: %v", err)
747 }
748 if summary.Status != "blocked" || !strings.Contains(summary.Blocker, "needs credentials") {
749 t.Fatalf("AutoResearch summary = %+v, want blocked with reason", summary)
750 }
751 if !containsNotice(notices, "AutoResearch task marked blocked.") {
752 t.Fatalf("notices = %+v, want autoresearch blocked notice", notices)
753 }
754 }
755
756 func TestPlainInputWithWeakResearchSignalStaysNormal(t *testing.T) {
757 prov := &scriptedTurns{turns: [][]provider.Chunk{
758 textTurn("Here is a normal answer."),
759 }}
760 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
761 events := make(chan event.Event, 4)
762 c := New(Options{
763 Runner: ag,
764 Executor: ag,
765 Sink: event.FuncSink(func(e event.Event) {
766 if e.Kind == event.TurnDone {
767 events <- e
768 }
769 }),
770 })
771
772 c.Submit("长期来看这个模块怎么优化?")
773 waitForTurnDone(t, events)
774
775 first := firstUserMessage(ag.Session().Messages)
776 if strings.Contains(first, "<active-goal>") || strings.Contains(first, "AutoResearch protocol") {
777 t.Fatalf("ordinary prompt should stay outside Goal and AutoResearch:\n%s", first)
778 }
779 if got := c.GoalStatus(); got != GoalStatusStopped {
780 t.Fatalf("GoalStatus() = %q, want stopped", got)
781 }
782 }
783
784 func TestCancelStopsIdleGoalWithIncompleteTodos(t *testing.T) {
785 ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
786 ag.SeedTodoState([]evidence.TodoItem{{Content: "finish the migration", Status: "in_progress"}})
787 c := New(Options{Executor: ag, Sink: event.Discard})
788 c.SetGoalWithResearchMode("finish the migration", GoalResearchOn)
789
790 c.Cancel()
791
792 if got := c.GoalStatus(); got != GoalStatusStopped {
793 t.Fatalf("GoalStatus() = %q, want stopped", got)
794 }
795 if got := c.Goal(); got != "finish the migration" {
796 t.Fatalf("Goal() = %q, want stopped goal text to remain for display/persistence", got)
797 }
798 if todos := c.Todos(); len(todos) != 1 || todos[0].Status != "in_progress" {
799 t.Fatalf("Todos() after stopping idle goal = %+v, want incomplete todo retained", todos)
800 }
801 }
802
803 func TestGoalRepeatedBlockedStopsAfterThreeTurns(t *testing.T) {
804 prov := &scriptedTurns{turns: flattenTurns(
805 goalToolTurn(GoalStatusBlocked, "Needs credentials.", ""),
806 )}
807 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
808 events := make(chan event.Event, 8)
809 c := New(Options{
810 Runner: ag,
811 Executor: ag,
812 Sink: event.FuncSink(func(e event.Event) {
813 if e.Kind == event.TurnDone || e.Kind == event.Notice {
814 events <- e
815 }
816 }),
817 })
818
819 c.Submit("/goal deploy the service")
820 waitForTurnDone(t, events)
821
822 if prov.call != 2 {
823 t.Fatalf("provider calls = %d, want 1 goal turn (report + final answer)", prov.call)
824 }
825 if got := c.GoalStatus(); got != GoalStatusBlocked {
826 t.Fatalf("GoalStatus() = %q, want blocked", got)
827 }
828 if rt := c.GoalRuntime(); rt.StopCause != "" {
829 t.Fatalf("StopCause = %q, want empty for a genuine task block", rt.StopCause)
830 }
831 }
832
833 // TestGoalBlockedReportTransitionsImmediately pins the FSM decision: a single
834 // blocked report ends the goal at once — no three-turn confirmation ritual and
835 // no intercept.
836 func TestGoalBlockedReportTransitionsImmediately(t *testing.T) {
837 g := &goalMachine{goal: "wait for user review", status: GoalStatusRunning}
838 g.turnsLimit = 10
839 g.noProgressLimit = defaultNoProgressLimit
840
841 res := g.advance(goalAdvanceInput{
842 report: &goalTurnReport{status: GoalStatusBlocked, reason: "waiting for user review"},
843 })
844
845 if res.cont {
846 t.Fatal("blocked report must stop the goal loop immediately")
847 }
848 if res.intercept != "" {
849 t.Fatalf("blocked report triggered an intercept %q", res.intercept)
850 }
851 if g.status != GoalStatusBlocked {
852 t.Fatalf("machine status = %q, want blocked", g.status)
853 }
854 }
855
856 func TestGoalRestartClearsBlockedAndCompletesOnRetry(t *testing.T) {
857 prov := &scriptedTurns{turns: flattenTurns(
858 goalToolTurn(GoalStatusBlocked, "needs credentials", ""),
859 goalToolTurn(GoalStatusComplete, "", ""),
860 )}
861 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
862 events := make(chan event.Event, 12)
863 c := New(Options{
864 Runner: ag,
865 Executor: ag,
866 Sink: event.FuncSink(func(e event.Event) {
867 if e.Kind == event.TurnDone || e.Kind == event.Notice {
868 events <- e
869 }
870 }),
871 })
872
873 c.Submit("/goal deploy the service")
874 waitForTurnDone(t, events)
875 if got := c.GoalStatus(); got != GoalStatusBlocked {
876 t.Fatalf("first run GoalStatus() = %q, want blocked", got)
877 }
878
879 c.Submit("/goal deploy the service")
880 waitForTurnDone(t, events)
881 if prov.call != 4 {
882 t.Fatalf("provider calls = %d, want 2 goal turns (blocked + resumed complete)", prov.call)
883 }
884 if got := c.GoalStatus(); got != GoalStatusComplete {
885 t.Fatalf("resumed GoalStatus() = %q, want complete; a fresh run starts a clean audit", got)
886 }
887 }
888
889 // TestIncompleteGoalTodos verifies that formatIncompleteTodos detects
890 // unfinished tasks and returns a formatted reminder, and returns empty
891 // when all todos are complete.
892 func TestIncompleteGoalTodos(t *testing.T) {
893 prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done")}}
894 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
895 c := New(Options{Runner: ag, Executor: ag, Sink: event.Discard})
896 reminder := func() string { return formatIncompleteTodos(c.goalTodos(), ag.ReadinessResult().Reason) }
897
898 // Seed with incomplete todos.
899 ag.SeedTodoState([]evidence.TodoItem{
900 {Content: "Fix the parser", Status: "in_progress"},
901 {Content: "Add tests", Status: "pending"},
902 })
903 msg := reminder()
904 if msg == "" {
905 t.Fatal("formatIncompleteTodos() returned empty string, expected reminder")
906 }
907 if !strings.Contains(msg, "Fix the parser") {
908 t.Fatalf("reminder should mention 'Fix the parser', got: %q", msg)
909 }
910 if !strings.Contains(msg, "Add tests") {
911 t.Fatalf("reminder should mention 'Add tests', got: %q", msg)
912 }
913 if !strings.Contains(msg, "todo_write") {
914 t.Fatalf("reminder should suggest updating todos via todo_write, got: %q", msg)
915 }
916
917 // Mark all complete.
918 ag.ReplaceTodoState([]evidence.TodoItem{
919 {Content: "Fix the parser", Status: "completed"},
920 {Content: "Add tests", Status: "completed"},
921 })
922 if got := reminder(); got != "" {
923 t.Fatalf("formatIncompleteTodos() with all-complete = %q, want empty", got)
924 }
925
926 // Empty todo list.
927 ag.ReplaceTodoState(nil)
928 if got := reminder(); got != "" {
929 t.Fatalf("formatIncompleteTodos() with empty list = %q, want empty", got)
930 }
931 }
932
933 // TestGoalInterceptsCompleteWithIncompleteTodos verifies that a complete report
934 // with unfinished canonical todos is always rejected: the FSM continues with
935 // the missing requirements, and completion is accepted only once the todos are
936 // actually done (there is no override path anymore).
937 func TestGoalInterceptsCompleteWithIncompleteTodos(t *testing.T) {
938 todoWrite, ok := tool.LookupBuiltin("todo_write")
939 if !ok {
940 t.Fatal("todo_write builtin not registered")
941 }
942 completeStep, ok := tool.LookupBuiltin("complete_step")
943 if !ok {
944 t.Fatal("complete_step builtin not registered")
945 }
946 reg := goalRegistry()
947 reg.Add(todoWrite)
948 reg.Add(completeStep)
949 completeTurn := [][]provider.Chunk{
950 {toolCallChunk("ug1", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}},
951 textTurn("All done."),
952 }
953 fixedTurn := [][]provider.Chunk{
954 {toolCallChunk("cs1", "complete_step", `{"step":"Fix the parser","result":"fixed","evidence":[{"kind":"manual","summary":"verified by inspection"}]}`), {Type: provider.ChunkDone}},
955 {toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Fix the parser","status":"completed"}]}`), {Type: provider.ChunkDone}},
956 {toolCallChunk("ug2", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}},
957 textTurn("All done now."),
958 }
959 prov := &scriptedTurns{turns: flattenTurns(completeTurn, fixedTurn)}
960 ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard)
961 // Seed incomplete todos before starting.
962 ag.SeedTodoState([]evidence.TodoItem{
963 {Content: "Fix the parser", Status: "in_progress"},
964 })
965
966 notices := make(chan string, 64)
967 done := make(chan event.Event, 1)
968 c := New(Options{
969 Runner: ag,
970 Executor: ag,
971 Sink: event.FuncSink(func(e event.Event) {
972 switch e.Kind {
973 case event.Notice:
974 notices <- e.Text
975 case event.TurnDone:
976 done <- e
977 }
978 }),
979 })
980
981 c.Submit("/goal fix everything")
982 <-done // wait for the entire goal loop to finish
983 close(notices)
984
985 // Collect all notices.
986 var allNotices []string
987 for n := range notices {
988 allNotices = append(allNotices, n)
989 }
990
991 found := false
992 for _, n := range allNotices {
993 if strings.Contains(n, "Goal is not ready to complete yet") {
994 found = true
995 break
996 }
997 }
998 if !found {
999 t.Fatalf("expected a not-ready continuation notice, got %v", allNotices)
1000 }
1001 if prov.call != 6 {
1002 t.Fatalf("provider calls = %d, want intercepted turn + fixed turn (2 and 4 calls)", prov.call)
1003 }
1004 if c.GoalStatus() != GoalStatusComplete {
1005 t.Fatalf("GoalStatus() = %q, want complete after the todos were actually finished", c.GoalStatus())
1006 }
1007 }
1008
1009 func TestGoalAdvanceResultCannotCrossGoalLifecycle(t *testing.T) {
1010 newResult := func(t *testing.T, g *goalMachine) goalAdvanceResult {
1011 t.Helper()
1012 g.set("old goal", GoalResearchAuto, "", nil)
1013 res := g.advance(goalAdvanceInput{
1014 report: &goalTurnReport{status: GoalStatusComplete, reason: ""},
1015 todos: []evidence.TodoItem{{
1016 Content: "unfinished work from old goal",
1017 Status: "in_progress",
1018 }},
1019 })
1020 if res.intercept == "" {
1021 t.Fatal("test setup: expected an incomplete-todo intercept")
1022 }
1023 return res
1024 }
1025
1026 t.Run("current result is accepted", func(t *testing.T) {
1027 var g goalMachine
1028 res := newResult(t, &g)
1029 if got, ok := g.acceptContinuation(res); !ok || got != res.intercept {
1030 t.Fatalf("acceptContinuation() = (%q, %v), want current intercept", got, ok)
1031 }
1032 })
1033
1034 t.Run("replacement goal invalidates result", func(t *testing.T) {
1035 var g goalMachine
1036 res := newResult(t, &g)
1037 g.set("replacement goal", GoalResearchAuto, "", nil)
1038 if got, ok := g.acceptContinuation(res); ok {
1039 t.Fatalf("replacement goal accepted stale intercept %q", got)
1040 }
1041 })
1042
1043 t.Run("stop and resume invalidates result", func(t *testing.T) {
1044 var g goalMachine
1045 res := newResult(t, &g)
1046 g.stop(GoalStatusStopped, nil)
1047 if _, _, _, resumed, _ := g.resume(nil); !resumed {
1048 t.Fatal("test setup: goal did not resume")
1049 }
1050 if got, ok := g.acceptContinuation(res); ok {
1051 t.Fatalf("resumed goal accepted stale intercept %q", got)
1052 }
1053 })
1054
1055 t.Run("newer advance invalidates result", func(t *testing.T) {
1056 var g goalMachine
1057 res := newResult(t, &g)
1058 g.advance(goalAdvanceInput{report: &goalTurnReport{status: GoalStatusRunning, reason: "keep going"}})
1059 if got, ok := g.acceptContinuation(res); ok {
1060 t.Fatalf("newer FSM step accepted stale intercept %q", got)
1061 }
1062 })
1063 }
1064
1065 // TestGoalCompletionRequiresAllTodosDone verifies that a goal with seeded
1066 // incomplete canonical todos cannot complete until the model marks them done;
1067 // the completing turn force-completes any stragglers via a synthetic todo_write
1068 // so the frontend panel reflects the final state.
1069 func TestGoalCompletionRequiresAllTodosDone(t *testing.T) {
1070 todoWrite, ok := tool.LookupBuiltin("todo_write")
1071 if !ok {
1072 t.Fatal("todo_write builtin not registered")
1073 }
1074 completeStep, ok := tool.LookupBuiltin("complete_step")
1075 if !ok {
1076 t.Fatal("complete_step builtin not registered")
1077 }
1078 reg := goalRegistry()
1079 reg.Add(todoWrite)
1080 reg.Add(completeStep)
1081 prov := &scriptedTurns{turns: flattenTurns(
1082 [][]provider.Chunk{
1083 {toolCallChunk("cs1", "complete_step", `{"step":"Step 1","result":"done","evidence":[{"kind":"manual","summary":"verified"}]}`), {Type: provider.ChunkDone}},
1084 {toolCallChunk("cs2", "complete_step", `{"step":"Step 2","result":"done","evidence":[{"kind":"manual","summary":"verified"}]}`), {Type: provider.ChunkDone}},
1085 {toolCallChunk("t1", "todo_write", `{"todos":[{"content":"Step 1","status":"completed"},{"content":"Step 2","status":"completed"}]}`), {Type: provider.ChunkDone}},
1086 {toolCallChunk("ug1", "update_goal", `{"status":"complete","reason":""}`), {Type: provider.ChunkDone}},
1087 textTurn("All done."),
1088 },
1089 )}
1090 ag := agent.New(prov, reg, agent.NewSession(""), agent.Options{}, event.Discard)
1091 ag.SeedTodoState([]evidence.TodoItem{
1092 {Content: "Step 1", Status: "in_progress"},
1093 {Content: "Step 2", Status: "pending"},
1094 })
1095
1096 var tools []event.Event
1097 done := make(chan event.Event, 1)
1098 c := New(Options{
1099 Runner: ag,
1100 Executor: ag,
1101 Sink: event.FuncSink(func(e event.Event) {
1102 switch e.Kind {
1103 case event.ToolDispatch, event.ToolResult:
1104 tools = append(tools, e)
1105 case event.TurnDone:
1106 done <- e
1107 }
1108 }),
1109 })
1110
1111 c.Submit("/goal do everything")
1112 <-done // wait for the goal loop to finish
1113
1114 if c.GoalStatus() != GoalStatusComplete {
1115 t.Fatalf("GoalStatus() = %q, want complete", c.GoalStatus())
1116 }
1117
1118 // All todos in the executor must be completed.
1119 for _, td := range c.executor.CanonicalTodoState() {
1120 if td.Status != "completed" {
1121 t.Fatalf("canonical todo %q = %s, want completed", td.Content, td.Status)
1122 }
1123 }
1124 }
1125
1126 // TestCompleteRemainingGoalTodosEdgeCases verifies that the helper is a no-op
1127 // when there are no incomplete todos or no todos at all.
1128 func TestCompleteRemainingGoalTodosEdgeCases(t *testing.T) {
1129 t.Run("empty todo list does nothing", func(t *testing.T) {
1130 ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1131 c := New(Options{Executor: ag, Sink: event.Discard})
1132 c.completeRemainingGoalTodos()
1133 if len(ag.CanonicalTodoState()) != 0 {
1134 t.Fatal("expected no changes to empty todo list")
1135 }
1136 })
1137
1138 t.Run("all completed does nothing", func(t *testing.T) {
1139 ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1140 ag.SeedTodoState([]evidence.TodoItem{
1141 {Content: "A", Status: "completed"},
1142 {Content: "B", Status: "completed"},
1143 })
1144 var events []event.Event
1145 c := New(Options{
1146 Executor: ag,
1147 Sink: event.FuncSink(func(e event.Event) {
1148 events = append(events, e)
1149 }),
1150 })
1151 c.completeRemainingGoalTodos()
1152 if len(events) > 0 {
1153 t.Fatalf("expected no events when all todos already completed, got %d", len(events))
1154 }
1155 })
1156
1157 t.Run("force-completes mixed todos", func(t *testing.T) {
1158 ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1159 ag.SeedTodoState([]evidence.TodoItem{
1160 {Content: "A", Status: "completed"},
1161 {Content: "B", Status: "in_progress"},
1162 {Content: "C", Status: "pending"},
1163 })
1164 var captured []event.Event
1165 c := New(Options{
1166 Executor: ag,
1167 Sink: event.FuncSink(func(e event.Event) {
1168 if e.Kind == event.ToolDispatch || e.Kind == event.ToolResult {
1169 captured = append(captured, e)
1170 }
1171 }),
1172 })
1173 c.completeRemainingGoalTodos()
1174 // All must be completed.
1175 for _, td := range ag.CanonicalTodoState() {
1176 if td.Status != "completed" {
1177 t.Fatalf("todo %q = %s, want completed", td.Content, td.Status)
1178 }
1179 }
1180 // Must include a ToolDispatch+ToolResult for the synthetic todo_write.
1181 if len(captured) != 2 {
1182 t.Fatalf("expected 2 synthetic events (dispatch+result), got %d", len(captured))
1183 }
1184 if captured[0].Kind != event.ToolDispatch || captured[0].Tool.Name != "todo_write" {
1185 t.Fatalf("first event should be ToolDispatch for todo_write, got %+v", captured[0].Kind)
1186 }
1187 if captured[1].Kind != event.ToolResult || captured[1].Tool.Name != "todo_write" {
1188 t.Fatalf("second event should be ToolResult for todo_write, got %+v", captured[1].Kind)
1189 }
1190 })
1191
1192 t.Run("empty-string status treated as incomplete", func(t *testing.T) {
1193 ag := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1194 ag.SeedTodoState([]evidence.TodoItem{
1195 {Content: "A", Status: ""},
1196 {Content: "B", Status: "completed"},
1197 })
1198 c := New(Options{Executor: ag, Sink: event.Discard})
1199 c.completeRemainingGoalTodos()
1200 for _, td := range ag.CanonicalTodoState() {
1201 if td.Status != "completed" {
1202 t.Fatalf("empty-string todo %q should be force-completed, got %q", td.Content, td.Status)
1203 }
1204 }
1205 })
1206 }
1207
1208 // TestRepeatedCompleteWithIncompleteTodosPausesOnBudget verifies that a goal
1209 // whose model keeps reporting complete while todos stay unfinished is
1210 // intercepted every turn (no override path) and finally pauses on the turn
1211 // budget instead of completing.
1212 func TestRepeatedCompleteWithIncompleteTodosPausesOnBudget(t *testing.T) {
1213 // The write-class budget is 20 turns; give the scripted provider a full
1214 // pair per turn so the model keeps reporting complete each time.
1215 turns := flattenTurns()
1216 for i := 0; i < 21; i++ {
1217 turns = append(turns, goalToolTurn(GoalStatusComplete, "", "")...)
1218 }
1219 prov := &scriptedTurns{turns: turns}
1220 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
1221 ag.SeedTodoState([]evidence.TodoItem{
1222 {Content: "Fix the parser", Status: "in_progress"},
1223 })
1224
1225 done := make(chan event.Event, 1)
1226 c := New(Options{
1227 Runner: ag,
1228 Executor: ag,
1229 Sink: event.FuncSink(func(e event.Event) {
1230 if e.Kind == event.TurnDone {
1231 done <- e
1232 }
1233 }),
1234 })
1235
1236 c.Submit("/goal fix everything")
1237 <-done // wait for the whole loop (intercept until the turn budget pauses)
1238
1239 if c.GoalStatus() == GoalStatusComplete {
1240 t.Fatal("goal must not complete with incomplete todos")
1241 }
1242 if got := c.GoalStatus(); got != GoalStatusBlocked {
1243 t.Fatalf("GoalStatus() = %q, want blocked (budget pause)", got)
1244 }
1245 rt := c.GoalRuntime()
1246 if rt.StopCause == "" {
1247 t.Fatal("expected a stop cause for the budget pause")
1248 }
1249 if rt.TurnsUsed != rt.TurnsLimit {
1250 t.Fatalf("turn budget was not enforced: %d/%d", rt.TurnsUsed, rt.TurnsLimit)
1251 }
1252 }
1253
1254 func readJSONFileForTest(t *testing.T, path string, out any) {
1255 t.Helper()
1256 data, err := os.ReadFile(path)
1257 if err != nil {
1258 t.Fatalf("ReadFile(%s): %v", path, err)
1259 }
1260 if err := json.Unmarshal(data, out); err != nil {
1261 t.Fatalf("Unmarshal(%s): %v", path, err)
1262 }
1263 }
1264
1265 func sessionContainsUserText(messages []provider.Message, needles ...string) bool {
1266 for _, msg := range messages {
1267 if msg.Role != provider.RoleUser {
1268 continue
1269 }
1270 ok := true
1271 for _, needle := range needles {
1272 if !strings.Contains(msg.Content, needle) {
1273 ok = false
1274 break
1275 }
1276 }
1277 if ok {
1278 return true
1279 }
1280 }
1281 return false
1282 }
1283
1284 func containsNotice(notices []string, needle string) bool {
1285 for _, notice := range notices {
1286 if strings.Contains(notice, needle) {
1287 return true
1288 }
1289 }
1290 return false
1291 }
1292
1293 // TestSessionRotationClearsActiveGoal pins the /new & /clear goal semantics:
1294 // a fresh session starts with no active goal (so the old goal's text stops
1295 // injecting into its first turns), while the OLD session's persisted
1296 // goal-state sidecar keeps the running goal so resuming it restores the goal.
1297 func TestSessionRotationClearsActiveGoal(t *testing.T) {
1298 dir := t.TempDir()
1299 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
1300 oldPath := filepath.Join(dir, "session.jsonl")
1301 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test"})
1302
1303 c.SetGoal("ship the release checklist")
1304 if got := c.Goal(); got != "ship the release checklist" {
1305 t.Fatalf("Goal() = %q after SetGoal", got)
1306 }
1307 if composed := c.Compose("hello"); !strings.Contains(composed, "<active-goal>") {
1308 t.Fatalf("running goal should inject into turns, composed = %q", composed)
1309 }
1310
1311 if err := c.NewSession(); err != nil {
1312 t.Fatalf("NewSession: %v", err)
1313 }
1314 if got := c.Goal(); got != "" {
1315 t.Fatalf("Goal() after /new = %q, want empty", got)
1316 }
1317 if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") {
1318 t.Fatalf("old goal leaked into the fresh session's turn: %q", composed)
1319 }
1320 // The old session keeps its running goal on disk for /resume.
1321 oldState, err := os.ReadFile(store.SessionGoalState(oldPath))
1322 if err != nil {
1323 t.Fatalf("read old goal state: %v", err)
1324 }
1325 if !strings.Contains(string(oldState), "ship the release checklist") || !strings.Contains(string(oldState), GoalStatusRunning) {
1326 t.Fatalf("old session's goal state was disturbed by /new: %s", oldState)
1327 }
1328 // The new session's sidecar records the cleared (stopped) state, so
1329 // profile restores read it as "no running goal".
1330 newState, err := os.ReadFile(store.SessionGoalState(c.SessionPath()))
1331 if err != nil {
1332 t.Fatalf("read new goal state: %v", err)
1333 }
1334 if strings.Contains(string(newState), "ship the release checklist") {
1335 t.Fatalf("new session's goal state carries the old goal: %s", newState)
1336 }
1337
1338 // Same contract for /clear.
1339 c.SetGoal("another goal")
1340 if err := c.ClearSession(); err != nil {
1341 t.Fatalf("ClearSession: %v", err)
1342 }
1343 if got := c.Goal(); got != "" {
1344 t.Fatalf("Goal() after /clear = %q, want empty", got)
1345 }
1346 if composed := c.Compose("hello"); strings.Contains(composed, "<active-goal>") {
1347 t.Fatalf("old goal leaked into the cleared session's turn: %q", composed)
1348 }
1349 }
1350
1351 func TestGoalSidecarRoundTripPreservesBlockedDeliveryCheckpoint(t *testing.T) {
1352 dir := t.TempDir()
1353 path := filepath.Join(dir, "session.jsonl")
1354 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
1355 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1356 c.SetGoal("finish the delivery")
1357 scopeID, _, ok := c.goals.deliveryScope()
1358 if !ok || scopeID == "" {
1359 t.Fatal("Goal did not allocate a delivery scope")
1360 }
1361 cp := evidence.DeliveryCheckpoint{
1362 ScopeID: scopeID,
1363 CriteriaEstablished: true,
1364 WorkObserved: true,
1365 MutationObserved: true,
1366 PendingMutation: true,
1367 }
1368 statePath, data, persist := c.goals.setDeliveryCheckpoint(cp, nil)
1369 c.persistGoalState(statePath, data, persist)
1370 c.stopGoal(GoalStatusBlocked)
1371
1372 freshExec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
1373 fresh := New(Options{Executor: freshExec, SessionDir: dir, Label: "fresh"})
1374 fresh.Resume(agent.NewSession("sys"), path)
1375 if fresh.Goal() != "finish the delivery" || fresh.GoalStatus() != GoalStatusBlocked {
1376 t.Fatalf("restored Goal = (%q, %q), want blocked Goal", fresh.Goal(), fresh.GoalStatus())
1377 }
1378 if got := freshExec.DeliveryCheckpoint(); got != cp {
1379 t.Fatalf("restored checkpoint = %+v, want %+v", got, cp)
1380 }
1381 if !fresh.ResumeGoal() {
1382 t.Fatal("ResumeGoal rejected a restored blocked Goal")
1383 }
1384 id, _, ok := fresh.goals.deliveryScope()
1385 if !ok || id != scopeID {
1386 t.Fatalf("resumed scope = %q, want %q", id, scopeID)
1387 }
1388 }
1389
1390 func TestLegacyRunningGoalSidecarAllocatesScope(t *testing.T) {
1391 dir := t.TempDir()
1392 path := filepath.Join(dir, "legacy.jsonl")
1393 data := []byte(`{"goal":"legacy goal","status":"running"}`)
1394 if err := os.WriteFile(store.SessionGoalState(path), data, 0o600); err != nil {
1395 t.Fatal(err)
1396 }
1397 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
1398 c := New(Options{Executor: exec, SessionDir: dir, Label: "test"})
1399 c.Resume(agent.NewSession("sys"), path)
1400 id, task, ok := c.goals.deliveryScope()
1401 if !ok || id == "" || task != "legacy goal" {
1402 t.Fatalf("legacy delivery scope = (%q, %q, %v)", id, task, ok)
1403 }
1404 if got := exec.DeliveryCheckpoint(); got.ScopeID != id {
1405 t.Fatalf("legacy checkpoint scope = %q, want %q", got.ScopeID, id)
1406 }
1407 }
1408
1408 lines GO