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.ResidentRuntime, error) {
36 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
56 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
231 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
281 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
332 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
369 return subagents.ResidentTurnRunner(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.ResidentRuntime, error) {
403 return subagents.ResidentTurnRunner(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}