resident_subagent_indicator_test.go RawHistoryFolder
1package modes
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "testing"
8 "time"
9
10 "github.com/bnema/zut/packages/agent/subagents"
11 "github.com/bnema/zut/packages/provider"
12 "github.com/bnema/zut/packages/tui"
13 "github.com/mattn/go-runewidth"
14)
15
16func TestRenderResidentSubagentActivityLinesShowsOnlyActiveChildren(t *testing.T) {
17 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
18 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{
19 {ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning, TurnStartedAt: now.Add(-90 * time.Second), ActivityUpdatedAt: now, WaitingForModel: true},
20 {ID: "queued-id", Profile: "planner", State: subagents.ResidentQueued, TurnStartedAt: now.Add(-10 * time.Second), ActivityUpdatedAt: now.Add(-5 * time.Second)},
21 {ID: "foreign-id", Profile: "another process", State: subagents.ResidentRunning, OwnedElsewhere: true, TurnStartedAt: now.Add(-10 * time.Second)},
22 {ID: "idle-id", Profile: "finished", State: subagents.ResidentIdle},
23 }, 80, now)
24 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
25 if !strings.Contains(got, "/ reviewer · Zzzz · activity 0s ago · 1m30s") || !strings.Contains(got, "… planner · queued · activity 5s ago · 10s") {
26 t.Fatalf("activity lines = %q", got)
27 }
28 if strings.Contains(got, "finished") || strings.Contains(got, "another process") {
29 t.Fatalf("activity lines include inactive or foreign child: %q", got)
30 }
31}
32
33type residentIndicatorTerminal struct {
34 alertTestTerminal
35 cols int
36 rows int
37}
38
39func (t *residentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
40func (t *residentIndicatorTerminal) Reset() {
41 t.mu.Lock()
42 defer t.mu.Unlock()
43 t.data = nil
44}
45
46func TestInteractiveResidentSubagentActivityRendersAllActiveChildrenWhenHeightAllows(t *testing.T) {
47 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
48 return func(ctx context.Context, _ string) error {
49 <-ctx.Done()
50 return ctx.Err()
51 }, nil
52 })
53 t.Cleanup(func() { _ = manager.Close(context.Background()) })
54 spawnRunningResidentIndicatorChildren(t, manager, 10)
55
56 term := &residentIndicatorTerminal{cols: 80, rows: 40}
57 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
58 interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
59 interactive.rend.Resize(term.cols, term.rows)
60 term.Reset()
61 interactive.redraw()
62
63 plain := stripANSIBytes(term.String())
64 for index := 0; index < 10; index++ {
65 if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
66 t.Fatalf("interactive output missing %q:\n%s", want, plain)
67 }
68 }
69
70 if err := manager.Close(context.Background()); err != nil {
71 t.Fatal(err)
72 }
73 term.Reset()
74 interactive.redraw()
75 plain = stripANSIBytes(term.String())
76 if strings.Contains(plain, "agent-00") {
77 t.Fatalf("interactive output kept finished subagent activity:\n%s", plain)
78 }
79}
80
81func TestInteractiveResidentSubagentActivitySummarizesBoundedPageUnderShortHeight(t *testing.T) {
82 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
83 return func(ctx context.Context, _ string) error {
84 <-ctx.Done()
85 return ctx.Err()
86 }, nil
87 })
88 t.Cleanup(func() { _ = manager.Close(context.Background()) })
89 spawnRunningResidentIndicatorChildren(t, manager, 6)
90
91 term := &residentIndicatorTerminal{cols: 80, rows: 8}
92 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
93 interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
94 interactive.rend.Resize(term.cols, term.rows)
95 term.Reset()
96 interactive.redraw()
97
98 plain := stripANSIBytes(term.String())
99 if count := strings.Count(plain, "agent-"); count != 1 {
100 t.Fatalf("interactive output showed %d active child rows, want one fitting row under short height:\n%s", count, plain)
101 }
102 if !strings.Contains(plain, "… 5 more active subagents") {
103 t.Fatalf("interactive output did not summarize omitted active children:\n%s", plain)
104 }
105}
106
107func TestInteractiveResidentSubagentActivityShowsAllActiveChildrenWhenShortHeightFits(t *testing.T) {
108 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
109 return func(ctx context.Context, _ string) error {
110 <-ctx.Done()
111 return ctx.Err()
112 }, nil
113 })
114 t.Cleanup(func() { _ = manager.Close(context.Background()) })
115 spawnRunningResidentIndicatorChildren(t, manager, 2)
116
117 term := &residentIndicatorTerminal{cols: 80, rows: 8}
118 interactive := NewInteractive(InteractiveConfig{Terminal: term, ResidentManager: manager, Theme: tui.Dark})
119 interactive.clock = func() time.Time { return time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC) }
120 interactive.rend.Resize(term.cols, term.rows)
121 term.Reset()
122 interactive.redraw()
123
124 plain := stripANSIBytes(term.String())
125 for index := 0; index < 2; index++ {
126 if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
127 t.Fatalf("interactive output missing fitting active child %q:\n%s", want, plain)
128 }
129 }
130 if strings.Contains(plain, "more active subagents") {
131 t.Fatalf("interactive output summarized fitting active children:\n%s", plain)
132 }
133}
134
135func spawnRunningResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
136 t.Helper()
137 updates := make(chan string, count*2)
138 manager.SetUpdateObserver(func(childID string) {
139 select {
140 case updates <- childID:
141 default:
142 }
143 })
144 spawnResidentIndicatorChildren(t, manager, count)
145 defer manager.SetUpdateObserver(nil)
146
147 running := make(map[string]struct{}, count)
148 timer := time.NewTimer(time.Second)
149 defer timer.Stop()
150 for len(running) < count {
151 select {
152 case childID := <-updates:
153 snapshot, ok := manager.SnapshotFor(childID)
154 if ok && snapshot.State == subagents.ResidentRunning {
155 running[childID] = struct{}{}
156 }
157 case <-timer.C:
158 snapshots, _ := manager.ActiveSnapshotPage(0)
159 t.Fatalf("running resident children = %d, want %d; active snapshots=%#v", len(running), count, snapshots)
160 }
161 }
162}
163
164func spawnResidentIndicatorChildren(t *testing.T, manager *subagents.ResidentManager, count int) {
165 t.Helper()
166 for index := 0; index < count; index++ {
167 spec := subagents.ResidentChildSpec{
168 ID: fmt.Sprintf("child-%02d", index), SessionID: fmt.Sprintf("session-%02d", index), Profile: fmt.Sprintf("agent-%02d", index),
169 Provider: "openai", Model: "gpt-test",
170 }
171 if _, err := manager.Spawn(context.Background(), spec, "task"); err != nil {
172 t.Fatalf("Spawn(%d): %v", index, err)
173 }
174 }
175}
176
177func TestRenderResidentSubagentActivityPageReportsHiddenActiveChildren(t *testing.T) {
178 manager := subagents.NewResidentManagerWithLimit(t.TempDir(), 16, func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
179 return func(ctx context.Context, _ string) error {
180 <-ctx.Done()
181 return ctx.Err()
182 }, nil
183 })
184 t.Cleanup(func() { _ = manager.Close(context.Background()) })
185 spawnResidentIndicatorChildren(t, manager, 5)
186
187 lines, hidden := renderResidentSubagentActivityPage(tui.Dark, manager, "/", 2, 80, time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC))
188 plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
189 if hidden != 3 {
190 t.Fatalf("hidden active children = %d, want 3", hidden)
191 }
192 if count := strings.Count(plain, "agent-"); count != 2 {
193 t.Fatalf("activity page rendered %d child rows, want bounded page of 2:\n%s", count, plain)
194 }
195}
196
197func TestRenderResidentSubagentActivityLinesShowsEveryActiveChildProvided(t *testing.T) {
198 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
199 var snapshots []subagents.ResidentSnapshot
200 for index := 0; index < 12; index++ {
201 snapshots = append(snapshots, subagents.ResidentSnapshot{
202 ID: fmt.Sprintf("child-%02d", index), Profile: fmt.Sprintf("agent-%02d", index), State: subagents.ResidentRunning,
203 TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now,
204 })
205 }
206 lines := renderResidentSubagentActivityLines(tui.Dark, "/", snapshots, 80, now)
207 plain := strings.Join(plainResidentIndicatorLines(lines), "\n")
208 for index := 0; index < 12; index++ {
209 if want := fmt.Sprintf("agent-%02d", index); !strings.Contains(plain, want) {
210 t.Fatalf("activity lines missing %q:\n%s", want, plain)
211 }
212 }
213}
214
215func TestRenderResidentSubagentActivityLinesRightAlignsUsageMetadataWhenItFits(t *testing.T) {
216 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
217 const width = 120
218 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
219 ID: "running-id", Profile: "審査", State: subagents.ResidentRunning,
220 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
221 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
222 }}, width, now)
223 plain := plainResidentIndicatorLines(lines)
224 if len(plain) != 1 {
225 t.Fatalf("activity lines = %#v, want one right-aligned row", plain)
226 }
227 metadata := "↑84k ↓1.5k R123k/ C59.4% $0.525 (sub) 16.6%/272k"
228 if !strings.HasSuffix(plain[0], metadata) {
229 t.Fatalf("activity line = %q, want metadata suffix %q", plain[0], metadata)
230 }
231 if gotWidth := runewidth.StringWidth(plain[0]); gotWidth != width {
232 t.Fatalf("activity line width = %d, want %d: %q", gotWidth, width, plain[0])
233 }
234}
235
236func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
237 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
238 const width = 34
239 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
240 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
241 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
242 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
243 }}, width, now)
244 plain := plainResidentIndicatorLines(lines)
245 if len(plain) < 3 {
246 t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
247 }
248 if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
249 t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
250 }
251 joinedMetadata := strings.Join(plain[1:], " ")
252 for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
253 if !strings.Contains(joinedMetadata, want) {
254 t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
255 }
256 }
257 for _, line := range plain {
258 if gotWidth := runewidth.StringWidth(line); gotWidth > width {
259 t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
260 }
261 }
262}
263
264func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
265 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
266 const width = 34
267 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
268 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
269 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
270 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
271 }}, width, now)
272 if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
273 t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
274 }
275
276 got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
277 return len(candidate)
278 }))
279 if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
280 t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
281 }
282}
283
284func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
285 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
286 snapshot := subagents.ResidentSnapshot{
287 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
288 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
289 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
290 }
291 for _, width := range []int{3, 4} {
292 t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
293 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
294 plain := plainResidentIndicatorLines(lines)
295 wantActivity := " " + strings.Repeat(".", width-runewidth.StringWidth(" "))
296 if len(plain) != 1 || plain[0] != wantActivity {
297 t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
298 }
299 if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
300 t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
301 }
302 if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
303 t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
304 }
305
306 limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
307 if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
308 t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
309 }
310 })
311 }
312}
313
314func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
315 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
316 now := started.Add(2 * time.Second)
317 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
318 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
319 TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
320 }}, 80, now)
321 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
322 if !strings.Contains(got, "zzZz") {
323 t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
324 }
325}
326
327func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
328 now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
329 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
330 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
331 TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
332 }}, 80, now)
333 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
334 if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
335 t.Fatalf("tool-work indicator = %q", got)
336 }
337}
338
339func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
340 lines := []string{" one", " two", " three", " four"}
341 bottomRows := func(candidate []string) int { return 2 + len(candidate) }
342
343 allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
344 if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
345 t.Fatalf("fit lines = %#v, want all activity rows", allFit)
346 }
347
348 compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
349 wantCompact := []string{" one", " … 3 more active subagents"}
350 if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
351 t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
352 }
353}
354
355func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
356 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
357 want := []string{"one", " … 2 more active subagents"}
358 if strings.Join(got, "\n") != strings.Join(want, "\n") {
359 t.Fatalf("limited lines = %#v, want %#v", got, want)
360 }
361}
362
363func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
364 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
365 want := []string{" … 3 more active subagents"}
366 if strings.Join(got, "\n") != strings.Join(want, "\n") {
367 t.Fatalf("limited lines = %#v, want %#v", got, want)
368 }
369}
370
371func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
372 lines := []string{" agent one", " usage one", " agent two", " usage two"}
373 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
374 want := []string{" agent one", " usage one", " agent two"}
375 if strings.Join(got, "\n") != strings.Join(want, "\n") {
376 t.Fatalf("limited lines = %#v, want %#v", got, want)
377 }
378}
379
380func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
381 lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
382 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
383 want := []string{" agent one", " … 2 more active subagents"}
384 if strings.Join(got, "\n") != strings.Join(want, "\n") {
385 t.Fatalf("limited lines = %#v, want %#v", got, want)
386 }
387}
388
389func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
390 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
391 want := []string{"one", " … 6 more active subagents"}
392 if strings.Join(got, "\n") != strings.Join(want, "\n") {
393 t.Fatalf("limited lines = %#v, want %#v", got, want)
394 }
395}
396
397func plainResidentIndicatorLines(lines []string) []string {
398 plain := make([]string, len(lines))
399 for index, line := range lines {
400 plain[index] = stripANSIBytes(line)
401 }
402 return plain
403}