返回 DeepSeek-Reasonix
canonical_todo_test.go
根目录 / internal / agent / canonical_todo_test.go
1 package agent
2
3 import (
4 "strings"
5 "testing"
6
7 "reasonix/internal/event"
8 "reasonix/internal/evidence"
9 "reasonix/internal/provider"
10 )
11
12 func TestFinalReadinessFallsBackToCanonicalTodos(t *testing.T) {
13 ran := evidence.Receipt{ToolName: "bash", Success: true, Command: "git push"}
14 open := []evidence.TodoItem{{Content: "push", Status: "completed"}, {Content: "rebase", Status: "pending"}}
15
16 // Turn did work (a successful bash) but issued no todo_write this turn, so the
17 // per-turn ledger has no list — the canonical state must still gate.
18 a := &Agent{evidence: readinessLedger(ran), todoState: open}
19 if got := a.ReadinessResult(); !strings.Contains(got.Reason, "incomplete") {
20 t.Fatalf("cross-turn gate = %q, want it to report incomplete canonical todos", got.Reason)
21 }
22
23 // A turn that touched nothing (pure Q&A) must never gate on stale canonical state.
24 idle := &Agent{evidence: evidence.NewLedger(), todoState: open}
25 if got := idle.ReadinessResult(); !got.Ready {
26 t.Fatalf("no-work turn gated on canonical todos: %+v", got)
27 }
28
29 // All canonical items completed → no gate even after work.
30 done := &Agent{evidence: readinessLedger(ran), todoState: []evidence.TodoItem{{Content: "push", Status: "completed"}}}
31 if got := done.ReadinessResult(); !got.Ready {
32 t.Fatalf("completed canonical todos still gated: %+v", got)
33 }
34 }
35
36 func TestAdvanceCanonicalTodoCompletesAndPromotes(t *testing.T) {
37 a := &Agent{
38 sink: event.Discard,
39 todoState: []evidence.TodoItem{
40 {Content: "sync branch", Status: "in_progress"},
41 {Content: "push to origin", Status: "pending"},
42 {Content: "rebase", Status: "pending"},
43 },
44 }
45 a.advanceCanonicalTodo("sync branch")
46
47 if a.todoState[0].Status != "completed" {
48 t.Fatalf("signed-off item not completed: %+v", a.todoState[0])
49 }
50 if a.todoState[1].Status != "in_progress" {
51 t.Fatalf("next pending item not promoted: %+v", a.todoState[1])
52 }
53 if a.todoState[2].Status != "pending" {
54 t.Fatalf("a later item was promoted out of order: %+v", a.todoState[2])
55 }
56 }
57
58 func TestAdvanceCanonicalTodoRejectsPendingMatchByNumber(t *testing.T) {
59 a := &Agent{sink: event.Discard, todoState: []evidence.TodoItem{
60 {Content: "first", Status: "in_progress"},
61 {Content: "second", Status: "pending"},
62 }}
63 a.advanceCanonicalTodo("2")
64 if a.todoState[1].Status != "pending" || a.todoState[0].Status != "in_progress" {
65 t.Fatalf("pending numeric step advanced out of order: %+v", a.todoState)
66 }
67 }
68
69 func TestRebuildTodoStateIgnoresHistoricalPendingSignoff(t *testing.T) {
70 msgs := []provider.Message{
71 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
72 ID: "t1", Name: "todo_write",
73 Arguments: `{"todos":[{"content":"a","status":"in_progress"},{"content":"b","status":"pending"}]}`,
74 }}},
75 {Role: provider.RoleTool, ToolCallID: "t1", Name: "todo_write", Content: "Todos updated"},
76 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
77 ID: "c1", Name: "complete_step", Arguments: `{"step":"b"}`,
78 }}},
79 {Role: provider.RoleTool, ToolCallID: "c1", Name: "complete_step", Content: "signed off"},
80 }
81 a := &Agent{}
82 a.rebuildTodoState(msgs)
83 if a.todoState[0].Status != "in_progress" || a.todoState[1].Status != "pending" {
84 t.Fatalf("historical pending signoff restored invalid state: %+v", a.todoState)
85 }
86 }
87
88 func TestSetTodoStateNormalizesLegacyOutOfOrderSnapshot(t *testing.T) {
89 a := &Agent{}
90 a.setTodoState([]evidence.TodoItem{
91 {Content: "first", Status: "in_progress"},
92 {Content: "second", Status: "completed"},
93 })
94 if a.todoState[0].Status != "in_progress" || a.todoState[1].Status != "pending" {
95 t.Fatalf("legacy snapshot was not normalized: %+v", a.todoState)
96 }
97 }
98
99 func TestRebuildTodoStateReplaysCompleteSteps(t *testing.T) {
100 msgs := []provider.Message{
101 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
102 ID: "t1", Name: "todo_write",
103 Arguments: `{"todos":[{"content":"a","status":"in_progress"},{"content":"b","status":"pending"}]}`,
104 }}},
105 {Role: provider.RoleTool, ToolCallID: "t1", Name: "todo_write", Content: "Todos updated"},
106 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
107 ID: "c1", Name: "complete_step", Arguments: `{"step":"a"}`,
108 }}},
109 {Role: provider.RoleTool, ToolCallID: "c1", Name: "complete_step", Content: "signed off"},
110 }
111 a := &Agent{}
112 a.rebuildTodoState(msgs)
113
114 if len(a.todoState) != 2 {
115 t.Fatalf("rebuilt %d todos, want 2", len(a.todoState))
116 }
117 if a.todoState[0].Status != "completed" {
118 t.Fatalf("complete_step not replayed onto canonical state: %+v", a.todoState[0])
119 }
120 if a.todoState[1].Status != "in_progress" {
121 t.Fatalf("next item not promoted on replay: %+v", a.todoState[1])
122 }
123 }
124
125 func TestRebuildTodoStateSkipsFailedCompleteStep(t *testing.T) {
126 msgs := []provider.Message{
127 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
128 ID: "t1", Name: "todo_write",
129 Arguments: `{"todos":[{"content":"a","status":"in_progress"}]}`,
130 }}},
131 {Role: provider.RoleTool, ToolCallID: "t1", Name: "todo_write", Content: "Todos updated"},
132 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
133 ID: "c1", Name: "complete_step", Arguments: `{"step":"a"}`,
134 }}},
135 {Role: provider.RoleTool, ToolCallID: "c1", Name: "complete_step", Content: "error: no evidence"},
136 }
137 a := &Agent{}
138 a.rebuildTodoState(msgs)
139
140 if a.todoState[0].Status == "completed" {
141 t.Fatalf("a failed complete_step must not advance canonical state: %+v", a.todoState[0])
142 }
143 }
144
145 func TestRebuildTodoStateRequiresToolResults(t *testing.T) {
146 msgs := []provider.Message{
147 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
148 ID: "t1", Name: "todo_write",
149 Arguments: `{"todos":[{"content":"a","status":"in_progress"}]}`,
150 }}},
151 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
152 ID: "c1", Name: "complete_step", Arguments: `{"step":"a"}`,
153 }}},
154 }
155 a := &Agent{}
156 a.rebuildTodoState(msgs)
157 if len(a.todoState) != 0 {
158 t.Fatalf("todo_write without tool result rebuilt canonical state: %+v", a.todoState)
159 }
160
161 msgs = append(msgs[:1],
162 provider.Message{Role: provider.RoleTool, ToolCallID: "t1", Name: "todo_write", Content: "Todos updated"},
163 msgs[1],
164 )
165 a.rebuildTodoState(msgs)
166 if len(a.todoState) != 1 {
167 t.Fatalf("successful todo_write did not rebuild canonical state: %+v", a.todoState)
168 }
169 if got := a.todoState[0].Status; got != "in_progress" {
170 t.Fatalf("complete_step without tool result changed status to %q", got)
171 }
172 }
173
174 func TestRebuildTodoStateHonorsEmptyTodoWriteClear(t *testing.T) {
175 msgs := []provider.Message{
176 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
177 ID: "t1",
178 Name: "todo_write",
179 Arguments: `{"todos":[{"content":"a","status":"in_progress"}]}`,
180 }}},
181 {Role: provider.RoleTool, ToolCallID: "t1", Name: "todo_write", Content: "Todos updated"},
182 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
183 ID: "t2",
184 Name: "todo_write",
185 Arguments: `{"todos":[]}`,
186 }}},
187 {Role: provider.RoleTool, ToolCallID: "t2", Name: "todo_write", Content: "Todos updated"},
188 }
189 a := &Agent{}
190 a.rebuildTodoState(msgs)
191 if len(a.todoState) != 0 {
192 t.Fatalf("empty todo_write should clear rebuilt canonical state: %+v", a.todoState)
193 }
194 }
195
196 func TestSeedTodoState(t *testing.T) {
197 a := &Agent{sink: event.Discard}
198 todos := []evidence.TodoItem{
199 {Content: "step 1", Status: "in_progress"},
200 {Content: "step 2", Status: "pending"},
201 }
202 a.SeedTodoState(todos)
203 if len(a.todoState) != 2 {
204 t.Fatalf("SeedTodoState: got %d items, want 2", len(a.todoState))
205 }
206 if a.todoState[0].Status != "in_progress" {
207 t.Fatalf("SeedTodoState: first item status = %q, want in_progress", a.todoState[0].Status)
208 }
209 }
210
211 func TestSeedTodoStateReplacesExisting(t *testing.T) {
212 a := &Agent{sink: event.Discard, todoState: []evidence.TodoItem{
213 {Content: "existing", Status: "in_progress"},
214 }}
215 a.SeedTodoState([]evidence.TodoItem{
216 {Content: "new", Status: "in_progress"},
217 })
218 if len(a.todoState) != 1 || a.todoState[0].Content != "new" {
219 t.Fatalf("SeedTodoState did not replace existing state: %+v", a.todoState)
220 }
221 }
222
223 func TestSeedTodoStateAllowsAdvanceAfterSeed(t *testing.T) {
224 a := &Agent{sink: event.Discard}
225 a.SeedTodoState([]evidence.TodoItem{
226 {Content: "step 1", Status: "in_progress"},
227 {Content: "step 2", Status: "pending"},
228 })
229 a.advanceCanonicalTodo("step 1")
230 if a.todoState[0].Status != "completed" {
231 t.Fatalf("advance after seed: item 0 status = %q, want completed", a.todoState[0].Status)
232 }
233 if a.todoState[1].Status != "in_progress" {
234 t.Fatalf("advance after seed: item 1 status = %q, want in_progress", a.todoState[1].Status)
235 }
236 }
237
238 func TestAdvanceCanonicalTodoWalksPhaseChain(t *testing.T) {
239 a := &Agent{sink: event.Discard, todoState: []evidence.TodoItem{
240 {Content: "Port the parser", Status: "pending"},
241 {Content: "move files", Status: "in_progress", Level: 1},
242 {Content: "fix imports", Status: "pending", Level: 1},
243 {Content: "Ship it", Status: "pending"},
244 {Content: "run tests", Status: "pending", Level: 1},
245 }}
246
247 a.advanceCanonicalTodo("Port the parser")
248 if a.todoState[0].Status != "pending" {
249 t.Fatalf("pending phase advanced ahead of its sub-steps: %+v", a.todoState)
250 }
251
252 a.advanceCanonicalTodo("move files")
253 if a.todoState[1].Status != "completed" || a.todoState[2].Status != "in_progress" {
254 t.Fatalf("completing a sub-step should promote its sibling: %+v", a.todoState)
255 }
256
257 a.advanceCanonicalTodo("fix imports")
258 if a.todoState[2].Status != "completed" || a.todoState[0].Status != "in_progress" {
259 t.Fatalf("after the last sub-step the phase should become the signable item: %+v", a.todoState)
260 }
261
262 a.advanceCanonicalTodo("Port the parser")
263 if a.todoState[0].Status != "completed" || a.todoState[3].Status != "pending" || a.todoState[4].Status != "in_progress" {
264 t.Fatalf("phase sign-off should promote the next phase's first sub-step: %+v", a.todoState)
265 }
266 }
267
267 lines GO