1package modes
2
3import (
4 "context"
5 "fmt"
6 "slices"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13)
14
15func interactivePlanCounters(i *Interactive) (current, total int) {
16 i.mu.Lock()
17 defer i.mu.Unlock()
18 return i.planCurrent, i.planTotal
19}
20
21func interactivePlanSnapshotCount(i *Interactive) int {
22 i.mu.Lock()
23 defer i.mu.Unlock()
24 return len(i.planSnapshots)
25}
26
27func interactivePlanSnapshot(i *Interactive, callID string) core.PlanUpdate {
28 i.mu.Lock()
29 defer i.mu.Unlock()
30 return i.planSnapshots[callID]
31}
32
33func TestInteractiveTracksCurrentPlanStep(t *testing.T) {
34 interactive := &Interactive{}
35 interactive.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: []core.PlanStep{
36 {Step: "Inspect", Status: core.PlanCompleted},
37 {Step: "Implement", Status: core.PlanInProgress},
38 {Step: "Verify", Status: core.PlanPending},
39 }}})
40
41 if current, total := interactivePlanCounters(interactive); current != 2 || total != 3 {
42 t.Fatalf("plan progress = %d/%d, want 2/3", current, total)
43 }
44 if got := interactivePlanSnapshot(interactive, "plan-1").Plan; len(got) != 3 {
45 t.Fatalf("plan snapshot = %#v, want the three-step checklist", got)
46 }
47
48 interactive.handleEvent(core.EvPlanUpdate{CallID: "plan-2", Update: core.PlanUpdate{Plan: []core.PlanStep{
49 {Step: "Inspect", Status: core.PlanCompleted},
50 }}})
51 if current, total := interactivePlanCounters(interactive); current != 0 || total != 1 {
52 t.Fatalf("completed plan progress = %d/%d, want hidden current with total 1", current, total)
53 }
54}
55
56// TestInteractivePlanCountersSurviveSecondTurn guards the removed per-turn reset:
57// a new turn must not wipe the session-scoped counters.
58func TestInteractivePlanCountersSurviveSecondTurn(t *testing.T) {
59 ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
60 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: &alertTestTerminal{}})
61 i.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: []core.PlanStep{
62 {Step: "one", Status: core.PlanCompleted},
63 {Step: "two", Status: core.PlanInProgress},
64 }}})
65
66 ctx, cancel := context.WithCancel(context.Background())
67 defer cancel()
68 i.runCtx = ctx
69 go i.runStreamPacer(ctx)
70 i.startTurn(ctx, "second turn")
71
72 // Wait until the turn owns the busy slot, which is after the turn-start
73 // reset site that used to clear planCurrent/planTotal.
74 deadline := time.Now().Add(time.Second)
75 started := false
76 for time.Now().Before(deadline) {
77 i.mu.Lock()
78 busy := i.busy
79 i.mu.Unlock()
80 if busy {
81 started = true
82 break
83 }
84 time.Sleep(time.Millisecond)
85 }
86 if !started {
87 t.Fatal("second turn did not start")
88 }
89 if current, total := interactivePlanCounters(i); current != 2 || total != 2 {
90 t.Fatalf("plan counters after a new turn = %d/%d, want 2/2", current, total)
91 }
92 i.CancelTurn()
93}
94
95// TestInteractiveRefreshPlanSeedsCountersAndSnapshots covers the resume path:
96// the CLI seeds the agent's core plan from the loaded session, and the host
97// derives its counters and historical checklists from it.
98func TestInteractiveRefreshPlanSeedsCountersAndSnapshots(t *testing.T) {
99 ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
100 ag.SetMessages([]provider.Message{
101 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
102 ID: "legacy-1", Name: "update_plan", Arguments: []byte(`{"plan":[{"step":"one","status":"completed"},{"step":"two","status":"in_progress"}]}`),
103 }}},
104 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
105 CallID: "legacy-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
106 }}},
107 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{
108 ID: "delta-1", Name: "plan", Arguments: []byte(`{"action":"add","steps":[{"step":"three"}]}`),
109 }}},
110 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{
111 CallID: "delta-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}},
112 }}},
113 })
114 wantPlan := []core.PlanStep{
115 {Step: "one", Status: core.PlanCompleted},
116 {Step: "two", Status: core.PlanInProgress},
117 {Step: "three", Status: core.PlanPending},
118 }
119 ag.SetPlan(wantPlan)
120
121 i := NewInteractive(InteractiveConfig{Agent: ag, CurrentPlan: ag.CurrentPlan})
122 // Clear the construction-time seed so RefreshPlan is what rebuilds it.
123 i.mu.Lock()
124 i.planSnapshots = nil
125 i.planRevision = 0
126 i.mu.Unlock()
127
128 i.RefreshPlan(core.PlanUpdateSnapshots(ag.Messages()))
129
130 if current, total := interactivePlanCounters(i); current != 2 || total != 3 {
131 t.Fatalf("refreshed counters = %d/%d, want 2/3", current, total)
132 }
133 i.mu.Lock()
134 legacy := i.planSnapshots["legacy-1"]
135 delta := i.planSnapshots["delta-1"]
136 i.mu.Unlock()
137 if !slices.Equal(legacy.Plan, []core.PlanStep{
138 {Step: "one", Status: core.PlanCompleted},
139 {Step: "two", Status: core.PlanInProgress},
140 }) {
141 t.Fatalf("legacy snapshot = %#v", legacy.Plan)
142 }
143 if !slices.Equal(delta.Plan, []core.PlanStep{
144 {Step: "one", Status: core.PlanCompleted},
145 {Step: "two", Status: core.PlanInProgress},
146 {Step: "three", Status: core.PlanPending},
147 }) {
148 t.Fatalf("delta snapshot = %#v", delta.Plan)
149 }
150}
151
152// TestInteractiveClearDropsPlanEverywhere covers /clear: core state, the
153// counters, the snapshot map, and the persisted session file.
154func TestInteractiveClearDropsPlanEverywhere(t *testing.T) {
155 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "test")
156 if err != nil {
157 t.Fatal(err)
158 }
159 t.Cleanup(func() { _ = sess.Close() })
160 if err := sess.AppendMessage(provider.Message{
161 Role: provider.RoleUser,
162 Content: []provider.Content{provider.TextBlock{Text: "hello"}},
163 }); err != nil {
164 t.Fatal(err)
165 }
166 steps := []core.PlanStep{{Step: "one", Status: core.PlanPending}}
167 if err := sess.UpdatePlan(&core.SessionPlan{Steps: steps}); err != nil {
168 t.Fatal(err)
169 }
170
171 ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
172 ag.SetPlan(steps)
173 persisted := 0
174 i := NewInteractive(InteractiveConfig{
175 Agent: ag,
176 CurrentPlan: ag.CurrentPlan,
177 PersistPlan: func(plan *core.SessionPlan) error {
178 persisted++
179 return sess.UpdatePlan(plan)
180 },
181 })
182 i.handleEvent(core.EvPlanUpdate{CallID: "plan-1", Update: core.PlanUpdate{Plan: steps}})
183
184 i.runSlash(context.Background(), "/clear")
185
186 if got := ag.CurrentPlan(); got != nil {
187 t.Fatalf("core plan after /clear = %#v, want nil", got)
188 }
189 if current, total := interactivePlanCounters(i); current != 0 || total != 0 {
190 t.Fatalf("counters after /clear = %d/%d, want 0/0", current, total)
191 }
192 if got := interactivePlanSnapshotCount(i); got != 0 {
193 t.Fatalf("snapshot map after /clear has %d entries, want 0", got)
194 }
195 if persisted != 1 {
196 t.Fatalf("PersistPlan calls = %d, want 1", persisted)
197 }
198 if sess.Meta.Plan != nil {
199 t.Fatalf("in-memory session plan after /clear = %#v, want nil", sess.Meta.Plan)
200 }
201 reopened, _, err := core.OpenSession(sess.Path)
202 if err != nil {
203 t.Fatal(err)
204 }
205 defer reopened.Close()
206 if reopened.Meta.Plan != nil {
207 t.Fatalf("persisted session plan after /clear = %#v, want nil", reopened.Meta.Plan)
208 }
209}
210
211// TestInteractiveClearWithoutPlanDoesNotPersist keeps /clear a no-op when there
212// is nothing to drop, so a --no-session run never surfaces a persistence error.
213func TestInteractiveClearWithoutPlanDoesNotPersist(t *testing.T) {
214 ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
215 called := false
216 i := NewInteractive(InteractiveConfig{
217 Agent: ag,
218 CurrentPlan: ag.CurrentPlan,
219 PersistPlan: func(*core.SessionPlan) error {
220 called = true
221 return nil
222 },
223 })
224 i.runSlash(context.Background(), "/clear")
225 if called {
226 t.Fatal("/clear persisted an empty plan")
227 }
228}
229
230// TestInteractiveClearPersistsWhenCoreAndFileDiverge guards GO-005: /clear must
231// clear the file plan even when core state lost it, because the bound session
232// still carries it and a stale file would seed the next resume.
233func TestInteractiveClearPersistsWhenCoreAndFileDiverge(t *testing.T) {
234 sess, err := core.NewSession(t.TempDir(), t.TempDir(), "provider", "model", "test")
235 if err != nil {
236 t.Fatal(err)
237 }
238 t.Cleanup(func() { _ = sess.Close() })
239 if err := sess.UpdatePlan(&core.SessionPlan{Steps: []core.PlanStep{{Step: "one", Status: core.PlanPending}}}); err != nil {
240 t.Fatal(err)
241 }
242
243 // Core state is empty while the session file still holds a plan.
244 ag := core.NewAgent(alertBlockingClient{}, "model", "", nil)
245 persisted := 0
246 i := NewInteractive(InteractiveConfig{
247 Agent: ag,
248 CurrentPlan: ag.CurrentPlan,
249 PersistedPlan: func() *core.SessionPlan {
250 if sess.Meta.Plan == nil {
251 return nil
252 }
253 plan := *sess.Meta.Plan
254 return &plan
255 },
256 PersistPlan: func(plan *core.SessionPlan) error {
257 persisted++
258 return sess.UpdatePlan(plan)
259 },
260 })
261
262 i.runSlash(context.Background(), "/clear")
263
264 if persisted != 1 {
265 t.Fatalf("PersistPlan calls = %d, want 1 for a divergent session plan", persisted)
266 }
267 if sess.Meta.Plan != nil {
268 t.Fatalf("session plan after /clear = %#v, want nil", sess.Meta.Plan)
269 }
270}
271
272// TestInteractivePlanSnapshotsRaceWithRender publishes plan updates from one
273// goroutine while another renders a frame. Run under -race, it guards the
274// copy-on-write snapshot map and the render clone that carries it.
275func TestInteractivePlanSnapshotsRaceWithRender(t *testing.T) {
276 ag := core.NewAgent(alertBlockingClient{}, "test-model", "", nil)
277 ag.SetMessages([]provider.Message{
278 {Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "plan-1", Name: "plan", Arguments: []byte(`{"action":"set"}`)}}},
279 {Role: provider.RoleTool, Content: []provider.Content{provider.ToolResultBlock{CallID: "plan-1", Content: []provider.Content{provider.TextBlock{Text: "Plan updated"}}}}},
280 })
281 i := NewInteractive(InteractiveConfig{Agent: ag, Terminal: &alertTestTerminal{}})
282 i.mu.Lock()
283 i.planSnapshots = map[string]core.PlanUpdate{
284 "plan-1": {Plan: []core.PlanStep{{Step: "one", Status: core.PlanPending}}},
285 }
286 i.mu.Unlock()
287
288 stop := make(chan struct{})
289 var wg sync.WaitGroup
290 wg.Add(2)
291 go func() {
292 defer wg.Done()
293 for n := 0; ; n++ {
294 select {
295 case <-stop:
296 return
297 default:
298 }
299 i.handleEvent(core.EvPlanUpdate{
300 CallID: fmt.Sprintf("plan-%d", n%4),
301 Update: core.PlanUpdate{Plan: []core.PlanStep{{Step: fmt.Sprintf("step-%d", n), Status: core.PlanInProgress}}},
302 })
303 }
304 }()
305 go func() {
306 defer wg.Done()
307 for {
308 select {
309 case <-stop:
310 return
311 default:
312 }
313 i.redraw()
314 }
315 }()
316
317 deadline := time.Now().Add(250 * time.Millisecond)
318 for time.Now().Before(deadline) {
319 time.Sleep(5 * time.Millisecond)
320 }
321 close(stop)
322 done := make(chan struct{})
323 go func() { wg.Wait(); close(done) }()
324 select {
325 case <-done:
326 case <-time.After(5 * time.Second):
327 t.Fatal("plan publish/render did not finish")
328 }
329}
330
331// TestModelSwapCarriesPlanAcrossProviderRebuild covers the other agent rebuild
332// that D4 calls out: a cross-provider /model swap builds a fresh agent, so the
333// session-scoped plan has to be carried like the transcript and cost.
334func TestModelSwapCarriesPlanAcrossProviderRebuild(t *testing.T) {
335 targets := provider.ModelsForProvider("anthropic")
336 if len(targets) == 0 {
337 t.Skip("anthropic catalog unavailable")
338 }
339 target := targets[0]
340
341 ag := core.NewAgent(alertBlockingClient{}, "start-model", "", nil)
342 ag.SetMessages([]provider.Message{{
343 Role: provider.RoleUser,
344 Content: []provider.Content{provider.TextBlock{Text: "hello"}},
345 }})
346 want := []core.PlanStep{{Step: "one", Status: core.PlanPending}}
347 ag.SetPlan(want)
348
349 i := NewInteractive(InteractiveConfig{Agent: ag, Provider: "openai", Model: "start-model"})
350 rebuilt := core.NewAgent(alertBlockingClient{}, target.ID, "", nil)
351 replaced, ok := i.swapModelUnserialized(target.Provider, target.ID, func(string, string) (*core.Agent, string, string, error) {
352 return rebuilt, target.Provider, target.ID, nil
353 }, false)
354 if !ok || !replaced {
355 t.Fatalf("cross-provider swap replaced=%v ok=%v", replaced, ok)
356 }
357 if got := rebuilt.CurrentPlan(); !slices.Equal(got, want) {
358 t.Fatalf("plan after provider rebuild = %#v, want %#v", got, want)
359 }
360}