| 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 |