From 061a32bd47d9ae703f6044a29eea6ffefc998c53 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 30 Aug 2026 21:49:03 +0200 Subject: [PATCH] fix(tui): mutate sub-agent logs in place, newest first attachSubLog froze after maxSubLogs lines, pinning a long-running agent's card on its first frames while the live header raced ahead. Lines now prepend (DESC) and the oldest roll off the cap, so the card always shows the agent's latest activity. --- internal/tui/events.go | 12 ++++++++---- internal/tui/steps_test.go | 35 ++++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/tui/events.go b/internal/tui/events.go index ef0b309..e0b5ea5 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -718,8 +718,11 @@ func looksLikeError(s string) bool { strings.Contains(t, "no such file or directory") } -// attachSubLog appends a sub-agent activity line to the most recent sub-agent -// step in message i, reporting whether one was found. +// attachSubLog nests a sub-agent activity line under the most recent +// sub-agent step in message i, reporting whether one was found. The log +// mutates in place — newest line first (DESC), capped at maxSubLogs with the +// oldest rolling off — so a long-running agent's card always shows its +// latest activity rather than freezing on its first frames. func (m *Model) attachSubLog(i int, line string) bool { const maxSubLogs = 8 steps := m.msgs[i].steps @@ -727,8 +730,9 @@ func (m *Model) attachSubLog(i int, line string) bool { if !steps[j].subagent { continue } - if len(steps[j].logs) < maxSubLogs { - steps[j].logs = append(steps[j].logs, sanitize(line)) + steps[j].logs = append([]string{sanitize(line)}, steps[j].logs...) + if len(steps[j].logs) > maxSubLogs { + steps[j].logs = steps[j].logs[:maxSubLogs] } return true } diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index 40dc73c..aae1a54 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" "testing" "time" @@ -136,25 +137,53 @@ func TestSubagentLogPayload(t *testing.T) { // Child-reported status rides status — surface it too. m.handleEvent(client.Event{Type: "subagent_log", SubType: "finished", Name: "explorer", Status: "success"}) step = m.msgs[0].steps[len(m.msgs[0].steps)-1] - if got := step.logs[len(step.logs)-1]; !strings.Contains(got, "success") { + if got := step.logs[0]; !strings.Contains(got, "success") { t.Errorf("status dropped: %q", got) } // Detail remains a fallback for legacy/synthetic senders. m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "stat", Detail: "fallback.md"}) step = m.msgs[0].steps[len(m.msgs[0].steps)-1] - if got := step.logs[len(step.logs)-1]; !strings.Contains(got, "fallback.md") { + if got := step.logs[0]; !strings.Contains(got, "fallback.md") { t.Errorf("detail fallback lost: %q", got) } // Oversized payloads are capped at construction (serve caps data at 8 KiB). m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "grep", Data: strings.Repeat("x", 8192)}) step = m.msgs[0].steps[len(m.msgs[0].steps)-1] - if got := step.logs[len(step.logs)-1]; len([]rune(got)) > 120 { + if got := step.logs[0]; len([]rune(got)) > 120 { t.Errorf("payload not capped (%d runes): %.40q", len([]rune(got)), got) } } +// TestSubagentLogMutatesInPlaceDesc pins the in-place mutation contract: +// the nested log keeps only the newest maxSubLogs lines with the newest +// first (DESC), so a long-running sub-agent's card always shows its latest +// activity instead of freezing on its first few frames. +func TestSubagentLogMutatesInPlaceDesc(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_task", Data: `{"task":"explore"}`}) + + const feed = 12 // overshoot the 8-line cap + for n := 1; n <= feed; n++ { + m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "shell", + Data: fmt.Sprintf("step-%02d", n)}) + } + step := m.msgs[0].steps[len(m.msgs[0].steps)-1] + if len(step.logs) != 8 { + t.Fatalf("log line count = %d, want capped 8", len(step.logs)) + } + for k, want := 0, feed; k < 8; k, want = k+1, want-1 { + tag := fmt.Sprintf("step-%02d", want) + if !strings.Contains(step.logs[k], tag) { + t.Errorf("logs[%d] = %q, want %q (newest-first DESC)", k, step.logs[k], tag) + } + } +} + // renderStepsForTest renders all steps of a message through renderStep, // mirroring the deleted renderSteps helper. func renderStepsForTest(m *Model, msg message, startLine, msgIdx int) (string, []stepRef) {