Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -799,7 +799,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 `<id>` in `~/.claude/projects/<dir>/<session>/subagents/agent-<id>.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. 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-<id>.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.
- `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-<id>.meta.json` (`SessionMetadata.Subagents`), falling back to the raw id; blocks are collapsed by default, `Enter` toggles, an expanded block's rows render together under its header (pulled out of the chronological interleaving — the DIR gutter's `│` marks them), member exchanges are numbered from 1 within the block while main rows keep session-wide numbers, a sort disables grouping, and a live search opens every block.

### Invocation action vocabulary

Expand Down
21 changes: 15 additions & 6 deletions cmd/agentop/tui/events_blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,23 +26,32 @@ type subagentBlock struct {
// has not landed contributes nothing yet, like its cells.
tokens int
cost float64
// msgs is the block's exchange count — the unit the # column numbers member
// rows in, so the header's rollup and the last row's number agree.
msgs int
// 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
}

// headerText is the block's collapsible line: the rollup the issue asked for —
// how many rows, how many tokens, what cost — behind the label. The caller wraps
// it in a one-cell table.Row (rendered as a section row spanning every column)
// and hands the SAME string to the search, so what the header displays is what
// the search matches — the label alone would hide the rollup figures from it.
// how many messages, how many tokens, what cost — behind the label. The caller
// wraps it in a one-cell table.Row (rendered as a section row spanning every
// column) and hands the SAME string to the search, so what the header displays
// is what the search matches — the label alone would hide the rollup figures
// from it. The count is in the # column's unit (exchanges, numbered from 1
// inside the block), so the rollup and the rows it summarizes cannot disagree.
func (b *subagentBlock) headerText(label string, open bool) string {
glyph := "▸"
if open {
glyph = "▾"
}
return fmt.Sprintf("%s─ %s · %d rows · %s tok · %s",
glyph, label, len(b.rows), humanizeCount(int64(b.tokens)), formatUSDTotal(b.cost))
unit := "msgs"
if b.msgs == 1 {
unit = "msg"
}
return fmt.Sprintf("%s─ %s · %d %s · %s tok · %s",
glyph, label, b.msgs, unit, humanizeCount(int64(b.tokens)), formatUSDTotal(b.cost))
}

// subagentLabel names a block. The wire carries only the 16-hex id, so a label a
Expand Down
197 changes: 191 additions & 6 deletions cmd/agentop/tui/events_blocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package tui

import (
"encoding/json"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -80,15 +81,17 @@ func TestBlocks_CollapsedByDefaultWithHeaderAtTheAnchor(t *testing.T) {
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.
// The header carries the rollup: a1 holds one exchange (2 rows) whose
// response carries 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") {
if !strings.Contains(h, "▸─") || !strings.Contains(h, "1 msg") || !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.
// Enter on a header opens the block: member rows appear together under the
// header (see TestBlocks_ExpandedMembersStayTogether), marked 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())

Expand Down Expand Up @@ -127,6 +130,188 @@ func TestBlocks_EnterToggles(t *testing.T) {
}
}

// An expanded block's rows render TOGETHER, directly under the header — pulled
// out of the chronological interleaving with the main conversation. Live traffic
// interleaves (a subagent's exchange spans several main-conversation turns), and
// rendering members at their own timestamps scattered the block across the pane,
// which read as "expanding did nothing" — the header said 56 rows and the rows
// below it were the main conversation's.
func TestBlocks_ExpandedMembersStayTogether(t *testing.T) {
events := []pipeline.SessionEvent{
{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: "a1", Subagent: "a1"},
// The main conversation's row, chronologically INSIDE a1's exchange.
{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: "m1"},
{Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "ete", RequestID: "a1", Subagent: "a1"},
{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: "m2"},
}
m := newBlockModel(t, events)

// Collapsed: header anchored at a1's first row, then the main rows.
if got := len(m.eventsTbl.Rows()); got != 3 {
t.Fatalf("collapsed rows = %d, want 3 (header + m1 + m2)", got)
}

m.eventsTbl.SetCursor(0)
if !m.toggleSubagentBlock() {
t.Fatal("Enter on the header row did not toggle")
}

// Expanded: header, a1's whole exchange, THEN the main rows — a1's response
// must not sit at its chronological slot below m1.
want := []string{"a1", "a1", "m1", "m2"}
if got := len(m.eventsTbl.Rows()); got != 5 {
t.Fatalf("expanded rows = %d, want 5 (header + a1's pair + m1 + m2)", got)
}
for i, id := range want {
// Row 0 is the header, whose visibleRows entry holds no event.
if rid := m.visibleRows[i+1].event.RequestID; rid != id {
t.Errorf("expanded row %d = %s, want %s", i+1, rid, id)
}
}
}

// Two subagents running concurrently, each interleaved with the other and with
// the main conversation — and a1's traffic resuming long after a2 finished its
// first exchange. Each block still renders as ONE contiguous segment under its
// own single header: membership is by subagent id over the whole session, not
// by time-contiguity, so interleaving cannot split a block into segments or
// emit a second header for the same agent.
func TestBlocks_ConcurrentSubagentsEachStayOneSegment(t *testing.T) {
req := func(id, sub string) pipeline.SessionEvent {
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: id, Subagent: sub}
}
resp := func(id, sub string) pipeline.SessionEvent {
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "ete", RequestID: id, Subagent: sub}
}
events := []pipeline.SessionEvent{
req("a1.1", "a1"),
req("m1", ""),
req("a2.1", "a2"), // a2 starts while a1's first exchange is in flight
resp("a1.1", "a1"),
resp("a2.1", "a2"),
req("m2", ""),
resp("m2", ""),
req("a2.2", "a2"), // a2 continues after main turns
resp("a2.2", "a2"),
req("m3", ""),
resp("m3", ""),
req("a1.2", "a1"), // a1's traffic resumes long after it started
resp("a1.2", "a1"),
req("m4", ""),
}
m := newBlockModel(t, events)
m.subagentOpen = map[string]bool{"a1": true, "a2": true}
m.rebuildEventsTable()

// Each subagent's rows render in arrival order within its segment.
order := map[string][]string{}
for _, vr := range m.visibleRows {
if vr.event != nil && vr.event.Subagent != "" {
order[vr.event.Subagent] = append(order[vr.event.Subagent], vr.event.RequestID)
}
}
if got := order["a1"]; !slices.Equal(got, []string{"a1.1", "a1.1", "a1.2", "a1.2"}) {
t.Errorf("a1's segment = %v, want its whole exchange run in order", got)
}
if got := order["a2"]; !slices.Equal(got, []string{"a2.1", "a2.1", "a2.2", "a2.2"}) {
t.Errorf("a2's segment = %v, want its whole exchange run in order", got)
}
// A subagent's rendered rows must form ONE contiguous run: their positions
// span exactly their count. A block split into segments — or a second
// header for the same agent — would leave gaps in that span.
for _, sub := range []string{"a1", "a2"} {
var pos []int
for i, vr := range m.visibleRows {
if vr.event != nil && vr.event.Subagent == sub {
pos = append(pos, i)
}
}
if len(pos) == 0 || pos[len(pos)-1]-pos[0]+1 != len(pos) {
t.Errorf("%s's rows at %v, not one contiguous run", sub, pos)
}
}
// Exactly two headers rendered.
if headers := len(m.headerRows); headers != 2 {
t.Errorf("headers = %d, want 2 (one per subagent, however interleaved)", headers)
}
}

// Member rows number their exchanges from 1 WITHIN the block — the block reads
// as the subagent's own transcript — while main rows keep the session-wide
// numbers. A request and its response still share one number, and the block's
// count agrees with the last number shown inside it.
func TestBlocks_MembersNumberFromOneInsideTheBlock(t *testing.T) {
req := func(id, sub string) pipeline.SessionEvent {
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionRequest, Host: "ete", RequestID: id, Subagent: sub}
}
resp := func(id, sub string) pipeline.SessionEvent {
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: pipeline.SessionResponse, Host: "ete", RequestID: id, Subagent: sub}
}
events := []pipeline.SessionEvent{
req("m1", ""), resp("m1", ""), // session exchange 1
req("a1.1", "a1"), resp("a1.1", "a1"), // session 2 — block-local 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: this fixture's in-block exchanges are strictly sequential (req, resp, req, resp), so it cannot catch the overlap case in events_pane.go. Please add an overlapping pair, e.g. req(a1.1), req(a1.2), resp(a1.1), resp(a1.2), and assert that the expanded # cells read 1,2,1,2. That pins the pairing invariant for block-local numbers the same way TestBlocks_ConcurrentSubagentsEachStayOneSegment pins contiguity.

req("m2", ""), resp("m2", ""), // session 3
req("a1.2", "a1"), resp("a1.2", "a1"), // session 4 — block-local 2
}
m := newBlockModel(t, events)
m.eventsTbl.SetCursor(2) // a1's header: m1's pair, then the header
if !m.toggleSubagentBlock() {
t.Fatal("Enter on the header row did not toggle")
}

// Rendered: m1 req, m1 resp, header, a1's two exchanges, m2's pair. The
// first cell of each EVENT row is the # column; the header row is one cell
// of its own, standing in for the position.
want := []string{"1", "1", "HEADER", "1", "1", "2", "2", "3", "3"}
rows := m.eventsTbl.Rows()
if len(rows) != len(want) {
t.Fatalf("expanded rows = %d, want %d", len(rows), len(want))
}
for i, n := range want {
if n == "HEADER" {
continue // checked below, with the rollup
}
if got := rows[i][0]; got != n {
t.Errorf("row %d # = %q, want %q", i, got, n)
}
}
// The header's rollup counts exchanges, in the same unit the rows show.
if h := rows[2][0]; !strings.Contains(h, "2 msgs") {
t.Errorf("header = %q, want the exchange count (2 msgs)", h)
}
}

// Two of one subagent's exchanges that overlap (A starts, B starts, A ends, B
// ends) keep their pairing: each row carries its own exchange's block-local
// number, not the count of exchanges seen before it.
func TestBlocks_OverlappingExchangesKeepTheirNumbers(t *testing.T) {
ev := func(id string, phase pipeline.SessionPhase) pipeline.SessionEvent {
return pipeline.SessionEvent{Direction: pipeline.Outbound, Phase: phase, Host: "ete", RequestID: id, Subagent: "a1"}
}
events := []pipeline.SessionEvent{
ev("a1.1", pipeline.SessionRequest), ev("a1.2", pipeline.SessionRequest),
ev("a1.1", pipeline.SessionResponse), ev("a1.2", pipeline.SessionResponse),
}
m := newBlockModel(t, events)
m.subagentOpen = map[string]bool{"a1": true}
m.rebuildEventsTable()

rows := m.eventsTbl.Rows()
if len(rows) != 5 {
t.Fatalf("expanded rows = %d, want 5 (header + 4)", len(rows))
}
var got []string
for _, r := range rows[1:] {
got = append(got, r[0])
}
if want := []string{"1", "2", "1", "2"}; !slices.Equal(got, want) {
t.Errorf("# cells = %v, want %v", got, want)
}
if h := rows[0][0]; !strings.Contains(h, "2 msgs") {
t.Errorf("header = %q, want 2 msgs", h)
}
}

// Enter on a message row is still the detail drill-down; only headers toggle.
func TestBlocks_EnterOnAMessageOpensDetail(t *testing.T) {
m := newBlockModel(t, blockEvents())
Expand Down Expand Up @@ -312,8 +497,8 @@ func TestBlocks_RollupFollowsTheInactiveFilter(t *testing.T) {
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") {
// a1's header counts what expanding it would show: the kept exchange, 1 msg.
if h := m.eventsTbl.Rows()[1][0]; !strings.Contains(h, "1 msg") {
t.Errorf("a1 header = %q, want the kept exchange counted", h)
}
}
Expand Down
11 changes: 10 additions & 1 deletion cmd/agentop/tui/events_columns.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type cellContext struct {
// ids pairs a request event with its response, computed once per rebuild by
// computeEventPairs. Carried here rather than recomputed per cell.
ids map[*pipeline.SessionEvent]int
// blockIDs renumbers a subagent block's member exchanges from 1 (#1047): the
// block reads as the subagent's own transcript, so its first exchange is 1,
// not the session-wide number the main conversation's rows keep. Nil unless
// blocks were collected this rebuild; a lookup that misses falls through to
// ids, which is what a main row always takes.
blockIDs map[*pipeline.SessionEvent]int
// invs is this row's invocations, computed once in the row loop.
//
// rebuildEventsTable already needs them for the hideInactive check, and
Expand Down Expand Up @@ -239,8 +245,11 @@ const (
// display order.
var eventColumns = []eventColumn{
{id: colIndex, width: 4, defaultOn: true, keep: keepHigh,
desc: "exchange number; a request and its response share one",
desc: "exchange number; a request and its response share one, numbered per subagent block",
cell: func(c cellContext) string {
if id, ok := c.blockIDs[c.row.event]; ok {
return strconv.Itoa(id)
}
if id, ok := c.ids[c.row.event]; ok {
return strconv.Itoa(id)
}
Expand Down
Loading
Loading