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 TestInteractiveResidentUpdatesRefreshOpenChildSession(t *testing.T) {
200 root := t.TempDir()
201 started := make(chan struct{})
202 stream := make(chan struct{})
203 release := make(chan struct{})
204 manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
205 return func(context.Context, string) error {
206 close(started)
207 <-stream
208 if err := journal.RecordAgentEvent(core.EvTextDelta{Delta: "streamed without input"}); err != nil {
209 return err
210 }
211 <-release
212 return nil
213 }, nil
214 })
215 t.Cleanup(func() {
216 close(release)
217 if err := manager.Close(context.Background()); err != nil {
218 t.Error(err)
219 }
220 })
221 interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
222 spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
223 if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
224 t.Fatal(err)
225 }
226 <-started
227 interactive.residentChildSession = newResidentChildSession(manager, "child", tui.Dark)
228 for {
229 select {
230 case <-interactive.dirty:
231 default:
232 goto drained
233 }
234 }
235
236drained:
237 close(stream)
238 select {
239 case <-interactive.dirty:
240 case <-time.After(time.Second):
241 t.Fatal("resident stream did not request a redraw")
242 }
243 lines := interactive.residentChildSession.Render(80, 24)
244 if !containsLine(lines, "streamed without input") {
245 t.Fatalf("open resident child session did not render streamed text: %q", lines)
246 }
247}
248
249func TestInteractiveResidentHistoryUpdatesRefreshOpenChildSession(t *testing.T) {
250 root := t.TempDir()
251 started := make(chan struct{})
252 persist := make(chan struct{})
253 release := make(chan struct{})
254 manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
255 return func(context.Context, string) error {
256 close(started)
257 <-persist
258 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 {
259 return err
260 }
261 if err := journal.RecordAgentEvent(core.EvToolResult{ID: "call-1", Result: core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: "fresh resident output"}}}}); err != nil {
262 return err
263 }
264 <-release
265 return nil
266 }, nil
267 })
268 t.Cleanup(func() {
269 close(release)
270 if err := manager.Close(context.Background()); err != nil {
271 t.Error(err)
272 }
273 })
274 interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
275 spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
276 if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
277 t.Fatal(err)
278 }
279 <-started
280 session := newResidentChildSession(manager, spec.ID, tui.Dark)
281 if err := session.LoadRecent(200); err != nil {
282 t.Fatal(err)
283 }
284 interactive.residentChildSession = session
285 close(persist)
286
287 deadline := time.Now().Add(time.Second)
288 for {
289 if lines := session.Render(100, 30); containsLine(lines, "fresh resident output") {
290 return
291 }
292 if time.Now().After(deadline) {
293 t.Fatalf("durable resident history did not refresh while running: %q", session.Render(100, 30))
294 }
295 time.Sleep(time.Millisecond)
296 }
297}
298
299func TestInteractiveResidentHistoryUpdateDoesNotBlockChildControlGoroutine(t *testing.T) {
300 root := t.TempDir()
301 started := make(chan struct{})
302 persist := make(chan struct{})
303 persisted := make(chan struct{})
304 release := make(chan struct{})
305 manager := subagents.NewResidentManager(root, func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
306 return func(context.Context, string) error {
307 close(started)
308 <-persist
309 if err := journal.RecordAgentEvent(core.EvAssistantMessage{Message: provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "durable update"}}}}); err != nil {
310 return err
311 }
312 close(persisted)
313 <-release
314 return nil
315 }, nil
316 })
317 t.Cleanup(func() {
318 close(release)
319 if err := manager.Close(context.Background()); err != nil {
320 t.Error(err)
321 }
322 })
323 interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
324 spec := subagents.ResidentChildSpec{ID: "child", SessionID: "session", Provider: "openai", Model: "gpt-5"}
325 if _, err := manager.Spawn(context.Background(), spec, "initial task"); err != nil {
326 t.Fatal(err)
327 }
328 <-started
329
330 interactive.mu.Lock()
331 interactive.residentChildSession = newResidentChildSession(manager, spec.ID, tui.Dark)
332 defer interactive.mu.Unlock()
333 close(persist)
334 select {
335 case <-persisted:
336 case <-time.After(time.Second):
337 t.Fatal("resident history update blocked the child control goroutine")
338 }
339}
340
341func TestInteractiveInputDownOpensResidentPickerAndEnterOpensLiveChild(t *testing.T) {
342 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
343 return func(context.Context, string) error { return nil }, nil
344 })
345 t.Cleanup(func() {
346 if err := manager.Close(context.Background()); err != nil {
347 t.Error(err)
348 }
349 })
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 interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
355 interactive.ed.SetValue("draft")
356
357 interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyDown})
358 if !interactive.residentSubagentsDialog.Active() {
359 t.Fatal("Down did not open the resident subagent picker")
360 }
361 if got := interactive.ed.Value(); got != "draft" {
362 t.Fatalf("editor value = %q, want draft preserved", got)
363 }
364 interactive.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter})
365 if interactive.residentSubagentsDialog.Active() {
366 t.Fatal("Enter left the resident subagent picker open")
367 }
368 if interactive.residentChildSession == nil || interactive.residentChildSession.childID != spec.ID {
369 t.Fatalf("resident child session = %#v, want %q", interactive.residentChildSession, spec.ID)
370 }
371}
372
373func TestInteractiveResidentActivityDrivesIndependentAnimation(t *testing.T) {
374 started := make(chan struct{})
375 release := make(chan struct{})
376 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
377 return func(context.Context, string) error {
378 close(started)
379 <-release
380 return nil
381 }, nil
382 })
383 t.Cleanup(func() {
384 if err := manager.Close(context.Background()); err != nil {
385 t.Error(err)
386 }
387 })
388 interactive := NewInteractive(InteractiveConfig{ResidentManager: manager, Theme: tui.Dark})
389 if interactive.residentAnimating.Load() {
390 t.Fatal("new interactive reported resident animation")
391 }
392 if _, err := manager.Spawn(context.Background(), subagents.ResidentChildSpec{ID: "indicator", SessionID: "session", Provider: "openai", Model: "gpt-5"}, "task"); err != nil {
393 t.Fatal(err)
394 }
395 <-started
396 if !interactive.residentAnimating.Load() {
397 t.Fatal("running resident did not enable independent animation")
398 }
399 close(release)
400 deadline := time.Now().Add(time.Second)
401 for interactive.residentAnimating.Load() {
402 if time.Now().After(deadline) {
403 t.Fatal("completed resident did not disable independent animation")
404 }
405 time.Sleep(time.Millisecond)
406 }
407}
408
409func containsLine(lines []string, needle string) bool {
410 for _, line := range lines {
411 if strings.Contains(line, needle) {
412 return true
413 }
414 }
415 return false
416}