v0.42.0 82578de

plan_test.go RawHistoryFolder

1package core
2
3import (
4	"slices"
5	"sync"
6	"testing"
7
8	"github.com/bnema/zut/packages/provider"
9)
10
11func planStep(text string, status PlanStepStatus) PlanStep {
12	return PlanStep{Step: text, Status: status}
13}
14
15func TestAgentPlanCopySemantics(t *testing.T) {
16	agent := &Agent{}
17	original := []PlanStep{
18		planStep("first", PlanPending),
19		planStep("second", PlanInProgress),
20	}
21	agent.SetPlan(original)
22
23	// Mutating the caller's slice after SetPlan must not affect state.
24	original[0].Step = "mutated"
25	if got := agent.CurrentPlan(); got[0].Step != "first" {
26		t.Fatalf("SetPlan aliased caller slice: %#v", got)
27	}
28
29	// Mutating the slice returned by CurrentPlan must not affect state.
30	snapshot := agent.CurrentPlan()
31	snapshot[0].Step = "mutated again"
32	snapshot[1].Status = PlanCompleted
33	if got := agent.CurrentPlan(); got[0].Step != "first" || got[1].Status != PlanInProgress {
34		t.Fatalf("CurrentPlan returned shared state: %#v", got)
35	}
36
37	agent.SetPlan(nil)
38	if got := agent.CurrentPlan(); got != nil {
39		t.Fatalf("SetPlan(nil) = %#v, want nil", got)
40	}
41}
42
43func TestPreviewPlanOperationDoesNotMutate(t *testing.T) {
44	agent := &Agent{}
45	agent.SetPlan([]PlanStep{
46		planStep("first", PlanInProgress),
47		planStep("second", PlanPending),
48	})
49	before := agent.CurrentPlan()
50
51	text := "renamed"
52	operations := []PlanOperation{
53		{Action: "set", Steps: []PlanStep{planStep("only", PlanPending)}},
54		{Action: "add", Steps: []PlanStep{planStep("third", PlanPending)}},
55		{Action: "update", Index: 2, Status: PlanCompleted},
56		{Action: "update", Index: 1, Text: &text},
57		{Action: "remove", Index: 1},
58		{Action: "clear"},
59		{Action: "show"},
60	}
61	for _, op := range operations {
62		if _, err := agent.previewPlanOperation(op); err != nil {
63			t.Fatalf("preview %q: %v", op.Action, err)
64		}
65		if got := agent.CurrentPlan(); !slices.Equal(got, before) {
66			t.Fatalf("preview %q mutated state: %#v, want %#v", op.Action, got, before)
67		}
68	}
69}
70
71func TestPreviewPlanOperationErrors(t *testing.T) {
72	base := []PlanStep{
73		planStep("first", PlanInProgress),
74		planStep("second", PlanPending),
75		planStep("third", PlanPending),
76	}
77	blank := ""
78	tests := []struct {
79		name string
80		op   PlanOperation
81		want string
82	}{
83		{
84			name: "missing action",
85			op:   PlanOperation{},
86			want: "plan: action is required",
87		},
88		{
89			name: "unknown action",
90			op:   PlanOperation{Action: "foo"},
91			want: `plan: unknown action "foo"`,
92		},
93		{
94			name: "update index out of range",
95			op:   PlanOperation{Action: "update", Index: 4, Status: PlanCompleted},
96			want: "plan: index 4 out of range (plan has 3 steps)",
97		},
98		{
99			name: "update index zero",
100			op:   PlanOperation{Action: "update", Index: 0, Status: PlanCompleted},
101			want: "plan: index 0 out of range (plan has 3 steps)",
102		},
103		{
104			name: "update without status or text",
105			op:   PlanOperation{Action: "update", Index: 2},
106			want: "plan: update requires status or step",
107		},
108		{
109			name: "update with blank text",
110			op:   PlanOperation{Action: "update", Index: 2, Text: &blank},
111			want: "plan: step text is required",
112		},
113		{
114			name: "remove index out of range",
115			op:   PlanOperation{Action: "remove", Index: 9},
116			want: "plan: index 9 out of range (plan has 3 steps)",
117		},
118		{
119			name: "remove index negative",
120			op:   PlanOperation{Action: "remove", Index: -1},
121			want: "plan: index -1 out of range (plan has 3 steps)",
122		},
123		{
124			name: "add without steps",
125			op:   PlanOperation{Action: "add"},
126			want: "plan: steps are required for add",
127		},
128		{
129			name: "set blank step",
130			op:   PlanOperation{Action: "set", Steps: []PlanStep{planStep("ok", PlanPending), planStep("  ", PlanPending)}},
131			want: "plan: step text is required",
132		},
133		{
134			name: "set without steps",
135			op:   PlanOperation{Action: "set"},
136			want: "plan: steps are required for set (use clear to remove every step)",
137		},
138		{
139			name: "set two in progress",
140			op:   PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanInProgress), planStep("b", PlanInProgress)}},
141			want: "plan: at most one step can be in_progress",
142		},
143		{
144			name: "add second in progress",
145			op:   PlanOperation{Action: "add", Steps: []PlanStep{planStep("fourth", PlanInProgress)}},
146			want: "plan: at most one step can be in_progress",
147		},
148		{
149			name: "update second in progress",
150			op:   PlanOperation{Action: "update", Index: 2, Status: PlanInProgress},
151			want: "plan: at most one step can be in_progress",
152		},
153		{
154			name: "set unknown status",
155			op:   PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanStepStatus("done"))}},
156			want: `plan: unknown status "done"`,
157		},
158		{
159			name: "update unknown status",
160			op:   PlanOperation{Action: "update", Index: 2, Status: PlanStepStatus("done")},
161			want: `plan: unknown status "done"`,
162		},
163	}
164	for _, tt := range tests {
165		t.Run(tt.name, func(t *testing.T) {
166			agent := &Agent{}
167			agent.SetPlan(base)
168			_, err := agent.previewPlanOperation(tt.op)
169			if err == nil {
170				t.Fatalf("preview succeeded, want error %q", tt.want)
171			}
172			if err.Error() != tt.want {
173				t.Fatalf("error = %q, want %q", err.Error(), tt.want)
174			}
175			if got := agent.CurrentPlan(); !slices.Equal(got, base) {
176				t.Fatalf("failed preview mutated state: %#v", got)
177			}
178		})
179	}
180}
181
182// TestPreviewPlanOperationDefaultsMissingStatus covers the schema's optional step
183// status. A step that omits it must materialize as pending, otherwise agent state
184// would hold a status the session normalizer later drops on reload and the live
185// plan would silently lose a step across a restart.
186func TestPreviewPlanOperationDefaultsMissingStatus(t *testing.T) {
187	tests := []struct {
188		name string
189		op   PlanOperation
190		want []PlanStep
191	}{
192		{
193			name: "set without status",
194			op:   PlanOperation{Action: "set", Steps: []PlanStep{{Step: "a"}}},
195			want: []PlanStep{planStep("a", PlanPending)},
196		},
197		{
198			name: "add without status",
199			op:   PlanOperation{Action: "add", Steps: []PlanStep{{Step: "first"}, {Step: "second"}}},
200			want: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
201		},
202	}
203	for _, tt := range tests {
204		t.Run(tt.name, func(t *testing.T) {
205			agent := &Agent{}
206			update, err := agent.previewPlanOperation(tt.op)
207			if err != nil {
208				t.Fatalf("preview: %v", err)
209			}
210			if !slices.Equal(update.Plan, tt.want) {
211				t.Fatalf("plan = %#v, want %#v", update.Plan, tt.want)
212			}
213			// The same steps must survive a persist/reload cycle unchanged.
214			reloaded := normalizeSessionPlan(&SessionPlan{Steps: update.Plan})
215			if reloaded == nil || !slices.Equal(reloaded.Steps, tt.want) {
216				t.Fatalf("reloaded plan = %#v, want %#v", reloaded, tt.want)
217			}
218		})
219	}
220}
221
222func TestPreviewThenCommitAppliesActions(t *testing.T) {
223	text := "renamed"
224	tests := []struct {
225		name  string
226		start []PlanStep
227		op    PlanOperation
228		want  []PlanStep
229	}{
230		{
231			name:  "set replaces",
232			start: []PlanStep{planStep("first", PlanPending)},
233			op:    PlanOperation{Action: "set", Steps: []PlanStep{planStep("a", PlanPending), planStep("b", PlanCompleted)}},
234			want:  []PlanStep{planStep("a", PlanPending), planStep("b", PlanCompleted)},
235		},
236		{
237			name:  "clear removes every step",
238			start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanCompleted)},
239			op:    PlanOperation{Action: "clear"},
240			want:  nil,
241		},
242		{
243			name:  "add appends",
244			start: []PlanStep{planStep("first", PlanPending)},
245			op:    PlanOperation{Action: "add", Steps: []PlanStep{planStep("second", PlanPending)}},
246			want:  []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
247		},
248		{
249			name:  "update status",
250			start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
251			op:    PlanOperation{Action: "update", Index: 2, Status: PlanCompleted},
252			want:  []PlanStep{planStep("first", PlanPending), planStep("second", PlanCompleted)},
253		},
254		{
255			name:  "update text",
256			start: []PlanStep{planStep("first", PlanPending)},
257			op:    PlanOperation{Action: "update", Index: 1, Text: &text},
258			want:  []PlanStep{planStep("renamed", PlanPending)},
259		},
260		{
261			name:  "remove",
262			start: []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)},
263			op:    PlanOperation{Action: "remove", Index: 1},
264			want:  []PlanStep{planStep("second", PlanPending)},
265		},
266		{
267			name:  "remove last",
268			start: []PlanStep{planStep("first", PlanPending)},
269			op:    PlanOperation{Action: "remove", Index: 1},
270			want:  nil,
271		},
272		{
273			name:  "clear",
274			start: []PlanStep{planStep("first", PlanPending)},
275			op:    PlanOperation{Action: "clear"},
276			want:  nil,
277		},
278		{
279			name:  "show is a no-op",
280			start: []PlanStep{planStep("first", PlanPending)},
281			op:    PlanOperation{Action: "show"},
282			want:  []PlanStep{planStep("first", PlanPending)},
283		},
284	}
285	for _, tt := range tests {
286		t.Run(tt.name, func(t *testing.T) {
287			agent := &Agent{}
288			agent.SetPlan(tt.start)
289			update, err := agent.previewPlanOperation(tt.op)
290			if err != nil {
291				t.Fatalf("preview: %v", err)
292			}
293			// A failed preview means nothing is persisted and nothing is committed,
294			// so state must still be the pre-operation plan.
295			if got := agent.CurrentPlan(); !slices.Equal(got, tt.start) {
296				t.Fatalf("preview mutated state: %#v", got)
297			}
298			agent.commitPlanUpdate(update)
299			if got := agent.CurrentPlan(); !slices.Equal(got, tt.want) {
300				t.Fatalf("plan = %#v, want %#v", got, tt.want)
301			}
302		})
303	}
304}
305
306// TestCommitPlanUpdateStoresPersistedValue covers the documented interleaving: a
307// SetPlan between preview and commit must not win, because the persisted list is
308// what the session file now records.
309func TestCommitPlanUpdateStoresPersistedValue(t *testing.T) {
310	agent := &Agent{}
311	agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
312	update, err := agent.previewPlanOperation(PlanOperation{
313		Action: "add",
314		Steps:  []PlanStep{planStep("second", PlanPending)},
315	})
316	if err != nil {
317		t.Fatalf("preview: %v", err)
318	}
319
320	agent.SetPlan([]PlanStep{planStep("cleared by /clear", PlanPending)})
321	agent.commitPlanUpdate(update)
322
323	want := []PlanStep{planStep("first", PlanPending), planStep("second", PlanPending)}
324	if got := agent.CurrentPlan(); !slices.Equal(got, want) {
325		t.Fatalf("plan = %#v, want the persisted %#v", got, want)
326	}
327}
328
329// TestAgentPlanStateIsConcurrencySafe exercises the plan accessors under -race.
330func TestAgentPlanStateIsConcurrencySafe(t *testing.T) {
331	agent := &Agent{}
332	agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
333
334	var wg sync.WaitGroup
335	for worker := 0; worker < 4; worker++ {
336		wg.Add(1)
337		go func() {
338			defer wg.Done()
339			for iter := 0; iter < 50; iter++ {
340				agent.SetPlan([]PlanStep{planStep("first", PlanPending)})
341				_ = agent.CurrentPlan()
342				if update, err := agent.previewPlanOperation(PlanOperation{
343					Action: "update",
344					Index:  1,
345					Status: PlanCompleted,
346				}); err == nil {
347					agent.commitPlanUpdate(update)
348				}
349			}
350		}()
351	}
352	wg.Wait()
353}
354
355func TestSessionPlanMetaRoundTrip(t *testing.T) {
356	root := t.TempDir()
357	cwd := "/project/plan"
358	sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
359	if err != nil {
360		t.Fatal(err)
361	}
362	steps := []PlanStep{
363		planStep("first", PlanCompleted),
364		planStep("second", PlanInProgress),
365	}
366	if err := sess.UpdatePlan(&SessionPlan{Steps: steps}); err != nil {
367		t.Fatalf("UpdatePlan: %v", err)
368	}
369	// Mutating the caller's slice after UpdatePlan must not affect session state.
370	steps[0].Step = "mutated"
371	if sess.Meta.Plan == nil || sess.Meta.Plan.Steps[0].Step != "first" {
372		t.Fatalf("UpdatePlan aliased caller slice: %#v", sess.Meta.Plan)
373	}
374	if err := sess.Close(); err != nil {
375		t.Fatal(err)
376	}
377
378	// A plan-only session must survive the fresh-file deletion guard.
379	reopened, messages, err := OpenSession(sess.Path)
380	if err != nil {
381		t.Fatalf("OpenSession: %v", err)
382	}
383	defer reopened.Close()
384	if len(messages) != 0 {
385		t.Fatalf("want no messages, got %d", len(messages))
386	}
387	if reopened.Meta.Plan == nil {
388		t.Fatal("plan not restored")
389	}
390	want := []PlanStep{planStep("first", PlanCompleted), planStep("second", PlanInProgress)}
391	if !slices.Equal(reopened.Meta.Plan.Steps, want) {
392		t.Fatalf("plan = %#v, want %#v", reopened.Meta.Plan.Steps, want)
393	}
394}
395
396func TestSessionUpdatePlanNilClears(t *testing.T) {
397	root := t.TempDir()
398	cwd := "/project/plan"
399	sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
400	if err != nil {
401		t.Fatal(err)
402	}
403	// Keep the session file after Close: a fresh plan-only session whose plan is
404	// cleared is deleted by the fresh-file guard, which this test is not about.
405	if err := sess.AppendMessage(provider.Message{
406		Role:    provider.RoleUser,
407		Content: []provider.Content{provider.TextBlock{Text: "clear my plan"}},
408	}); err != nil {
409		t.Fatal(err)
410	}
411	if err := sess.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("first", PlanPending)}}); err != nil {
412		t.Fatal(err)
413	}
414	if err := sess.UpdatePlan(nil); err != nil {
415		t.Fatal(err)
416	}
417	if sess.Meta.Plan != nil {
418		t.Fatalf("in-memory plan = %#v, want nil", sess.Meta.Plan)
419	}
420	if err := sess.Close(); err != nil {
421		t.Fatal(err)
422	}
423	reopened, _, err := OpenSession(sess.Path)
424	if err != nil {
425		t.Fatalf("OpenSession: %v", err)
426	}
427	defer reopened.Close()
428	if reopened.Meta.Plan != nil {
429		t.Fatalf("persisted plan = %#v, want nil", reopened.Meta.Plan)
430	}
431}
432
433func TestSessionPlanNormalizesOnLoad(t *testing.T) {
434	root := t.TempDir()
435	cwd := "/project/plan"
436	sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
437	if err != nil {
438		t.Fatal(err)
439	}
440	if err := sess.AppendMessage(provider.Message{
441		Role:    provider.RoleUser,
442		Content: []provider.Content{provider.TextBlock{Text: "normalize me"}},
443	}); err != nil {
444		t.Fatal(err)
445	}
446	// UpdatePlan writes verbatim; the load path is responsible for normalization.
447	raw := &SessionPlan{Steps: []PlanStep{
448		planStep("", PlanPending),
449		planStep("unknown", "sleeping"),
450		planStep("first", PlanInProgress),
451		planStep("second", PlanInProgress),
452		planStep("third", PlanCompleted),
453	}}
454	if err := sess.UpdatePlan(raw); err != nil {
455		t.Fatal(err)
456	}
457	_ = sess.Close()
458
459	reopened, _, err := OpenSession(sess.Path)
460	if err != nil {
461		t.Fatalf("OpenSession: %v", err)
462	}
463	defer reopened.Close()
464	want := []PlanStep{
465		planStep("first", PlanInProgress),
466		planStep("third", PlanCompleted),
467	}
468	if reopened.Meta.Plan == nil || !slices.Equal(reopened.Meta.Plan.Steps, want) {
469		t.Fatalf("normalized plan = %#v, want %#v", reopened.Meta.Plan, want)
470	}
471}
472
473func TestImportSessionPreservesPlan(t *testing.T) {
474	root := t.TempDir()
475	cwd := "/project/plan"
476	sess, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
477	if err != nil {
478		t.Fatal(err)
479	}
480	_ = sess.AppendMessage(provider.Message{
481		Role:    provider.RoleUser,
482		Content: []provider.Content{provider.TextBlock{Text: "carry my plan"}},
483	})
484	want := []PlanStep{planStep("first", PlanCompleted), planStep("second", PlanInProgress)}
485	if err := sess.UpdatePlan(&SessionPlan{Steps: want}); err != nil {
486		t.Fatal(err)
487	}
488	_ = sess.Close()
489
490	exportDir := t.TempDir()
491	exportPath, err := ExportSession(sess.Path, exportDir)
492	if err != nil {
493		t.Fatalf("ExportSession: %v", err)
494	}
495	root2 := t.TempDir()
496	importedPath, err := ImportSession(exportPath, root2, "/other/project", "0.0.0-test")
497	if err != nil {
498		t.Fatalf("ImportSession: %v", err)
499	}
500	imported, _, err := OpenSession(importedPath)
501	if err != nil {
502		t.Fatalf("OpenSession: %v", err)
503	}
504	defer imported.Close()
505	if imported.Meta.Plan == nil {
506		t.Fatal("imported session dropped the plan")
507	}
508	if !slices.Equal(imported.Meta.Plan.Steps, want) {
509		t.Fatalf("imported plan = %#v, want %#v", imported.Meta.Plan.Steps, want)
510	}
511}
512
513func TestBranchSessionReconstructsPlanAtForkPoint(t *testing.T) {
514	root := t.TempDir()
515	cwd := "/project/plan"
516	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
517	if err != nil {
518		t.Fatal(err)
519	}
520	appendPlanTranscript(t, parent)
521
522	// The persisted plan deliberately differs from what the fork-point replay
523	// should reconstruct, proving replay wins over the snapshot plan.
524	if err := parent.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("unrelated", PlanPending)}}); err != nil {
525		t.Fatal(err)
526	}
527	_ = parent.Close()
528
529	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 5)
530	if err != nil {
531		t.Fatalf("BranchSession: %v", err)
532	}
533	branch, _, err := OpenSession(branchPath)
534	if err != nil {
535		t.Fatalf("OpenSession: %v", err)
536	}
537	defer branch.Close()
538	want := []PlanStep{
539		planStep("one", PlanInProgress),
540		planStep("two", PlanPending),
541		planStep("three", PlanPending),
542	}
543	if branch.Meta.Plan == nil {
544		t.Fatal("branch has no plan")
545	}
546	if !slices.Equal(branch.Meta.Plan.Steps, want) {
547		t.Fatalf("fork-point plan = %#v, want %#v", branch.Meta.Plan.Steps, want)
548	}
549
550	// Forking earlier reconstructs the plan as it stood then: only the legacy
551	// update_plan call has been replayed.
552	earlyPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
553	if err != nil {
554		t.Fatalf("BranchSession(early): %v", err)
555	}
556	early, _, err := OpenSession(earlyPath)
557	if err != nil {
558		t.Fatalf("OpenSession(early): %v", err)
559	}
560	defer early.Close()
561	earlyWant := []PlanStep{planStep("one", PlanInProgress), planStep("two", PlanPending)}
562	if early.Meta.Plan == nil || !slices.Equal(early.Meta.Plan.Steps, earlyWant) {
563		t.Fatalf("early plan = %#v, want %#v", early.Meta.Plan, earlyWant)
564	}
565}
566
567// TestBranchSessionFullForkKeepsPersistedPlan covers the end-of-transcript fork
568// rule: the parent's persisted plan is authoritative, including for a compacted
569// parent whose plan calls are no longer in the transcript.
570func TestBranchSessionFullForkKeepsPersistedPlan(t *testing.T) {
571	root := t.TempDir()
572	cwd := "/project/plan"
573	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
574	if err != nil {
575		t.Fatal(err)
576	}
577	_ = parent.AppendMessage(provider.Message{
578		Role:    provider.RoleUser,
579		Content: []provider.Content{provider.TextBlock{Text: "no plan calls here"}},
580	})
581	_ = parent.AppendMessage(provider.Message{
582		Role:    provider.RoleAssistant,
583		Content: []provider.Content{provider.TextBlock{Text: "a compacted parent"}},
584	})
585	want := []PlanStep{planStep("inherited", PlanInProgress)}
586	if err := parent.UpdatePlan(&SessionPlan{Steps: want}); err != nil {
587		t.Fatal(err)
588	}
589	_ = parent.Close()
590
591	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
592	if err != nil {
593		t.Fatalf("BranchSession: %v", err)
594	}
595	branch, _, err := OpenSession(branchPath)
596	if err != nil {
597		t.Fatalf("OpenSession: %v", err)
598	}
599	defer branch.Close()
600	if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
601		t.Fatalf("fallback plan = %#v, want %#v", branch.Meta.Plan, want)
602	}
603}
604
605// TestBranchSessionNormalizesReplayedPlan guards the fork replay against a
606// transcript that carries a step the live agent could never hold, such as a
607// hand-edited or pre-validation session file.
608func TestBranchSessionNormalizesReplayedPlan(t *testing.T) {
609	root := t.TempDir()
610	cwd := "/project/plan"
611	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
612	if err != nil {
613		t.Fatal(err)
614	}
615	messages := []provider.Message{
616		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
617		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
618			ID:   "legacy-1",
619			Name: legacyPlanToolName,
620			Arguments: []byte(`{"plan":[
621				{"step":"keep me","status":"pending"},
622				{"step":"bogus status","status":"done"},
623				{"step":"  ","status":"pending"}
624			]}`),
625		}}},
626		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
627			CallID:  "legacy-1",
628			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
629		}}},
630		// Keeps the fork point short of the transcript end so the replay path runs
631		// instead of the end-of-transcript rule.
632		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
633	}
634	for _, msg := range messages {
635		if err := parent.AppendMessage(msg); err != nil {
636			t.Fatalf("AppendMessage: %v", err)
637		}
638	}
639	_ = parent.Close()
640
641	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 2)
642	if err != nil {
643		t.Fatalf("BranchSession: %v", err)
644	}
645	branch, _, err := OpenSession(branchPath)
646	if err != nil {
647		t.Fatalf("OpenSession: %v", err)
648	}
649	defer branch.Close()
650
651	want := []PlanStep{planStep("keep me", PlanPending)}
652	if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
653		t.Fatalf("branch plan = %#v, want %#v", branch.Meta.Plan, want)
654	}
655}
656
657// TestBranchSessionSkipsFailedPlanCalls guards the replay against calls that left
658// a transcript row but changed nothing: a rejected, denied, or abandoned plan
659// call must not shape the branch's plan.
660func TestBranchSessionSkipsFailedPlanCalls(t *testing.T) {
661	root := t.TempDir()
662	cwd := "/project/plan"
663	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
664	if err != nil {
665		t.Fatal(err)
666	}
667	messages := []provider.Message{
668		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
669		// Applied: one in_progress step and one pending step.
670		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
671			ID:        "applied-1",
672			Name:      legacyPlanToolName,
673			Arguments: []byte(`{"plan":[{"step":"one","status":"in_progress"},{"step":"two","status":"pending"}]}`),
674		}}},
675		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
676			CallID:  "applied-1",
677			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
678		}}},
679		// Rejected by the tool: the result is an error, so nothing was applied.
680		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
681			ID:        "rejected-1",
682			Name:      legacyPlanToolName,
683			Arguments: []byte(`{"plan":[{"step":"never applied","status":"pending"}]}`),
684		}}},
685		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
686			CallID:  "rejected-1",
687			IsError: true,
688			Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
689		}}},
690		// Denied clear: also an error result.
691		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
692			ID:        "denied-1",
693			Name:      planToolName,
694			Arguments: []byte(`{"action":"clear"}`),
695		}}},
696		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
697			CallID:  "denied-1",
698			IsError: true,
699			Content: []provider.Content{provider.TextBlock{Text: "tool call refused by extension guard"}},
700		}}},
701		// Keeps the fork point short of the transcript end so the replay path runs.
702		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
703	}
704	for _, msg := range messages {
705		if err := parent.AppendMessage(msg); err != nil {
706			t.Fatalf("AppendMessage: %v", err)
707		}
708	}
709	_ = parent.Close()
710
711	// Fork after the rejected call so the replay covers every call above.
712	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 7)
713	if err != nil {
714		t.Fatalf("BranchSession: %v", err)
715	}
716	branch, _, err := OpenSession(branchPath)
717	if err != nil {
718		t.Fatalf("OpenSession: %v", err)
719	}
720	defer branch.Close()
721
722	want := []PlanStep{planStep("one", PlanInProgress), planStep("two", PlanPending)}
723	if branch.Meta.Plan == nil || !slices.Equal(branch.Meta.Plan.Steps, want) {
724		t.Fatalf("branch plan = %#v, want only the applied call %#v", branch.Meta.Plan, want)
725	}
726}
727
728// TestBranchSessionPartialForkWithoutAppliedCallHasNoPlan covers the other half of
729// the rule: a short fork whose only plan call failed inherits nothing, rather than
730// reaching forward to the parent's later plan the way goals refuse to do.
731func TestBranchSessionPartialForkWithoutAppliedCallHasNoPlan(t *testing.T) {
732	root := t.TempDir()
733	cwd := "/project/plan"
734	parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
735	if err != nil {
736		t.Fatal(err)
737	}
738	messages := []provider.Message{
739		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
740		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
741			ID:        "rejected-1",
742			Name:      legacyPlanToolName,
743			Arguments: []byte(`{"plan":[{"step":"never applied","status":"pending"}]}`),
744		}}},
745		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
746			CallID:  "rejected-1",
747			IsError: true,
748			Content: []provider.Content{provider.TextBlock{Text: "invalid arguments"}},
749		}}},
750		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "later work"}}},
751	}
752	for _, msg := range messages {
753		if err := parent.AppendMessage(msg); err != nil {
754			t.Fatalf("AppendMessage: %v", err)
755		}
756	}
757	// The parent's own plan describes work after the fork point.
758	if err := parent.UpdatePlan(&SessionPlan{Steps: []PlanStep{planStep("later", PlanPending)}}); err != nil {
759		t.Fatal(err)
760	}
761	_ = parent.Close()
762
763	branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
764	if err != nil {
765		t.Fatalf("BranchSession: %v", err)
766	}
767	branch, _, err := OpenSession(branchPath)
768	if err != nil {
769		t.Fatalf("OpenSession: %v", err)
770	}
771	defer branch.Close()
772
773	if branch.Meta.Plan != nil {
774		t.Fatalf("branch plan = %#v, want none", branch.Meta.Plan)
775	}
776}
777
778func TestNormalizeSessionPlan(t *testing.T) {
779	tests := []struct {
780		name string
781		in   *SessionPlan
782		want []PlanStep
783	}{
784		{
785			name: "nil stays nil",
786			in:   nil,
787			want: nil,
788		},
789		{
790			name: "nothing remains",
791			in:   &SessionPlan{Steps: []PlanStep{planStep("", PlanPending), planStep("kept", "unknown")}},
792			want: nil,
793		},
794		{
795			name: "drops empty text and unknown status",
796			in: &SessionPlan{Steps: []PlanStep{
797				planStep("", PlanPending),
798				planStep("unknown", "sleeping"),
799				planStep("kept", PlanCompleted),
800			}},
801			want: []PlanStep{planStep("kept", PlanCompleted)},
802		},
803		{
804			name: "keeps only first in progress",
805			in: &SessionPlan{Steps: []PlanStep{
806				planStep("first", PlanInProgress),
807				planStep("second", PlanInProgress),
808				planStep("third", PlanPending),
809			}},
810			want: []PlanStep{planStep("first", PlanInProgress), planStep("third", PlanPending)},
811		},
812	}
813	for _, tt := range tests {
814		t.Run(tt.name, func(t *testing.T) {
815			got := normalizeSessionPlan(tt.in)
816			var gotSteps []PlanStep
817			if got != nil {
818				gotSteps = got.Steps
819			}
820			if !slices.Equal(gotSteps, tt.want) {
821				t.Fatalf("plan = %#v, want %#v", gotSteps, tt.want)
822			}
823		})
824	}
825
826	long := &SessionPlan{}
827	for i := 0; i < 50; i++ {
828		long.Steps = append(long.Steps, planStep("step", PlanPending))
829	}
830	if got := normalizeSessionPlan(long); got == nil || len(got.Steps) != 50 {
831		t.Fatalf("plan was capped: %d steps", len(got.Steps))
832	}
833}
834
835func appendPlanTranscript(t *testing.T, sess *Session) {
836	t.Helper()
837	messages := []provider.Message{
838		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "start"}}},
839		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
840			ID:   "legacy-1",
841			Name: legacyPlanToolName,
842			Arguments: []byte(`{"explanation":"initial","plan":[
843				{"step":"one","status":"in_progress"},
844				{"step":"two","status":"pending"}
845			]}`),
846		}}},
847		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
848			CallID:  "legacy-1",
849			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
850		}}},
851		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
852			ID:        "plan-2",
853			Name:      planToolName,
854			Arguments: []byte(`{"action":"add","steps":[{"step":"three","status":"pending"}]}`),
855		}}},
856		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
857			CallID:  "plan-2",
858			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
859		}}},
860		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "more"}}},
861	}
862	for _, msg := range messages {
863		if err := sess.AppendMessage(msg); err != nil {
864			t.Fatalf("AppendMessage: %v", err)
865		}
866	}
867}
868
869// TestPlanUpdateSnapshotsRebuildsPerCallChecklists guards the load-time replay
870// the host renders from: a transcript mixing the legacy update_plan full-list
871// shape and the plan delta shape must yield the materialized checklist after
872// each applied call, keyed by call id, while failed and read-only calls produce
873// no snapshot.
874func TestPlanUpdateSnapshotsRebuildsPerCallChecklists(t *testing.T) {
875	explanation := "replanned"
876	messages := []provider.Message{
877		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
878			ID:        "legacy-1",
879			Name:      legacyPlanToolName,
880			Arguments: []byte(`{"plan":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"}]}`),
881		}}},
882		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
883			CallID:  "legacy-1",
884			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
885		}}},
886		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
887			ID:        "delta-1",
888			Name:      planToolName,
889			Arguments: []byte(`{"action":"add","explanation":"replanned","steps":[{"step":"three"}]}`),
890		}}},
891		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
892			CallID:  "delta-1",
893			Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
894		}}},
895		// A read never changes state and must not produce a snapshot.
896		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
897			ID:        "show-1",
898			Name:      planToolName,
899			Arguments: []byte(`{"action":"show"}`),
900		}}},
901		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
902			CallID:  "show-1",
903			Content: []provider.Content{provider.TextBlock{Text: "1. [x] one"}},
904		}}},
905		// A rejected call left a transcript row but changed nothing.
906		{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
907			ID:        "rejected-1",
908			Name:      planToolName,
909			Arguments: []byte(`{"action":"set","steps":[{"step":"never","status":"pending"}]}`),
910		}}},
911		{Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
912			CallID:  "rejected-1",
913			IsError: true,
914			Content: []provider.Content{provider.TextBlock{Text: "plan: step text is required"}},
915		}}},
916	}
917
918	snapshots := PlanUpdateSnapshots(messages)
919
920	legacy, ok := snapshots["legacy-1"]
921	if !ok {
922		t.Fatalf("legacy call produced no snapshot: %#v", snapshots)
923	}
924	wantLegacy := []PlanStep{planStep("one", PlanCompleted), planStep("two", PlanInProgress)}
925	if !slices.Equal(legacy.Plan, wantLegacy) {
926		t.Fatalf("legacy snapshot = %#v, want %#v", legacy.Plan, wantLegacy)
927	}
928
929	delta, ok := snapshots["delta-1"]
930	if !ok {
931		t.Fatalf("delta call produced no snapshot: %#v", snapshots)
932	}
933	wantDelta := []PlanStep{planStep("one", PlanCompleted), planStep("two", PlanInProgress), planStep("three", PlanPending)}
934	if !slices.Equal(delta.Plan, wantDelta) {
935		t.Fatalf("delta snapshot = %#v, want %#v", delta.Plan, wantDelta)
936	}
937	if delta.Explanation == nil || *delta.Explanation != explanation {
938		t.Fatalf("delta explanation = %#v, want %q", delta.Explanation, explanation)
939	}
940
941	if snap, ok := snapshots["show-1"]; ok {
942		t.Fatalf("read-only call produced a snapshot: %#v", snap)
943	}
944	if snap, ok := snapshots["rejected-1"]; ok {
945		t.Fatalf("rejected call produced a snapshot: %#v", snap)
946	}
947
948	// Each snapshot must own its slice: mutating the newest must not corrupt an
949	// earlier checklist the transcript still renders.
950	delta.Plan[0].Step = "mutated"
951	if legacy.Plan[0].Step != "one" {
952		t.Fatalf("snapshots share a backing slice: legacy = %#v", legacy.Plan)
953	}
954}

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