返回 DeepSeek-Reasonix
todo_progress_guard_test.go
根目录 / internal / agent / todo_progress_guard_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "testing"
8
9 "reasonix/internal/agent/testutil"
10 "reasonix/internal/event"
11 "reasonix/internal/evidence"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14
15 _ "reasonix/internal/tool/builtin"
16 )
17
18 func TestTodoProgressGuardPausesSemanticToolDrift(t *testing.T) {
19 turns := []testutil.Turn{{ToolCalls: []provider.ToolCall{{
20 ID: "todo", Name: "todo_write",
21 Arguments: `{"todos":[{"content":"finish the task","status":"in_progress"}]}`,
22 }}}}
23 // The first unique read renews the lease; exact repeats after it do not.
24 for i := 0; i < maxTodoStallRounds+1; i++ {
25 turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
26 ID: fmt.Sprintf("read-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
27 }}})
28 }
29
30 reg := tool.NewRegistry()
31 reg.Add(fakeTool{name: "inspect", readOnly: true})
32 reg.Add(mustBuiltinTool(t, "todo_write"))
33 mp := testutil.NewMock("m", turns...)
34 a := New(mp, reg, NewSession(""), Options{}, event.Discard)
35
36 err := a.Run(context.Background(), "work until the todo is complete")
37 var pause *todoStallPause
38 if !errors.As(err, &pause) {
39 t.Fatalf("Run error = %v, want todoStallPause", err)
40 }
41 if mp.CallCount() != maxTodoStallRounds+2 {
42 t.Fatalf("provider calls = %d, want %d", mp.CallCount(), maxTodoStallRounds+2)
43 }
44 if !sessionContains(a, "Host progress check") {
45 t.Fatal("semantic drift did not receive the adaptive progress nudge")
46 }
47 }
48
49 func TestTodoProgressGuardRenewsOnUniqueHostWork(t *testing.T) {
50 turns := []testutil.Turn{{ToolCalls: []provider.ToolCall{{
51 ID: "todo", Name: "todo_write",
52 Arguments: `{"todos":[{"content":"finish the task","status":"in_progress"}]}`,
53 }}}}
54 for i := 0; i < todoProgressNudgeRounds-1; i++ {
55 turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
56 ID: fmt.Sprintf("read-a-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
57 }}})
58 }
59 turns = append(turns,
60 testutil.Turn{ToolCalls: []provider.ToolCall{{ID: "write", Name: "write_file", Arguments: `{"path":"result.txt","content":"done"}`}}},
61 )
62 for i := 0; i < todoProgressNudgeRounds-1; i++ {
63 turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
64 ID: fmt.Sprintf("read-b-%d", i), Name: "inspect", Arguments: `{"path":"same"}`,
65 }}})
66 }
67 turns = append(turns,
68 testutil.Turn{ToolCalls: []provider.ToolCall{{
69 ID: "done", Name: "complete_step",
70 Arguments: `{"step":"finish the task","result":"done","evidence":[{"kind":"files","summary":"created result","paths":["result.txt"]}]}`,
71 }}},
72 testutil.Turn{Text: "done"},
73 )
74
75 reg := tool.NewRegistry()
76 reg.Add(fakeTool{name: "inspect", readOnly: true})
77 reg.Add(fakeTool{name: "write_file", readOnly: false})
78 reg.Add(mustBuiltinTool(t, "todo_write"))
79 reg.Add(mustBuiltinTool(t, "complete_step"))
80 a := New(testutil.NewMock("m", turns...), reg, NewSession(""), Options{}, event.Discard)
81 if err := a.Run(context.Background(), "finish the todo"); err != nil {
82 t.Fatalf("Run: %v", err)
83 }
84 if sessionContains(a, "Host progress check") {
85 t.Fatal("unique host work should renew the progress lease before the nudge threshold")
86 }
87 }
88
89 func TestCanonicalTodoProgressIgnoresTitleAndPendingListChurn(t *testing.T) {
90 a := &Agent{todoState: []evidence.TodoItem{
91 {Content: "finish the task", Status: "in_progress"},
92 {Content: "write tests", Status: "pending"},
93 }}
94 before, tracking := a.canonicalTodoProgress()
95 if !tracking {
96 t.Fatal("incomplete todo list should be tracked")
97 }
98 a.setTodoState([]evidence.TodoItem{
99 {Content: "finish the task carefully", Status: "in_progress"},
100 {Content: "write tests", Status: "pending"},
101 {Content: "update docs", Status: "pending"},
102 })
103 after, tracking := a.canonicalTodoProgress()
104 if !tracking || after != before {
105 t.Fatalf("title/pending churn changed progress from %d to %d", before, after)
106 }
107 }
108
109 func TestMaxStepsGraceSummaryBypassesIncompleteTodoReadiness(t *testing.T) {
110 reg := tool.NewRegistry()
111 reg.Add(mustBuiltinTool(t, "todo_write"))
112 reg.Add(fakeTool{name: "write_file", readOnly: false})
113 mp := testutil.NewMock("m",
114 testutil.Turn{ToolCalls: []provider.ToolCall{
115 {ID: "todo", Name: "todo_write", Arguments: `{"todos":[{"content":"unfinished","status":"in_progress"}]}`},
116 {ID: "write", Name: "write_file", Arguments: `{"path":"unfinished.txt"}`},
117 }},
118 testutil.Turn{Text: "Progress saved; the todo remains unfinished."},
119 )
120 a := New(mp, reg, NewSession(""), Options{MaxSteps: 1, DeliveryProfile: true}, event.Discard)
121
122 err := a.Run(context.Background(), "start a long task")
123 var pause *maxStepsPause
124 if !errors.As(err, &pause) {
125 t.Fatalf("Run error = %v, want maxStepsPause instead of final-readiness retries", err)
126 }
127 if mp.CallCount() != 2 {
128 t.Fatalf("provider calls = %d, want tool round plus one summary round", mp.CallCount())
129 }
130 }
131
132 func mustBuiltinTool(t *testing.T, name string) tool.Tool {
133 t.Helper()
134 builtin, ok := tool.LookupBuiltin(name)
135 if !ok {
136 t.Fatalf("builtin %q is not registered", name)
137 }
138 return builtin
139 }
140
140 lines GO