From 8bc0a7ecdc7a217aea04d18e321e26f76e12778a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 11 Sep 2026 21:42:20 +0200 Subject: [PATCH] fix(tui): refresh open drawer tabs from wire events and live cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents tab no longer waits out the 3s poll while sub-agents run: subagent_state frames set a kickAgents flag drained by flushKicks() into ONE coalesced fetch per burst (per-event cmds would starve inside ingestWireBatch and flood the endpoint), and the row renderer prefers the live card's phase/tool/step/iters/tokens/cost/duration over the coarser REST snapshot — including the final telemetry at finish, never a lost card's frozen values, with the glyph reading the merged state. The events tab gains a seq-guarded 3s tick chain while visible with seq-stamped fetches so a late landing never clobbers a filter change or drill-in, and memory_event frames refetch the open memory tab the same coalesced way. Drawer selections now anchor by identity (fact text / TaskID) across rebuilds so a refresh never silently retargets the detail view or the stop gate, and events feed shrinks clamp the selection. Docs updated: README /events + Agents rows, AGENTS.md cadence contract. --- AGENTS.md | 11 +- README.md | 6 +- internal/tui/drawer.go | 57 +++++- internal/tui/events.go | 39 +++- internal/tui/mgmt.go | 101 +++++++++-- internal/tui/model.go | 11 +- internal/tui/realtime_tabs_test.go | 279 +++++++++++++++++++++++++++++ 7 files changed, 482 insertions(+), 22 deletions(-) create mode 100644 internal/tui/realtime_tabs_test.go diff --git a/AGENTS.md b/AGENTS.md index 86197bc..7ef0d51 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -260,7 +260,16 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` `kickJobsFetch()` for an immediate snapshot, watcher tick as fallback; `bg_wake` frames become transient notes. Generation counters (`jobsSeq`/`jobsWatchSeq`) drop stale ticks — keep both chains - generation-guarded when touching the cadence. + generation-guarded when touching the cadence. The same push-beats-poll + pattern drives the other tabs: `subagent_state` and `memory_event` frames + set `kickAgents`/`kickMemory` flags that `flushKicks()` drains into ONE + fetch per burst (per-event cmds would starve inside `ingestWireBatch`); + the events tab polls the runtime ring at 3s while visible with seq-stamped + fetches (`eventsMsg.seq` — a late landing never clobbers a filter change, + and open/toggle/clear bump `eventsTabSeq`). Agents-tab rows prefer live + card telemetry over the REST snapshot (skipping lost cards), and drawer + selections anchor by identity (fact text / TaskID) across rebuilds so a + refresh never silently retargets the detail view or the stop gate. - The narration-line plan strip (`planStripLabel` in `plan.go`) patches on `plan` tool_call (`applyPlanMutation`) so the count moves on that frame. REST (`GET /api/sessions/{id}/plan`) confirms after diff --git a/README.md b/README.md index a9cd60a..8b6f64a 100644 --- a/README.md +++ b/README.md @@ -486,7 +486,7 @@ full command and press `⏎`. | `/sessions` | Browse, search, pin, rename, export & resume sessions | | `/runs` | Headless REST runs — live status, remote approvals, cancel | | `/run ` | Start a headless run (fresh session) and watch it in the runs tab | -| `/events` | The `odek.event/v1` runtime feed | +| `/events` | The `odek.event/v1` runtime feed (live 3s refresh while the tab is open) | | `/jobs` | Background jobs — live status, output viewer, `s` stop (requires odek ≥ v1.38) | | `/plan` | Structured task plan of this session (live status) | | `/memory` | Facts by target, pending-episode promote, consolidate | @@ -522,7 +522,9 @@ shared grammar: `⏎` 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; +- **Agents** — the serve instance's sub-agent registry, live-polled every 3s + and refreshed immediately on every sub-agent state frame; rows prefer the + live telemetry (tool, step, tokens, cost, duration) over the REST snapshot; `c` stop the highlighted row (two-step, same gate as `/stop`), `o` jump to the delegating transcript step, `⏎` the full registry record — trust, budget, cost, and artifact lines included. diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index 202eb55..04ffc1b 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -60,10 +60,12 @@ type runsMsg struct { err error } -// eventsMsg carries an events fetch. +// eventsMsg carries an events fetch; seq stamps the eventsTabSeq it was +// armed under so a late landing cannot clobber a newer filter state. type eventsMsg struct { events []client.RuntimeEvent err error + seq int } // runActionMsg reports a run cancel / approval-answer outcome. @@ -131,6 +133,16 @@ func (m *Model) fetchAgents() tea.Cmd { const agentsPollEvery = 3 * time.Second +// kickAgentsFetch refreshes the open agents tab immediately on a wire +// subagent_state frame — the same push-beats-poll pattern kickJobsFetch +// uses for bg_job frames. The 3s chain stays as the fallback. +func (m *Model) kickAgentsFetch() tea.Cmd { + if m.panel != panelAgents || m.cl == nil { + return nil + } + return m.fetchAgents() +} + // 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 } @@ -462,13 +474,47 @@ func (m *Model) renderRunStatus(i int, label string) string { // ── events tab ────────────────────────────────────────────────────────────── +// eventsPollEvery is the events-tab refresh cadence while visible — the +// ring is live server-side, so the view must not be a frozen slice. +const eventsPollEvery = 3 * time.Second + +// eventsTickMsg re-arms the events-tab poll (runsTickMsg pattern). +type eventsTickMsg struct{ seq int } + +// armEventsPoll schedules the next events refresh while the tab is visible. +func (m *Model) armEventsPoll() tea.Cmd { + m.eventsTabSeq++ + seq := m.eventsTabSeq + return tea.Tick(eventsPollEvery, func(time.Time) tea.Msg { + return eventsTickMsg{seq: seq} + }) +} + +// handleEventsTick refetches the ring only for the newest generation on the +// visible tab — stale ticks and closed tabs drop silently. +func (m *Model) handleEventsTick(msg eventsTickMsg) tea.Cmd { + if msg.seq != m.eventsTabSeq || m.panel != panelEvents { + return nil + } + if m.cl == nil { + return nil // no connection: nothing to poll + } + return m.fetchEvents() +} + func (m *Model) openEvents() tea.Cmd { m.panel = panelEvents m.panelSel = 0 m.panelEdit = panelEditNone m.panelMsg = "loading events…" + m.eventsTabSeq++ // in-flight fetches from a previous view state are stale m.relayout() m.refresh() + if m.cl == nil { + return nil // no connection: nothing to fetch, nothing to poll + } + // The fetch stays the single returned cmd so exec()-style tests keep + // working; the first eventsMsg arms the poll chain (see Update). return m.fetchEvents() } @@ -480,9 +526,10 @@ func (m *Model) fetchEvents() tea.Cmd { } else if m.evSessionFilter { sid = m.sessionID } + seq := m.eventsTabSeq // stamp: a late landing must not clobber a newer filter state return func() tea.Msg { evs, err := cl.RuntimeEvents(100, rid, sid) - return eventsMsg{events: evs, err: err} + return eventsMsg{events: evs, err: err, seq: seq} } } @@ -493,6 +540,7 @@ func (m *Model) toggleEventFilter() tea.Cmd { if m.evSessionFilter { m.evRunFilter = "" } + m.eventsTabSeq++ // the in-flight unfiltered poll must not clobber the new filter return m.fetchEvents() } @@ -500,6 +548,7 @@ func (m *Model) toggleEventFilter() tea.Cmd { func (m *Model) clearEventFilters() tea.Cmd { m.evRunFilter = "" m.evSessionFilter = false + m.eventsTabSeq++ return m.fetchEvents() } @@ -516,11 +565,15 @@ func (m *Model) drillIntoRunEvents() tea.Cmd { } func (m *Model) handleEventsMsg(msg eventsMsg) { + if msg.seq != 0 && msg.seq != m.eventsTabSeq { + return // a fetch armed before a filter change / reopen — stale + } if msg.err != nil { m.panelMsg = "error: " + msg.err.Error() return } m.feed = msg.events + m.panelSel = max(0, min(m.panelSel, len(m.feed)-1)) // shrink clamps the selection if len(m.feed) == 0 { m.panelMsg = "no runtime events yet — every WS prompt and REST run feeds this ring" } else { diff --git a/internal/tui/events.go b/internal/tui/events.go index 61345ce..f3220b5 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -67,6 +67,8 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { } } pm := model.(*Model) + // Kicks ride as model flags so a burst coalesces into ONE fetch per + // kind — a swarm of state frames must not flood the registry endpoint. return pm, tea.Batch( listen(pm.events), pm.rearmRenderFlush(), @@ -74,6 +76,7 @@ func (m *Model) ingestWireBatch(events []client.Event) (tea.Model, tea.Cmd) { pm.approvalSweep(), pm.sendQueued(), pm.planFollowup(), + pm.flushKicks(), ) } @@ -440,6 +443,10 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.addTransientNote("skill · " + strings.TrimSpace(ev.SubType+" "+ev.SkillName) + eventTail(ev)) case "memory_event": m.addTransientNote("memory · " + strings.TrimSpace(ev.SubType+" "+ev.Target) + eventTail(ev)) + // Facts just changed server-side: refresh the open memory tab so + // the list never lies about what exists. The flag flushes as ONE + // coalesced fetch (bursty writers must not flood the endpoint). + m.kickMemory = true case "agent_signal": if silentAgentSignal(ev.SubType) { // Engine housekeeping: context trimming and tool-running @@ -476,6 +483,11 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { // The finished frame's final cost banks first — spent is spent // even when the frame has no step left to attach to. m.recordSubCost(ev) + // Progress moved: refresh the open agents tab now instead of + // waiting for the 3s poll. The flag flushes as ONE coalesced fetch + // per burst; the live card below also feeds the renderer between + // polls. + m.kickAgents = true if i := m.cur(); i >= 0 && m.attachSubState(i, ev) { stream = true // coalesce redraws — state frames arrive in bursts m.subagentTerminalNote(ev) @@ -573,11 +585,34 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } if stream { - return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.queueRender()) + return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.queueRender(), m.flushKicks()) } m.refresh() // A turn that just ended (done / error) drains the next queued prompt. - return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.sendQueued(), m.planFollowup(), attn) + return m, tea.Batch(listen(m.events), m.noticeSweep(), m.approvalSweep(), m.sendQueued(), m.planFollowup(), attn, m.flushKicks()) +} + +// flushKicks drains the pending open-tab refresh flags into ONE fetch per +// kind — bursty wire frames coalesce instead of flooding the endpoints, +// and the flags survive batch ingestion (a per-event cmd would not). +func (m *Model) flushKicks() tea.Cmd { + var cmds []tea.Cmd + if m.kickAgents { + m.kickAgents = false + if cmd := m.kickAgentsFetch(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if m.kickMemory { + m.kickMemory = false + if cmd := m.kickMemoryFetch(); cmd != nil { + cmds = append(cmds, cmd) + } + } + if len(cmds) == 0 { + return nil + } + return tea.Batch(cmds...) } // openWakeTurn opens a streaming assistant card for a server-initiated turn diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index f4dea06..a11c368 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -110,6 +110,22 @@ func (m *Model) openMemory() tea.Cmd { m.relayout() m.refresh() cl := m.cl + if cl == nil { + return nil + } + return func() tea.Msg { + mem, err := cl.Memory() + return mgmtMsg{tab: panelMemory, mem: mem, err: err} + } +} + +// kickMemoryFetch refreshes the open memory tab on a memory_event wire +// frame (kick pattern, jobs-tab) so facts written mid-session appear. +func (m *Model) kickMemoryFetch() tea.Cmd { + if m.panel != panelMemory || m.cl == nil { + return nil + } + cl := m.cl return func() tea.Msg { mem, err := cl.Memory() return mgmtMsg{tab: panelMemory, mem: mem, err: err} @@ -176,8 +192,15 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } switch msg.tab { case panelMemory: + // Anchor the selection by identity across rebuilds: a fact inserted + // above the selection must not silently swap the open detail. + var sel string + if m.panelSel >= 0 && m.panelSel < len(m.memRows) { + sel = m.memRows[m.panelSel].text + } m.memView = msg.mem m.memRows = buildMemRows(msg.mem) + m.panelSel = anchorRow(m.panelSel, m.memRows, sel) if len(m.memRows) == 0 { m.panelMsg = "no facts or pending episodes" } else { @@ -201,7 +224,19 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con) m.panelMsg = "" case panelAgents: + // Anchor the selection by TaskID across rebuilds: a registry insert + // above must not silently re-target the stop gate. + var selTask string + if m.panelSel >= 0 && m.panelSel < len(m.agentsReg) { + selTask = m.agentsReg[m.panelSel].TaskID + } m.agentsReg = msg.sag + for i := range m.agentsReg { + if m.agentsReg[i].TaskID == selTask { + m.panelSel = i + break + } + } if len(msg.sag) == 0 { m.panelMsg = "no sub-agent activity recorded" } else if m.confirm != confirmStopAgent { @@ -213,6 +248,20 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } } +// anchorRow re-locates a selection by its row text after a rebuild: an +// insert above must not silently change what the selection points at. Falls +// back to clamping when the row vanished. +func anchorRow(prev int, rows []memRow, text string) int { + if text != "" { + for i := range rows { + if rows[i].text == text { + return i + } + } + } + return max(0, min(prev, len(rows)-1)) +} + func buildMemRows(v client.MemoryView) []memRow { var rows []memRow for _, target := range []string{"user", "env"} { @@ -616,26 +665,52 @@ func (m *Model) agentRowsRender(w int) []string { sa = a.idx + 1 } var detail string - if e.Phase == "finished" { - detail = fmt.Sprintf(" %s · %d it · %s tok", collapse(e.Status), e.Iterations, human(e.TokensUsed)) + // Prefer the live card's telemetry — it moves on every wire state + // frame while the REST row lags up to a poll period behind. Lost + // cards (socket dropped mid-run) are skipped: their frozen values + // would lie forever. A finished card always supplies the FINAL + // telemetry (the wire carries it at finish; discarding it would roll + // the numbers back for a poll period), and cost comes from the card + // too so the row never pairs fresh tokens with a stale dollar figure. + phase, status, step, tool := e.Phase, e.Status, e.Step, e.LastTool + iters, tokens, durS, cost := e.Iterations, e.TokensUsed, e.DurationSeconds, e.CostUSD + if card := m.liveCard(e.TaskID); card != nil && !card.lost { + phase, status = card.phase, card.status + step, tool = card.step, card.tool + iters, tokens, durS = card.iters, card.tokens, card.durS + if card.costUSD > 0 { + cost = card.costUSD + } + } else if card := m.cardByTask(e.TaskID); card != nil && !card.lost { + phase, status = card.phase, card.status + step, tool = card.step, card.tool + iters, tokens, durS = card.iters, card.tokens, card.durS + if card.costUSD > 0 { + cost = card.costUSD + } + } + if phase == "finished" { + detail = fmt.Sprintf(" %s · %d it · %s tok", collapse(status), iters, human(tokens)) } else { - tool := collapse(e.LastTool) - if tool == "" && e.Step > 0 { - tool = fmt.Sprintf("step %d", e.Step) + t := collapse(tool) + if t == "" && step > 0 { + t = fmt.Sprintf("step %d", step) } - if tool == "" { - tool = "running" + if t == "" { + t = "running" } - detail = " running · " + tool + detail = " running · " + t } - if e.DurationSeconds > 0 { - detail += fmt.Sprintf(" · %.1fs", e.DurationSeconds) + if durS > 0 { + detail += fmt.Sprintf(" · %.1fs", durS) } - if e.CostUSD > 0 { - detail += " · " + fmtCost(e.CostUSD) + if cost > 0 { + detail += " · " + fmtCost(cost) } budget := w - 2 - lipgloss.Width(detail) - label := agentStatusGlyph(e.Phase, e.Status) + fmt.Sprintf(" SA%d ", sa) + goal + // The glyph reads the merged phase/status so a row never shows a + // spinning glyph beside a finished summary (or the reverse). + label := agentStatusGlyph(phase, status) + fmt.Sprintf(" SA%d ", sa) + goal prefix, lab := " ", th.acItem.Render(truncate(label, budget)) if i == m.panelSel { prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(label, budget)) diff --git a/internal/tui/model.go b/internal/tui/model.go index 772bba7..32f122f 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -275,8 +275,11 @@ type Model struct { confirm confirmKind // armed destructive action: y fires, any other key disarms stopTarget string // task_id armed by confirmStopAgent - agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot - agentsSeq int // agents-tab poll generation; stale ticks drop + agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot + agentsSeq int // agents-tab poll generation; stale ticks drop + eventsTabSeq int // events-tab poll generation; stale ticks drop + kickAgents bool // pending agents-tab refresh (flushKicks) + kickMemory bool // pending memory-tab refresh (flushKicks) // Background jobs tab + lifecycle watcher (odek v1.38+ /api/jobs — the // engine pushes nothing for job lifecycle, so bodek watches REST). @@ -681,9 +684,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.panel == panelEvents { m.handleEventsMsg(msg) m.refresh() + return m, m.armEventsPoll() // keeps the 3s chain alive while visible } return m, nil + case eventsTickMsg: + return m, m.handleEventsTick(msg) + case runsTickMsg: return m, m.handleRunsTick(msg) diff --git a/internal/tui/realtime_tabs_test.go b/internal/tui/realtime_tabs_test.go new file mode 100644 index 0000000..0e0dadf --- /dev/null +++ b/internal/tui/realtime_tabs_test.go @@ -0,0 +1,279 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// ── realtime tab freshness (plan: .plans/REALTIME_TABS_FIX_PLAN.md) ───────── +// Wire frames must refresh the open tab, live cards must feed the agents +// renderer, and the events tab must tick while visible — the same kick + +// generation-guarded-poll pattern the jobs and runs tabs use. + +// TestSubagentStateKicksAgentsFetch: a subagent_state frame refreshes the +// open agents tab immediately instead of waiting for the 3s poll. +func TestSubagentStateKicksAgentsFetch(t *testing.T) { + m := stateFixture(t) + m.cl = &client.Client{} // kick construction only; the fetch never runs + m.panel = panelAgents + if cmd := m.kickAgentsFetch(); cmd == nil { + t.Fatal("state frame must kick a fetch while the agents tab is open") + } + // Tab closed: no kick. + m.panel = panelNone + if cmd := m.kickAgentsFetch(); cmd != nil { + t.Fatal("kick must not fire while another surface is open") + } +} + +// TestAgentsRowsPreferLiveCard: the agents tab row renders the live card's +// telemetry (tool/step, elapsed) — not just the REST snapshot's coarse data. +func TestAgentsRowsPreferLiveCard(t *testing.T) { + m := stateFixture(t) + // Live card: the agent is on step 3 running "multi_grep", 40s elapsed. + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, + Phase: "active", Status: "running", Step: 3, Tool: "multi_grep"}) + // REST row is stale: server snapshot still says step 1 / shell, 2s. + m.panel = panelAgents // handleMgmtMsg drops cross-tab results + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "active", Status: "running", Goal: "audit", Step: 1, LastTool: "shell", DurationSeconds: 2}, + }}) + rows := m.agentRowsRender(120) + if len(rows) == 0 { + t.Fatal("no rows rendered") + } + joined := strings.Join(rows, " ") + if !strings.Contains(joined, "multi_grep") { + t.Errorf("row ignores the live card's tool: %q", joined) + } + if strings.Contains(joined, "shell") { + t.Errorf("stale REST tool wins over the live card: %q", joined) + } + // The live card's step shows only when the tool is empty (tool wins). + _ = 3 +} + +// TestMemoryEventKicksMemoryTab: a memory_event frame refetches the open +// memory tab so facts written mid-session appear. +func TestMemoryEventKicksMemoryTab(t *testing.T) { + m := stateFixture(t) + m.cl = &client.Client{} + m.panel = panelMemory + if cmd := m.kickMemoryFetch(); cmd == nil { + t.Fatal("memory_event must kick a refetch while the memory tab is open") + } + m.panel = panelNone + if cmd := m.kickMemoryFetch(); cmd != nil { + t.Fatal("memory kick must not fire while another surface is open") + } + // No client: never fetch. + m.panel = panelMemory + m.cl = nil + if cmd := m.kickMemoryFetch(); cmd != nil { + t.Fatal("memory kick must not fire without a client") + } +} + +// TestMemoryKickExecutesFetch: the memory kick's fetch actually runs against +// a stand-in server and lands as a memory-tab snapshot. +func TestMemoryKickExecutesFetch(t *testing.T) { + m := wired(t) + m.panel = panelMemory + cmd := m.kickMemoryFetch() + if cmd == nil { + t.Fatal("kick must fire on the open memory tab with a client") + } + m.Update(exec(cmd)) + if len(m.memRows) == 0 { + t.Logf("memory tab: msg %q", m.panelMsg) + } +} + +// TestAgentsKickNeedsClient: the agents kick never fetches without a client. +func TestAgentsKickNeedsClient(t *testing.T) { + m := stateFixture(t) + m.cl = nil + m.panel = panelAgents + if cmd := m.kickAgentsFetch(); cmd != nil { + t.Fatal("agents kick must not fire without a client") + } +} + +// TestAgentsRowsFallbackBranches: finished cards render the summary tail; +// a card with neither tool nor step falls back to "running". +func TestAgentsRowsFallbackBranches(t *testing.T) { + m := stateFixture(t) + m.panel = panelAgents + // Finished live card beats a REST row that agrees it finished. + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, + Phase: "finished", Status: "success", Iterations: 7, TokensUsed: 1234}) + // Running card with no tool and no step → bare "running". + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, + Phase: "active", Status: "running"}) + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "finished", Status: "partial", Goal: "g1", LastTool: "shell"}, + {TaskID: "t2", Phase: "active", Status: "running", Goal: ""}, + }}) + rows := m.agentRowsRender(120) + joined := strings.Join(rows, " ") + if !strings.Contains(joined, "success · 7 it") { + t.Errorf("finished card tail not rendered: %q", joined) + } + if !strings.Contains(joined, "running · running") { + t.Errorf("bare running fallback missing: %q", joined) + } + if !strings.Contains(joined, "(no goal recorded)") { + t.Errorf("empty-goal placeholder missing: %q", joined) + } +} + +// TestKickCoalescesPerBatch: a wire burst with multiple subagent_state and +// memory_event frames flushes exactly ONE fetch per kind (flags, not cmds — +// per-event cmds would starve inside ingestWireBatch). +func TestKickCoalescesPerBatch(t *testing.T) { + m := wired(t) + m.panel = panelAgents + _, cmd := m.ingestWireBatch([]client.Event{ + {Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}, + {Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "active", Status: "running"}, + {Type: "subagent_state", TaskID: "t3", TaskIdx: 2, Phase: "active", Status: "running"}, + {Type: "memory_event", SubType: "stored", Target: "user"}, + }) + if cmd == nil { + t.Fatal("batch with kick-worthy frames must return follow-up cmds") + } + // The flush ran at batch construction: flags cleared, no flood on a + // second flush. + if m.kickAgents || m.kickMemory { + t.Fatal("flush must clear pending flags") + } + if cmd := m.flushKicks(); cmd != nil { + t.Fatal("second flush with no pending flags must be nil") + } +} + +// TestLostCardDoesNotOverrideRestRow: a lost card (socket dropped mid-run) +// never overrides the fresher REST row on the agents tab. +func TestLostCardDoesNotOverrideRestRow(t *testing.T) { + m := stateFixture(t) + m.panel = panelAgents + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, + Phase: "active", Status: "running", Tool: "shell"}) + m.loseLiveAgents() // disconnect orphans the card + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "finished", Status: "success", Goal: "g"}, + }}) + rows := m.agentRowsRender(120) + joined := strings.Join(rows, " ") + if strings.Contains(joined, "running") { + t.Errorf("lost card overrode the finished REST row: %q", joined) + } + if !strings.Contains(joined, "success") { + t.Errorf("REST row's terminal state not shown: %q", joined) + } +} + +// TestStaleEventsMsgDropped: an eventsMsg armed before a filter change +// (stale seq) must not clobber the feed; a fresh-seq msg applies and clamps +// a past-the-end selection. +func TestStaleEventsMsgDropped(t *testing.T) { + m := newTestModel() + m.panel = panelEvents + m.eventsTabSeq = 5 + m.feed = []client.RuntimeEvent{{Type: "current"}} + m.Update(eventsMsg{seq: 3, events: []client.RuntimeEvent{{Type: "stale"}}}) + if len(m.feed) != 1 || m.feed[0].Type != "current" { + t.Fatalf("stale eventsMsg overwrote the feed: %+v", m.feed) + } + // Fresh msg shrinks the feed; selection clamps into range. (The stale + // drop above re-armed the poll, so read the current generation.) + m.panelSel = 9 + m.Update(eventsMsg{seq: m.eventsTabSeq, events: []client.RuntimeEvent{{Type: "a"}}}) + if m.panelSel != 0 { + t.Errorf("panelSel = %d, want 0 after shrink clamp", m.panelSel) + } +} + +// TestAgentsSelectionAnchoredByTaskID: a registry rebuild that inserts a row +// above the selection must not retarget it (the stop gate acts on panelSel). +func TestAgentsSelectionAnchoredByTaskID(t *testing.T) { + m := stateFixture(t) + m.panel = panelAgents + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Goal: "one"}, + {TaskID: "t2", Goal: "two"}, + }}) + m.panelSel = 1 // t2 + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t0", Goal: "new"}, // inserted above + {TaskID: "t1", Goal: "one"}, + {TaskID: "t2", Goal: "two"}, + }}) + if m.agentsReg[m.panelSel].TaskID != "t2" { + t.Fatalf("selection drifted to %s, want t2", m.agentsReg[m.panelSel].TaskID) + } +} + +// TestMemorySelectionAnchoredByText: anchorRow keeps the selection on the +// same fact across rebuilds and clamps when the row is gone. +func TestMemorySelectionAnchoredByText(t *testing.T) { + if got := anchorRow(1, []memRow{{text: "x"}, {text: "y"}, {text: "z"}}, "y"); got != 1 { + t.Errorf("anchorRow identity = %d, want 1", got) + } + if got := anchorRow(1, []memRow{{text: "x"}}, "gone"); got != 0 { + t.Errorf("anchorRow clamp = %d, want 0", got) + } +} + +// TestAgentGlyphFollowsCard: a finished card must flip the row glyph too — +// never a spinner beside a finished summary. +func TestAgentGlyphFollowsCard(t *testing.T) { + m := stateFixture(t) + m.panel = panelAgents + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, + Phase: "finished", Status: "success"}) + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", Phase: "active", Status: "running", Goal: "g"}, + }}) + rows := m.agentRowsRender(120) + if strings.Contains(strings.Join(rows, " "), "⟳") { + t.Errorf("running glyph shown for a finished card: %q", rows) + } +} + +// TestEventsTabTicksWhileOpen: the events tab refetches on a fresh tick and +// drops stale/closed ticks (runsTickMsg pattern). +func TestEventsTabTicksWhileOpen(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} // cmd construction only; the fetch never runs + if cmd := m.armEventsPoll(); cmd == nil { + t.Fatal("armEventsPoll should schedule a tick") + } + seq := m.eventsTabSeq + m.panel = panelEvents + if cmd := m.handleEventsTick(eventsTickMsg{seq: seq}); cmd == nil { + t.Fatal("fresh tick on the visible events tab should refetch") + } + m.eventsTabSeq = seq + 5 + if cmd := m.handleEventsTick(eventsTickMsg{seq: seq}); cmd != nil { + t.Fatal("stale events tick should be dropped") + } + m.panel = panelNone + if cmd := m.handleEventsTick(eventsTickMsg{seq: m.eventsTabSeq}); cmd != nil { + t.Fatal("events tick with the tab closed should be dropped") + } +} + +// TestOpenEventsArmsPoll: the first eventsMsg arms the tick chain (open → +// fetch → eventsMsg → arm → tick → …). +func TestOpenEventsArmsPoll(t *testing.T) { + m := newTestModel() + m.cl = &client.Client{} + m.openEvents() + m.Update(eventsMsg{}) // handleEventsMsg + arm + if m.eventsTabSeq == 0 { + t.Fatal("the first eventsMsg must arm the poll chain") + } +}