v0.51.0 a9d674e

interactive_plan_test.go RawHistoryFolder

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}

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