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
19 changes: 1 addition & 18 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand All @@ -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 —
Expand Down Expand Up @@ -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
Expand Down
27 changes: 11 additions & 16 deletions internal/tui/subagent_follow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand All @@ -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
Expand Down
59 changes: 59 additions & 0 deletions internal/tui/subagent_timeout_note_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
}
10 changes: 6 additions & 4 deletions internal/tui/subagents.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down