1package core
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/provider"
10)
11
12// planCompactionAgent returns an agent whose retained internal-context message
13// is "clock", so a test can tell exactly what the plan block was merged into.
14func planCompactionAgent(t *testing.T, steps []PlanStep) *Agent {
15 t.Helper()
16 agent := NewAgent(&compactLifecycleClient{}, "compact-model", "system", Registry{})
17 agent.SetMessages([]provider.Message{
18 {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "clock"}}, Meta: map[string]string{internalContextMarker: "true"}},
19 textMessage(provider.RoleUser, "do the thing"),
20 textMessage(provider.RoleAssistant, "working"),
21 })
22 agent.SetPlan(steps)
23 return agent
24}
25
26func compactOnce(t *testing.T, agent *Agent) {
27 t.Helper()
28 if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
29 t.Fatalf("Compact: %v", err)
30 }
31}
32
33func TestCompactCarriesPlanIntoRetainedInternalContext(t *testing.T) {
34 agent := planCompactionAgent(t, []PlanStep{
35 {Step: "first", Status: PlanCompleted},
36 {Step: "second", Status: PlanInProgress},
37 {Step: "third", Status: PlanPending},
38 })
39 compactOnce(t, agent)
40
41 // Assert on the post-compaction message list, not only the summarize call:
42 // the block has to survive the rebuild that replaces the transcript.
43 messages := agent.Messages()
44 if len(messages) != 2 {
45 t.Fatalf("compacted messages = %d, want retained internal context then summary", len(messages))
46 }
47 if messages[0].Role != provider.RoleDeveloper || !isInternalContextMessage(messages[0]) {
48 t.Fatalf("first post-compaction message = %#v, want retained internal context", messages[0])
49 }
50 want := "clock\n\n## Current plan\n1. [x] first\n2. [~] second\n3. [ ] third"
51 if got := internalContextText(messages[0]); got != want {
52 t.Fatalf("post-compaction internal context =\n%q\nwant\n%q", got, want)
53 }
54}
55
56func TestCompactWithoutPlanLeavesInternalContextByteIdentical(t *testing.T) {
57 agent := planCompactionAgent(t, nil)
58 before := internalContextText(agent.Messages()[0])
59 compactOnce(t, agent)
60
61 messages := agent.Messages()
62 got := internalContextText(messages[0])
63 if got != before {
64 t.Fatalf("internal context changed without a plan: got %q, want %q", got, before)
65 }
66 if strings.Contains(got, planContextHeader) {
67 t.Fatalf("internal context gained a plan block without a plan: %q", got)
68 }
69}
70
71func TestCompactPlanBlockTruncatesAfterTwelveSteps(t *testing.T) {
72 steps := make([]PlanStep, 15)
73 for i := range steps {
74 steps[i] = PlanStep{Step: fmt.Sprintf("step %d", i+1), Status: PlanPending}
75 }
76 agent := planCompactionAgent(t, steps)
77 compactOnce(t, agent)
78
79 got := internalContextText(agent.Messages()[0])
80 if !strings.Contains(got, "12. [ ] step 12") {
81 t.Fatalf("block did not include the twelfth step:\n%s", got)
82 }
83 if !strings.Contains(got, `…and 3 more (call action:"show")`) {
84 t.Fatalf("block missing truncation trailer:\n%s", got)
85 }
86 if strings.Contains(got, "13. [") {
87 t.Fatalf("block included a step past the cap:\n%s", got)
88 }
89}
90
91func TestCompactPlanBlockReplacesOnSecondCompaction(t *testing.T) {
92 agent := planCompactionAgent(t, []PlanStep{{Step: "alpha", Status: PlanPending}})
93 compactOnce(t, agent)
94
95 agent.SetPlan([]PlanStep{{Step: "beta", Status: PlanInProgress}})
96 compactOnce(t, agent)
97
98 got := internalContextText(agent.Messages()[0])
99 if count := strings.Count(got, planContextHeader); count != 1 {
100 t.Fatalf("plan header appeared %d times, want 1:\n%s", count, got)
101 }
102 if strings.Contains(got, "alpha") {
103 t.Fatalf("stale plan block survived the second compaction:\n%s", got)
104 }
105 want := "clock\n\n## Current plan\n1. [~] beta"
106 if got != want {
107 t.Fatalf("second-compaction internal context =\n%q\nwant\n%q", got, want)
108 }
109}
110
111func TestCompactPlanBlockHiddenWithoutRetainedInternalContext(t *testing.T) {
112 agent := NewAgent(&compactLifecycleClient{}, "compact-model", "system", Registry{})
113 agent.SetMessages([]provider.Message{
114 textMessage(provider.RoleUser, "do the thing"),
115 textMessage(provider.RoleAssistant, "working"),
116 })
117 agent.SetPlan([]PlanStep{{Step: "alpha", Status: PlanPending}})
118 compactOnce(t, agent)
119
120 // No retained internal-context message means nowhere to merge the block:
121 // the transcript stays the bare summary.
122 for _, message := range agent.Messages() {
123 if strings.Contains(internalContextText(message), planContextHeader) {
124 t.Fatalf("plan block injected without a retained internal-context message: %#v", agent.Messages())
125 }
126 }
127}