v0.4.2 83c2c60

state_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close