v0.51.0 a9d674e

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

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