diff --git a/README.md b/README.md index 684b984..2cc9d5a 100644 --- a/README.md +++ b/README.md @@ -192,14 +192,19 @@ own front-end settings are separate; see [Configuration](#configuration). tests`, `πŸ“– reading client.go`, `πŸš€ pushing`) with a live elapsed timer. - **Sub-agents** β€” delegations are labelled and their `subagent_log` activity nests beneath the delegating call, so a sub-agent's progress reads as its - own branch of the step tree. Per-task `subagent_state` telemetry - (odek v1.30+) drives live cards β€” step, tool, iterations, tokens, - duration β€” and terminal status glyphs (`βœ“` success, `◐` partial, `βœ—` error, - `⊘` cancelled, `⏱` timeout), with a `1/2 agents Β· 6.3k tok` rollup on the - collapsed line. Framed delegate results render as a structured card β€” - status, summary, changed files, and usage. `ctrl+s` (or `/stop `, - two-step confirmed) stops one running sub-agent, and the `/agents` drawer - tab lists the serve instance's registry snapshot. + own branch of the step tree. Each card carries its task's goal (parsed + from the `delegate_tasks` argument; tasks the wire hasn't confirmed yet + show as `pending`), live step/tool/iterations/tokens telemetry from + `subagent_state` frames (odek v1.30+), a client-side elapsed timer between + frame bursts, and terminal status glyphs (`βœ“` success, `◐` partial, `βœ—` + error, `⊘` cancelled, `⏱` timeout). The collapsed rollup counts failures β€” + `2/3 Β· 1 βœ— Β· 8.1k tok` β€” a terminal failure sticks to the notice strip + until the turn ends, and every delegating turn closes with a + `swarm: 5 βœ“ Β· 1 βœ— β€” SA4 error` verdict. A disconnect retires in-flight + cards (`Γ— lost on disconnect`) instead of leaving ghost spinners. + `ctrl+s` (or `/stop `, two-step confirmed) stops one running + sub-agent of the current turn; the `/agents` tab's `c` reaches any live + task through the instance registry. - **Model switcher** (`^O`) β€” change the model for the next turn. The picker merges the server's configured model with its built-in profile catalog (`/api/profiles`), each annotated with its context window. @@ -349,7 +354,7 @@ full command and press `⏎`. | `/thinking [on\|off]` | Toggle extended thinking for the next turn | | `/cancel` | Cancel the running turn | | `/stop ` | Stop one running sub-agent (bare `/stop` lists them) | -| `/agents` | Sub-agent registry β€” recent delegated tasks (drawer tab) | +| `/agents` | Sub-agent registry β€” live 3s poll, `c` stop (two-step), `o` jump to transcript | | `/attach ` | Stage a file to send with the next prompt (5 MB each, 10 MB total) | | `/unattach [name]` | Drop staged files (all when no name given) | | `/quit` | Exit bodek | @@ -370,6 +375,9 @@ full command and press `⏎`. `⏎` resume. - **Runs** β€” live 3s poll, `A`/`D`/`T` remote approvals, `c` cancel, `p` refresh pending approvals, `e` drill into the run's event trail. +- **Agents** β€” the serve instance's sub-agent registry, live-polled every 3s; + `c` stop the highlighted row (two-step, same gate as `/stop`), `o` jump to + the delegating transcript step, `⏎` the full registry record. - **Events** β€” the `odek.event/v1` ring: `f` filter to this session, `x` clear filters (a runs-tab drill-in scopes it to one run). - **Plan** β€” the engine's structured task plan (Telegram-parity renderer): diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 6999165..ffb7193 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -108,7 +108,7 @@ func slashCommands() []command { {"stop", "stop one sub-agent β€” /stop ", func(m *Model, args string) tea.Cmd { return m.stopByLabel(args) }}, - {"agents", "sub-agent registry β€” recent delegated tasks", func(m *Model, _ string) tea.Cmd { + {"agents", "sub-agent registry β€” c stop Β· o jump (live poll)", func(m *Model, _ string) tea.Cmd { return m.openAgents() }}, {"attach", "stage a file for the next prompt β€” /attach ", func(m *Model, args string) tea.Cmd { diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index f80a4eb..f51ff37 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -108,7 +108,10 @@ func (m *Model) openAgents() tea.Cmd { m.panelMsg = "loading sub-agents…" m.relayout() m.refresh() - return m.fetchAgents() + if m.cl == nil { + return nil // no connection: nothing to fetch, nothing to poll + } + return tea.Batch(m.fetchAgents(), m.armAgentsPoll()) } // fetchAgents refetches the registry snapshot; r re-runs it while open. @@ -123,6 +126,71 @@ func (m *Model) fetchAgents() tea.Cmd { } } +const agentsPollEvery = 3 * time.Second + +// agentsTickMsg re-arms the agents-tab poll (runsTickMsg pattern) β€” the +// registry is a live view while visible, not a stale snapshot. +type agentsTickMsg struct{ seq int } + +// armAgentsPoll schedules the next registry refresh while the tab is visible. +func (m *Model) armAgentsPoll() tea.Cmd { + m.agentsSeq++ + seq := m.agentsSeq + return tea.Tick(agentsPollEvery, func(time.Time) tea.Msg { + return agentsTickMsg{seq: seq} + }) +} + +// handleAgentsTick refetches the snapshot only for the newest generation on +// the visible tab β€” stale ticks and closed tabs drop silently. +func (m *Model) handleAgentsTick(msg agentsTickMsg) tea.Cmd { + if msg.seq != m.agentsSeq || m.panel != panelAgents { + return nil + } + return m.fetchAgents() +} + +// stopSelectedAgent arms the two-step stop gate on the highlighted registry +// row β€” the same confirmStopAgent the transcript's /stop uses, resolved +// through the cross-turn live registry. +func (m *Model) stopSelectedAgent() tea.Cmd { + if m.panelSel >= len(m.agentsReg) { + return m.transientNoteCmd("no sub-agent selected") + } + e := m.agentsReg[m.panelSel] + if e.Phase == "finished" { + return m.transientNoteCmd("sub-agent already finished") + } + label := truncate(collapse(e.Goal), 24) + if label == "" { + label = shortID(e.TaskID) + } + return m.armStopAgent(e.TaskID, label) +} + +// jumpToAgentStep closes the drawer onto the transcript step that owns the +// selected task, expanded β€” the registry jumps to the thing it describes. +func (m *Model) jumpToAgentStep() tea.Cmd { + if m.panelSel >= len(m.agentsReg) { + return nil + } + taskID := m.agentsReg[m.panelSel].TaskID + for i := range m.msgs { + for j := range m.msgs[i].steps { + if m.msgs[i].steps[j].card(taskID) == nil { + continue + } + m.msgs[i].steps[j].expanded = true + m.panel = panelNone + m.relayout() + m.scrollToMessage(i) + m.refresh() + return nil + } + } + return m.transientNoteCmd("no transcript card for this task (resumed or foreign run)") +} + // drawerTab is one tab of the management drawer. type drawerTab struct { name string diff --git a/internal/tui/events.go b/internal/tui/events.go index c211b11..b7b9627 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -93,7 +93,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if i := m.cur(); i >= 0 { m.msgs[i].steps = append(m.msgs[i].steps, step{name: ev.Name, arg: arg, subagent: isSubagent(ev.Name), started: time.Now()}) - m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: len(m.msgs[i].steps) - 1}) + last := len(m.msgs[i].steps) - 1 + if m.msgs[i].steps[last].subagent { + // Per-task identity (goals, profiles) lives in the parent's + // argument, not on the subagent_state frames. + m.msgs[i].steps[last].manifest = parseDelegateManifest(ev.Data) + } + m.msgs[i].items = append(m.msgs[i].items, turnItem{stepIdx: last}) } m.lastTool = ev.Name m.lastArg = arg @@ -313,6 +319,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // frames) fall back to a notice so nothing vanishes silently. if i := m.cur(); i >= 0 && m.attachSubState(i, ev) { stream = true // coalesce redraws β€” state frames arrive in bursts + m.subagentTerminalNote(ev) break } m.addTransientNote("subagent Β· " + stateNoticeLine(ev)) @@ -349,6 +356,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { if i := m.cur(); i >= 0 { setTurnMarker(&m.msgs[i], "**Interrupted:** connection lost") } + if n := m.loseLiveAgents(); n > 0 { + // In-flight cards just became unknowable β€” say so once instead of + // leaving spinners that will never settle. + m.addNote("sub-agent state lost on disconnect") + } m.finalize() m.relayout() // the busy status line is gone with the socket if cmd := m.scheduleReconnect(0); cmd != nil { @@ -555,12 +567,86 @@ func markCancel(msg *message) { setTurnMarker(msg, "**Cancelled.**") } -// finalize closes out the streaming assistant message and drops the cursor. +// 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. func (m *Model) finalize() { if i := m.cur(); i >= 0 { + if v := m.swarmVerdict(&m.msgs[i]); v != "" { + setTurnMarker(&m.msgs[i], v) + } m.closeTurn(&m.msgs[i]) } m.curIdx = -1 + m.clearStickyNotes() +} + +// swarmVerdict summarizes a turn's sub-agent outcomes as a turn marker β€” +// "**swarm: 5 βœ“ Β· 1 βœ— β€” SA4 error**" β€” so a failure in a multi-agent turn +// can't scroll by uncounted. "" when the turn delegated nothing. +func (m *Model) swarmVerdict(msg *message) string { + var ok, partial, failed, cancelled, timed, live, lostN int + var bad []string + for j := range msg.steps { + if !msg.steps[j].subagent { + continue + } + for _, a := range msg.steps[j].agents { + switch { + case !a.finished(): + if a.lost { + lostN++ // orphaned by a disconnect: neither live nor terminal + } else { + live++ + } + case a.status == "success": + ok++ + case a.status == "partial": + partial++ + case a.status == "error": + failed++ + bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status)) + case a.status == "timeout": + timed++ + bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status)) + case a.status == "cancelled": + cancelled++ + bad = append(bad, fmt.Sprintf("SA%d %s", a.idx+1, a.status)) + } + } + } + total := ok + partial + failed + cancelled + timed + live + lostN + if total == 0 { + return "" + } + parts := make([]string, 0, 6) + if ok > 0 { + parts = append(parts, fmt.Sprintf("%d βœ“", ok)) + } + if partial > 0 { + parts = append(parts, fmt.Sprintf("%d ◐", partial)) + } + if failed > 0 { + parts = append(parts, fmt.Sprintf("%d βœ—", failed)) + } + if timed > 0 { + parts = append(parts, fmt.Sprintf("%d ⏱", timed)) + } + if cancelled > 0 { + parts = append(parts, fmt.Sprintf("%d ⊘", cancelled)) + } + if lostN > 0 { + parts = append(parts, fmt.Sprintf("%d lost", lostN)) + } + if live > 0 { + parts = append(parts, fmt.Sprintf("%d live", live)) + } + line := "swarm: " + strings.Join(parts, " Β· ") + if len(bad) > 0 { + line += " β€” " + strings.Join(bad, ", ") + } + return "**" + line + "**" } // closeTurn renders finalized markdown for one assistant turn: each reply @@ -631,6 +717,21 @@ 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/mgmt.go b/internal/tui/mgmt.go index bb03619..a69fd3e 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -204,8 +204,8 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { m.agentsReg = msg.sag if len(msg.sag) == 0 { m.panelMsg = "no sub-agent activity recorded" - } else { - m.panelMsg = "" + } else if m.confirm != confirmStopAgent { + m.panelMsg = "" // keep the armed stop gate's prompt visible } } if m.panelSel >= m.panelLen() { @@ -543,7 +543,7 @@ func (m *Model) cfgRowsRender(w int) []string { // mgmtPanel reports whether p is a management drawer tab. func mgmtPanel(p panelMode) bool { switch p { - case panelPlan, panelMemory, panelSkills, panelTools, panelConfig: + case panelAgents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig: return true } return false diff --git a/internal/tui/model.go b/internal/tui/model.go index cddf5d7..285c5da 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -35,6 +35,7 @@ type step struct { subagent bool // this call delegates to a sub-agent (renders its log tree) logs []string // nested sub-agent activity, from subagent_log events agents []*agentCard // live per-task telemetry, from subagent_state frames + manifest []taskSlot // delegate arg parsed at tool-call time: per-task identity resultCard *agentResult // framed result envelope (delegate tools) expanded bool // user has expanded this step to show full output/logs started time.Time // when the tool_call arrived; zero for resumed history @@ -212,6 +213,9 @@ type Model struct { profiles []client.Profile // built-in model catalog (picker + context gauge) agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot + agentsSeq int // agents-tab poll generation; stale ticks drop + + liveTasks map[string]*agentCard // every unfinished card, any turn: stop paths resolve across turns // Drawer state: runs polling + the events feed. runs []client.Run @@ -502,6 +506,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case runsTickMsg: return m, m.handleRunsTick(msg) + case agentsTickMsg: + return m, m.handleAgentsTick(msg) + case planMsg: return m, m.handlePlanMsg(msg) @@ -533,6 +540,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case mgmtMsg: m.handleMgmtMsg(msg) m.refresh() + if msg.tab == panelAgents && m.panel == panelAgents { + return m, m.armAgentsPoll() // keeps the 3s chain alive while visible + } return m, nil case mgmtActionMsg: @@ -566,6 +576,11 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case stopAgentDoneMsg: if msg.err != nil { + // The stop never left β€” un-advertise it so the card doesn't + // claim a stop is in flight. + if a := m.liveCard(msg.taskID); a != nil { + a.stopSent = false + } m.addNote("stop failed Β· " + msg.err.Error()) m.refresh() } @@ -1240,6 +1255,22 @@ func (m *Model) focusTurnAt(line int) { } } +// scrollToMessage parks the viewport at a message's turn head (one line of +// context above it), bottom when the turn isn't indexed yet (in-flight). +func (m *Model) scrollToMessage(msgIdx int) { + for _, r := range m.turnLineIndex { + if r.msgIdx == msgIdx { + off := r.line + if off > 0 { + off-- + } + m.vp.SetYOffset(off) + return + } + } + m.vp.GotoBottom() +} + // turnAtLine maps a viewport content line to a turn head (stepIdx -1). func (m *Model) turnAtLine(line int) (msgIdx int, ok bool) { for _, r := range m.turnLineIndex { diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 6a440ab..56cd28c 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -379,6 +379,9 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.panel == panelMemory { return m, m.memConsolidate("user") } + if m.panel == panelAgents { + return m, m.stopSelectedAgent() + } case "f", "F": if m.panel == panelEvents { return m, m.toggleEventFilter() @@ -426,6 +429,11 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.panel == panelMemory { return m, m.memConsolidate("env") } + case "o": + if m.panel == panelAgents { + // Jump to the delegating transcript step, expanded. + return m, m.jumpToAgentStep() + } case "r": if m.panel == panelSessions { if m.panelSel < len(m.sessions) { diff --git a/internal/tui/subagent_follow_test.go b/internal/tui/subagent_follow_test.go new file mode 100644 index 0000000..7a11d26 --- /dev/null +++ b/internal/tui/subagent_follow_test.go @@ -0,0 +1,169 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestLiveTaskRegistryAcrossTurns: stop resolution must not be scoped to the +// current turn β€” a card left running by turn 1 stays stoppable after turn 2 +// opens (the drawer registry stops whatever the user highlights). +func TestLiveTaskRegistryAcrossTurns(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + + // Turn 1 ends, turn 2 opens with its own delegation. + m.finalize() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 1 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", Data: `{"tasks":["c"]}`}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 0, Phase: "active", Status: "running"}) + + if m.liveCard("t1") == nil { + t.Fatal("t1 lost to the current-turn scan β€” drawer stop would false-negative") + } + if m.liveCard("t2") == nil { + t.Fatal("t2 not tracked") + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 0, Phase: "finished", Status: "success"}) + if m.liveCard("t2") != nil { + t.Fatal("terminal t2 still tracked as live") + } +} + +// TestAgentsTabLivePoll: the agents tab polls every 3s while visible β€” the +// registry stops being a stale snapshot. +func TestAgentsTabLivePoll(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} // cmd construction only; the fetch never runs here + if cmd := m.openAgents(); cmd == nil { + t.Fatal("openAgents with a client should fetch") + } + seq := m.agentsSeq + if cmd := m.handleAgentsTick(agentsTickMsg{seq: seq}); cmd == nil { + t.Fatal("fresh tick on the visible tab should refetch") + } + m.agentsSeq = seq + 5 + if cmd := m.handleAgentsTick(agentsTickMsg{seq: seq}); cmd != nil { + t.Fatal("stale tick should be dropped") + } + m.panel = panelNone + if cmd := m.handleAgentsTick(agentsTickMsg{seq: seq + 5}); cmd != nil { + t.Fatal("tick with the tab closed should be dropped") + } +} + +// TestAgentsTabStop: `c` arms the same two-step stop gate on the highlighted +// registry row; finished rows refuse, and the gate fires through the +// cross-turn live registry. +func TestAgentsTabStop(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.openAgents() + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "active", Status: "running", Goal: "audit the auth flow"}, + {TaskID: "t9", Phase: "finished", Status: "success"}, + }}) + + m.panelSel = 0 + if cmd := m.stopSelectedAgent(); cmd != nil { + t.Fatal("arming should return nil (the gate waits for y)") + } + if m.confirm != confirmStopAgent || m.stopTarget != "t1" { + t.Fatalf("gate not armed on the running row: confirm=%v target=%q", m.confirm, m.stopTarget) + } + if !strings.Contains(m.panelMsg, "audit the auth flow") { + t.Errorf("gate text missing the goal label: %q", m.panelMsg) + } + + m.confirm = confirmNone + m.panelSel = 1 + if cmd := m.stopSelectedAgent(); cmd == nil { + t.Fatal("finished row should decline with a note (non-nil sweep)") + } + if m.confirm != confirmNone { + t.Fatalf("finished row armed a gate: %v", m.confirm) + } +} + +// TestAgentsTabJump: `o` closes the drawer onto the transcript step that owns +// the selected task, expanding it; tasks with no card degrade to a note. +func TestAgentsTabJump(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.openAgents() + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "active", Status: "running"}, + {TaskID: "t404", Phase: "finished", Status: "success"}, + }}) + + m.panelSel = 0 + if cmd := m.jumpToAgentStep(); cmd != nil { + t.Fatal("jump returns no cmd") + } + if m.panel != panelNone { + t.Fatalf("drawer still open: %v", m.panel) + } + if s := stateStep(t, m); !s.expanded { + t.Fatal("target step not expanded") + } + + m.openAgents() + m.panelSel = 1 + if cmd := m.jumpToAgentStep(); cmd == nil { + t.Fatal("cardless task should degrade to a note (non-nil sweep)") + } + if m.panel != panelAgents { + t.Fatalf("failed jump closed the drawer: %v", m.panel) + } +} + +// 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) { + 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 + for i, exp := range m.noticeExp { + if exp.IsZero() && strings.Contains(m.notices[i], "SA1") { + sticky++ + } + } + if sticky != 1 { + t.Fatalf("sticky error notes = %d, want 1: %q", sticky, 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]) + } + } + + m.finalize() + msg := m.msgs[0] + if !strings.Contains(msg.content, "**swarm: 1 βœ— Β· 1 ⊘ β€” SA1 error, SA2 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 +// marker β€” finalize output is unchanged. +func TestSwarmVerdictSkipsPlainTurns(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, content: "done"}) + m.curIdx = 0 + m.finalize() + if strings.Contains(m.msgs[0].content, "swarm") { + t.Errorf("plain turn got a swarm marker: %q", m.msgs[0].content) + } +} diff --git a/internal/tui/subagent_manifest_test.go b/internal/tui/subagent_manifest_test.go new file mode 100644 index 0000000..6888e43 --- /dev/null +++ b/internal/tui/subagent_manifest_test.go @@ -0,0 +1,162 @@ +package tui + +import ( + "strings" + "testing" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// manifestFixture builds a model whose in-flight turn carries a delegate_tasks +// call with object-form tasks β€” the shape the LLM actually sends. +func manifestFixture(t *testing.T) *Model { + t.Helper() + 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_tasks", Data: `{"tasks":[ + {"goal":"audit the auth flow end to end and report the gaps","profile":"default"}, + {"goal":"write failing tests first, then the fix"}, + {"goal":"ship it and sync the docs"}]}`}) + return m +} + +// TestDelegateManifestParsed: the delegate arg becomes a per-step manifest +// with excerpted goals; string-form task arrays and junk args degrade safely. +func TestDelegateManifestParsed(t *testing.T) { + m := manifestFixture(t) + s := stateStep(t, m) + if len(s.manifest) != 3 { + t.Fatalf("manifest = %d slots, want 3", len(s.manifest)) + } + g := s.manifest[0].goal + if !strings.HasPrefix(g, "audit the auth flow") || len([]rune(g)) > 32 { + t.Errorf("slot 0 goal not excerpted to ≀32 runes: %q (%d runes)", g, len([]rune(g))) + } + if s.manifest[0].profile != "default" { + t.Errorf("slot 0 profile = %q", s.manifest[0].profile) + } + if s.manifest[2].goal != "ship it and sync the docs" { + t.Errorf("slot 2 goal = %q", s.manifest[2].goal) + } + + // String-form arrays become goal-only slots (the stateFixture shape). + m2 := stateFixture(t) + if s2 := stateStep(t, m2); len(s2.manifest) != 2 || s2.manifest[0].goal != "a" { + t.Errorf("string-form manifest = %#v", s2.manifest) + } + + // Junk args leave no manifest β€” the step still renders exactly as before. + m3 := newTestModel() + m3.msgs = append(m3.msgs, message{role: roleAsst, streaming: true}) + m3.curIdx = 0 + m3.busy = true + m3.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", Data: `not json at all`}) + if s3 := stateStep(t, m3); s3.manifest != nil { + t.Errorf("junk arg produced a manifest: %#v", s3.manifest) + } +} + +// TestCardCarriesGoal: state frames inherit the slot's goal, rendered last on +// the card line so narrow-width truncation kills the garnish before vitals. +func TestCardCarriesGoal(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 7}) + s := stateStep(t, m) + if s.agents[0].goal != s.manifest[0].goal { + t.Fatalf("card goal = %q, want manifest slot %q", s.agents[0].goal, s.manifest[0].goal) + } + line := agentCardLine(s.agents[0]) + if !strings.Contains(line, "audit the auth flow") { + t.Errorf("card line missing goal: %q", line) + } + if strings.Index(line, "audit the auth flow") < strings.Index(line, "tok") { + t.Errorf("goal must render after the telemetry tail: %q", line) + } +} + +// TestPendingSlots: manifest slots with no card yet render as pending β€” +// labelled inference, dropped once the frame arrives. +func TestPendingSlots(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t3", TaskIdx: 2, Phase: "active", Status: "running"}) + s := stateStep(t, m) + pending := s.pendingAgentLines() + if len(pending) != 1 { + t.Fatalf("pending lines = %d, want 1: %q", len(pending), pending) + } + for _, want := range []string{"SA2", "pending (not yet reported)", "write failing tests"} { + if !strings.Contains(pending[0], want) { + t.Errorf("pending line missing %q: %q", want, pending[0]) + } + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "active", Status: "running"}) + if s = stateStep(t, m); len(s.pendingAgentLines()) != 0 { + t.Errorf("pending lines after all frames = %q", s.pendingAgentLines()) + } +} + +// TestFailureAwareRollup: the collapsed head counts failures β€” "1/2 Β· 1 βœ—" β€” +// while the success-only rollup keeps its exact shape. +func TestFailureAwareRollup(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", TokensUsed: 1200}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "finished", Status: "error", TokensUsed: 2000}) + s := stateStep(t, m) + if got, want := agentRollup(s), "2/2 agents Β· 1 βœ— Β· 3.2k tok"; got != want { + t.Errorf("rollup = %q, want %q", got, want) + } +} + +// TestLiveElapsed: running cards tick client-side between frame bursts; +// terminal cards show the frame-reported duration only. +func TestLiveElapsed(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + s := stateStep(t, m) + s.agents[0].seen = time.Now().Add(-90 * time.Second) + if line := agentCardLine(s.agents[0]); !strings.Contains(line, "1m30s") { + t.Errorf("live line missing elapsed: %q", line) + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", DurationSeconds: 4.2}) + s = stateStep(t, m) + if line := agentCardLine(s.agents[0]); strings.Contains(line, "1m30s") { + t.Errorf("terminal line shows live elapsed: %q", line) + } +} + +// TestLostOnDisconnect: a socket drop retires in-flight cards β€” no ghost +// spinners β€” and leaves one quiet note instead of silence. +func TestLostOnDisconnect(t *testing.T) { + m := manifestFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 7}) + m.handleEvent(client.Event{Type: client.EventDisconnected}) + s := stateStep(t, m) + card := s.agents[0] + if !card.lost || card.finished() { + t.Fatalf("card not marked lost: %#v", card) + } + if line := agentCardLine(card); !strings.Contains(line, "lost on disconnect") { + t.Errorf("lost line missing marker: %q", line) + } + if line := agentCardLine(card); strings.Contains(line, "step 7") { + t.Errorf("lost card still shows live telemetry: %q", line) + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "sub-agent state lost on disconnect") { + t.Errorf("disconnect note missing: %q", got) + } + // The verdict counts lost cards as lost, not live. + if !strings.Contains(m.msgs[0].content, "swarm: 1 lost") { + t.Errorf("verdict missing lost bucket: %q", m.msgs[0].content) + } + // Frames resuming after a reconnect revive the card. + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 8}) + if s = stateStep(t, m); s.agents[0].lost { + t.Error("card still marked lost after frames resumed") + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index 62ec761..13875d4 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -28,7 +28,10 @@ type agentCard struct { iters int tokens int durS float64 - stopSent bool // subagent_cancel sent; terminal frame still pending + stopSent bool // subagent_cancel sent; terminal frame still pending + goal string // manifest goal excerpt (parent arg; "" when unknown) + seen time.Time // first frame locally observed; drives the live elapsed + lost bool // socket dropped while running: retired, never a ghost } // finished reports whether the card reached a terminal state. @@ -38,6 +41,9 @@ func (a *agentCard) finished() bool { return a.phase == "finished" } // odek's status framing (user cancel and deadline timeout never conflate). func (a *agentCard) glyph() string { if !a.finished() { + if a.lost { + return "Γ—" // orphaned by a disconnect: dead, not spinning + } return "⟳" } switch a.status { @@ -92,7 +98,12 @@ func (m *Model) attachSubState(i int, ev client.Event) bool { s := &msg.steps[j] card := s.card(ev.TaskID) if card == nil { - card = &agentCard{taskID: ev.TaskID, idx: ev.TaskIdx, status: "running"} + card = &agentCard{taskID: ev.TaskID, idx: ev.TaskIdx, status: "running", seen: time.Now()} + // Seed identity from the delegate manifest: goal and profile ride + // the parent's tool_call arg, never the wire frames. + if ev.TaskIdx >= 0 && ev.TaskIdx < len(s.manifest) { + card.goal = s.manifest[ev.TaskIdx].goal + } s.agents = append(s.agents, card) } if ev.Phase != "" { @@ -108,6 +119,16 @@ func (m *Model) attachSubState(i int, ev client.Event) bool { card.iters = ev.Iterations card.tokens = ev.TokensUsed card.durS = ev.DurationSeconds + if card.lost { + card.lost = false // frames resumed after a reconnect: alive again + } + // Registry bookkeeping: every unfinished card stays reachable regardless + // of which turn owns it, so stops resolve across turns. + if card.finished() { + m.untrackLive(card.taskID) + } else { + m.trackLive(card) + } return true } return false @@ -120,11 +141,20 @@ func agentCardLine(a *agentCard) string { var b strings.Builder fmt.Fprintf(&b, "%s SA%d", a.glyph(), a.idx+1) if !a.finished() { - if a.step > 0 { - fmt.Fprintf(&b, " Β· step %d", a.step) - } - if a.tool != "" { - b.WriteString(" Β· " + a.tool) + if a.lost { + b.WriteString(" Β· lost on disconnect") + } else { + if a.step > 0 { + fmt.Fprintf(&b, " Β· step %d", a.step) + } + if a.tool != "" { + b.WriteString(" Β· " + a.tool) + } + if !a.seen.IsZero() { + // Client-side elapsed: state frames arrive in bursts, so the + // server-reported duration freezes between them. + b.WriteString(" Β· " + formatStepDur(time.Since(a.seen))) + } } } else if a.status != "" && a.status != "success" { b.WriteString(" Β· " + a.status) @@ -141,28 +171,125 @@ func agentCardLine(a *agentCard) string { if a.durS > 0 { b.WriteString(" Β· " + formatStepDur(time.Duration(a.durS*float64(time.Second)))) } + // The goal renders last on purpose: right-edge truncation on narrow + // terminals eats the garnish before the vitals. + if a.goal != "" { + b.WriteString(" Β· " + a.goal) + } return b.String() } -// agentRollup is the collapsed-head aggregate: "1/2 agents Β· 6.3k tok". +// agentRollup is the collapsed-head aggregate: "1/2 agents Β· 6.3k tok", +// with the failure count spelled out once one exists β€” "2/3 Β· 1 βœ— Β· 8.1k tok". func agentRollup(s *step) string { if len(s.agents) == 0 { return "" } - done, tokens := 0, 0 + done, failed, tokens := 0, 0, 0 for _, a := range s.agents { if a.finished() { done++ } + if a.failed() { + failed++ + } tokens += a.tokens } rollup := fmt.Sprintf("%d/%d agents", done, len(s.agents)) + if failed > 0 { + rollup += fmt.Sprintf(" Β· %d βœ—", failed) + } if tokens > 0 { rollup += " Β· " + human(tokens) + " tok" } return rollup } +// ── delegate manifest (per-task identity from the parent's arg) ─────────────── + +// taskSlot is one delegate_tasks argument entry, parsed at tool-call time: +// the identity the per-task frames don't carry. Fields are sanitized on +// ingest β€” slots render verbatim afterwards. +type taskSlot struct { + goal string // collapsed excerpt, ≀32 runes ("" when absent) + profile string // requested profile id, as sent ("" when unset) +} + +// parseDelegateManifest extracts per-task slots from a delegate_tasks JSON +// arg: {"tasks":[…]} or a bare array. Tolerant by design β€” string entries +// become goal-only slots, objects contribute goal/profile, and anything +// unparseable returns nil so the step renders exactly as before. +func parseDelegateManifest(data string) []taskSlot { + var slots []taskSlot + appendSlot := func(raw json.RawMessage) { + var str string + if err := json.Unmarshal(raw, &str); err == nil { + slots = append(slots, taskSlot{goal: excerptGoal(str)}) + return + } + var obj struct { + Goal string `json:"goal"` + Profile string `json:"profile"` + } + if err := json.Unmarshal(raw, &obj); err == nil { + slots = append(slots, taskSlot{goal: excerptGoal(obj.Goal), profile: collapse(obj.Profile)}) + } + } + var env struct { + Tasks []json.RawMessage `json:"tasks"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(data)), &env); err == nil && len(env.Tasks) > 0 { + for _, raw := range env.Tasks { + appendSlot(raw) + } + return slots + } + var bare []json.RawMessage + if err := json.Unmarshal([]byte(strings.TrimSpace(data)), &bare); err == nil { + for _, raw := range bare { + appendSlot(raw) + } + return slots + } + return nil +} + +// excerptGoal collapses a task goal to a ≀32-rune one-line excerpt. +func excerptGoal(s string) string { + return truncate(collapse(s), 32) +} + +// pendingAgentLines renders manifest slots that have not reported a card yet +// β€” labelled inference: the parent declared the task, the wire has not +// confirmed it. Empty once every slot has a frame. +func (s *step) pendingAgentLines() []string { + if len(s.manifest) == 0 { + return nil + } + var out []string + for k := range s.manifest { + if s.cardByIdx(k) != nil { + continue + } + line := fmt.Sprintf("β—Œ SA%d Β· pending (not yet reported)", k+1) + if g := s.manifest[k].goal; g != "" { + line += " Β· " + g + } + out = append(out, line) + } + return out +} + +// cardByIdx finds a step's agent card by task index. +func (s *step) cardByIdx(idx int) *agentCard { + for _, a := range s.agents { + if a.idx == idx { + return a + } + } + return nil +} + // stateNoticeLine renders a subagent_state frame that had nowhere to attach // as a transient notice line. func stateNoticeLine(ev client.Event) string { @@ -259,8 +386,29 @@ func (m *Model) liveAgents() []*agentCard { return out } -// liveCard finds an unfinished card by task id across the current turn. +// trackLive registers an unfinished card so stops resolve it from any turn β€” +// the drawer registry and /stop must not be scoped to whatever turn happens +// to be current at keypress time. +func (m *Model) trackLive(a *agentCard) { + if m.liveTasks == nil { + m.liveTasks = make(map[string]*agentCard) + } + m.liveTasks[a.taskID] = a +} + +// untrackLive drops a card from the live registry once it settles. +func (m *Model) untrackLive(taskID string) { + if m.liveTasks != nil { + delete(m.liveTasks, taskID) + } +} + +// liveCard finds an unfinished card by task id: the cross-turn live registry +// first, then the current-turn scan as a fallback. func (m *Model) liveCard(taskID string) *agentCard { + if a := m.liveTasks[taskID]; a != nil && !a.finished() { + return a + } for _, a := range m.liveAgents() { if a.taskID == taskID { return a @@ -269,6 +417,36 @@ func (m *Model) liveCard(taskID string) *agentCard { return nil } +// loseLiveAgents retires every in-flight card β€” a socket drop orphans them +// (frames never replay), so they must stop claiming to be live. Returns how +// many cards were retired; 0 means nothing looked alive. +func (m *Model) loseLiveAgents() int { + n := 0 + for _, a := range m.liveTasks { + if !a.finished() && !a.lost { + a.lost = true + n++ + } + } + m.liveTasks = nil + 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). +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{}) + case "cancelled": + m.addTransientNote(fmt.Sprintf("sub-agent SA%d cancelled", ev.TaskIdx+1)) + } +} + // stopByLabel resolves /stop (or a bare number) to a live card and // arms the stop gate. With no argument it lists the live labels. func (m *Model) stopByLabel(args string) tea.Cmd { diff --git a/internal/tui/view.go b/internal/tui/view.go index c4cd64e..8123126 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -809,6 +809,9 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in } details = append(details, th.stepRes.Render(truncate(line, detailBudget))) } + for _, ln := range s.pendingAgentLines() { + details = append(details, th.stepArg.Render(truncate(ln, detailBudget))) + } for _, lg := range s.logs { if strings.TrimSpace(lg) != "" { details = append(details, th.stepRes.Render(truncate(lg, detailBudget))) @@ -1113,6 +1116,28 @@ func (m *Model) footer() string { th.footer.Render("esc close"), ) } + if m.panel == panelAgents { + if m.confirm == confirmStopAgent { + return m.panelFooter( + th.footerDanger.Render("stop this sub-agent?"), + th.footerKey.Render("y")+th.footerDanger.Render(" stop"), + th.footer.Render("any other key cancels"), + ) + } + if m.panelDetail { + return m.panelFooter( + th.footer.Render("↑↓ scroll"), + th.footer.Render("esc back"), + ) + } + return m.panelFooter( + th.footer.Render("↑↓ select Β· ⏎ detail Β· ]/[ tabs"), + th.footerKey.Render("c")+th.footer.Render(" stop β†’ y confirm"), + th.footerKey.Render("o")+th.footer.Render(" open in transcript"), + th.footerKey.Render("r")+th.footer.Render(" refresh Β· 3s poll"), + th.footer.Render("esc close"), + ) + } if m.panel == panelEvents { filter := "all" if m.evRunFilter != "" {