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