1package main
2
3import (
4 "encoding/json"
5 "os"
6 "path/filepath"
7 "runtime"
8 "strings"
9 "sync"
10 "testing"
11)
12
13func stringPtr(value string) *string { return &value }
14func boolPtr(value bool) *bool { return &value }
15
16func transition(t *testing.T, state PlanState, params toolArgs) PlanState {
17 t.Helper()
18 next, _, err := applyAction(ensureState(state), params)
19 if err != nil {
20 t.Fatalf("apply %s: %v", params.Action, err)
21 }
22 return ensureState(next)
23}
24
25func sampleState() PlanState {
26 return ensureState(PlanState{
27 Version: 1,
28 Spec: "Ship compact tasked phases output without losing source-of-truth state.",
29 Phases: []Phase{
30 {
31 ID: "phase-1",
32 Title: "Finished discovery",
33 Goal: "Understand the old behavior",
34 Tasks: []PhaseTask{
35 {ID: "task-1", Text: "Completed discovery task", Checked: true},
36 {ID: "task-2", Text: "Completed contract task", Checked: true},
37 },
38 },
39 {
40 ID: "phase-2",
41 Title: "Implement compact output",
42 Goal: "Reduce repeated context",
43 Tasks: []PhaseTask{
44 {ID: "task-3", Text: "Completed implementation task", Checked: true},
45 {ID: "task-4", Text: "Remaining implementation task", Checked: false},
46 },
47 },
48 },
49 CurrentPhaseID: "phase-2",
50 NextPhaseNumber: 3,
51 NextTaskNumber: 5,
52 })
53}
54
55func TestTransitionsGenerateIDsAndSelectCurrentPhase(t *testing.T) {
56 state := transition(t, createEmptyState(), toolArgs{
57 Action: actionReplacePlan,
58 Phases: &[]PhaseInput{
59 {Title: "First", Tasks: &[]TaskInput{{Text: "one"}}},
60 {Title: "Second", Tasks: &[]TaskInput{{Text: "two"}}},
61 },
62 })
63 if got := state.Phases[0].ID; got != "phase-1" {
64 t.Fatalf("first generated phase id = %q", got)
65 }
66 if got := state.Phases[1].ID; got != "phase-2" {
67 t.Fatalf("second generated phase id = %q", got)
68 }
69 if got := state.Phases[0].Tasks[0].ID; got != "task-1" {
70 t.Fatalf("first generated task id = %q", got)
71 }
72 if state.CurrentPhaseID != "phase-1" || state.NextPhaseNumber != 3 || state.NextTaskNumber != 3 {
73 t.Fatalf("generated counters/current phase = %q, %d, %d", state.CurrentPhaseID, state.NextPhaseNumber, state.NextTaskNumber)
74 }
75
76 state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
77 if state.CurrentPhaseID != "phase-2" {
78 t.Fatalf("current phase did not advance to the first incomplete phase: %q", state.CurrentPhaseID)
79 }
80 state = transition(t, state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("Third")})
81 if got := state.Phases[2].ID; got != "phase-3" {
82 t.Fatalf("added phase id = %q", got)
83 }
84 state = transition(t, state, toolArgs{Action: actionAddTask, PhaseID: "phase-3", TaskText: stringPtr("three")})
85 if got := state.Phases[2].Tasks[0].ID; got != "task-3" {
86 t.Fatalf("added task id = %q", got)
87 }
88}
89
90func TestReplacePlanRejectsDuplicateIDs(t *testing.T) {
91 state := createEmptyState()
92 _, _, err := applyAction(state, toolArgs{
93 Action: actionReplacePlan,
94 Phases: &[]PhaseInput{
95 {ID: stringPtr("phase-1"), Title: "First"},
96 {ID: stringPtr("phase-1"), Title: "Duplicate"},
97 },
98 })
99 if err == nil || !strings.Contains(err.Error(), "duplicate phase id") {
100 t.Fatalf("duplicate phase id error = %v", err)
101 }
102
103 _, _, err = applyAction(state, toolArgs{
104 Action: actionReplacePlan,
105 Phases: &[]PhaseInput{
106 {Title: "First", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "one"}}},
107 {Title: "Second", Tasks: &[]TaskInput{{ID: stringPtr("task-1"), Text: "duplicate"}}},
108 },
109 })
110 if err == nil || !strings.Contains(err.Error(), "duplicate task id") {
111 t.Fatalf("duplicate task id error = %v", err)
112 }
113}
114
115func TestClosedPlanRulesAndReopen(t *testing.T) {
116 state := transition(t, createEmptyState(), toolArgs{
117 Action: actionReplacePlan,
118 Phases: &[]PhaseInput{{Title: "Done", Tasks: &[]TaskInput{{Text: "finished", Checked: boolPtr(true)}}}},
119 })
120 if !isPlanClosed(state) || state.CurrentPhaseID != "" {
121 t.Fatalf("complete plan was not closed: %+v", state)
122 }
123 if !strings.Contains(buildSummary(state), "Plan closed: Completed 1/1 tasks across 1 phase(s).") {
124 t.Fatalf("closed summary missing: %s", buildSummary(state))
125 }
126
127 if _, _, err := applyAction(state, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr("new")}); err == nil || !strings.Contains(err.Error(), "already complete and closed") {
128 t.Fatalf("add to closed plan error = %v", err)
129 }
130 if _, _, err := applyAction(state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{}}); err == nil {
131 t.Fatal("replace_plan unexpectedly bypassed closed-plan guard")
132 }
133
134 state = transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(false)})
135 if isPlanClosed(state) || state.CurrentPhaseID != "phase-1" || state.Phases[0].Tasks[0].Checked {
136 t.Fatalf("unchecked reopen produced %+v", state)
137 }
138
139 state = transition(t, state, toolArgs{Action: actionSetSpec, Spec: stringPtr("new work")})
140 if len(state.Phases) != 1 {
141 t.Fatalf("set_spec on reopened plan unexpectedly cleared phases")
142 }
143 closed := transition(t, state, toolArgs{Action: actionSetTaskChecked, TaskID: "task-1", Checked: boolPtr(true)})
144 if !isPlanClosed(closed) {
145 t.Fatal("plan did not close again")
146 }
147 state = transition(t, closed, toolArgs{Action: actionSetSpec, Spec: stringPtr("fresh work")})
148 if len(state.Phases) != 0 || state.Spec != "fresh work" || state.NextPhaseNumber != 1 || state.NextTaskNumber != 1 {
149 t.Fatalf("set_spec did not restart a closed plan: %+v", state)
150 }
151 state = transition(t, state, toolArgs{Action: actionReplacePlan, Phases: &[]PhaseInput{{Title: "Fresh"}}})
152 if len(state.Phases) != 1 || isPlanClosed(state) {
153 t.Fatalf("replace after set_spec failed: %+v", state)
154 }
155}
156
157func TestPhaseLookupAcceptsRawBracketedSummaryAndUniqueTitle(t *testing.T) {
158 state := sampleState()
159 for _, input := range []string{
160 "phase-2",
161 "[phase-2]",
162 "Implement compact output [phase-2]",
163 "Implement compact output [phase-2] (1 remaining) - Reduce repeated context",
164 "Implement compact output",
165 } {
166 if phase := findPhase(state, input); phase == nil || phase.ID != "phase-2" {
167 t.Fatalf("findPhase(%q) = %#v", input, phase)
168 }
169 }
170 if findPhase(state, "missing") != nil || findPhase(state, "") != nil {
171 t.Fatal("missing phase lookup unexpectedly matched")
172 }
173 duplicate := cloneState(state)
174 duplicate.Phases[0].Title = "Duplicate"
175 duplicate.Phases[1].Title = "Duplicate"
176 if findPhase(duplicate, "Duplicate") != nil {
177 t.Fatal("ambiguous title unexpectedly matched")
178 }
179 if phase := findPhase(duplicate, "phase-2"); phase == nil || phase.ID != "phase-2" {
180 t.Fatal("raw id did not win over duplicate title")
181 }
182}
183
184func TestSummariesAreFullOrCompactAsAppropriate(t *testing.T) {
185 state := sampleState()
186 compact := buildToolResultText(actionSetTaskChecked, state, "Checked task task-3", nil)
187 if want := "Checked task task-3 (p 1/2 | t 3/4)"; compact != want {
188 t.Fatalf("mutation result = %q, want %q", compact, want)
189 }
190 full := buildToolResultText(actionGetStatus, state, "Current phased plan status", nil)
191 if !strings.Contains(full, "Phases:") || !strings.Contains(full, "Completed discovery task [task-1]") {
192 t.Fatalf("full summary omitted checklist: %s", full)
193 }
194 if !strings.Contains(buildSummary(state), "[phase-2] (1/2) - Reduce repeated context") {
195 t.Fatal("phase goal/progress missing from full summary")
196 }
197}
198
199func TestTurnContextContainsOnlyCurrentFocus(t *testing.T) {
200 state := sampleState()
201 state.Phases[1].Tasks = append(state.Phases[1].Tasks,
202 PhaseTask{ID: "task-5", Text: "Next focused task"},
203 PhaseTask{ID: "task-6", Text: "Another focused task"},
204 PhaseTask{ID: "task-7", Text: "Deferred focused task"},
205 )
206 state.Phases = append(state.Phases, Phase{
207 ID: "phase-3",
208 Title: "Future phase",
209 Tasks: []PhaseTask{{ID: "task-8", Text: "Future task"}},
210 })
211 context := buildTurnContext(state)
212 for _, want := range []string{
213 "Tasked phases focus:",
214 "Current phase: Implement compact output [phase-2]",
215 "Goal: Reduce repeated context",
216 "[ ] Remaining implementation task [task-4]",
217 "[ ] Next focused task [task-5]",
218 "[ ] Another focused task [task-6]",
219 } {
220 if !strings.Contains(context, want) {
221 t.Errorf("turn context missing %q: %s", want, context)
222 }
223 }
224 for _, unwanted := range []string{
225 "Ship compact tasked phases output",
226 "Finished discovery",
227 "Completed discovery task",
228 "Future phase",
229 "Future task",
230 "Deferred focused task [task-7]",
231 } {
232 if strings.Contains(context, unwanted) {
233 t.Errorf("turn context included %q: %s", unwanted, context)
234 }
235 }
236}
237
238func TestTurnContextRemindsAgentToTrackProgressContinuously(t *testing.T) {
239 context := buildTurnContext(sampleState())
240 for _, want := range []string{
241 "[TASKED PHASES STATE - SOURCE OF TRUTH]",
242 "Update tasked_phases continuously while implementing, not only at the end.",
243 "After completing each checklist task, immediately call tasked_phases set_task_checked.",
244 "After moving to another phase, immediately call tasked_phases set_current_phase.",
245 "When a phase is shown as Title [id], pass phaseId as the raw id only, without brackets.",
246 "Do not rely on prose alone for completion state.",
247 } {
248 if !strings.Contains(context, want) {
249 t.Errorf("turn context missing continuous-tracking guidance %q: %s", want, context)
250 }
251 }
252}
253
254func TestTurnContextIsBoundedForUntrustedIdentifiers(t *testing.T) {
255 state := sampleState()
256 state.Phases[1].ID = strings.Repeat("phase-id-", 1000)
257 state.CurrentPhaseID = state.Phases[1].ID
258 state.Phases[1].Tasks[0].ID = strings.Repeat("task-id-", 1000)
259
260 context := buildTurnContext(state)
261 if got := len([]rune(context)); got > maxTurnContextChars {
262 t.Fatalf("turn context length = %d; want at most %d", got, maxTurnContextChars)
263 }
264 if !strings.Contains(context, "Tasked phases focus:") || !strings.Contains(context, "Current phase:") || !strings.Contains(context, "Goal: Reduce repeated context") {
265 t.Fatalf("bounded turn context lost its useful focus: %q", context)
266 }
267 if !strings.Contains(context, "Remaining implementation task") {
268 t.Fatalf("per-field id bounding did not leave room for task lines: %q", context)
269 }
270 for _, line := range strings.Split(context, "\n") {
271 if got := len([]rune(line)); got > 400 {
272 t.Fatalf("unbounded field produced a %d-rune line: %q", got, line)
273 }
274 }
275}
276
277func TestDisplayRenderingSanitizesUntrustedTextWithoutMutatingState(t *testing.T) {
278 state := sampleState()
279 state.Spec = "spec \x1b[31mred\x1b[0m\x00"
280 state.Phases[1].Title = "title \x1b]0;evil\a"
281 state.Phases[1].Goal = "goal\r\t\x1b[2J"
282 state.Phases[1].Tasks[0].Text = "task \x1b[?25l\x7f"
283 original := state
284
285 for _, rendered := range []string{
286 buildSummary(state), buildTurnContext(state), strings.Join(buildViewLines(state), "\n"),
287 } {
288 if strings.Contains(rendered, "\x1b") || strings.ContainsRune(rendered, '\x00') || strings.ContainsRune(rendered, '\x7f') || strings.ContainsRune(rendered, '\r') || strings.ContainsRune(rendered, '\t') {
289 t.Fatalf("rendered text contains terminal/control injection: %q", rendered)
290 }
291 }
292 if state.Spec != original.Spec || state.Phases[1].Title != original.Phases[1].Title || state.Phases[1].Tasks[0].Text != original.Phases[1].Tasks[0].Text {
293 t.Fatal("render sanitization mutated persisted plan state")
294 }
295}
296
297func TestTaskErrorsDistinguishMissingAndUnknownIDs(t *testing.T) {
298 state := sampleState()
299 for _, action := range []string{actionUpdateTask, actionSetTaskChecked} {
300 if _, _, err := applyAction(state, toolArgs{Action: action}); err == nil || !strings.Contains(err.Error(), "taskId is required") {
301 t.Fatalf("%s missing task id error = %v", action, err)
302 }
303 if _, _, err := applyAction(state, toolArgs{Action: action, TaskID: "missing"}); err == nil || !strings.Contains(err.Error(), "Valid incomplete task ids: task-4") {
304 t.Fatalf("%s unknown task id error = %v", action, err)
305 }
306 }
307}
308
309func TestPanelRendersSpecGoalsTasksAndEmptyState(t *testing.T) {
310 empty := strings.Join(buildViewLines(createEmptyState()), "\n")
311 if !strings.Contains(empty, "No spec or phases stored yet.") || !strings.Contains(empty, "Press Escape to close") {
312 t.Fatalf("empty panel = %s", empty)
313 }
314 panel := strings.Join(buildViewLines(sampleState()), "\n")
315 for _, want := range []string{
316 "Tasked phases",
317 "Spec",
318 "Progress: 3/4 tasks checked",
319 "[x] Completed discovery task [task-1]",
320 "[ ] Remaining implementation task [task-4]",
321 "Understand the old behavior",
322 "Press Escape to close",
323 } {
324 if !strings.Contains(panel, want) {
325 t.Errorf("panel missing %q: %s", want, panel)
326 }
327 }
328}
329
330func TestStatePersistenceIsProjectScopedAtomicAndRestrictive(t *testing.T) {
331 dataDir := t.TempDir()
332 first := newStateStore()
333 if err := first.load(dataDir, "/projects/one"); err != nil {
334 t.Fatal(err)
335 }
336 state, _, err := first.transact(func(current PlanState) (PlanState, string, error) {
337 return applyAction(current, toolArgs{
338 Action: actionReplacePlan,
339 Phases: &[]PhaseInput{{Title: "Persisted", Tasks: &[]TaskInput{{Text: "remember me"}}}},
340 })
341 })
342 if err != nil {
343 t.Fatal(err)
344 }
345 if state.Phases[0].ID != "phase-1" {
346 t.Fatalf("persisted state = %+v", state)
347 }
348 if runtime.GOOS != "windows" {
349 info, err := os.Stat(first.path)
350 if err != nil {
351 t.Fatal(err)
352 }
353 if got := info.Mode().Perm(); got != 0o600 {
354 t.Fatalf("state file mode = %o, want 600", got)
355 }
356 if got := (mustStat(t, filepath.Dir(first.path))).Mode().Perm(); got != 0o700 {
357 t.Fatalf("state directory mode = %o, want 700", got)
358 }
359 }
360
361 second := newStateStore()
362 if err := second.load(dataDir, "/projects/one"); err != nil {
363 t.Fatal(err)
364 }
365 loaded, err := second.snapshot()
366 if err != nil {
367 t.Fatal(err)
368 }
369 if loaded.Phases[0].Tasks[0].Text != "remember me" {
370 t.Fatalf("loaded state = %+v", loaded)
371 }
372 otherProject := newStateStore()
373 if err := otherProject.load(dataDir, "/projects/two"); err != nil {
374 t.Fatal(err)
375 }
376 other, err := otherProject.snapshot()
377 if err != nil {
378 t.Fatal(err)
379 }
380 if hasStoredPlan(other) {
381 t.Fatal("state leaked between project CWDs")
382 }
383 files, err := os.ReadDir(filepath.Dir(first.path))
384 if err != nil {
385 t.Fatal(err)
386 }
387 for _, file := range files {
388 if strings.HasSuffix(file.Name(), ".tmp") {
389 t.Fatalf("temporary file left behind: %s", file.Name())
390 }
391 }
392
393 var decoded PlanState
394 data, err := os.ReadFile(first.path)
395 if err != nil {
396 t.Fatal(err)
397 }
398 if err := json.Unmarshal(data, &decoded); err != nil {
399 t.Fatal(err)
400 }
401 if decoded.Phases[0].Tasks[0].ID != "task-1" {
402 t.Fatalf("on-disk state = %+v", decoded)
403 }
404}
405
406func TestStateStoreDoesNotOverwriteCorruptStateAfterSessionActivation(t *testing.T) {
407 dataDir := t.TempDir()
408 cwd := "/projects/corrupt"
409 path := projectStatePath(dataDir, cwd)
410 original := []byte("not valid plan json")
411 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
412 t.Fatal(err)
413 }
414 if err := os.WriteFile(path, original, 0o600); err != nil {
415 t.Fatal(err)
416 }
417
418 store := newStateStore()
419 if err := store.load(dataDir, cwd); err == nil {
420 t.Fatal("corrupt state unexpectedly loaded")
421 }
422 if err := store.activateSession(nil); err == nil {
423 t.Fatal("session activation cleared the corrupt-state fault")
424 }
425 if _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
426 return applyAction(current, toolArgs{Action: actionClear})
427 }); err == nil {
428 t.Fatal("transaction unexpectedly wrote through a corrupt-state fault")
429 }
430 got, err := os.ReadFile(path)
431 if err != nil {
432 t.Fatal(err)
433 }
434 if string(got) != string(original) {
435 t.Fatalf("corrupt state bytes changed from %q to %q", original, got)
436 }
437}
438
439func TestStateStoreSerializesConcurrentTransitions(t *testing.T) {
440 store := newStateStore()
441 if err := store.load(t.TempDir(), "/projects/concurrent"); err != nil {
442 t.Fatal(err)
443 }
444 const count = 12
445 var wg sync.WaitGroup
446 for i := 0; i < count; i++ {
447 wg.Add(1)
448 go func(i int) {
449 defer wg.Done()
450 title := "phase " + string(rune('a'+i))
451 _, _, err := store.transact(func(current PlanState) (PlanState, string, error) {
452 return applyAction(current, toolArgs{Action: actionAddPhase, PhaseTitle: stringPtr(title)})
453 })
454 if err != nil {
455 t.Errorf("concurrent transition: %v", err)
456 }
457 }(i)
458 }
459 wg.Wait()
460 state, err := store.snapshot()
461 if err != nil {
462 t.Fatal(err)
463 }
464 if len(state.Phases) != count {
465 t.Fatalf("serialized phase count = %d, want %d", len(state.Phases), count)
466 }
467}
468
469func mustStat(t *testing.T, path string) os.FileInfo {
470 t.Helper()
471 info, err := os.Stat(path)
472 if err != nil {
473 t.Fatal(err)
474 }
475 return info
476}