返回 DeepSeek-Reasonix
plan_seed_test.go
根目录 / internal / control / plan_seed_test.go
1 package control
2
3 import (
4 "encoding/json"
5 "testing"
6
7 "reasonix/internal/agent"
8 "reasonix/internal/event"
9 "reasonix/internal/evidence"
10 )
11
12 func TestParsePlanTodos(t *testing.T) {
13 tests := []struct {
14 name string
15 plan string
16 want []seedTodo
17 }{
18 {
19 name: "bulleted list",
20 plan: "Here's the plan:\n- Add the parser\n- Wire it up\n- Add tests",
21 want: []seedTodo{
22 {Content: "Add the parser", Status: "in_progress"},
23 {Content: "Wire it up", Status: "pending"},
24 {Content: "Add tests", Status: "pending"},
25 },
26 },
27 {
28 name: "numbered list with both . and ) delimiters",
29 plan: "1. First step\n2) Second step",
30 want: []seedTodo{
31 {Content: "First step", Status: "in_progress"},
32 {Content: "Second step", Status: "pending"},
33 },
34 },
35 {
36 name: "strips inline markdown and checkbox syntax",
37 plan: "- [ ] **Add** the `parser`\n* Plain item",
38 want: []seedTodo{
39 {Content: "Add the parser", Status: "in_progress"},
40 {Content: "Plain item", Status: "pending"},
41 },
42 },
43 {
44 name: "prose without list items yields nothing (the model's todo_write covers it)",
45 plan: "总结:这是一个简单的三步骤测试——创建文件 → 编辑文件 → 删除文件。",
46 want: nil,
47 },
48 {
49 name: "numbered list embedded in a longer plan",
50 plan: "My understanding:\n1. Create the file\n2. Write content\n3. Delete it\n\nReady when you are.",
51 want: []seedTodo{
52 {Content: "Create the file", Status: "in_progress"},
53 {Content: "Write content", Status: "pending"},
54 {Content: "Delete it", Status: "pending"},
55 },
56 },
57 {
58 name: "a digit run that isn't a list item is ignored",
59 plan: "Version 2 is the target.\n- Real item",
60 want: []seedTodo{{Content: "Real item", Status: "in_progress"}},
61 },
62 {
63 name: "two-level plan: phases at level 0, indented sub-steps at level 1",
64 plan: "1. Add the loader\n - parse the TOML\n - validate fields\n2. Wire it up\n - call from boot",
65 want: []seedTodo{
66 {Content: "Add the loader", Status: "pending", Level: 0},
67 {Content: "parse the TOML", Status: "in_progress", Level: 1},
68 {Content: "validate fields", Status: "pending", Level: 1},
69 {Content: "Wire it up", Status: "pending", Level: 0},
70 {Content: "call from boot", Status: "pending", Level: 1},
71 },
72 },
73 {
74 name: "tab-indented sub-step is level 1",
75 plan: "- Phase one\n\t- nested by tab",
76 want: []seedTodo{
77 {Content: "Phase one", Status: "pending", Level: 0},
78 {Content: "nested by tab", Status: "in_progress", Level: 1},
79 },
80 },
81 {
82 // The real model wrote phases as numbered ### headings with indented
83 // bullet sub-steps, and a leading "## Plan" title — the shape a live
84 // run surfaced that flat list-only parsing collapsed to all level 1.
85 name: "numbered headings are phases; title is ignored; bullets are sub-steps",
86 plan: "## Plan: add a flag\n\n### 1. Define the field\n - add Verbose bool\n - document it\n\n### 2. Wire it up\n - read from config",
87 want: []seedTodo{
88 {Content: "Define the field", Status: "pending", Level: 0},
89 {Content: "add Verbose bool", Status: "in_progress", Level: 1},
90 {Content: "document it", Status: "pending", Level: 1},
91 {Content: "Wire it up", Status: "pending", Level: 0},
92 {Content: "read from config", Status: "pending", Level: 1},
93 },
94 },
95 {
96 name: "leading indented item is promoted instead of seeding an orphan sub-step",
97 plan: " - recover configuration\n- restart normally",
98 want: []seedTodo{
99 {Content: "recover configuration", Status: "in_progress", Level: 0},
100 {Content: "restart normally", Status: "pending", Level: 0},
101 },
102 },
103 }
104 for _, tc := range tests {
105 t.Run(tc.name, func(t *testing.T) {
106 got := parsePlanTodos(tc.plan)
107 if len(got) != len(tc.want) {
108 t.Fatalf("got %d todos, want %d: %+v", len(got), len(tc.want), got)
109 }
110 for i := range got {
111 if got[i] != tc.want[i] {
112 t.Errorf("todo %d = %+v, want %+v", i, got[i], tc.want[i])
113 }
114 }
115 })
116 }
117 }
118
119 func TestPlanTodosJSONAlwaysSatisfiesSerialContract(t *testing.T) {
120 plans := []string{
121 "1. Flat first\n2. Flat second",
122 "1. Phase one\n - child one\n - child two\n2. Phase two\n - child three",
123 " - accidentally indented first\n - nested child\n2. Next phase",
124 }
125 for _, plan := range plans {
126 args := PlanTodosJSON(plan)
127 if args == "" {
128 t.Fatalf("PlanTodosJSON(%q) returned no seed", plan)
129 }
130 var payload struct {
131 Todos []evidence.TodoItem `json:"todos"`
132 }
133 if err := json.Unmarshal([]byte(args), &payload); err != nil {
134 t.Fatalf("PlanTodosJSON(%q): %v", plan, err)
135 }
136 if err := evidence.ValidateSerialTodos(payload.Todos); err != nil {
137 t.Fatalf("PlanTodosJSON(%q) emitted invalid state: %v\n%s", plan, err, args)
138 }
139 }
140 }
141
142 func TestParsePlanTodosCapsAtTwenty(t *testing.T) {
143 plan := ""
144 for i := 0; i < 30; i++ {
145 plan += "- item\n"
146 }
147 if got := parsePlanTodos(plan); len(got) != 20 {
148 t.Fatalf("got %d todos, want cap of 20", len(got))
149 }
150 }
151
152 func TestSeedPlanTodosSeedsAgentState(t *testing.T) {
153 var events []event.Event
154 sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
155 executor := &agent.Agent{}
156 c := &Controller{
157 sink: sink,
158 executor: executor,
159 }
160 plan := "1. Add the parser\n2. Wire it up\n3. Add tests"
161 args := c.seedPlanTodos(plan)
162 if args == "" {
163 t.Fatal("seedPlanTodos returned empty args for a valid plan")
164 }
165 var todoDispatches, todoResults int
166 for _, e := range events {
167 if e.Kind == event.ToolDispatch && e.Tool.Name == "todo_write" {
168 todoDispatches++
169 }
170 if e.Kind == event.ToolResult && e.Tool.Name == "todo_write" {
171 todoResults++
172 }
173 }
174 if todoDispatches != 1 || todoResults != 1 {
175 t.Fatalf("plan-seed events: %d dispatches, %d results; want 1,1", todoDispatches, todoResults)
176 }
177 if got := executor.CanonicalTodoState(); len(got) != 3 || got[0].Content != "Add the parser" || got[0].Status != "in_progress" {
178 t.Fatalf("seedPlanTodos did not seed canonical todo state: %+v", got)
179 }
180 }
181
182 func TestSeedPlanTodosEmptyPlanNoOp(t *testing.T) {
183 c := &Controller{
184 sink: event.Discard,
185 executor: &agent.Agent{},
186 }
187 args := c.seedPlanTodos("no list items here")
188 if args != "" {
189 t.Fatalf("empty plan returned args = %q, want empty", args)
190 }
191 }
192
193 func TestCompletePlanTodosMirrorsAgentState(t *testing.T) {
194 var events []event.Event
195 sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
196 executor := &agent.Agent{}
197 c := &Controller{
198 sink: sink,
199 executor: executor,
200 }
201
202 args := c.seedPlanTodos("1. Add the parser\n2. Wire it up")
203 c.completePlanTodos(args)
204
205 got := executor.CanonicalTodoState()
206 if len(got) != 2 {
207 t.Fatalf("canonical todo count = %d, want 2: %+v", len(got), got)
208 }
209 for i, todo := range got {
210 if todo.Status != "completed" {
211 t.Fatalf("canonical todo %d status = %q, want completed: %+v", i, todo.Status, got)
212 }
213 }
214
215 var completedResults int
216 for _, e := range events {
217 if e.Kind == event.ToolResult && e.Tool.Name == "todo_write" && e.Tool.Output == "approved plan finished" {
218 completedResults++
219 }
220 }
221 if completedResults != 1 {
222 t.Fatalf("completed plan UI events = %d, want 1", completedResults)
223 }
224 }
225
225 lines GO