v0.29.1 d92a60f

subagent_session_test.go RawHistoryFolder

1package modes
2
3import (
4	"context"
5	"encoding/json"
6	"strings"
7	"testing"
8	"time"
9
10	"github.com/bnema/zut/packages/agent/subagents"
11	"github.com/bnema/zut/packages/core"
12	"github.com/bnema/zut/packages/provider"
13	"github.com/bnema/zut/packages/tui"
14)
15
16func TestResidentChildSessionBuildsSharedTranscriptView(t *testing.T) {
17	root := t.TempDir()
18	journal, err := subagents.OpenResidentJournal(root, "child")
19	if err != nil {
20		t.Fatal(err)
21	}
22	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
23	if err := journal.Accept(spec, "task"); err != nil {
24		t.Fatal(err)
25	}
26	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call", Name: "bash", Arguments: json.RawMessage(`{"command":"pwd"}`)}}}}); err != nil {
27		t.Fatal(err)
28	}
29	if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "/repo"}}}}); err != nil {
30		t.Fatal(err)
31	}
32	if err := journal.Close(); err != nil {
33		t.Fatal(err)
34	}
35	manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36		return func(context.Context, string) error { return nil }, nil
37	})
38	session := newResidentChildSession(manager, "child", tui.Dark)
39	if err := session.LoadRecent(20); err != nil {
40		t.Fatal(err)
41	}
42	view := session.View()
43	if view == nil || !view.ExpandAll || len(view.Messages) != 2 {
44		t.Fatalf("view = %#v", view)
45	}
46	lines := view.Build(80)
47	if len(lines) == 0 {
48		t.Fatal("shared view rendered no child history")
49	}
50}
51
52func TestResidentChildSessionKeepsComposerUntilFollowUpIsAccepted(t *testing.T) {
53	root := t.TempDir()
54	runs := make(chan string, 2)
55	manager := subagents.NewResidentManager(root, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
56		return func(_ context.Context, prompt string) error {
57			runs <- prompt
58			return nil
59		}, nil
60	})
61	t.Cleanup(func() {
62		if err := manager.Close(context.Background()); err != nil {
63			t.Error(err)
64		}
65	})
66	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
67	if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
68		t.Fatal(err)
69	}
70	if got := <-runs; got != "initial task" {
71		t.Fatalf("initial prompt = %q", got)
72	}
73	session := newResidentChildSession(manager, "child", tui.Dark)
74	session.composer.SetValue("follow up")
75	prompt, submit := session.HandleKey(tui.Key{Kind: tui.KeyEnter})
76	if !submit || prompt != "follow up" {
77		t.Fatalf("submission = (%q, %v)", prompt, submit)
78	}
79	if got := session.composer.Value(); got != "follow up" {
80		t.Fatalf("composer cleared before durable acceptance: %q", got)
81	}
82	if err := manager.Resume(context.Background(), "child", prompt); err != nil {
83		t.Fatal(err)
84	}
85	session.FinishSubmission(nil)
86	if got := session.composer.Value(); got != "" {
87		t.Fatalf("composer after acceptance = %q", got)
88	}
89	if got := <-runs; got != "follow up" {
90		t.Fatalf("follow-up prompt = %q", got)
91	}
92}
93
94func TestResidentChildSessionPreservesScrolledViewportAndMarksUnread(t *testing.T) {
95	session := newResidentChildSession(nil, "child", tui.Dark)
96	session.view.Messages = []provider.Message{
97		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
98		{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first response"}}},
99		{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second"}}},
100	}
101	_ = session.Render(60, 12)
102	session.Scroll(2)
103	session.view.Messages = append(session.view.Messages, provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "new response"}}})
104	lines := session.Render(60, 12)
105	if !containsLine(lines, "new updates below") {
106		t.Fatalf("scrolled child session did not mark unread updates: %q", lines)
107	}
108	session.FollowTail()
109	lines = session.Render(60, 12)
110	if containsLine(lines, "new updates below") {
111		t.Fatalf("following tail retained unread marker: %q", lines)
112	}
113}
114
115func TestResidentChildSessionReloadRecentShowsFinalizedTurn(t *testing.T) {
116	root := t.TempDir()
117	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
118	journal, err := subagents.OpenResidentJournal(root, spec.ID)
119	if err != nil {
120		t.Fatal(err)
121	}
122	if err := journal.Accept(spec, "task"); err != nil {
123		t.Fatal(err)
124	}
125	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first"}}}}); err != nil {
126		t.Fatal(err)
127	}
128	if err := journal.Close(); err != nil {
129		t.Fatal(err)
130	}
131	manager := subagents.NewResidentManager(root, nil)
132	session := newResidentChildSession(manager, spec.ID, tui.Dark)
133	if err := session.LoadRecent(20); err != nil {
134		t.Fatal(err)
135	}
136	journal, err = subagents.OpenResidentJournal(root, spec.ID)
137	if err != nil {
138		t.Fatal(err)
139	}
140	if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "finalized without scrolling"}}}}); err != nil {
141		t.Fatal(err)
142	}
143	if err := journal.Close(); err != nil {
144		t.Fatal(err)
145	}
146	if err := session.ReloadRecent(20); err != nil {
147		t.Fatal(err)
148	}
149	if got := strings.Join(session.View().Build(80), "\n"); !strings.Contains(got, "finalized without scrolling") {
150		t.Fatalf("reloaded child session omitted finalized turn: %q", got)
151	}
152}
153
154func TestResidentChildSessionCoalescesCompletionDuringHistoryLoad(t *testing.T) {
155	session := newResidentChildSession(nil, "child", tui.Dark)
156	if !session.BeginLoad() {
157		t.Fatal("initial history load was not accepted")
158	}
159	if session.RequestRecentReload() {
160		t.Fatal("completion refresh started alongside active history load")
161	}
162	if !session.FinishLoad(nil) {
163		t.Fatal("completion refresh was not retained after active history load")
164	}
165}
166
167func TestResidentChildSessionKeepsReloadReservationUntilFinishLoad(t *testing.T) {
168	session := newResidentChildSession(nil, "child", tui.Dark)
169	if !session.BeginLoad() {
170		t.Fatal("initial history load was not accepted")
171	}
172	if session.RequestRecentReload() {
173		t.Fatal("second history load started alongside active history load")
174	}
175	// replaceRecent is the tail of the first reader. Its reservation must
176	// remain held until the controller consumes the pending reload through
177	// FinishLoad, otherwise another durable event can start a concurrent read.
178	session.replaceRecent(nil, "")
179	if session.RequestRecentReload() {
180		t.Fatal("history reload started before the first reader finished")
181	}
182	if !session.FinishLoad(nil) {
183		t.Fatal("coalesced history reload was lost")
184	}
185	if !session.RequestRecentReload() {
186		t.Fatal("coalesced history reload did not reserve after first reader finished")
187	}
188}
189
190func TestResidentChildSessionRenderRespectsRequestedHeight(t *testing.T) {
191	session := newResidentChildSession(nil, "child", tui.Dark)
192	for _, height := range []int{0, 1, 2} {
193		if lines := session.Render(80, height); len(lines) > height {
194			t.Fatalf("height %d rendered %d lines: %#v", height, len(lines), lines)
195		}
196	}
197}
198
199func TestInteractiveResidentChildMouseWheelScrollsHistory(t *testing.T) {
200	interactive := NewInteractive(InteractiveConfig{Theme: tui.Dark})
201	session := newResidentChildSession(nil, "child", tui.Dark)
202	interactive.residentChildSession = session
203
204	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelUp})
205	if session.scrollOffset != 1 {
206		t.Fatalf("wheel up scroll offset = %d, want 1", session.scrollOffset)
207	}
208	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyMouseWheelDown})
209	if session.scrollOffset != 0 {
210		t.Fatalf("wheel down scroll offset = %d, want 0", session.scrollOffset)
211	}
212}
213
214func TestInteractiveResidentChildMouseReportingFollowsOverlay(t *testing.T) {
215	term := &alertTestTerminal{}
216	interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark})
217	interactive.runCtx = context.Background()
218	interactive.setResidentChildMouseReporting(true)
219	interactive.setResidentChildMouseReporting(false)
220	if got := term.String(); got != tui.SeqMouseOn+tui.SeqMouseOff {
221		t.Fatalf("mouse reporting sequences = %q", got)
222	}
223}
224
225func TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
226	root := t.TempDir()
227	started := make(chan struct{})
228	stream := make(chan struct{})
229	release := make(chan struct{})
230	manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
231		return func(context.Context, string) error {
232			close(started)
233			<-stream
234			if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {
235				return err
236			}
237			<-release
238			return nil
239		}, nil
240	})
241	t.Cleanup(func() {
242		close(release)
243		if err := manager.Close(context.Background()); err != nil {
244			t.Error(err)
245		}
246	})
247	interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
248	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
249	if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
250		t.Fatal(err)
251	}
252	<-started
253	interactive.residentChildSession = newResidentChildSession(manager, "child", tui.Dark)
254	for {
255		select {
256		case <-interactive.dirty:
257		default:
258			goto drained
259		}
260	}
261
262drained:
263	close(stream)
264	select {
265	case <-interactive.dirty:
266	case <-time.After(time.Second):
267		t.Fatal("resident stream did not request a redraw")
268	}
269	lines := interactive.residentChildSession.Render(80, 24)
270	if !containsLine(lines, "streamed without input") {
271		t.Fatalf("open resident child session did not render streamed text: %q", lines)
272	}
273}
274
275func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) {
276	root := t.TempDir()
277	started := make(chan struct{})
278	persist := make(chan struct{})
279	release := make(chan struct{})
280	manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
281		return func(context.Context, string) error {
282			close(started)
283			<-persist
284			if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ToolCallBlock{ID: "call-1", Name: "bash", Arguments: json.RawMessage(`{"command":"printf fresh"}`)}}}}); err != nil {
285				return err
286			}
287			if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "fresh resident output"}}}}); err != nil {
288				return err
289			}
290			<-release
291			return nil
292		}, nil
293	})
294	t.Cleanup(func() {
295		close(release)
296		if err := manager.Close(context.Background()); err != nil {
297			t.Error(err)
298		}
299	})
300	interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
301	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
302	if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
303		t.Fatal(err)
304	}
305	<-started
306	session := newResidentChildSession(manager, spec.ID, tui.Dark)
307	if err := session.LoadRecent(200); err != nil {
308		t.Fatal(err)
309	}
310	interactive.residentChildSession = session
311	close(persist)
312
313	deadline := time.Now().Add(time.Second)
314	for {
315		if lines := session.Render(100, 30); containsLine(lines, "fresh resident output") {
316			return
317		}
318		if time.Now().After(deadline) {
319			t.Fatalf("durable resident history did not refresh while running: %q", session.Render(100, 30))
320		}
321		time.Sleep(time.Millisecond)
322	}
323}
324
325func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *testing.T) {
326	root := t.TempDir()
327	started := make(chan struct{})
328	persist := make(chan struct{})
329	persisted := make(chan struct{})
330	release := make(chan struct{})
331	manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
332		return func(context.Context, string) error {
333			close(started)
334			<-persist
335			if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {
336				return err
337			}
338			close(persisted)
339			<-release
340			return nil
341		}, nil
342	})
343	t.Cleanup(func() {
344		close(release)
345		if err := manager.Close(context.Background()); err != nil {
346			t.Error(err)
347		}
348	})
349	interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
350	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
351	if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
352		t.Fatal(err)
353	}
354	<-started
355
356	interactive.mu.Lock()
357	interactive.residentChildSession = newResidentChildSession(manager, spec.ID, tui.Dark)
358	defer interactive.mu.Unlock()
359	close(persist)
360	select {
361	case <-persisted:
362	case <-time.After(time.Second):
363		t.Fatal("resident history update blocked the child control goroutine")
364	}
365}
366
367func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {
368	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
369		return func(context.Context, string) error { return nil }, nil
370	})
371	t.Cleanup(func() {
372		if err := manager.Close(context.Background()); err != nil {
373			t.Error(err)
374		}
375	})
376	spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
377	if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
378		t.Fatal(err)
379	}
380	interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
381	interactive.ed.SetValue("draft")
382
383	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyDown})
384	if !interactive.residentSubagentsDialog.Active() {
385		t.Fatal("Down did not open the resident subagent picker")
386	}
387	if got := interactive.ed.Value(); got != "draft" {
388		t.Fatalf("editor value = %q, want draft preserved", got)
389	}
390	interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
391	if interactive.residentSubagentsDialog.Active() {
392		t.Fatal("Enter left the resident subagent picker open")
393	}
394	if interactive.residentChildSession == nil || interactive.residentChildSession.childID != spec.ID {
395		t.Fatalf("resident child session = %#v, want %q", interactive.residentChildSession, spec.ID)
396	}
397}
398
399func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {
400	started := make(chan struct{})
401	release := make(chan struct{})
402	manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
403		return func(context.Context, string) error {
404			close(started)
405			<-release
406			return nil
407		}, nil
408	})
409	t.Cleanup(func() {
410		if err := manager.Close(context.Background()); err != nil {
411			t.Error(err)
412		}
413	})
414	interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
415	if interactive.residentAnimating.Load() {
416		t.Fatal("new interactive reported resident animation")
417	}
418	if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "indicator", SessionID: "session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
419		t.Fatal(err)
420	}
421	<-started
422	if !interactive.residentAnimating.Load() {
423		t.Fatal("running resident did not enable independent animation")
424	}
425	close(release)
426	deadline := time.Now().Add(time.Second)
427	for interactive.residentAnimating.Load() {
428		if time.Now().After(deadline) {
429			t.Fatal("completed resident did not disable independent animation")
430		}
431		time.Sleep(time.Millisecond)
432	}
433}
434
435func containsLine(lines []string, needle string) bool {
436	for _, line := range lines {
437		if strings.Contains(line, needle) {
438			return true
439		}
440	}
441	return false
442}

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