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 TestResidentUsageMetadataShowsContextOnly(t *testing.T) {
237 metadata := residentUsageMetadata(subagents.ResidentSnapshot{
238 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500},
239 ContextUsed: 45_152, ContextMax: 272_000,
240 })
241 if strings.Contains(metadata, "budget:") {
242 t.Fatalf("usage metadata carries budget = %q", metadata)
243 }
244 if !strings.Contains(metadata, "16.6%/272k") {
245 t.Fatalf("usage metadata missing context gauge = %q", metadata)
246 }
247}
248
249func TestCompactResidentTokensAvoidsThousandKBoundary(t *testing.T) {
250 for _, tc := range []struct {
251 tokens int64
252 want string
253 }{
254 {tokens: 999_499, want: "999k"},
255 {tokens: 999_500, want: "1.0M"},
256 {tokens: 999_999, want: "1.0M"},
257 {tokens: 1_000_000, want: "1.0M"},
258 } {
259 if got := compactResidentTokens(tc.tokens); got != tc.want {
260 t.Errorf("compactResidentTokens(%d) = %q, want %q", tc.tokens, got, tc.want)
261 }
262 }
263}
264
265func TestRenderResidentSubagentActivityLinesWrapsUsageMetadataWhenItDoesNotFit(t *testing.T) {
266 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
267 const width = 34
268 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
269 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
270 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
271 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
272 }}, width, now)
273 plain := plainResidentIndicatorLines(lines)
274 if len(plain) < 3 {
275 t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
276 }
277 if strings.Contains(plain[0], "↑84k") || !strings.HasPrefix(plain[1], " ") {
278 t.Fatalf("activity lines = %#v, want metadata on indented wrapped lines", plain)
279 }
280 joinedMetadata := strings.Join(plain[1:], " ")
281 for _, want := range []string{"↑84k", "↓1.5k", "$0.525 (sub)", "16.6%/272k"} {
282 if !strings.Contains(joinedMetadata, want) {
283 t.Fatalf("wrapped metadata = %q, missing %q", joinedMetadata, want)
284 }
285 }
286 for _, line := range plain {
287 if gotWidth := runewidth.StringWidth(line); gotWidth > width {
288 t.Fatalf("wrapped line width = %d, want <= %d: %q", gotWidth, width, line)
289 }
290 }
291}
292
293func TestFitResidentSubagentActivityLinesKeepsActivityWhenWrappedMetadataDoesNotFit(t *testing.T) {
294 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
295 const width = 34
296 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
297 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
298 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
299 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
300 }}, width, now)
301 if plain := plainResidentIndicatorLines(lines); len(plain) < 3 {
302 t.Fatalf("activity lines = %#v, want wrapped metadata", plain)
303 }
304
305 got := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width, func(candidate []string) int {
306 return len(candidate)
307 }))
308 if len(got) != 1 || !strings.Contains(got[0], "reviewer") || strings.Contains(got[0], "more active") || strings.Contains(got[0], "↑84k") {
309 t.Fatalf("constrained activity lines = %#v, want only the activity/name row", got)
310 }
311}
312
313func TestRenderResidentSubagentActivityLinesOmitsMetadataWhenIndentDoesNotFit(t *testing.T) {
314 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
315 snapshot := subagents.ResidentSnapshot{
316 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
317 Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
318 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
319 }
320 for _, width := range []int{3, 4} {
321 t.Run(fmt.Sprintf("width_%d", width), func(t *testing.T) {
322 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{snapshot}, width, now)
323 plain := plainResidentIndicatorLines(lines)
324 wantActivity := " " + strings.Repeat(".", width-runewidth.StringWidth(" "))
325 if len(plain) != 1 || plain[0] != wantActivity {
326 t.Fatalf("activity lines = %#v, want only activity row %#v", plain, []string{wantActivity})
327 }
328 if strings.Contains(strings.Join(plain, "\n"), "↑84k") {
329 t.Fatalf("activity lines include metadata despite unavailable indent: %#v", plain)
330 }
331 if gotWidth := runewidth.StringWidth(plain[0]); gotWidth > width {
332 t.Fatalf("activity line width = %d, want <= %d: %q", gotWidth, width, plain[0])
333 }
334
335 limited := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 1, width))
336 if len(limited) != 1 || limited[0] != plain[0] || strings.Contains(limited[0], "more active") {
337 t.Fatalf("limited activity lines = %#v, want preserved activity row %#v without overflow", limited, plain)
338 }
339 })
340 }
341}
342
343func TestRenderResidentSubagentActivityLinesAnimatesModelWait(t *testing.T) {
344 started := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
345 now := started.Add(2 * time.Second)
346 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
347 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
348 TurnStartedAt: started, ActivityUpdatedAt: started, WaitingForModel: true,
349 }}, 80, now)
350 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
351 if !strings.Contains(got, "zzZz") {
352 t.Fatalf("model-wait indicator = %q, want animated waiting frame", got)
353 }
354}
355
356func TestRenderResidentSubagentActivityLinesShowsRunningDuringToolWork(t *testing.T) {
357 now := time.Date(2026, time.August, 13, 12, 0, 2, 0, time.UTC)
358 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
359 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
360 TurnStartedAt: now.Add(-time.Minute), ActivityUpdatedAt: now.Add(-time.Second),
361 }}, 80, now)
362 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
363 if !strings.Contains(got, "/ reviewer · running · activity 1s ago · 1m00s") || strings.Contains(got, "Zzzz") {
364 t.Fatalf("tool-work indicator = %q", got)
365 }
366}
367
368func TestFitResidentSubagentActivityLinesUsesAvailableHeightBeforeOverflow(t *testing.T) {
369 lines := []string{" one", " two", " three", " four"}
370 bottomRows := func(candidate []string) int { return 2 + len(candidate) }
371
372 allFit := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 6, 80, bottomRows))
373 if strings.Join(allFit, "\n") != strings.Join(lines, "\n") {
374 t.Fatalf("fit lines = %#v, want all activity rows", allFit)
375 }
376
377 compact := plainResidentIndicatorLines(fitResidentSubagentActivityLines(tui.Dark, lines, 0, 4, 80, bottomRows))
378 wantCompact := []string{" one", " … 3 more active subagents"}
379 if strings.Join(compact, "\n") != strings.Join(wantCompact, "\n") {
380 t.Fatalf("compact lines = %#v, want %#v", compact, wantCompact)
381 }
382}
383
384func TestLimitResidentSubagentActivityLinesSummarizesOverflow(t *testing.T) {
385 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 0, 2, 80))
386 want := []string{"one", " … 2 more active subagents"}
387 if strings.Join(got, "\n") != strings.Join(want, "\n") {
388 t.Fatalf("limited lines = %#v, want %#v", got, want)
389 }
390}
391
392func TestLimitResidentSubagentActivityLinesUsesSingleRowForOverflowWhenAgentsAreHidden(t *testing.T) {
393 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one"}, 2, 1, 80))
394 want := []string{" … 3 more active subagents"}
395 if strings.Join(got, "\n") != strings.Join(want, "\n") {
396 t.Fatalf("limited lines = %#v, want %#v", got, want)
397 }
398}
399
400func TestLimitResidentSubagentActivityLinesPrefersActivityRowsBeforeMetadata(t *testing.T) {
401 lines := []string{" agent one", " usage one", " agent two", " usage two"}
402 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 0, 3, 80))
403 want := []string{" agent one", " usage one", " agent two"}
404 if strings.Join(got, "\n") != strings.Join(want, "\n") {
405 t.Fatalf("limited lines = %#v, want %#v", got, want)
406 }
407}
408
409func TestLimitResidentSubagentActivityLinesCountsOnlyHiddenAgentsWhenMetadataTruncates(t *testing.T) {
410 lines := []string{" agent one", " usage one", " usage continued", " agent two", " usage two"}
411 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, lines, 1, 2, 80))
412 want := []string{" agent one", " … 2 more active subagents"}
413 if strings.Join(got, "\n") != strings.Join(want, "\n") {
414 t.Fatalf("limited lines = %#v, want %#v", got, want)
415 }
416}
417
418func TestLimitResidentSubagentActivityLinesIncludesAlreadyHiddenChildren(t *testing.T) {
419 got := plainResidentIndicatorLines(limitResidentSubagentActivityLines(tui.Dark, []string{"one", "two", "three"}, 4, 2, 80))
420 want := []string{"one", " … 6 more active subagents"}
421 if strings.Join(got, "\n") != strings.Join(want, "\n") {
422 t.Fatalf("limited lines = %#v, want %#v", got, want)
423 }
424}
425
426func plainResidentIndicatorLines(lines []string) []string {
427 plain := make([]string, len(lines))
428 for index, line := range lines {
429 plain[index] = stripANSIBytes(line)
430 }
431 return plain
432}