Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -718,17 +718,21 @@ 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
for j := len(steps) - 1; j >= 0; j-- {
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
}
Expand Down
35 changes: 32 additions & 3 deletions internal/tui/steps_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package tui

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -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) {
Expand Down