diff --git a/internal/tui/events.go b/internal/tui/events.go index 030e636..5e96319 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -663,8 +663,7 @@ func markCancel(msg *message) { // finalize closes out the streaming assistant message and drops the cursor: // the swarm verdict (when the turn delegated sub-agents) lands as the turn -// marker, and sticky sub-agent failure notices retire — the verdict line -// supersedes them. +// marker. func (m *Model) finalize() { if i := m.cur(); i >= 0 { if v := m.swarmVerdict(&m.msgs[i]); v != "" { @@ -674,7 +673,6 @@ func (m *Model) finalize() { } m.curIdx = -1 m.wakeArmed = false // the window closed with the turn - m.clearStickyNotes() } // swarmVerdict summarizes a turn's sub-agent outcomes as a turn marker — @@ -814,21 +812,6 @@ func (m *Model) pruneNotices(now time.Time) { m.noticeExp = keptExp } -// clearStickyNotes retires zero-expiry notes (sticky sub-agent failures) — -// used when the context that made them sticky is gone (turn finalized). -func (m *Model) clearStickyNotes() { - kept := m.notices[:0] - keptExp := m.noticeExp[:0] - for i, n := range m.notices { - if !m.noticeExp[i].IsZero() { - kept = append(kept, n) - keptExp = append(keptExp, m.noticeExp[i]) - } - } - m.notices = kept - m.noticeExp = keptExp -} - // noticeSweep schedules the next expiry sweep at the earliest pending // notice expiry; nil when the strip has nothing pending. The tick handler // prunes and re-arms, so expired notes disappear even on an idle TUI and diff --git a/internal/tui/subagent_follow_test.go b/internal/tui/subagent_follow_test.go index 2923b63..8883eb7 100644 --- a/internal/tui/subagent_follow_test.go +++ b/internal/tui/subagent_follow_test.go @@ -121,26 +121,26 @@ func TestAgentsTabJump(t *testing.T) { } } -// TestStickyFailureAndVerdict: terminal errors/timeout stick until the turn -// finalizes (user cancels stay transient), and finalize appends the swarm -// verdict marker to the turn. -func TestStickyFailureAndVerdict(t *testing.T) { +// TestFailureNoteBoundedAndVerdict: terminal error/timeout notes surface at +// alert tier (bounded dwell — nothing sticky in the strip; user cancels stay +// transient), and finalize appends the swarm verdict marker to the turn. +func TestFailureNoteBoundedAndVerdict(t *testing.T) { m := manifestFixture(t) m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "error"}) m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "finished", Status: "cancelled"}) - sticky := 0 + bounded := 0 for i, exp := range m.noticeExp { - if exp.IsZero() && strings.Contains(m.notices[i], "SA1") { - sticky++ + if !exp.IsZero() && strings.Contains(m.notices[i], "SA1") { + bounded++ } } - if sticky != 1 { - t.Fatalf("sticky error notes = %d, want 1: %q", sticky, m.notices) + if bounded != 1 { + t.Fatalf("bounded error notes = %d, want 1: %q", bounded, m.notices) } for i, n := range m.notices { - if strings.Contains(n, "SA2") && m.noticeExp[i].IsZero() { - t.Errorf("cancelled surfaced sticky, want transient: %q (exp %v)", n, m.noticeExp[i]) + if exp := m.noticeExp[i]; exp.IsZero() { + t.Errorf("sticky note in the strip: %q", n) } } @@ -149,11 +149,6 @@ func TestStickyFailureAndVerdict(t *testing.T) { if !strings.Contains(msg.content, "**sub-agents: 1 ✗ · 1 ⊘ — #1 error, #2 cancelled**") { t.Errorf("swarm verdict missing: %q", msg.content) } - for i, exp := range m.noticeExp { - if exp.IsZero() { - t.Errorf("sticky note survived finalize: %q", m.notices[i]) - } - } } // TestSwarmVerdictSkipsPlainTurns: turns without sub-agent cards get no diff --git a/internal/tui/subagent_timeout_note_test.go b/internal/tui/subagent_timeout_note_test.go new file mode 100644 index 0000000..a65f41e --- /dev/null +++ b/internal/tui/subagent_timeout_note_test.go @@ -0,0 +1,59 @@ +package tui + +import ( + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// Sub-agent failure notes ("sub-agent SA4 timeout") used to be sticky — +// pushed with a zero expiry and only retired at turn finalize. A turn that +// kept streaming after the sub-agent died kept the note on screen +// indefinitely. The strip is bounded by design: failure notes now dwell at +// alert tier and fade like every other alert. Nothing is lost — the failure +// stays on the card's agent chip and the turn's swarm verdict. +func TestSubagentTimeoutNoteIsBounded(t *testing.T) { + m := newTestModel() + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", Data: `{"tasks":[]}`}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "timeout"}) + + if n := len(m.notices); n == 0 { + t.Fatal("timeout note missing") + } + note := m.notices[len(m.notices)-1] + if note != "sub-agent SA1 timeout" { + t.Fatalf("note = %q, want %q", note, "sub-agent SA1 timeout") + } + if exp := m.noticeExp[len(m.noticeExp)-1]; exp.IsZero() { + t.Fatal("timeout note is sticky (zero expiry) — it would never disappear") + } + // The failure itself stays on the card's agent chip regardless. + if i := m.cur(); i < 0 { + t.Fatal("delegate card lost") + } else if a := m.msgs[i].steps[0].card("t1"); a == nil || a.status != "timeout" { + t.Fatalf("agent card status = %+v, want timeout", a) + } + + // Past the alert dwell the note is gone. + m.pruneNotices(time.Now().Add(alertTTL + time.Second)) + for _, n := range m.notices { + if n == "sub-agent SA1 timeout" { + t.Fatal("timeout note outlived alertTTL") + } + } +} + +// Every terminal sub-agent note is bounded: errors and timeouts at alert +// tier, user-initiated cancels transient — nothing sticky in the strip. +func TestSubagentFailureNotesAllBounded(t *testing.T) { + m := newTestModel() + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", Data: `{"tasks":[]}`}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "error"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "finished", Status: "cancelled"}) + for i, exp := range m.noticeExp { + if exp.IsZero() { + t.Fatalf("notice %q is sticky", m.notices[i]) + } + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index 69d2be6..276a857 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -542,16 +542,18 @@ func (m *Model) loseLiveAgents() int { return n } -// subagentTerminalNote surfaces a card's terminal state: failures stick (no -// autoclose) until the turn finalizes — a ✗ buried in an eight-agent swarm -// must not scroll by; user-initiated cancels stay transient (you did that). +// subagentTerminalNote surfaces a card's terminal state as a strip note: +// failures dwell at alert tier — long enough to read, bounded like every +// notice in the strip. A ✗ buried in an eight-agent swarm is not lost when +// the note fades: it stays on the card's agent chip and the turn's swarm +// verdict. User-initiated cancels stay transient (you did that). func (m *Model) subagentTerminalNote(ev client.Event) { if ev.Phase != "finished" { return } switch ev.Status { case "error", "timeout": - m.pushNote(fmt.Sprintf("sub-agent SA%d %s", ev.TaskIdx+1, ev.Status), time.Time{}) + m.addNote(fmt.Sprintf("sub-agent SA%d %s", ev.TaskIdx+1, ev.Status)) case "cancelled": m.addTransientNote(fmt.Sprintf("sub-agent SA%d cancelled", ev.TaskIdx+1)) }