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}