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}