From 183e640fd9a1f654e165f440ecdf93d37e3977b1 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 30 Aug 2026 18:30:58 +0200 Subject: [PATCH 1/3] fix(tui): surface subagent_log payloads and status The relay delivers sub-agent log payloads in the data field and the child log status in status; the handler read detail, which serve never sends on this frame, so live runs rendered bare event names. Read data first (detail stays a legacy fallback), surface status, and cap the segment like every other one-line preview. --- internal/client/client.go | 2 ++ internal/tui/events.go | 12 ++++++++-- internal/tui/steps_test.go | 47 ++++++++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 52bd012..f9389c3 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -85,9 +85,11 @@ type Event struct { // skill_event / memory_event / agent_signal / subagent_log: the event // subtype (e.g. "loaded", "merge", "trim") plus a few shared details. + // Status carries the child-reported log status on subagent_log frames. SubType string `json:"event"` Target string `json:"target"` Detail string `json:"detail"` + Status string `json:"status,omitempty"` SkillName string `json:"skill_name"` Untrusted bool `json:"untrusted"` Count int `json:"count"` diff --git a/internal/tui/events.go b/internal/tui/events.go index 61b21f0..f279ed4 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -279,8 +279,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { m.addTransientNote("signal · " + strings.TrimSpace(ev.SubType+" "+ev.Detail) + eventTail(ev)) case "subagent_log": line := strings.TrimSpace(ev.SubType + " " + ev.Name) - if d := collapse(ev.Detail); d != "" { - line = strings.TrimSpace(line + " · " + d) + // The relay delivers the payload in data (Detail is only a legacy + // fallback) and the child's log status in status — surface both, + // capped like every other one-line preview. + if d := collapse(ev.Data); d != "" { + line = strings.TrimSpace(line + " · " + truncate(d, 72)) + } else if d := collapse(ev.Detail); d != "" { + line = strings.TrimSpace(line + " · " + truncate(d, 72)) + } + if ev.Status != "" { + line = strings.TrimSpace(line + " · " + ev.Status) } line += eventTail(ev) // Nest the log under the in-flight sub-agent step when there is one; diff --git a/internal/tui/steps_test.go b/internal/tui/steps_test.go index 0020440..40dc73c 100644 --- a/internal/tui/steps_test.go +++ b/internal/tui/steps_test.go @@ -102,16 +102,59 @@ func TestSubagentLogNesting(t *testing.T) { // A sub-agent tool: subsequent logs nest under its step. m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_task", Data: `{"task":"explore the repo"}`}) - m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Detail: "main.go"}) + m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Data: "main.go"}) step := m.msgs[0].steps[len(m.msgs[0].steps)-1] if !step.subagent { t.Fatal("delegate step not flagged as sub-agent") } - if len(step.logs) != 1 || !strings.Contains(step.logs[0], "read") { + if len(step.logs) != 1 || !strings.Contains(step.logs[0], "main.go") { t.Errorf("sub-agent log not nested: %#v", step.logs) } } +// TestSubagentLogPayload pins the wire-accurate subagent_log frame shape: +// the payload rides the data field (the serve relay sends data, never +// detail) and the child-reported status rides status — Detail is only a +// legacy/synthetic fallback. +func TestSubagentLogPayload(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"}`}) + + // Wire shape: the payload rides data — surface it in the nested log. + m.handleEvent(client.Event{Type: "subagent_log", SubType: "tool_call", Name: "read", Data: "handlers/user.go", TaskIdx: 2}) + step := m.msgs[0].steps[len(m.msgs[0].steps)-1] + if len(step.logs) != 1 { + t.Fatalf("expected 1 nested log, got %#v", step.logs) + } + if got := step.logs[0]; !strings.Contains(got, "handlers/user.go") { + t.Errorf("payload dropped: %q", got) + } + + // 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") { + 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") { + 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 { + t.Errorf("payload not capped (%d runes): %.40q", len([]rune(got)), got) + } +} + // 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) { From b6bb36a47d65ce868d12e12ac338834f39c7d65c Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 30 Aug 2026 18:40:59 +0200 Subject: [PATCH 2/3] feat(tui): live sub-agent telemetry cards from subagent_state Per-task lifecycle frames (odek v1.30+) drive in-place agent cards: live step/tool/iteration/token/duration while running, terminal glyphs mirroring odek status framing (success/partial/error/ cancelled/timeout), and a done/tokens rollup on the collapsed step line. Stray frames fall back to notices, wire text is sanitized on ingest, and older servers render exactly as before. --- README.md | 5 +- internal/client/client.go | 13 +++ internal/tui/events.go | 10 ++ internal/tui/model.go | 1 + internal/tui/subagent_state_test.go | 152 ++++++++++++++++++++++++ internal/tui/subagents.go | 175 ++++++++++++++++++++++++++++ internal/tui/view.go | 11 ++ 7 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 internal/tui/subagent_state_test.go create mode 100644 internal/tui/subagents.go diff --git a/README.md b/README.md index 2011dde..a103619 100644 --- a/README.md +++ b/README.md @@ -299,7 +299,10 @@ collect an approval for a prompt the engine already abandoned. footer, and tinted with a `✗` when the call fails. - **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. + 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. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, diff --git a/internal/client/client.go b/internal/client/client.go index f9389c3..245ccc0 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -94,6 +94,19 @@ type Event struct { Untrusted bool `json:"untrusted"` Count int `json:"count"` TaskIdx int `json:"task_idx"` + + // subagent_state: per-task lifecycle telemetry (odek v1.30+). All + // omitempty — older servers degrade to zero values and the TUI renders + // exactly what it did before. Status is shared with the block above + // (both frames carry "status"). + TaskID string `json:"task_id,omitempty"` + RunKey string `json:"run_key,omitempty"` + Phase string `json:"phase,omitempty"` // started | active | finished + Step int `json:"step,omitempty"` + Iterations int `json:"iterations,omitempty"` + Tool string `json:"tool,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` + TokensUsed int `json:"tokens_used,omitempty"` } // EventDisconnected is a synthetic Type emitted on the Events channel when the diff --git a/internal/tui/events.go b/internal/tui/events.go index f279ed4..25c6e71 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -298,6 +298,16 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.addTransientNote("subagent · " + line) + case "subagent_state": + // Per-task lifecycle telemetry (odek v1.30+): attach to the + // in-flight sub-agent step; strays (resumed turn, idle, late + // 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 + break + } + m.addTransientNote("subagent · " + stateNoticeLine(ev)) + case client.EventDisconnected: m.disconn = true m.busy = false diff --git a/internal/tui/model.go b/internal/tui/model.go index cf1aae4..8b2ec98 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -34,6 +34,7 @@ type step struct { isErr bool // the result reads as a failure (tints the status glyph red) 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 expanded bool // user has expanded this step to show full output/logs started time.Time // when the tool_call arrived; zero for resumed history dur time.Duration // wall-clock the call took; 0 until the result lands diff --git a/internal/tui/subagent_state_test.go b/internal/tui/subagent_state_test.go new file mode 100644 index 0000000..358dfbe --- /dev/null +++ b/internal/tui/subagent_state_test.go @@ -0,0 +1,152 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// stateFixture spins a model with an in-flight delegate_tasks step — the +// anchor every subagent_state frame attaches to. +func stateFixture(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":["a","b"]}`}) + return m +} + +// stateStep returns the message's sub-agent step. +func stateStep(t *testing.T, m *Model) *step { + t.Helper() + msg := &m.msgs[0] + for j := len(msg.steps) - 1; j >= 0; j-- { + if msg.steps[j].subagent { + return &msg.steps[j] + } + } + t.Fatal("no sub-agent step") + return nil +} + +// TestSubagentStateLifecycle drives started → active → finished and checks +// the upserted card, the terminal line, and the collapsed rollup. +func TestSubagentStateLifecycle(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "started", Status: "running"}) + s := stateStep(t, m) + if len(s.agents) != 1 || s.agents[0].phase != "started" { + t.Fatalf("card not created: %#v", s.agents) + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 7, Tool: "read main.go", Iterations: 5, TokensUsed: 3200}) + s = stateStep(t, m) + if len(s.agents) != 1 { + t.Fatalf("active frame duplicated the card: %#v", s.agents) + } + if s.agents[0].step != 7 || s.agents[0].tool != "read main.go" || s.agents[0].tokens != 3200 { + t.Fatalf("telemetry not updated: %#v", s.agents[0]) + } + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", Iterations: 6, TokensUsed: 3200, DurationSeconds: 4.2}) + s = stateStep(t, m) + card := s.agents[0] + if !card.finished() || card.status != "success" { + t.Fatalf("card not terminal: %#v", card) + } + line := agentCardLine(card) + for _, want := range []string{"✓ SA1", "6 it", "3.2k tok", "4.2s"} { + if !strings.Contains(line, want) { + t.Errorf("terminal line missing %q: %q", want, line) + } + } + if strings.Contains(line, "read main.go") { + t.Errorf("terminal line still shows live tool: %q", line) + } + if r := agentRollup(s); r != "1/1 agents · 3.2k tok" { + t.Errorf("rollup = %q", r) + } +} + +// TestSubagentStateGlyphs pins the glyph per terminal status and the error +// styling of failed states. +func TestSubagentStateGlyphs(t *testing.T) { + cases := []struct { + status string + glyph string + failed bool + }{ + {"success", "✓", false}, + {"partial", "◐", false}, + {"error", "✗", true}, + {"cancelled", "⊘", true}, + {"timeout", "⏱", true}, + } + for _, tc := range cases { + a := &agentCard{taskID: "t", phase: "finished", status: tc.status} + if got := a.glyph(); got != tc.glyph { + t.Errorf("status %q glyph = %q, want %q", tc.status, got, tc.glyph) + } + if a.failed() != tc.failed { + t.Errorf("status %q failed = %v", tc.status, a.failed()) + } + } + if got := (&agentCard{phase: "active", status: "running"}).glyph(); got != "⟳" { + t.Errorf("live glyph = %q", got) + } +} + +// TestSubagentStateStray: with no in-flight sub-agent step the frame falls +// back to a notice instead of vanishing — including a frame that arrives +// after the delegate step already closed. +func TestSubagentStateStray(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: "subagent_state", TaskID: "t9", TaskIdx: 2, Phase: "started", Status: "running"}) + got := strings.Join(m.notices, "\n") + if !strings.Contains(got, "state SA3") || !strings.Contains(got, "started") { + t.Errorf("stray state frame not noticed: %q", got) + } + + m = stateFixture(t) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"success"}`}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success"}) + got = strings.Join(m.notices, "\n") + if !strings.Contains(got, "state SA1") || !strings.Contains(got, "finished") { + t.Errorf("late frame not noticed: %q", got) + } +} + +// TestSubagentStateRollup: the collapsed head aggregates done count and +// tokens; the expanded view renders one live card line per task. +func TestSubagentStateRollup(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", TokensUsed: 3200}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "active", Status: "running", Step: 3, Tool: "grep x", TokensUsed: 3100}) + s := stateStep(t, m) + if r := agentRollup(s); r != "1/2 agents · 6.3k tok" { + t.Fatalf("rollup = %q", r) + } + s.expanded = true + out, _ := renderStepsForTest(m, m.msgs[0], 0, 0) + if !strings.Contains(out, "1/2 agents") || !strings.Contains(out, "⟳ SA2") { + t.Errorf("render missing rollup or live card: %q", out) + } +} + +// TestSubagentStateSanitize: wire-derived tool text is sanitized and +// single-lined before it reaches a card. +func TestSubagentStateSanitize(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Tool: "read \x1b[31mmain.go\nsecond line", Step: 2}) + line := agentCardLine(stateStep(t, m).agents[0]) + if strings.ContainsAny(line, "\x1b\n") { + t.Errorf("card line carries control bytes: %q", line) + } + if !strings.Contains(line, "main.go second line") { + t.Errorf("tool text mangled: %q", line) + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go new file mode 100644 index 0000000..b55ddc2 --- /dev/null +++ b/internal/tui/subagents.go @@ -0,0 +1,175 @@ +package tui + +// Sub-agent live telemetry: the agentCard model, subagent_state ingestion, +// and card rendering. Driven by the per-task lifecycle frames odek serve +// v1.30+ emits (started → active → finished); every field is wire-derived +// and sanitized on ingest, and cards render only for tasks that report. + +import ( + "fmt" + "strings" + "time" + + "github.com/BackendStack21/bodek/internal/client" +) + +// agentCard is one delegated task's live telemetry inside a sub-agent step. +type agentCard struct { + taskID string + idx int + phase string // started | active | finished + status string // running | success | partial | error | cancelled | timeout + step int + tool string + iters int + tokens int + durS float64 +} + +// finished reports whether the card reached a terminal state. +func (a *agentCard) finished() bool { return a.phase == "finished" } + +// glyph picks the status glyph: live ⟳, then the terminal set mirroring +// odek's status framing (user cancel and deadline timeout never conflate). +func (a *agentCard) glyph() string { + if !a.finished() { + return "⟳" + } + switch a.status { + case "success": + return "✓" + case "partial": + return "◐" + case "error": + return "✗" + case "cancelled": + return "⊘" + case "timeout": + return "⏱" + default: + return "•" + } +} + +// failed reports terminal states that render in the error style. +func (a *agentCard) failed() bool { + switch a.status { + case "error", "cancelled", "timeout": + return true + } + return false +} + +// card finds a step's agent card by task id. +func (s *step) card(taskID string) *agentCard { + for _, a := range s.agents { + if a.taskID == taskID { + return a + } + } + return nil +} + +// attachSubState routes a subagent_state frame into the message's in-flight +// sub-agent step, upserting the task's card in place. It returns false when +// there is no live sub-agent step to attach to — the caller falls back to a +// transient notice (resumed turn, idle, stray frame). Idempotent: replayed +// frames overwrite the same card. +func (m *Model) attachSubState(i int, ev client.Event) bool { + if ev.TaskID == "" { + return false + } + msg := &m.msgs[i] + for j := len(msg.steps) - 1; j >= 0; j-- { + if !msg.steps[j].subagent || msg.steps[j].done { + continue + } + s := &msg.steps[j] + card := s.card(ev.TaskID) + if card == nil { + card = &agentCard{taskID: ev.TaskID, idx: ev.TaskIdx, status: "running"} + s.agents = append(s.agents, card) + } + if ev.Phase != "" { + card.phase = ev.Phase + } + if ev.Status != "" { + card.status = ev.Status + } + card.step = ev.Step + if ev.Tool != "" { + card.tool = collapse(ev.Tool) + } + card.iters = ev.Iterations + card.tokens = ev.TokensUsed + card.durS = ev.DurationSeconds + return true + } + return false +} + +// agentCardLine renders one card: glyph + label + the live telemetry tail — +// "⟳ SA1 · step 7 · read main.go · 5 it · 3.2k tok" while running, +// "✓ SA2 · 6 it · 3.2k tok · 4.2s" once terminal. +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) + } + } else if a.status != "" && a.status != "success" { + b.WriteString(" · " + a.status) + } + if a.iters > 0 { + fmt.Fprintf(&b, " · %d it", a.iters) + } + if a.tokens > 0 { + b.WriteString(" · " + human(a.tokens) + " tok") + } + if a.durS > 0 { + b.WriteString(" · " + formatStepDur(time.Duration(a.durS*float64(time.Second)))) + } + return b.String() +} + +// agentRollup is the collapsed-head aggregate: "1/2 agents · 6.3k tok". +func agentRollup(s *step) string { + if len(s.agents) == 0 { + return "" + } + done, tokens := 0, 0 + for _, a := range s.agents { + if a.finished() { + done++ + } + tokens += a.tokens + } + rollup := fmt.Sprintf("%d/%d agents", done, len(s.agents)) + if tokens > 0 { + rollup += " · " + human(tokens) + " tok" + } + return rollup +} + +// stateNoticeLine renders a subagent_state frame that had nowhere to attach +// as a transient notice line. +func stateNoticeLine(ev client.Event) string { + parts := []string{fmt.Sprintf("state SA%d", ev.TaskIdx+1), ev.Phase, ev.Status} + if ev.Step > 0 { + parts = append(parts, fmt.Sprintf("step %d", ev.Step)) + } + if ev.TokensUsed > 0 { + parts = append(parts, human(ev.TokensUsed)+" tok") + } + out := make([]string, 0, len(parts)) + for _, p := range parts { + if p != "" { + out = append(out, p) + } + } + return strings.Join(out, " · ") +} diff --git a/internal/tui/view.go b/internal/tui/view.go index cbfd79a..6716188 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -772,6 +772,9 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in left := chevron + " " + icon + " " + th.toolIcon.Render(toolGlyph(s.name)) + " " + th.stepName.Render(s.name) if s.subagent { left += th.stepArg.Render(" · sub-agent") + if r := agentRollup(&s); r != "" { + left += th.stepArg.Render(" · " + r) + } } // Right rail: response time once the call lands, plus the typed chip // (diffstat / test verdict) — right-aligned so durations read as a @@ -804,6 +807,14 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in // styled, already-truncated lines (truncating styled text would // corrupt ANSI sequences) — append those verbatim. var details []string + for _, a := range s.agents { + line := agentCardLine(a) + if a.failed() { + details = append(details, th.stepErr.Render(truncate(line, detailBudget))) + continue + } + details = append(details, th.stepRes.Render(truncate(line, detailBudget))) + } for _, lg := range s.logs { if strings.TrimSpace(lg) != "" { details = append(details, th.stepRes.Render(truncate(lg, detailBudget))) From 7fc7ddc815a82d9686dd9363c54bf7a8e4737dc2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 30 Aug 2026 19:16:37 +0200 Subject: [PATCH 3/3] feat(tui): sub-agent stop, framed result cards, agents registry tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three milestones of the sub-agents UI plan land together (they share the same files): per-agent stop over the WS subagent_cancel frame (ctrl+s or /stop behind the two-step confirm; acks never flip card state — the terminal subagent_state does), structured cards for framed delegate results (tolerant parse, prose fallback), and an /agents drawer tab over GET /api/subagents using the shared drawer grammar with the full detail view. --- README.md | 9 +- internal/client/client.go | 22 ++- internal/client/rest.go | 35 ++++ internal/client/subagent_cancel_test.go | 61 ++++++ internal/client/subagents_rest_test.go | 51 +++++ internal/tui/commands.go | 6 + internal/tui/commands_e2e_test.go | 13 ++ internal/tui/drawer.go | 26 +++ internal/tui/drawer_test.go | 17 +- internal/tui/events.go | 11 ++ internal/tui/mgmt.go | 78 ++++++++ internal/tui/model.go | 45 +++-- internal/tui/panels.go | 21 ++ internal/tui/subagent_result_test.go | 84 ++++++++ internal/tui/subagent_stop_test.go | 127 ++++++++++++ internal/tui/subagents.go | 245 +++++++++++++++++++++++- internal/tui/subagents_panel_test.go | 76 ++++++++ internal/tui/view.go | 6 +- 18 files changed, 899 insertions(+), 34 deletions(-) create mode 100644 internal/client/subagent_cancel_test.go create mode 100644 internal/client/subagents_rest_test.go create mode 100644 internal/tui/subagent_result_test.go create mode 100644 internal/tui/subagent_stop_test.go create mode 100644 internal/tui/subagents_panel_test.go diff --git a/README.md b/README.md index a103619..7b464bc 100644 --- a/README.md +++ b/README.md @@ -205,13 +205,15 @@ command and press `⏎`. | `/model [name]` | Switch model (opens a picker with no argument) | | `/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) | | `/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 | ### The management drawer -`/sessions`, `/runs`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and +`/sessions`, `/runs`, `/agents`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and `/config` all open tabs of **one drawer** with a shared grammar: - `]` / `[` cycle tabs · `1`–`8` jump · `r` refresh · `esc` closes. @@ -303,6 +305,11 @@ collect an approval for a prompt the engine already abandoned. 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 — while prose results keep the generic preview. + `ctrl+s` (or `/stop `, two-step confirmed) stops one running + sub-agent, and the `/agents` drawer tab lists the serve instance's + registry snapshot — recent delegated tasks with goal, status, and usage. - **Security approvals** — odek's `danger` engine prompts surface as an inline panel; your answer is sent straight back over the socket. - **Live reasoning** — the model's pre-tool thinking streams in dimmed text, diff --git a/internal/client/client.go b/internal/client/client.go index 245ccc0..8587838 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -71,9 +71,11 @@ type Event struct { TimeoutSeconds int `json:"timeout_seconds,omitempty"` // approval_ack (Action echoes the client's reply) / cancelled (Idle is - // true when nothing was running for the target session). - Action string `json:"action"` - Idle bool `json:"idle"` + // true when nothing was running for the target session) / + // subagent_cancelled (Accepted:false is a benign race — task already done). + Action string `json:"action"` + Idle bool `json:"idle"` + Accepted bool `json:"accepted"` // server_info / pong snapshot. T is the pong timestamp (unix ms); the // client measures round-trip latency from its own send clock instead. @@ -267,6 +269,20 @@ func (c *Client) SendCancel(sessionID, authToken string) error { }{Type: "cancel", SessionID: sessionID, AuthToken: authToken}) } +// SendSubagentCancel stops ONE running sub-agent by task id over the +// WebSocket (handled inline by the socket reader, so it works while +// delegate_tasks occupies the prompt processor; session-scoped auth). The +// subagent_cancelled ack replies; the card's terminal state arrives as a +// subagent_state transition. +func (c *Client) SendSubagentCancel(sessionID, authToken, taskID string) error { + return c.send(struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + AuthToken string `json:"auth_token,omitempty"` + TaskID string `json:"task_id"` + }{Type: "subagent_cancel", SessionID: sessionID, AuthToken: authToken, TaskID: taskID}) +} + // SessionSwitch adopts an existing session without sending a prompt: the // connection's agent restores the session's memory buffer and the server // emits the standard `session` event. diff --git a/internal/client/rest.go b/internal/client/rest.go index 67b026f..78ac4ba 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -300,6 +300,41 @@ func (c *Client) Cancel(sessionID, token string) error { // ── low-level helpers ──────────────────────────────────────────────────────── +// SubagentEntry is one delegated task's lifecycle record from the serve +// registry snapshot (GET /api/subagents). +type SubagentEntry struct { + TaskID string `json:"task_id"` + RunKey string `json:"run_key"` + Goal string `json:"goal,omitempty"` + Status string `json:"status,omitempty"` + Phase string `json:"phase"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at,omitempty"` + Iterations int `json:"iterations,omitempty"` + Step int `json:"step,omitempty"` + LastTool string `json:"last_tool,omitempty"` + DurationSeconds float64 `json:"duration_seconds,omitempty"` + TokensUsed int `json:"tokens_used,omitempty"` +} + +// Subagents fetches the sub-agent registry snapshot, optionally filtered by +// run key. Auth mirrors the other instance-level GETs. +func (c *Client) Subagents(runKey string) ([]SubagentEntry, error) { + u := c.baseURL + "/api/subagents" + if runKey != "" { + u += "?key=" + url.QueryEscape(runKey) + } + var out struct { + Entries []SubagentEntry `json:"entries"` + Count int `json:"count"` + } + if err := c.getJSON(u, "", &out); err != nil { + return nil, err + } + return out.Entries, nil +} + func (c *Client) do(method, u, sessionToken string) (*http.Response, error) { req, err := http.NewRequest(method, u, nil) if err != nil { diff --git a/internal/client/subagent_cancel_test.go b/internal/client/subagent_cancel_test.go new file mode 100644 index 0000000..fe34d05 --- /dev/null +++ b/internal/client/subagent_cancel_test.go @@ -0,0 +1,61 @@ +package client + +import ( + "encoding/json" + "net/http" + "sync" + "testing" + "time" + + ws "golang.org/x/net/websocket" +) + +// TestSendSubagentCancelFrame pins the wire shape of the per-agent stop: +// session-scoped auth plus the task id, mirroring the cancel frame. +func TestSendSubagentCancelFrame(t *testing.T) { + var mu sync.Mutex + var frames []map[string]any + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + for { + var data []byte + if err := ws.Message.Receive(c, &data); err != nil { + return + } + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + continue + } + mu.Lock() + frames = append(frames, m) + mu.Unlock() + } + })) + cl, _ := newTestServer(t, mux) + + if err := cl.SendSubagentCancel("s1", "a1", "task-uuid"); err != nil { + t.Fatalf("SendSubagentCancel: %v", err) + } + + deadline := time.After(3 * time.Second) + for { + mu.Lock() + n := len(frames) + mu.Unlock() + if n >= 1 { + break + } + select { + case <-deadline: + t.Fatal("no frame arrived") + case <-time.After(20 * time.Millisecond): + } + } + + mu.Lock() + defer mu.Unlock() + f := frames[0] + if f["type"] != "subagent_cancel" || f["session_id"] != "s1" || f["auth_token"] != "a1" || f["task_id"] != "task-uuid" { + t.Errorf("subagent_cancel frame = %v", f) + } +} diff --git a/internal/client/subagents_rest_test.go b/internal/client/subagents_rest_test.go new file mode 100644 index 0000000..ae560bd --- /dev/null +++ b/internal/client/subagents_rest_test.go @@ -0,0 +1,51 @@ +package client + +import ( + "encoding/json" + "net/http" + "testing" + + ws "golang.org/x/net/websocket" +) + +// TestSubagentsREST pins GET /api/subagents decoding and the ?key= filter. +func TestSubagentsREST(t *testing.T) { + var gotKey string + mux := http.NewServeMux() + mux.Handle("/ws", ws.Handler(func(c *ws.Conn) { + buf := make([]byte, 512) + for { + if _, err := c.Read(buf); err != nil { + return + } + } + })) + mux.HandleFunc("/api/subagents", func(w http.ResponseWriter, r *http.Request) { + gotKey = r.URL.Query().Get("key") + _ = json.NewEncoder(w).Encode(map[string]any{ + "entries": []map[string]any{ + { + "task_id": "t1", "run_key": "rk1", "goal": "explore the repo", + "phase": "finished", "status": "success", + "iterations": 3, "tokens_used": 1500, "duration_seconds": 4.2, + }, + }, + "count": 1, + }) + }) + cl, _ := newTestServer(t, mux) + + entries, err := cl.Subagents("") + if err != nil { + t.Fatalf("Subagents: %v", err) + } + if len(entries) != 1 || entries[0].TaskID != "t1" || entries[0].Goal != "explore the repo" || entries[0].TokensUsed != 1500 { + t.Fatalf("entries = %+v", entries) + } + if _, err := cl.Subagents("rk1"); err != nil { + t.Fatalf("Subagents(key): %v", err) + } + if gotKey != "rk1" { + t.Errorf("key filter not sent, got %q", gotKey) + } +} diff --git a/internal/tui/commands.go b/internal/tui/commands.go index 2f11ac0..6999165 100644 --- a/internal/tui/commands.go +++ b/internal/tui/commands.go @@ -105,6 +105,12 @@ func slashCommands() []command { {"cancel", "cancel the running turn", func(m *Model, _ string) tea.Cmd { return m.cancelRun() }}, + {"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 { + return m.openAgents() + }}, {"attach", "stage a file for the next prompt — /attach ", func(m *Model, args string) tea.Cmd { return m.attachFile(args) }}, diff --git a/internal/tui/commands_e2e_test.go b/internal/tui/commands_e2e_test.go index 8ac8b8e..185580c 100644 --- a/internal/tui/commands_e2e_test.go +++ b/internal/tui/commands_e2e_test.go @@ -266,6 +266,19 @@ func TestE2EAllCommands(t *testing.T) { t.Fatal("/cancel did not clear the busy state") } }, + "/stop": func(t *testing.T, m *Model) { + if m.confirm != confirmNone { + t.Fatal("/stop without live agents armed a gate") + } + if !notePresent(m, "no running sub-agents") { + t.Errorf("/stop note missing: %v", m.notices) + } + }, + "/agents": func(t *testing.T, m *Model) { + if m.panel != panelAgents { + t.Fatalf("/agents opened panel %d", m.panel) + } + }, "/attach": func(t *testing.T, m *Model) { if len(m.attachments) != 1 || m.attachments[0].Name != "notes.txt" { t.Fatalf("/attach staged = %+v", m.attachments) diff --git a/internal/tui/drawer.go b/internal/tui/drawer.go index cbaa758..95e0826 100644 --- a/internal/tui/drawer.go +++ b/internal/tui/drawer.go @@ -98,6 +98,31 @@ func (m *Model) handleRunStarted(msg runStartedMsg) tea.Cmd { return tea.Batch(note, m.openRuns()) } +// ── agents tab ────────────────────────────────────────────────────────────── + +// openAgents opens the sub-agent registry tab (GET /api/subagents snapshot). +func (m *Model) openAgents() tea.Cmd { + m.panel = panelAgents + m.panelSel = 0 + m.panelEdit = panelEditNone + m.panelMsg = "loading sub-agents…" + m.relayout() + m.refresh() + return m.fetchAgents() +} + +// fetchAgents refetches the registry snapshot; r re-runs it while open. +func (m *Model) fetchAgents() tea.Cmd { + if m.cl == nil { + return nil + } + cl := m.cl + return func() tea.Msg { + entries, err := cl.Subagents("") + return mgmtMsg{tab: panelAgents, sag: entries, err: err} + } +} + // drawerTab is one tab of the management drawer. type drawerTab struct { name string @@ -112,6 +137,7 @@ func drawerTabs() []drawerTab { return []drawerTab{ {"sessions", panelSessions, func(m *Model) tea.Cmd { return m.openSessions() }}, {"runs", panelRuns, func(m *Model) tea.Cmd { return m.openRuns() }}, + {"agents", panelAgents, func(m *Model) tea.Cmd { return m.openAgents() }}, {"events", panelEvents, func(m *Model) tea.Cmd { return m.openEvents() }}, {"plan", panelPlan, func(m *Model) tea.Cmd { return m.openPlan() }}, {"memory", panelMemory, func(m *Model) tea.Cmd { return m.openMemory() }}, diff --git a/internal/tui/drawer_test.go b/internal/tui/drawer_test.go index 31a1d01..76928d9 100644 --- a/internal/tui/drawer_test.go +++ b/internal/tui/drawer_test.go @@ -71,16 +71,16 @@ func TestDrawerEventsTab(t *testing.T) { } } -// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL seven +// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL nine // drawer tabs (management panels included — they are full tabs, not loose // overlays) and esc closes from any of them. func TestDrawerTabCycling(t *testing.T) { m := wired(t) m.Update(exec(m.openRuns())) - // ] walks the full ring: runs → events → plan → memory → skills → - // tools → config → sessions. - want := []panelMode{panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} + // ] walks the full ring: runs → agents → events → plan → memory → + // skills → tools → config → sessions. + want := []panelMode{panelAgents, panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions} for _, w := range want { _, cmd := m.Update(key("]")) m.Update(exec(cmd)) @@ -96,8 +96,9 @@ func TestDrawerTabCycling(t *testing.T) { } // Digits jump straight to any tab. for d, w := range map[string]panelMode{ - "1": panelSessions, "2": panelRuns, "3": panelEvents, "4": panelPlan, - "5": panelMemory, "6": panelSkills, "7": panelTools, "8": panelConfig, + "1": panelSessions, "2": panelRuns, "3": panelAgents, "4": panelEvents, + "5": panelPlan, "6": panelMemory, "7": panelSkills, "8": panelTools, + "9": panelConfig, } { _, cmd := m.Update(key(d)) m.Update(exec(cmd)) @@ -108,13 +109,13 @@ func TestDrawerTabCycling(t *testing.T) { // The strip renders every tab name, and r refreshes a management tab // the same as a core tab (they are drawer tabs now). out := plain(m.View()) - for _, name := range []string{"sessions", "runs", "events", "plan", "memory", "skills", "tools", "config"} { + for _, name := range []string{"sessions", "runs", "agents", "events", "plan", "memory", "skills", "tools", "config"} { if !strings.Contains(out, name) { t.Errorf("tab strip missing %q:\n%s", name, out) } } m.Update(exec(m.fetchSessionsPage("", 0, false))) - _, cmd = m.Update(key("5")) // memory tab (plan is 4 since its insertion) + _, cmd = m.Update(key("6")) // memory tab (agents shifted the digits) m.Update(exec(cmd)) _, cmd = m.Update(key("r")) if cmd == nil { diff --git a/internal/tui/events.go b/internal/tui/events.go index 25c6e71..ef0b309 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -112,6 +112,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { steps[j].done = true steps[j].result = resultPreview(ev.Data) steps[j].isErr = looksLikeError(steps[j].result) + if steps[j].subagent { + steps[j].resultCard = parseAgentResult(ev.Data) + } if !steps[j].started.IsZero() { steps[j].dur = time.Since(steps[j].started) } @@ -308,6 +311,14 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { } m.addTransientNote("subagent · " + stateNoticeLine(ev)) + case "subagent_cancelled": + // Stop ack. accepted:false is a benign race — the task already + // finished. The card's terminal state comes exclusively from the + // subagent_state frame; the ack never flips UI state. + if !ev.Accepted { + m.addTransientNote("stop declined · sub-agent already finished") + } + case client.EventDisconnected: m.disconn = true m.busy = false diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 28fd43c..bb03619 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -29,6 +29,7 @@ type mgmtMsg struct { cfg map[string]any usr client.Usage con []client.Connection + sag []client.SubagentEntry err error } @@ -199,6 +200,13 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { case panelConfig: m.cfgRows = buildCfgRows(msg.cfg, msg.usr, msg.con) m.panelMsg = "" + case panelAgents: + m.agentsReg = msg.sag + if len(msg.sag) == 0 { + m.panelMsg = "no sub-agent activity recorded" + } else { + m.panelMsg = "" + } } if m.panelSel >= m.panelLen() { m.panelSel = max(m.panelLen()-1, 0) @@ -583,6 +591,56 @@ func (m *Model) toolSelected() *toolRow { // mgmtDetailLines renders the selected row's detail block, wrapped to w. // Everything from the wire goes through sanitize(). +// agentRowsRender renders the agents tab: one row per registry entry — +// status glyph, redacted goal, and a compact usage tail. +func (m *Model) agentRowsRender(w int) []string { + th := m.th + rows := make([]string, 0, len(m.agentsReg)) + for i, e := range m.agentsReg { + goal := e.Goal + if goal == "" { + goal = "(no goal recorded)" + } + var detail string + if e.Phase == "finished" { + detail = fmt.Sprintf(" %s · %d it · %s tok", e.Status, e.Iterations, human(e.TokensUsed)) + } else { + detail = fmt.Sprintf(" running · step %d · %s", e.Step, e.LastTool) + } + if e.DurationSeconds > 0 { + detail += fmt.Sprintf(" · %.1fs", e.DurationSeconds) + } + budget := w - 2 - lipgloss.Width(detail) + label := agentStatusGlyph(e.Phase, e.Status) + " " + goal + prefix, lab := " ", th.acItem.Render(truncate(label, budget)) + if i == m.panelSel { + prefix, lab = th.acSel.Render("› "), th.acSel.Render(truncate(label, budget)) + } + rows = append(rows, prefix+lab+th.acDetail.Render(detail)) + } + return rows +} + +// agentStatusGlyph mirrors the live-card glyph set. +func agentStatusGlyph(phase, status string) string { + if phase != "finished" { + return "⟳" + } + switch status { + case "success": + return "✓" + case "partial": + return "◐" + case "error": + return "✗" + case "cancelled": + return "⊘" + case "timeout": + return "⏱" + } + return "•" +} + func (m *Model) mgmtDetailLines(w int) []string { th := m.th var out []string @@ -641,6 +699,26 @@ func (m *Model) mgmtDetailLines(w int) []string { } out = append(out, "") out = append(out, wrapText(sanitize(r.text), w)...) + case panelAgents: + if m.panelSel >= len(m.agentsReg) { + return []string{th.acDim.Render("no entry selected")} + } + e := m.agentsReg[m.panelSel] + out = append(out, th.acSel.Render("› "+agentStatusGlyph(e.Phase, e.Status)+" "+sanitize(e.Goal))) + meta := []string{e.Phase, e.Status, "task " + sanitize(e.TaskID)} + if e.LastTool != "" { + meta = append(meta, "last "+sanitize(e.LastTool)) + } + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + out = append(out, "") + out = append(out, th.acDetail.Render(fmt.Sprintf("run %s · %d iterations · %d tokens · %.1fs", + sanitize(e.RunKey), e.Iterations, e.TokensUsed, e.DurationSeconds))) + if !e.StartedAt.IsZero() { + out = append(out, th.acDetail.Render("started "+e.StartedAt.String())) + } + if !e.FinishedAt.IsZero() { + out = append(out, th.acDetail.Render("finished "+e.FinishedAt.String())) + } case panelTools: r := m.toolSelected() if r == nil { diff --git a/internal/tui/model.go b/internal/tui/model.go index 8b2ec98..cddf5d7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -27,17 +27,18 @@ const ( // step is a single tool invocation within an assistant turn. type step struct { - name string - arg string - result string // sanitized tool output (multi-line); excerpted at render - done bool - isErr bool // the result reads as a failure (tints the status glyph red) - 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 - expanded bool // user has expanded this step to show full output/logs - started time.Time // when the tool_call arrived; zero for resumed history - dur time.Duration // wall-clock the call took; 0 until the result lands + name string + arg string + result string // sanitized tool output (multi-line); excerpted at render + done bool + isErr bool // the result reads as a failure (tints the status glyph red) + 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 + 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 + dur time.Duration // wall-clock the call took; 0 until the result lands } // stepRef maps a rendered transcript line to a specific step for mouse @@ -207,8 +208,10 @@ type Model struct { panelEdit panelEditMode // text-entry submode while a panel is open panelDraft string // the text being edited (search query / rename) confirm confirmKind // armed destructive action: y fires, any other key disarms + stopTarget string // task_id armed by confirmStopAgent - profiles []client.Profile // built-in model catalog (picker + context gauge) + profiles []client.Profile // built-in model catalog (picker + context gauge) + agentsReg []client.SubagentEntry // agents tab: sub-agent registry snapshot // Drawer state: runs polling + the events feed. runs []client.Run @@ -561,6 +564,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, m.noticeSweep() + case stopAgentDoneMsg: + if msg.err != nil { + m.addNote("stop failed · " + msg.err.Error()) + m.refresh() + } + return m, m.noticeSweep() + case updateCheckMsg: // Silent on error or when already current: the hint only ever nags // once, at startup, when a newer release is confirmed. @@ -732,6 +742,17 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.refresh() } return m, nil + case "ctrl+s": + // Stop one running sub-agent — a chord, so queue typing is never + // hijacked — behind the same two-step gate as every destructive + // action. /stop targets a specific card. + if m.busy { + if id, label, ok := m.firstLiveAgent(); ok { + return m, m.armStopAgent(id, label) + } + return m, m.transientNoteCmd("no running sub-agents") + } + return m, nil case "ctrl+t": m.thinkOn = !m.thinkOn state := "off" diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 40198f3..f78703c 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -26,6 +26,7 @@ const ( panelSkills panelTools panelConfig + panelAgents ) // panelEditMode is the text-entry submode a panel can capture: `/` search in @@ -50,6 +51,7 @@ const ( confirmFactDelete confirmClear confirmCancel + confirmStopAgent ) // handleConfirmKey resolves an armed delete: y fires it against the @@ -72,6 +74,11 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // cancelRun self-guards: if the run settled while the gate sat // armed, it degrades to the "nothing to cancel" note. return m, m.cancelRun() + case confirmStopAgent: + // stopAgent self-guards: a card that settled while the gate sat + // armed degrades to a notice — the terminal state still comes + // from subagent_state, never from the ack. + return m, m.stopAgent(m.stopTarget) } } m.refresh() @@ -88,6 +95,8 @@ func (m *Model) armConfirm(kind confirmKind, what string) tea.Cmd { verb = "clear " case confirmCancel: verb = "cancel " + case confirmStopAgent: + verb = "stop " } m.panelMsg = verb + what + "? y confirm · any other key cancels" m.refresh() @@ -525,6 +534,8 @@ func (m *Model) panelLen() int { return len(m.toolRows) case panelConfig: return len(m.cfgRows) + case panelAgents: + return len(m.agentsReg) } return 0 } @@ -614,6 +625,13 @@ func (m *Model) panelSelect() tea.Cmd { } case panelEvents: return m.fetchEvents() + case panelAgents: + if m.panelSel < len(m.agentsReg) { + m.panelDetail = true // readable goal text through sanitize() — house grammar + m.detailScroll = 0 + m.refresh() + } + return nil case panelMemory, panelSkills, panelTools, panelConfig: // Enter expands the selected row into its detail view — the // promote/delete gates assume the human can read what they gate. @@ -1099,6 +1117,9 @@ func (m *Model) renderPanel(w, h int) string { case panelConfig: title = "⚙ config" rows = m.cfgRowsRender(w - 6) + case panelAgents: + title = "◈ agents" + rows = m.agentRowsRender(w - 6) } header := th.acTitle.Render(title) diff --git a/internal/tui/subagent_result_test.go b/internal/tui/subagent_result_test.go new file mode 100644 index 0000000..6cea76c --- /dev/null +++ b/internal/tui/subagent_result_test.go @@ -0,0 +1,84 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestParseAgentResult pins the tolerant framed-result parse: the envelope +// must carry status AND summary; prose never parses; a trailing artifact +// block after the JSON object is tolerated. +func TestParseAgentResult(t *testing.T) { + r := parseAgentResult(`{"status":"success","summary":"Built user handlers.","files_changed":["a.go","b.go"],"tokens_used":4200,"iterations":5}`) + if r == nil { + t.Fatal("valid envelope did not parse") + } + if r.status != "success" || r.summary != "Built user handlers." || len(r.files) != 2 || r.tokens != 4200 || r.iters != 5 { + t.Fatalf("parsed fields wrong: %+v", r) + } + + if parseAgentResult(`no json here`) != nil { + t.Error("prose parsed as a result card") + } + if parseAgentResult(`{"files_changed":[]}`) != nil { + t.Error("envelope without status/summary parsed") + } + + // JSON object followed by trailing artifact metadata lines. + r = parseAgentResult(`{"status":"error","summary":"boom","files_changed":null,"tokens_used":0,"iterations":0}` + "\n" + `📎 report.md · text/markdown · 48 KiB · a1b2c3d4`) + if r == nil || r.status != "error" || r.summary != "boom" { + t.Fatalf("envelope with trailing lines parsed wrong: %+v", r) + } +} + +// TestAgentResultCard: a framed delegate result renders as a structured +// card; a prose result keeps the generic preview. +func TestAgentResultCard(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "success", TokensUsed: 3200}) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"success","summary":"Built handlers and routing. 3 tests added.","files_changed":["handlers/user.go","routes.go"],"tokens_used":13100,"iterations":7}`}) + s := stateStep(t, m) + if s.resultCard == nil { + t.Fatal("framed result did not attach a result card") + } + if s.resultCard.status != "success" || len(s.resultCard.files) != 2 { + t.Fatalf("result card fields wrong: %+v", s.resultCard) + } + s.expanded = true + out, _ := renderStepsForTest(m, m.msgs[0], 0, 0) + for _, want := range []string{"Built handlers and routing.", "handlers/user.go", "2 files", "13.1k tok"} { + if !strings.Contains(out, want) { + t.Errorf("rendered card missing %q", want) + } + } + + // Prose result: no card, generic preview still lands. + m2 := stateFixture(t) + m2.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: "plain text output\nline two"}) + s2 := stateStep(t, m2) + if s2.resultCard != nil { + t.Fatal("prose result produced a card") + } + if s2.result == "" { + t.Error("prose result lost the generic preview") + } +} + +// TestAgentResultSanitize: every wire-derived string is sanitized before it +// reaches a card line. +func TestAgentResultSanitize(t *testing.T) { + r := parseAgentResult("{\"status\":\"success\",\"summary\":\"bad \\u001b[31mtext\\nleak\",\"files_changed\":[\"x\\u001b.go\"],\"tokens_used\":10,\"iterations\":1}") + if r == nil { + t.Fatal("envelope did not parse") + } + m := stateFixture(t) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"success","summary":"ok","files_changed":["a.go"],"tokens_used":10,"iterations":1}`}) + s := stateStep(t, m) + lines := agentResultLines(m, s.resultCard, 200) + joined := strings.Join(lines, "\n") + if strings.ContainsAny(joined, "\x1b") { + t.Errorf("card lines carry escape bytes: %q", joined) + } +} diff --git a/internal/tui/subagent_stop_test.go b/internal/tui/subagent_stop_test.go new file mode 100644 index 0000000..633f352 --- /dev/null +++ b/internal/tui/subagent_stop_test.go @@ -0,0 +1,127 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestStopAgentGate: ctrl+s arms the stop gate on the first live agent, +// any other key disarms, y fires (self-guarded without a connection). +func TestStopAgentGate(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 2}) + + m2, _ := m.Update(key("ctrl+s")) + m = m2.(*Model) + if m.confirm != confirmStopAgent { + t.Fatalf("ctrl+s did not arm confirmStopAgent: %v", m.confirm) + } + if !strings.Contains(m.panelMsg, "stop") || !strings.Contains(m.panelMsg, "SA1") { + t.Errorf("gate text missing target: %q", m.panelMsg) + } + + m2, _ = m.Update(key("x")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("non-confirm key did not disarm: %v", m.confirm) + } + + m2, _ = m.Update(key("ctrl+s")) + m = m2.(*Model) + m2, _ = m.Update(key("y")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("gate still armed after firing: %v", m.confirm) + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "nothing to stop") { + t.Errorf("sessionless fire should self-guard, notices=%q", got) + } +} + +// TestStopAgentIdle: with no live cards ctrl+s is a no-op, not a gate. +func TestStopAgentIdle(t *testing.T) { + m := stateFixture(t) // delegate step exists but no state frames → no cards + m2, _ := m.Update(key("ctrl+s")) + m = m2.(*Model) + if m.confirm != confirmNone { + t.Fatalf("ctrl+s without live agents armed a gate: %v", m.confirm) + } +} + +// TestStopCommand: /stop lists live labels bare, resolves , and arms +// the same gate; unknown labels are refused. +func TestStopCommand(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "active", Status: "running"}) + + if cmd := m.stopByLabel(""); cmd == nil { + t.Fatal("bare /stop should return the notice sweep") + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "SA1") || !strings.Contains(got, "SA2") { + t.Errorf("bare /stop should list live labels, notices=%q", got) + } + + m.confirm = confirmNone + m.panelMsg = "" + if cmd := m.stopByLabel("sa2"); cmd != nil { + t.Fatal("arming should return nil (the gate waits for y)") + } + if m.confirm != confirmStopAgent || m.stopTarget != "t2" { + t.Fatalf("/stop sa2 did not arm the gate for t2: %v %q", m.confirm, m.stopTarget) + } + if !strings.Contains(m.panelMsg, "SA2") { + t.Errorf("gate text missing SA2: %q", m.panelMsg) + } + + m.confirm = confirmNone + if cmd := m.stopByLabel("9"); cmd == nil { + t.Fatal("unknown label should return the notice sweep") + } + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "SA9") { + t.Errorf("unknown label not surfaced: %q", got) + } + + // The slash registry exposes /stop. + names := make([]string, 0, 8) + for _, c := range slashCommands() { + names = append(names, c.name) + } + if !slices.Contains(names, "stop") { + t.Errorf("/stop not registered: %v", names) + } +} + +// TestStopAck: accepted:true stays silent; accepted:false (benign race) gets +// an explicit notice. Terminal state always comes from subagent_state. +func TestStopAck(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running"}) + m.handleEvent(client.Event{Type: "subagent_cancelled", TaskID: "t1", Accepted: true}) + if got := strings.Join(m.notices, "\n"); got != "" { + t.Errorf("accepted ack should stay silent, notices=%q", got) + } + m.handleEvent(client.Event{Type: "subagent_cancelled", TaskID: "t9", Accepted: false}) + if got := strings.Join(m.notices, "\n"); !strings.Contains(got, "already finished") { + t.Errorf("benign-race ack not surfaced: %q", got) + } +} + +// TestStopSentMarker: the card advertises the in-flight stop until the +// terminal frame replaces the line. +func TestStopSentMarker(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", Step: 4}) + s := stateStep(t, m) + s.agents[0].stopSent = true + if line := agentCardLine(s.agents[0]); !strings.Contains(line, "stop sent") { + t.Errorf("running line missing stop marker: %q", line) + } + s.agents[0].phase, s.agents[0].status = "finished", "cancelled" + if line := agentCardLine(s.agents[0]); strings.Contains(line, "stop sent") { + t.Errorf("terminal line still shows stop marker: %q", line) + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index b55ddc2..62ec761 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -6,24 +6,29 @@ package tui // and sanitized on ingest, and cards render only for tasks that report. import ( + "encoding/json" "fmt" + "strconv" "strings" "time" + tea "github.com/charmbracelet/bubbletea" + "github.com/BackendStack21/bodek/internal/client" ) // agentCard is one delegated task's live telemetry inside a sub-agent step. type agentCard struct { - taskID string - idx int - phase string // started | active | finished - status string // running | success | partial | error | cancelled | timeout - step int - tool string - iters int - tokens int - durS float64 + taskID string + idx int + phase string // started | active | finished + status string // running | success | partial | error | cancelled | timeout + step int + tool string + iters int + tokens int + durS float64 + stopSent bool // subagent_cancel sent; terminal frame still pending } // finished reports whether the card reached a terminal state. @@ -124,6 +129,9 @@ func agentCardLine(a *agentCard) string { } else if a.status != "" && a.status != "success" { b.WriteString(" · " + a.status) } + if !a.finished() && a.stopSent { + b.WriteString(" · stop sent") + } if a.iters > 0 { fmt.Fprintf(&b, " · %d it", a.iters) } @@ -173,3 +181,222 @@ func stateNoticeLine(ev client.Event) string { } return strings.Join(out, " · ") } + +// ── per-agent stop (subagent_cancel) ───────────────────────────────────────── + +// stopAgentDoneMsg reports a failed WS stop so the failure isn't silent; +// success stays silent — the terminal subagent_state settles the card. +type stopAgentDoneMsg struct { + taskID string + err error +} + +// armStopAgent arms the stop gate for one task id. +func (m *Model) armStopAgent(taskID, label string) tea.Cmd { + m.stopTarget = taskID + return m.armConfirm(confirmStopAgent, "sub-agent "+label) +} + +// stopAgent sends the WS subagent_cancel for one task. Self-guarding: a +// card that settled while the gate sat armed degrades to a notice, and the +// terminal state still comes exclusively from subagent_state. +func (m *Model) stopAgent(taskID string) tea.Cmd { + if m.cl == nil || m.sessionID == "" || taskID == "" { + return m.transientNoteCmd("nothing to stop") + } + card := m.liveCard(taskID) + if card == nil { + return m.transientNoteCmd("sub-agent already finished") + } + card.stopSent = true + m.refresh() + cl, sid, tok := m.cl, m.sessionID, m.authToken + return func() tea.Msg { + if err := cl.SendSubagentCancel(sid, tok, taskID); err != nil { + return stopAgentDoneMsg{taskID: taskID, err: err} + } + return nil // the terminal subagent_state settles the card + } +} + +// firstLiveAgent picks the default stop target: the expanded sub-agent +// step's first live card, else the first live card of any in-flight step. +func (m *Model) firstLiveAgent() (id, label string, ok bool) { + if i := m.cur(); i >= 0 { + for pass := 0; pass < 2; pass++ { + for j := range m.msgs[i].steps { + s := &m.msgs[i].steps[j] + if !s.subagent || s.done || (pass == 0 && !s.expanded && !m.expandAll) { + continue + } + for _, a := range s.agents { + if !a.finished() { + return a.taskID, fmt.Sprintf("SA%d", a.idx+1), true + } + } + } + } + } + return "", "", false +} + +// liveAgents collects the live cards of the current turn in card order. +func (m *Model) liveAgents() []*agentCard { + var out []*agentCard + if i := m.cur(); i >= 0 { + for j := range m.msgs[i].steps { + s := &m.msgs[i].steps[j] + if !s.subagent || s.done { + continue + } + for _, a := range s.agents { + if !a.finished() { + out = append(out, a) + } + } + } + } + return out +} + +// liveCard finds an unfinished card by task id across the current turn. +func (m *Model) liveCard(taskID string) *agentCard { + for _, a := range m.liveAgents() { + if a.taskID == taskID { + return a + } + } + return nil +} + +// 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 { + live := m.liveAgents() + if len(live) == 0 { + return m.transientNoteCmd("no running sub-agents") + } + ref := strings.ToLower(strings.TrimSpace(args)) + ref = strings.TrimPrefix(ref, "sa") + n := 0 + if v, err := strconv.Atoi(ref); err == nil { + n = v + } + if n == 0 { + labels := make([]string, 0, len(live)) + for _, a := range live { + labels = append(labels, fmt.Sprintf("SA%d", a.idx+1)) + } + return m.transientNoteCmd("running: " + strings.Join(labels, ", ") + " — /stop <#>") + } + for _, a := range live { + if a.idx+1 == n { + return m.armStopAgent(a.taskID, fmt.Sprintf("SA%d", a.idx+1)) + } + } + return m.transientNoteCmd(fmt.Sprintf("SA%d is not running", n)) +} + +// ── structured result card (framed results, odek M0) ───────────────────────── + +// agentResult is the framed result a sub-agent returns inside its parent's +// tool_result (headline capped at 2048 by serve): status, summary, changed +// files, and usage — parsed tolerantly, rendered as a card in the details. +type agentResult struct { + status string + summary string + files []string + tokens int + iters int +} + +// parseAgentResult extracts the framed-result envelope from delegate tool +// output. Tolerant by design: it parses only a JSON object carrying both +// status and summary — anything else (prose, partial JSON, older servers) +// returns nil and the generic preview stays. +func parseAgentResult(data string) *agentResult { + obj := firstJSONObject(data) + if obj == "" { + return nil + } + var env struct { + Status string `json:"status"` + Summary string `json:"summary"` + FilesChanged []string `json:"files_changed"` + TokensUsed int `json:"tokens_used"` + Iterations int `json:"iterations"` + } + if err := json.Unmarshal([]byte(obj), &env); err != nil { + return nil + } + if env.Status == "" || env.Summary == "" { + return nil + } + r := &agentResult{status: env.Status, summary: collapse(env.Summary), tokens: env.TokensUsed, iters: env.Iterations} + for _, f := range env.FilesChanged { + if f = collapse(f); f != "" { + r.files = append(r.files, f) + } + } + return r +} + +// firstJSONObject returns the first balanced top-level JSON object in s — +// the framed result may be followed by artifact metadata lines. +func firstJSONObject(s string) string { + start := strings.IndexByte(s, '{') + if start < 0 { + return "" + } + depth, inStr, esc := 0, false, false + for i := start; i < len(s); i++ { + c := s[i] + if inStr { + switch { + case esc: + esc = false + case c == '\\': + esc = true + case c == '"': + inStr = false + } + continue + } + switch c { + case '"': + inStr = true + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + return s[start : i+1] + } + } + } + return "" +} + +// agentResultLines renders the structured result card for the expanded step +// details: summary, status/usage head, then one line per changed file. +func agentResultLines(m *Model, r *agentResult, budget int) []string { + th := m.th + parts := []string{r.status, fmt.Sprintf("%d files", len(r.files))} + if r.iters > 0 { + parts = append(parts, fmt.Sprintf("%d it", r.iters)) + } + if r.tokens > 0 { + parts = append(parts, human(r.tokens)+" tok") + } + head := strings.Join(parts, " · ") + style := th.stepRes + if r.status == "error" { + style = th.stepErr + } + lines := []string{th.stepRes.Render(truncate(r.summary, budget))} + lines = append(lines, style.Render(truncate(head, budget))) + for _, f := range r.files { + lines = append(lines, th.stepRes.Render(truncate("· "+f, budget))) + } + return lines +} diff --git a/internal/tui/subagents_panel_test.go b/internal/tui/subagents_panel_test.go new file mode 100644 index 0000000..5650fc9 --- /dev/null +++ b/internal/tui/subagents_panel_test.go @@ -0,0 +1,76 @@ +package tui + +import ( + "slices" + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// TestAgentsTabFlow: the drawer's agents tab opens, decodes the registry +// snapshot, renders rows, and opens the ⏎ detail. +func TestAgentsTabFlow(t *testing.T) { + m := newTestModel() + if cmd := m.openAgents(); cmd != nil { + t.Fatal("openAgents without a connection should skip the fetch") + } + if m.panel != panelAgents { + t.Fatalf("openAgents did not open the tab: %v", m.panel) + } + + m.handleMgmtMsg(mgmtMsg{tab: panelAgents, sag: []client.SubagentEntry{ + {TaskID: "t1", RunKey: "rk1", Goal: "explore the repo", Phase: "finished", Status: "success", Iterations: 3, TokensUsed: 1500}, + {TaskID: "t2", Phase: "active", Status: "running", Step: 4, LastTool: "read"}, + }}) + if m.panelLen() != 2 { + t.Fatalf("panelLen = %d, want 2", m.panelLen()) + } + rows := m.agentRowsRender(120) + joined := strings.Join(rows, "\n") + for _, want := range []string{"✓", "explore the repo", "1.5k tok", "⟳", "read"} { + if !strings.Contains(joined, want) { + t.Errorf("rows missing %q: %q", want, joined) + } + } + + // ⏎ opens the detail with the full metadata through sanitize(). + m.panelSelect() + if !m.panelDetail { + t.Fatal("enter did not open the detail view") + } + detail := strings.Join(m.mgmtDetailLines(120), "\n") + for _, want := range []string{"explore the repo", "task t1", "rk1"} { + if !strings.Contains(detail, want) { + t.Errorf("detail missing %q: %q", want, detail) + } + } + + // Tab cycling reaches the new tab and resets the detail submode. + m2, _ := m.Update(key("]")) + m = m2.(*Model) + if m.panelDetail { + t.Error("tab switch did not reset the detail submode") + } +} + +// TestAgentsTabCommand: /agents is a registered slash command opening the tab. +func TestAgentsTabCommand(t *testing.T) { + names := make([]string, 0, 12) + for _, c := range slashCommands() { + names = append(names, c.name) + } + if !slices.Contains(names, "agents") { + t.Fatalf("/agents not registered: %v", names) + } + m := newTestModel() + for _, c := range slashCommands() { + if c.name == "agents" { + c.run(m, "") + break + } + } + if m.panel != panelAgents { + t.Fatalf("/agents did not open the tab: %v", m.panel) + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index 6716188..4c36b08 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -820,7 +820,11 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in details = append(details, th.stepRes.Render(truncate(lg, detailBudget))) } } - details = append(details, stepDetail(s.name, s.result, m.vp.Width, th)...) + if s.resultCard != nil { + details = append(details, agentResultLines(m, s.resultCard, detailBudget)...) + } else { + details = append(details, stepDetail(s.name, s.result, m.vp.Width, th)...) + } if len(details) > 200 { details = details[:200] details = append(details, th.stepArg.Render("… output truncated"))