From 6b67ecbade41d043fc5807668b38a11d3c0db4bd Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:56:26 -0400 Subject: [PATCH 1/5] Feat: Harvest Claude Code subagent labels from meta.json Reads /subagents/agent-.meta.json beside each transcript into SessionMetadata.Subagents, keyed by the bare id the proxy records on a subagent's rows. The agent-*.jsonl transcripts beside them are not read. Skipped (unchanged) sessions keep the Subagents their entry already carried; a session only gains a subagent by appending the Agent call that spawned it, which moves the transcript's mtime and re-parses the session. SessionMetadata is no longer struct-comparable (the map); the one test that compared it against the zero value now checks its fields. Refs #1047 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- core/observe/claude/harvest.go | 48 ++++++++++++++++++++++++++ core/observe/claude/harvest_test.go | 53 ++++++++++++++++++++++++++++- core/observe/claude/metadata.go | 19 +++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/core/observe/claude/harvest.go b/core/observe/claude/harvest.go index d83718acc..47ed88558 100644 --- a/core/observe/claude/harvest.go +++ b/core/observe/claude/harvest.go @@ -482,12 +482,60 @@ func ReadSessions(configDir string, since map[string]SessionMetadata) (map[strin AgentConfigDir: configDir, LogFile: path, LogModTime: st.ModTime(), + // The subagents' labels live in a sibling DIRECTORY of the transcript + // (/subagents), whose mtime the skip above knows nothing about. That + // is fine, because a session only gains a subagent by appending the Agent + // tool call that spawned it to the transcript — which moves the + // transcript's mtime and re-parses the session, meta files and all. A + // skipped session keeps the Subagents its `since` entry already carried. + Subagents: readSubagents(filepath.Join(projDir, id, "subagents")), } } } return out, partial, skipped, nil } +// readSubagents reads one session's subagents/agent-.meta.json files into a map keyed +// by the bare id. Nil when the directory is missing or holds nothing harvestable — a +// session that never spawned a subagent, which is most of them, must not carry an empty +// map the JSON would then spell out. +// +// Best-effort per file, for the same reason titleFromTranscript is: one unparseable meta +// file must not cost the other labels. The transcripts sitting beside the meta files are +// deliberately not read — see SessionMetadata.Subagents. +func readSubagents(dir string) map[string]SubagentInfo { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out map[string]SubagentInfo + for _, e := range entries { + name := e.Name() + if !strings.HasPrefix(name, "agent-") || !strings.HasSuffix(name, ".meta.json") { + continue + } + // The subagent transcripts share the agent- prefix (agent-.jsonl), so the + // suffix is what says which kind of file this is. + id := strings.TrimSuffix(strings.TrimPrefix(name, "agent-"), ".meta.json") + if id == "" { + continue + } + data, err := os.ReadFile(filepath.Join(dir, name)) //nolint:gosec // operator-supplied transcript dir + if err != nil { + continue + } + var info SubagentInfo + if json.Unmarshal(data, &info) != nil { + continue + } + if out == nil { + out = make(map[string]SubagentInfo) + } + out[id] = info + } + return out +} + // harvestedAt reports the transcript mtime m was harvested at, for the incremental skip. // The zero time means "cannot tell", which never skips. // diff --git a/core/observe/claude/harvest_test.go b/core/observe/claude/harvest_test.go index 023eac437..13a29976b 100644 --- a/core/observe/claude/harvest_test.go +++ b/core/observe/claude/harvest_test.go @@ -187,7 +187,9 @@ func TestHarvest_IncrementalParsesWhatItCannotValidate(t *testing.T) { t.Fatal(err) } seed := map[string]SessionMetadata{} - if tc.entry != (SessionMetadata{}) { + // Not != (SessionMetadata{}): the Subagents map makes the struct + // non-comparable, and the cases only ever populate these two fields. + if tc.entry.Title != "" || tc.entry.LogFile != "" { seed["s1"] = tc.entry } if err := SaveMetadata(path, seed); err != nil { @@ -2960,3 +2962,52 @@ func TestSaveMetadata_AFailedWriteKeepsTheOldFile(t *testing.T) { } } } + +// A session's subagent labels are harvested from its subagents/agent-.meta.json files, +// keyed by the bare id — the same id the proxy records on a subagent's rows. The transcripts +// beside them are not read, a missing subagents directory costs nothing, and one bad meta +// file costs only its own label. +func TestHarvest_ReadsSubagentMetaFiles(t *testing.T) { + metadataHome(t) + cfg := filepath.Join(t.TempDir(), "claude") + proj := filepath.Join(cfg, "projects", "-p") + writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"t"}`) + subs := filepath.Join(proj, "s1", "subagents") + writeSessionTranscript(t, subs, "agent-a0974b0b8591dc76b.meta.json", + `{"agentType":"general-purpose","description":"PR review (execution-first)"}`) + writeSessionTranscript(t, subs, "agent-bad.meta.json", `{"agentType":`) // malformed JSON + writeSessionTranscript(t, subs, "agent-a2172bb34d27cb12.jsonl", `{}`) // a transcript, not a meta file + + res, err := Harvest(Options{ConfigDir: cfg, Merge: true}) + if err != nil { + t.Fatal(err) + } + got := readMetadataFile(t, res.Path)["s1"].Subagents + if len(got) != 1 { + t.Fatalf("Subagents = %v, want exactly the one parseable meta file", got) + } + info, ok := got["a0974b0b8591dc76b"] + if !ok { + t.Fatalf("keyed by the bare id; got %v", got) + } + if info.AgentType != "general-purpose" || info.Description != "PR review (execution-first)" { + t.Errorf("info = %+v", info) + } +} + +// A session with no subagents carries no Subagents map at all, so the JSON spells nothing +// out for the sessions that have none — which is most of them. +func TestHarvest_SessionWithoutSubagentsHasNoMap(t *testing.T) { + metadataHome(t) + cfg := filepath.Join(t.TempDir(), "claude") + proj := filepath.Join(cfg, "projects", "-p") + writeSessionTranscript(t, proj, "s1.jsonl", `{"type":"ai-title","aiTitle":"t"}`) + + res, err := Harvest(Options{ConfigDir: cfg, Merge: true}) + if err != nil { + t.Fatal(err) + } + if got := readMetadataFile(t, res.Path)["s1"].Subagents; got != nil { + t.Errorf("Subagents = %v, want nil", got) + } +} diff --git a/core/observe/claude/metadata.go b/core/observe/claude/metadata.go index 43aef6af8..dc4714b06 100644 --- a/core/observe/claude/metadata.go +++ b/core/observe/claude/metadata.go @@ -91,6 +91,25 @@ type SessionMetadata struct { // which is why the rule that matters is stated on the READING side: harvestedAt treats // the zero value as "cannot tell" and re-parses. LogModTime time.Time `json:"logModTime"` + // Subagents are the subagents Claude Code spawned in this session, keyed by the + // id it uses everywhere (the X-Claude-Code-Agent-Id a request carries, and the + // in subagents/agent-.meta.json). Read from those meta.json files, not + // the subagent transcripts beside them: a label is what the pane wants, and the + // transcript holds none that the meta file lacks. + // + // Like every field but LogModTime, optional — a session with no subagents, or + // harvested before this field existed, has none, and a consumer falls back to + // the raw id (which is what a non-Claude-Code agent or an agent-team teammate + // renders anyway, having no meta.json to join to). + Subagents map[string]SubagentInfo `json:"subagents,omitempty"` +} + +// SubagentInfo is one harvested subagent: what Claude Code's own record of it says +// that the wire does not carry. Every field optional, for the same reason as +// SessionMetadata's — this is another agent's on-disk layout, not a contract. +type SubagentInfo struct { + AgentType string `json:"agentType,omitempty"` + Description string `json:"description,omitempty"` } // SessionMetadataRel is the harvested metadata file, relative to the user's home. From 6496e894457cd3a42ed3f782684bbb3a8c46b007 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:56:33 -0400 Subject: [PATCH 2/5] Feat: Section rows in agentop's table A row of exactly one cell now renders as one line spanning every column, in the same row/cursor/match styles a data row gets, instead of one cell truncated to the first column's width. No caller rendered a one-cell row before, so the convention changes nothing that existed; the events pane's subagent block headers are its first user. Refs #1047 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/table/table.go | 43 ++++++++++++++++++++++++ cmd/agentop/tui/table/table_test.go | 51 +++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/cmd/agentop/tui/table/table.go b/cmd/agentop/tui/table/table.go index e0a93b921..626a776ac 100644 --- a/cmd/agentop/tui/table/table.go +++ b/cmd/agentop/tui/table/table.go @@ -557,7 +557,18 @@ func (m Model) headersView() string { return lipgloss.JoinHorizontal(lipgloss.Top, s...) } +// renderRow draws one row. A row of EXACTLY ONE CELL is a section row: it renders as +// one line spanning every column, not as one cell squeezed into the first column's +// width — the shape the events pane's subagent block headers need, where the label is +// longer than any single column. Fewer cells than columns is already harmless +// elsewhere; this pins the one-cell case to a meaning rather than leaving it a +// truncated first cell. No caller rendered a one-cell row before this (the pipeline +// pane's "── (app) ──" divider carries a cell per column), so the convention changes +// nothing that existed. func (m *Model) renderRow(r int) string { + if len(m.rows[r]) == 1 { + return m.renderSectionRow(r) + } texts := make([]string, len(m.rows[r])) var spans [][]find.Range if r < len(m.hits) && m.hits[r] { @@ -602,6 +613,38 @@ func (m *Model) renderRow(r int) string { return lipgloss.JoinHorizontal(lipgloss.Top, s...) } +// renderSectionRow draws a one-cell row across the whole table width, in the same +// row/cursor/match styles a data row gets. The line is the sum of the column widths +// — without each cell's padding, which a spanning line has no use for, so it sits a +// few columns short of a full data row's right edge and reads as its own kind of row. +func (m *Model) renderSectionRow(r int) string { + total := 0 + for _, col := range m.cols { + if col.Width > 0 { + total += col.Width + } + } + text := runewidth.Truncate(m.rows[r][0], total, "…") + var spans []find.Range + lit := false + if r < len(m.hits) && m.hits[r] { + spans = m.cellSpans(r, 0, text) + lit = len(spans) > 0 + } + + row, match := lipgloss.NewStyle(), m.styles.Match + switch { + case r == m.cursor: + row, match = m.styles.Selected, m.styles.CurrentMatch + case spans != nil && !lit: + row = m.styles.Marked + } + match = match.Inherit(row) + + filled := runewidth.FillRight(text, total) + return m.styles.Cell.Inherit(row).Render(paint(filled, spans, row, match)) +} + func clamp(v, low, high int) int { return min(max(v, low), high) } diff --git a/cmd/agentop/tui/table/table_test.go b/cmd/agentop/tui/table/table_test.go index 2427ee627..b5ff4f04a 100644 --- a/cmd/agentop/tui/table/table_test.go +++ b/cmd/agentop/tui/table/table_test.go @@ -4,6 +4,7 @@ import ( "fmt" "math/rand" "regexp" + "slices" "strconv" "strings" "testing" @@ -187,3 +188,53 @@ func TestResizeAndRows_KeepTheCursorOnScreen(t *testing.T) { checkScreen(t, tb, fmt.Sprintf("rows shrank to %d", n)) } } + +// A one-cell row renders as one line spanning every column — the section-row shape the +// events pane's subagent block headers use. A label longer than the first column's width +// must survive whole, which is the whole point of the convention: squeezed into column +// one it would read "▸ PR…". +func TestSectionRowSpansTheColumns(t *testing.T) { + tb := table.New( + table.WithColumns([]table.Column{ + {Title: "row", Width: 6}, + {Title: "col", Width: 40}, + }), + table.WithRows([]table.Row{ + {"1", "a"}, + {"▸ PR review (execution-first) · 14 rows"}, + {"2", "b"}, + }), + table.WithFocused(true), + ) + tb.SetHeight(10) + v := tb.View() + if !strings.Contains(v, "▸ PR review (execution-first) · 14 rows") { + t.Errorf("section row truncated or missing:\n%s", v) + } + // The cursor can land on it, and it renders selected rather than vanishing. + tb.SetCursor(1) + if v := tb.View(); !strings.Contains(v, "▸ PR review (execution-first) · 14 rows") { + t.Errorf("section row missing under the cursor:\n%s", v) + } +} + +// A section row is searchable like any other row: a hit marks it, and one that does not +// match still renders while the search is live. +func TestSectionRowSearches(t *testing.T) { + tb := table.New( + table.WithColumns([]table.Column{{Title: "row", Width: 6}, {Title: "col", Width: 10}}), + table.WithRows([]table.Row{ + {"1", "a"}, + {"▸ PR review (execution-first)"}, + }), + table.WithFocused(true), + ) + tb.SetHeight(10) + tb.SetSearch("execution", []table.SearchRow{ + {Cells: []string{"", ""}}, + {Cells: []string{"▸ PR review (execution-first)"}}, + }) + if got := tb.Matches(); !slices.Equal(got, []int{1}) { + t.Errorf("matches = %v, want [1]", got) + } +} From bf0323d4eb5fb4857af835612a89b64c32d4d6ea Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:56:43 -0400 Subject: [PATCH 3/5] Feat: Collapsible subagent blocks in the events pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each subagent's rows group under a header anchored at the subagent's first surviving row, with a rollup of rows, tokens and cost (#1047). Blocks are collapsed by default; Enter on a header toggles it, and a member row's DIR cell carries a │ gutter while its block is open. The main conversation's rows stay top-level in time order between the blocks, so interleaving stays readable. The label is the harvested meta.json description, then the agent type, then the raw id. Grouping is strict: a row belongs to the block its subagent field names, and unattributed rows (an opaque CONNECT carries no agent-id header) stay top-level — folding by contiguity would misfile main rows interleaved with a subagent's. A sort disables grouping, a live search opens every block so a match inside one is findable, and the Timeline's reveal opens the block holding its target rather than waiting for a row that cannot appear. Refs #1047 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- cmd/agentop/tui/app.go | 20 +- cmd/agentop/tui/events_blocks.go | 130 +++++++++++ cmd/agentop/tui/events_blocks_test.go | 322 ++++++++++++++++++++++++++ cmd/agentop/tui/events_columns.go | 19 +- cmd/agentop/tui/events_pane.go | 48 +++- cmd/agentop/tui/keys.go | 6 + cmd/agentop/tui/timeline_pane.go | 16 ++ 7 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 cmd/agentop/tui/events_blocks.go create mode 100644 cmd/agentop/tui/events_blocks_test.go diff --git a/cmd/agentop/tui/app.go b/cmd/agentop/tui/app.go index 078ad8628..70b742c35 100644 --- a/cmd/agentop/tui/app.go +++ b/cmd/agentop/tui/app.go @@ -723,9 +723,27 @@ type model struct { // one per network message. Populated by rebuildEventsTable so // selectedEvent / selectedEventRow can return the message the cursor is // on (and any folded tunnel) without re-walking the cache. Reset on - // every rebuild. + // every rebuild. A subagent block's HEADER row contributes an empty + // eventRow here (it has no event of its own); headerRows says which + // rendered rows those are. visibleRows []eventRow + // subagentBlocks is the events pane's per-agent grouping of the session + // being viewed (#1047): one block per subagent id named by a row, nil + // while a sort ranks the rows (grouping would contradict the ranking) or + // when no row names one. Rebuilt on every rebuild; the collapse state + // lives in subagentOpen, not here. + subagentBlocks map[string]*subagentBlock + // subagentOpen is the subagent ids whose blocks are expanded. Absent = + // collapsed, which is the default. Kept across rebuilds so a live tail + // does not re-collapse a block the operator opened; cleared when another + // session is opened, since ids are per-session. + subagentOpen map[string]bool + // headerRows maps a rendered-row index to the block whose header row sits + // there, so Enter can tell a header from a message without a second walk. + // Rebuilt on every rebuild. + headerRows map[int]*subagentBlock + // selectedEventKey pins the events-pane cursor to an event across // rebuilds (see eventKey). Zero value = unpinned. selectedEventKey eventKey diff --git a/cmd/agentop/tui/events_blocks.go b/cmd/agentop/tui/events_blocks.go new file mode 100644 index 000000000..29eabea0d --- /dev/null +++ b/cmd/agentop/tui/events_blocks.go @@ -0,0 +1,130 @@ +package tui + +import ( + "fmt" + + "github.com/rossoctl/cortex/cmd/agentop/tui/table" +) + +// subagentBlock is one subagent's share of the events pane (#1047): the rows its +// requests produced, grouped under a collapsible header anchored at the first of +// them, so an agent's fan-out can be read as one line — or opened, when the +// question is which of two subagents is the slow or costly one. +// +// Membership is STRICT: a row belongs to the block its `subagent` field names, and +// a row naming no agent is the main conversation's and stays top-level. Rows the +// header can't attribute — an opaque CONNECT, whose CONNECT carries no agent-id +// header — stay top-level too, because folding unattributed rows in by +// time-contiguity would misfile the main conversation's rows that interleave with +// a subagent's. +type subagentBlock struct { + id string + // rows indexes cellContexts, in arrival order, among the rows that passed the + // inactive filter — the same population the row loop renders, so the header's + // rollup counts what expanding would show. + rows []int + // tokens and cost are the block's rollup, from the same readings the TOKENS + // and COST cells sort by (rowTokens / rowCostUSD). A request whose response + // has not landed contributes nothing yet, like its cells. + tokens int + cost float64 + // anchored is set when the header has been emitted for this rebuild, so the + // header lands at the block's FIRST surviving row and never before it. + anchored bool +} + +// headerRow is the block's collapsible line: the rollup the issue asked for — +// how many rows, how many tokens, what cost — behind the label. One cell, which +// the table renders as a section row spanning every column. +func (b *subagentBlock) headerRow(label string, open bool) table.Row { + glyph := "▸" + if open { + glyph = "▾" + } + return table.Row{fmt.Sprintf("%s─ %s · %d rows · %s tok · %s", + glyph, label, len(b.rows), humanizeCount(int64(b.tokens)), formatUSDTotal(b.cost))} +} + +// subagentLabel names a block. The wire carries only the 16-hex id, so a label a +// human can act on comes from the harvested meta.json for this session +// (SessionMetadata.Subagents), falling back to the raw id for everything the +// harvest cannot name — a teammate, a non-Claude-Code agent, a stale harvest. +// Description first: it is the one Claude Code shows in its own tree, and the +// type alone ("general-purpose") answers less than the id does about WHICH +// general-purpose agent this was. +func (m *model) subagentLabel(id string) string { + if info, ok := m.sessionsData[m.selectedSess].Subagents[id]; ok { + if info.Description != "" { + return sanitizeLabel(info.Description) + } + if info.AgentType != "" { + return sanitizeLabel(info.AgentType) + } + } + return id +} + +// blockOpen reports whether a block's rows are shown. Collapsed is the default — +// the issue's motivating case is an agent whose subagents drown the conversation, +// so the pane opens quiet and Enter buys the detail. A live search opens every +// block: a match inside a collapsed block must be findable, and hidden rows are +// unreachable from a search that only sees rendered rows. +func (m *model) blockOpen(id string, searching bool) bool { + return searching || m.subagentOpen[id] +} + +// rowInactiveHidden is the hideInactive (`s` toggle) predicate, stated once so the +// row loop and collectSubagentBlocks filter the same population: a block's rollup +// must count the rows expanding it would show, and a second spelling of the +// predicate here would drift with the first edit to either. +func (m *model) rowInactiveHidden(ctxs []cellContext, partner map[int]int, j int) bool { + return m.hideInactive && eventInactive(ctxs[j].invs) && !partnerActive(ctxs, partner, j) +} + +// collectSubagentBlocks groups the rows that survive the inactive filter by their +// `subagent` field. Nil when no row names one, which is every session without +// Claude Code subagents — those rebuild exactly as before. +func (m *model) collectSubagentBlocks(ctxs []cellContext, partner map[int]int) map[string]*subagentBlock { + var blocks map[string]*subagentBlock + for j, cc := range ctxs { + if m.rowInactiveHidden(ctxs, partner, j) { + continue + } + id := cc.row.event.Subagent + if id == "" { + continue + } + if blocks == nil { + blocks = make(map[string]*subagentBlock) + } + b := blocks[id] + if b == nil { + b = &subagentBlock{id: id} + blocks[id] = b + } + b.rows = append(b.rows, j) + b.tokens += rowTokens(cc) + b.cost += rowCostUSD(cc) + } + return blocks +} + +// toggleSubagentBlock flips the block under the events cursor, if the cursor is +// on a header row. Reports whether it acted, so the caller can skip the +// detail-pane open that Enter otherwise means on this pane. +func (m *model) toggleSubagentBlock() bool { + b, ok := m.headerRows[m.eventsTbl.Cursor()] + if !ok { + return false + } + if m.subagentOpen == nil { + m.subagentOpen = make(map[string]bool) + } + if m.subagentOpen[b.id] { + delete(m.subagentOpen, b.id) + } else { + m.subagentOpen[b.id] = true + } + m.rebuildEventsTable() + return true +} diff --git a/cmd/agentop/tui/events_blocks_test.go b/cmd/agentop/tui/events_blocks_test.go new file mode 100644 index 000000000..9756fcb22 --- /dev/null +++ b/cmd/agentop/tui/events_blocks_test.go @@ -0,0 +1,322 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/rossoctl/cortex/core/observe/claude" + "github.com/rossoctl/cortex/core/pipeline" +) + +// dirColumn is the DIR column's definition, for the gutter test. eventColumns is +// the single definition; this picks the one entry out rather than indexing by +// position, which a reordered list would silently move. +func dirColumn() eventColumn { + for _, c := range eventColumns { + if c.id == colDir { + return c + } + } + panic("no DIR column") +} + +// blockEvents is a session with two subagents running beside the main conversation, +// interleaved as live traffic is. Each subagent carries a priced response so the +// header's rollup has figures to sum. +func blockEvents() []pipeline.SessionEvent { + sub := func(id string, phase pipeline.SessionPhase, tokens int) pipeline.SessionEvent { + return pipeline.SessionEvent{ + Direction: pipeline.Outbound, Phase: phase, Host: "ete", RequestID: id, + Subagent: id, + Inference: &pipeline.InferenceExtension{OutputTokens: tokens}, + Plugins: map[string]json.RawMessage{ + "cost": json.RawMessage(`{"output_usd":0.1}`), + }, + } + } + return []pipeline.SessionEvent{ + {Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: "m1"}, + sub("a1", pipeline.SessionRequest, 0), + sub("a1", pipeline.SessionResponse, 900), + {Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: "m2"}, + sub("a2", pipeline.SessionRequest, 0), + sub("a2", pipeline.SessionResponse, 40), + } +} + +func newBlockModel(t *testing.T, events []pipeline.SessionEvent) *model { + t.Helper() + m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": events}} + m.eventsTbl = newEventsTable() + m.rebuildEventsTable() + return m +} + +// The default view: each subagent's rows are hidden behind a collapsed header +// anchored at its first row, and the main conversation's rows stay top-level and +// in time order between them. +func TestBlocks_CollapsedByDefaultWithHeaderAtTheAnchor(t *testing.T) { + m := newBlockModel(t, blockEvents()) + + if m.subagentBlocks == nil || len(m.subagentBlocks) != 2 { + t.Fatalf("blocks = %v, want 2", m.subagentBlocks) + } + // Rendered: m1, header(a1), m2, header(a2) — every member row hidden. + if got := len(m.eventsTbl.Rows()); got != 4 { + t.Fatalf("rendered rows = %d, want 4 (2 main + 2 headers)", got) + } + // visibleRows stays parallel to the rendered rows; headers hold empty rows. + if len(m.visibleRows) != 4 { + t.Fatalf("visibleRows = %d, want 4", len(m.visibleRows)) + } + if m.visibleRows[0].event.RequestID != "m1" || m.visibleRows[2].event.RequestID != "m2" { + t.Errorf("main rows moved: %s, %s", m.visibleRows[0].event.RequestID, m.visibleRows[2].event.RequestID) + } + // headerRows names the two header positions. + if b := m.headerRows[1]; b == nil || b.id != "a1" { + t.Errorf("header at 1 = %v, want a1", m.headerRows[1]) + } + if b := m.headerRows[3]; b == nil || b.id != "a2" { + t.Errorf("header at 3 = %v, want a2", m.headerRows[3]) + } + // The header carries the rollup: a1's response holds 900 tokens at $0.10. + h := m.eventsTbl.Rows()[1][0] + if !strings.Contains(h, "▸─") || !strings.Contains(h, "2 rows") || !strings.Contains(h, "900 tok") || !strings.Contains(h, "$0.10") { + t.Errorf("a1 header = %q", h) + } +} + +// Enter on a header opens the block: member rows appear in time order, indented +// via the DIR gutter, and the header's glyph turns over. Enter again closes it. +func TestBlocks_EnterToggles(t *testing.T) { + m := newBlockModel(t, blockEvents()) + + m.eventsTbl.SetCursor(1) + if !m.toggleSubagentBlock() { + t.Fatal("Enter on the header row did not toggle") + } + if got := len(m.eventsTbl.Rows()); got != 6 { + t.Fatalf("open block: rendered rows = %d, want 6", got) + } + // The member rows sit inside the block, between the header and m2. Row 0 is + // m1, row 1 the header (its visibleRows entry is empty — no event of its own). + if rid := m.visibleRows[0].event.RequestID; rid != "m1" { + t.Fatalf("row 0 = %s, want m1", rid) + } + if m.visibleRows[1].event != nil { + t.Errorf("header row holds an event: %+v", m.visibleRows[1].event) + } + for _, i := range []int{2, 3} { + if m.visibleRows[i].event.Subagent != "a1" { + t.Errorf("row %d not a1's: %+v", i, m.visibleRows[i].event) + } + } + if h := m.eventsTbl.Rows()[1][0]; !strings.Contains(h, "▾─") { + t.Errorf("open header = %q, want ▾", h) + } + // a2 stays collapsed through a1's rebuild. + if got := len(m.eventsTbl.Rows()); got != 6 { + t.Fatalf("a2 should still be collapsed: %d rows", got) + } + + m.eventsTbl.SetCursor(1) + m.toggleSubagentBlock() + if got := len(m.eventsTbl.Rows()); got != 4 { + t.Fatalf("re-collapsed: rendered rows = %d, want 4", got) + } +} + +// Enter on a message row is still the detail drill-down; only headers toggle. +func TestBlocks_EnterOnAMessageOpensDetail(t *testing.T) { + m := newBlockModel(t, blockEvents()) + m.eventsTbl.SetCursor(0) + if m.toggleSubagentBlock() { + t.Fatal("Enter on a message row toggled a block") + } +} + +// A live search opens every block, so a match inside a collapsed block is +// findable — and the search rows stay parallel to the rendered rows. +func TestBlocks_SearchExpandsCollapsedBlocks(t *testing.T) { + m := newBlockModel(t, blockEvents()) + m.search = map[paneID]string{paneEvents: "ete"} + m.rebuildEventsTable() + + if got := len(m.eventsTbl.Rows()); got != 8 { + t.Fatalf("searching: rendered rows = %d, want 8 (all rows + 2 headers)", got) + } + if len(m.eventsTbl.Matches()) == 0 { + t.Error("search matched nothing") + } +} + +// A sort ranks the rows, so no blocks: every row renders flat, no header, no +// gutter — grouping would contradict the ranking the operator asked for. +func TestBlocks_SortDisablesGrouping(t *testing.T) { + m := newBlockModel(t, blockEvents()) + m.sortCol = colDuration + m.rebuildEventsTable() + + if m.subagentBlocks != nil { + t.Fatal("blocks active under a sort") + } + if got := len(m.eventsTbl.Rows()); got != 6 { + t.Fatalf("sorted: rendered rows = %d, want 6 flat", got) + } +} + +// The DIR cell carries the block's gutter while blocks are active, and the bare +// direction once they are not — under a sort, or for a main row. +func TestBlocks_DirGutterMarksMemberRows(t *testing.T) { + events := blockEvents() + m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": events}} + m.eventsTbl = newEventsTable() + m.rebuildEventsTable() + + cc := cellContext{m: m, row: eventRow{event: &events[1]}} + if got := dirColumn().cell(cc); got != "│out" { + t.Errorf("member row DIR = %q, want │out", got) + } + cc = cellContext{m: m, row: eventRow{event: &events[0]}} + if got := dirColumn().cell(cc); got != "out" { + t.Errorf("main row DIR = %q, want out", got) + } + + // Under a sort the same row renders bare: m.subagentBlocks is nil. + m.sortCol = colDuration + m.rebuildEventsTable() + cc = cellContext{m: m, row: eventRow{event: &events[1]}} + if got := dirColumn().cell(cc); got != "out" { + t.Errorf("sorted member row DIR = %q, want out", got) + } +} + +// The label prefers the harvested description, then the agent type, then falls +// back to the raw id — which is what a teammate or a stale harvest renders. +func TestBlocks_LabelSources(t *testing.T) { + m := newBlockModel(t, blockEvents()) + if got := m.subagentLabel("a1"); got != "a1" { + t.Errorf("no harvest: label = %q, want the id", got) + } + m.sessionsData = map[string]SessionMetadata{"s": {Subagents: map[string]claude.SubagentInfo{ + "a1": {AgentType: "general-purpose", Description: "PR review (execution-first)"}, + "a2": {AgentType: "Explore"}, + }}} + if got := m.subagentLabel("a1"); got != "PR review (execution-first)" { + t.Errorf("label = %q, want the description", got) + } + if got := m.subagentLabel("a2"); got != "Explore" { + t.Errorf("label = %q, want the agent type", got) + } +} + +// A session whose rows name no subagent rebuilds exactly as before: no blocks, +// no headers, the same rendered row count. +func TestBlocks_NoSubagentsNoBlocks(t *testing.T) { + events := []pipeline.SessionEvent{ + {Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "a", RequestID: "m1"}, + {Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "a", RequestID: "m1"}, + } + m := newBlockModel(t, events) + if m.subagentBlocks != nil || m.headerRows != nil { + t.Fatal("blocks built for a session with no subagent rows") + } + if got := len(m.eventsTbl.Rows()); got != 2 { + t.Fatalf("rendered rows = %d, want 2", got) + } +} + +// Opening another session drops the previous one's collapse state: subagent ids +// belong to the session that spawned them, and a same-named id in another +// session is a different agent. +func TestBlocks_CollapseStateIsPerSession(t *testing.T) { + m := newBlockModel(t, blockEvents()) + m.eventsTbl.SetCursor(1) + m.toggleSubagentBlock() // a1 open + + other := blockEvents() + m.events["t"] = other + m.selectedSess = "t" + m.rebuildEventsTable() + if m.subagentOpen != nil { + t.Fatalf("collapse state survived a session change: %v", m.subagentOpen) + } + if got := len(m.eventsTbl.Rows()); got != 4 { + t.Fatalf("new session: rendered rows = %d, want 4 (collapsed again)", got) + } +} + +// The block's population is the rows the pane would show: hideInactive hides a +// subagent's whole exchange, and a block with no surviving rows leaves no header +// behind — a header naming nothing is worse than no header, because it claims a +// subagent the filter just said produced nothing visible. +func TestBlocks_RollupFollowsTheInactiveFilter(t *testing.T) { + active := func(id, sub string, phase pipeline.SessionPhase) pipeline.SessionEvent { + return pipeline.SessionEvent{ + Direction: pipeline.Outbound, Phase: phase, Host: "ete", RequestID: id, Subagent: sub, + Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{ + {Plugin: "inference-parser", Action: pipeline.ActionObserve}, + }}, + } + } + inactive := func(id, sub string, phase pipeline.SessionPhase) pipeline.SessionEvent { + return pipeline.SessionEvent{ + Direction: pipeline.Outbound, Phase: phase, Host: "ete", RequestID: id, Subagent: sub, + } + } + events := []pipeline.SessionEvent{ + active("m1", "", pipeline.SessionRequest), + active("a1", "a1", pipeline.SessionRequest), + // a1's response is inactive but kept: its partner is shown (an exchange + // hides whole or not at all). + inactive("a1", "a1", pipeline.SessionResponse), + active("m2", "", pipeline.SessionRequest), + // a2's whole exchange is inactive: no block, no header. + inactive("a2", "a2", pipeline.SessionRequest), + inactive("a2", "a2", pipeline.SessionResponse), + } + m := &model{selectedSess: "s", events: map[string][]pipeline.SessionEvent{"s": events}, hideInactive: true} + m.eventsTbl = newEventsTable() + m.rebuildEventsTable() + + if _, ok := m.subagentBlocks["a2"]; ok { + t.Fatal("a2 has a block despite every row being hidden") + } + // Rendered: m1, header(a1), m2 — collapsed by default, so the kept exchange + // sits behind the header rather than on screen. No a2 header anywhere. + if got := len(m.eventsTbl.Rows()); got != 3 { + t.Fatalf("rendered rows = %d, want 3", got) + } + for i, b := range m.headerRows { + if b.id == "a2" { + t.Errorf("header for a2 at row %d", i) + } + } + // a1's header counts the rows expanding it would show: the kept exchange, 2. + if h := m.eventsTbl.Rows()[1][0]; !strings.Contains(h, "2 rows") { + t.Errorf("a1 header = %q, want the kept exchange counted", h) + } +} + +// The Timeline's Enter names subagent rows as readily as the conversation's, and +// a subagent's row inside a collapsed block is not on screen — the reveal must +// open the block rather than wait forever for a row that cannot appear. +func TestBlocks_RevealOpensACollapsedBlock(t *testing.T) { + events := blockEvents() + // Give the target a seq; the fixture leaves them zero. + events[1].Seq = 11 + m := newBlockModel(t, events) + m.revealSeq, m.revealSess = 11, "s" + + if !m.revealPendingSeq() { + t.Fatal("reveal did not land") + } + if !m.subagentOpen["a1"] { + t.Error("a1's block was not opened") + } + cur := m.eventsTbl.Cursor() + if m.visibleRows[cur].event == nil || m.visibleRows[cur].event.Seq != 11 { + t.Errorf("cursor on %v, want the event with seq 11", m.visibleRows[cur].event) + } +} diff --git a/cmd/agentop/tui/events_columns.go b/cmd/agentop/tui/events_columns.go index b7d60b331..a88a5e7ca 100644 --- a/cmd/agentop/tui/events_columns.go +++ b/cmd/agentop/tui/events_columns.go @@ -256,8 +256,23 @@ var eventColumns = []eventColumn{ cell: func(c cellContext) string { return c.row.event.At.Format("15:04:05.00") }, sortKey: func(c cellContext) sortValue { return numKey(c.row.event.At.UnixNano()) }}, {id: colDir, width: 4, defaultOn: true, keep: keepLow, - desc: "in = toward your agent, out = toward an upstream", - cell: func(c cellContext) string { return shortDirection(c.row.event.Direction) }, + desc: "in = toward your agent, out = toward an upstream; │ marks a subagent's row", + // "│" prefixes a subagent's row while its block is expanded (#1047) — the + // indent that says which rows are inside the block. It lives in THIS column + // because it is the first with a spare character ("out" is three of four), + // so the gutter costs no width and no refit. Only while blocks are active: + // under a sort the rows render flat (see rebuildEventsTable), and the + // subagent id still names the row in the detail pane. A search for "out" + // still matches, the gutter being a prefix. + cell: func(c cellContext) string { + d := shortDirection(c.row.event.Direction) + if c.row.event.Subagent != "" && c.m.subagentBlocks != nil { + return "│" + d + } + return d + }, + // The bare direction, not the guttered cell: a sort groups like with like, + // and the gutter is not a value. sortKey: func(c cellContext) sortValue { return strKey(shortDirection(c.row.event.Direction)) }}, // 6 is the heading with a sort glyph; no value is wider than "resp". It was 7 for // the bracket glyphs that used to prefix the phase ("└│ resp"). diff --git a/cmd/agentop/tui/events_pane.go b/cmd/agentop/tui/events_pane.go index e2e9e9283..83fd0c098 100644 --- a/cmd/agentop/tui/events_pane.go +++ b/cmd/agentop/tui/events_pane.go @@ -167,6 +167,26 @@ func (m *model) rebuildEventsTable() { rows := make([]table.Row, 0, len(eventRows)) m.visibleRows = m.visibleRows[:0] m.hiddenInactive = 0 + + // Subagent blocks (#1047), only while CHRONOLOGICAL: under a sort the row + // order is a ranking, and pulling a subagent's rows under a header would + // contradict the very thing the operator sorted to see. Collected over the + // same population the row loop renders — the inactive filter is applied + // identically, via the same predicate — so a header's rollup counts the rows + // expanding it would show. + // + // headerRows is rebuilt with them; subagentOpen (the operator's collapses) + // is cleared when the session under the table changed, since a subagent id + // belongs to the session that spawned it. + m.headerRows = nil + if m.eventsBuiltFor != m.selectedSess { + m.subagentOpen = nil + } + if m.sortCol == "" { + m.subagentBlocks = m.collectSubagentBlocks(ctxs, partner) + } else { + m.subagentBlocks = nil + } // keys is the sort value for each row that survived the filters, parallel to // rows and m.visibleRows. Collected inside the loop because the sortKey funcs // take the same cellContext the cells do — including the chronological index the @@ -197,11 +217,37 @@ func (m *model) rebuildEventsTable() { // activity (deny/modify/observe/allow). It hides whole exchanges: a row whose // partner is shown is shown too, or a routed request refused for its model — // no plugin acts on the 403 — would look as if it never got an answer. - if m.hideInactive && eventInactive(cc.invs) && !partnerActive(ctxs, partner, j) { + // The predicate is rowInactiveHidden, shared with collectSubagentBlocks + // so the two filter the same rows. + if m.rowInactiveHidden(ctxs, partner, j) { m.hiddenInactive++ continue } + // Subagent blocks: a row naming an agent joins that agent's block, whose + // header sits at the block's first surviving row. Collapsed (the default, + // and always while no search is live) the row is not rendered at all — the + // header's rollup is what a collapsed block says. m.visibleRows keeps an + // empty eventRow for the header so it stays parallel to the rendered rows; + // headerRows is what says a rendered row is a header. + if b := m.subagentBlocks[cc.row.event.Subagent]; b != nil { + if !b.anchored { + b.anchored = true + rows = append(rows, b.headerRow(m.subagentLabel(b.id), m.blockOpen(b.id, q != ""))) + m.visibleRows = append(m.visibleRows, eventRow{}) + if m.headerRows == nil { + m.headerRows = make(map[int]*subagentBlock) + } + m.headerRows[len(rows)-1] = b + if q != "" { + full = append(full, table.SearchRow{Cells: []string{m.subagentLabel(b.id)}}) + } + } + if !m.blockOpen(b.id, q != "") { + continue + } + } + // Cells come from the selected columns, in their order — see // events_columns.go. Previously this was a positional table.Row literal // parallel to a []table.Column slice, so inserting a column meant editing diff --git a/cmd/agentop/tui/keys.go b/cmd/agentop/tui/keys.go index 7374e74b9..b812fc3f6 100644 --- a/cmd/agentop/tui/keys.go +++ b/cmd/agentop/tui/keys.go @@ -698,6 +698,12 @@ func (m *model) handleKey(msg tea.KeyMsg) tea.Cmd { // Snapshot in case the stream hasn't yet delivered history. return m.snapshotCmd(id) case paneEvents: + // Enter on a subagent block's header collapses/expands it, the tree + // convention; on a message it stays the detail drill-down. One key, + // the row decides — same as the pickers. + if m.toggleSubagentBlock() { + return nil + } er, ok := m.selectedEventRow() if !ok { return nil diff --git a/cmd/agentop/tui/timeline_pane.go b/cmd/agentop/tui/timeline_pane.go index 68f61096c..ce33ee44f 100644 --- a/cmd/agentop/tui/timeline_pane.go +++ b/cmd/agentop/tui/timeline_pane.go @@ -504,6 +504,22 @@ func (m *model) revealPendingSeq() bool { if m.revealSeq == 0 || m.revealSess != m.selectedSess { return false } + // An event inside a COLLAPSED subagent block is not among visibleRows, so the + // loop below could not land on it and the reveal would wait forever. The + // Timeline draws a lane per subagent, so Enter there names subagent rows as + // readily as the conversation's — open the block that holds the target and + // rebuild, then land as usual. + for _, ev := range m.events[m.selectedSess] { + if ev.Seq != m.revealSeq || ev.Subagent == "" || m.blockOpen(ev.Subagent, false) { + continue + } + if m.subagentOpen == nil { + m.subagentOpen = make(map[string]bool) + } + m.subagentOpen[ev.Subagent] = true + m.rebuildEventsTable() + break + } for i, r := range m.visibleRows { if r.event != nil && r.event.Seq == m.revealSeq && eventSession(m.selectedSess, r.event) == m.selectedSess { setCursorVisible(&m.eventsTbl, i) From 21c8ac1bb17b562b44718e627335cbafbc86e8bc Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Fri, 30 Oct 2026 08:56:49 -0400 Subject: [PATCH 4/5] Docs: Subagent blocks in the events pane README keybindings and the events-pane section, the claude-code agent doc's Subagents paragraph (the meta.json labels are now harvested and shown), and one sentence on the parentSubagent bullet in CLAUDE.md. Refs #1047 Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- CLAUDE.md | 2 +- cmd/agentop/README.md | 14 +++++++++++++- docs/agents/claude-code.md | 8 ++++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6fc149803..75e671242 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -680,7 +680,7 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries: - `dialAddr` — the address the egress guard checked and the proxy dialed, `"ip:port"`, on a tunnel's open row when `listener.egress_guard` is on. With `host` it is the audit trail against DNS rebinding: under the guard the proxy resolves once, checks that answer, and dials exactly it, so the two fields together say "asked for the name, reached this address". Absent when the guard is off and on rows that dialed nothing. - `httpMethod`, `httpPath` — the HTTP verb and path, so a request no parser recognized is still identifiable rather than showing only a host. Distinct from the `method` inside `a2a` / `mcp`, which is a protocol method name. On an opaque tunnel `httpMethod` is `CONNECT` and `httpPath` is absent — opaque bytes carry no request line. The path is query-stripped and percent-decoded, so query-borne credentials never reach the timeline, but a secret in a path *segment* (a bot token, a webhook path) does survive on this unauthenticated surface — worth knowing before exporting events off-box. - `subagent` — present on rows from a Claude Code subagent or agent-team teammate: the `X-Claude-Code-Agent-Id` the request carried. For a subagent that is also the `` in `~/.claude/projects///subagents/agent-.meta.json`; for a teammate the join is unverified. Resolved by `Context.Subagent`, pinned with the User-Agent by `ResolveClient` so no plugin can re-file a row, and **refused rather than sanitised** when unusable (control runes, over 128 bytes), because it is a join key and a cleaned-up id joins nothing. It does not move the row: a subagent's requests stay in their parent's session, so the header must not be added to `session.id_headers`. **It names an agent, it does not classify the row:** `inference.agentRole` stays the field that says whether a row is a subagent's, and a teammate's row is `agentRole: main` with `subagent` set — Claude Code writes `cc_is_subagent=true` only for `agentType: subagent`. -- `parentSubagent` — the agent that started `subagent`, from `X-Claude-Code-Parent-Agent-Id`, resolved with it in one memo (`Context.ParentSubagent`) and under the same rules. Claude Code sends it only when the spawner was itself an agent, so **absent on a row that names a `subagent` means the conversation started it**, not "unknown". The two keys are the tree's node and its edge. +- `parentSubagent` — the agent that started `subagent`, from `X-Claude-Code-Parent-Agent-Id`, resolved with it in one memo (`Context.ParentSubagent`) and under the same rules. Claude Code sends it only when the spawner was itself an agent, so **absent on a row that names a `subagent` means the conversation started it**, not "unknown". The two keys are the tree's node and its edge. agentop's events pane groups each subagent's rows under a collapsible header anchored at its first row (#1047): the label is the subagent's description from the harvested `agent-.meta.json` (`SessionMetadata.Subagents`), falling back to the raw id; blocks are collapsed by default, `Enter` toggles, a sort disables grouping, and a live search opens every block. ### Invocation action vocabulary diff --git a/cmd/agentop/README.md b/cmd/agentop/README.md index 5dd9c310b..d227c7a17 100644 --- a/cmd/agentop/README.md +++ b/cmd/agentop/README.md @@ -1123,6 +1123,18 @@ agentop is for, and the other four are surfaces you visit and leave. the same as a body of zero bytes: a body no plugin asked to buffer is relayed unmeasured and reads exactly as a body-less GET does. + A session with subagent traffic groups each subagent's rows under a + collapsible header at the subagent's first row: `▸─