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
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,7 +582,8 @@ Every event on `/v1/sessions/{id}` and `/v1/events` carries:
`error.kind: "dial_failed"`.
- `requestedHost` — present only when a plugin redirected the request (`pctx.Redirect`): the host the client asked for. `host` is where the request actually went, and usage, the cost ledger and pricing all follow `host`. agentop's detail pane shows both on a `redirected:` line.
- `inference.requestedModel` — present only when a plugin changed the model (`pctx.SetRequestModel`): the model the client asked for. `inference.model` is the model the request was sent for, which settlement prices; agentop's detail pane shows both on a `model:` line.
- `tunnel`, `tunnelReason`, `bytesUp`, `bytesDown` — an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing a `requestId`: the open (`phase: "request"`, `tunnelReason` saying why the bytes stayed opaque) and, when the tunnel ends, the close (`phase: "response"`). The close carries the CONNECT's own `statusCode` — 200, or 502 with the dial error in `error` when the destination could not be reached (`tunnelReason: "dial-failed"`) — plus `durationMs` for how long the tunnel stayed open and the bytes it carried each way (up = client to destination). It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of `/v1/usage`: a tunnel's lifetime is not a request latency.
- `bytesUp`, `bytesDown` — **body** bytes, one direction per row: a request row carries `bytesUp`, the body **as forwarded** (so a `tool-prune`-style rewrite is reflected, since the count is taken after the pipeline ran), and a response row carries `bytesDown`, the body **as it came back from the destination** — not as delivered, because extproc records the row before it emits a response mutator's replacement. Headers are not counted. A tunnel close is the one row carrying both. **Zero means the listener counted nothing, not that the body was empty**, and there is no third value for "unknown": every listener buffers bodies only when a plugin asks, so an unbuffered request reports zero, and so does a response relayed straight through — both proxies copy that one after the row is appended. agentop renders a zero side blank rather than as `0B`, and that is why its BYTES column can be trusted as a size when it shows one. All three listeners count the same thing for the same response, which took some doing: extproc sums the chunks Envoy hands it, while the proxies count off the upstream body at one choke point each, because the arm that re-frames SSE sees `sseframe` payloads with the `data: ` prefixes and blank-line separators already stripped — 32 bytes short on a four-event response. `core/listener/internal/bodycount` and `core/listener/parity`'s bytes fixtures hold that line.
- `tunnel`, `tunnelReason` — an opaque CONNECT (or transparent-redirect) tunnel records two rows sharing a `requestId`: the open (`phase: "request"`, `tunnelReason` saying why the bytes stayed opaque) and, when the tunnel ends, the close (`phase: "response"`). The close carries the CONNECT's own `statusCode` — 200, or 502 with the dial error in `error` when the destination could not be reached (`tunnelReason: "dial-failed"`) — plus `durationMs` for how long the tunnel stayed open and, in `bytesUp`/`bytesDown`, the bytes it carried each way (up = client to destination; opaque bytes have no body/header split, so these are everything that crossed). It is not the destination's status: that travels inside the client's end-to-end TLS. A bridged tunnel's open is recorded with its first decrypted request — in that request's session, directly before it, stamped with its time — and records no close, because the request carries its own response; a bridged tunnel that recorded no request gets its open and a close when it ends. Tunnel rows are kept out of `/v1/usage`: a tunnel's lifetime is not a request latency.
- `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.

### Invocation action vocabulary
Expand Down
52 changes: 32 additions & 20 deletions cmd/agentop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1082,7 +1082,7 @@ agentop is for, and the other three are surfaces you visit and leave.
100% of rows. Avoided spend now lives per session in the sessions table and per
series in the `$` breakdown; volume readings live in the Usage pane.
- **Events**: per-session event table. `c` opens a column picker — a popup with
a checkbox and a one-line description per column, since twelve abbreviated
a checkbox and a one-line description per column, since thirteen abbreviated
headers are not self-describing.

The picker is also where sorting lives: `s` orders the table by the column
Expand All @@ -1100,47 +1100,59 @@ agentop is for, and the other three are surfaces you visit and leave.
toward. Sorting never changes the `#` exchange pairing or the per-row token and
cost figures; it reorders the finished rows only.

The twelve default columns together need ~168 terminal columns, so the table
The thirteen default columns together need ~185 terminal columns, so the table
drops what does not fit and the footer says how many (`→ N more columns`). Columns carry a
keep rank rather than being equally expendable: DIR, DURATION, TOKENS and COST
give way first, while `#` and HOST survive longest. That is what makes HOST
usable at 80 columns despite being last in display order — it is the column
most people open this pane for.

Twelve of the thirteen are on by default: `#` (exchange number, shared by a
request and its response), TIME, DIR, PHASE, ACTION, PLUGIN, METHOD, STATUS,
DURATION, TOKENS, COST, HOST. The thirteenth, BYTES, is opt-in through the
picker (`c`), because only an opaque tunnel's close row has a figure for it.
On a narrow terminal the low-ranked ones are hidden rather than turned off,
so widening the window brings them back without touching the picker.
All thirteen are on by default: `#` (exchange number, shared by a request and
its response), TIME, DIR, PHASE, ACTION, PLUGIN, METHOD, STATUS, DURATION,
BYTES, TOKENS, COST, HOST. On a narrow terminal the low-ranked ones are hidden
rather than turned off, so widening the window brings them back without
touching the picker.

BYTES was the one opt-in column until recently, on the grounds that only an
opaque tunnel's close row had a figure for it. Ordinary rows now carry one too
— a request row the body size it forwarded, a response row the body size that
came back — so the reason for hiding it is gone and the picker is where you go
to turn it *off*. A blank cell means the proxy counted nothing, which is not
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.

Live-updates while in view — if the cursor is on the last row, it
auto-follows new events.

All twelve columns, on a terminal wide enough for them. Each `#` appears
All thirteen columns, on a terminal wide enough for them. Each `#` appears
twice — once for the request, once for its response — which is how a row
with no STATUS is read as "still in flight" rather than "failed":

```
agentop · ctx-abc-1234…

# TIME DIR PHASE ACTION PLUGIN METHOD STATUS DURATION TOKENS COST HOST
1 14:23:07.41 in req allow jwt-validation weather-agent
1 14:23:07.52 in resp — — 200 118ms weather-agent
2 14:23:07.71 out req observe inference-parser claude-sonnet-5 681,300(−9.9k) $0.2546(−$0.0037) api.anthropic.com
2 14:23:08.91 out resp — — claude-sonnet-5 200 1.20s 412 api.anthropic.com
3 14:23:09.01 out req modify token-exchange tools/call github-tool-mcp
3 14:23:09.10 out resp — — tools/call 503 96ms github-tool-mcp
# TIME DIR PHASE ACTION PLUGIN METHOD STATUS DURATION BYTES TOKENS COST HOST
1 14:23:07.41 in req allow jwt-validation ↑1.4kB weather-agent
1 14:23:07.52 in resp — — 200 118ms ↓842B weather-agent
2 14:23:07.71 out req observe inference-parser claude-sonnet-5 ↑118.6kB 681,300(−9.9k) $0.2546(−$0.0037) api.anthropic.com
2 14:23:08.91 out resp — — claude-sonnet-5 200 1.20s ↓12.4kB 412 api.anthropic.com
3 14:23:09.01 out req modify token-exchange tools/call ↑318B github-tool-mcp
3 14:23:09.10 out resp — — tools/call 503 96ms github-tool-mcp

● connected 2.1 events/sec [/anthropic 2 matches] [sort: DURATION▼]
[↑↓] nav [b/f] page [↵] detail [c] columns [u] usage [s] hide passthru/skip [p] pause [/] search [n/N] next/prev [esc] back · → 4 more columns ([c] to choose) [?] keys [q] quit
[↑↓] nav [b/f] page [↵] detail [c] columns [u] usage [s] hide passthru/skip [p] pause [/] search [n/N] next/prev [esc] back · [?] keys [q] quit
```

Exchange 3's response has no BYTES figure because there was nothing to count:
token-exchange could not reach the IdP, so the 503 is the proxy's own and no
upstream body ever arrived.

`—` in ACTION and PLUGIN means no plugin acted on that message; a `tunnel`
there is an opaque CONNECT, where METHOD is blank because opaque bytes carry
no request line. The tunnel's STATUS arrives on its `resp` row when it closes,
with DURATION for how long it stayed open and, in BYTES, what it carried each
way. That STATUS is the
way — the one row that reports both directions at once, since opaque bytes
have no body-and-headers split to separate. That STATUS is the
CONNECT's own (200, or 502 when the destination could not be reached), never
the destination's, which travels inside the client's TLS. The TOKENS and COST figures on a request
row carry what `tool-prune` saved in parentheses — `−` for a counted saving,
Expand Down Expand Up @@ -1562,7 +1574,7 @@ events:
| `usage.window` | string | `10m0s` | usage-pane window: `10m0s`, `1h0m0s` or `6h0m0s` |
| `usage.group` | string | `none` | usage-pane breakdown. `[b]` cycles `none`, `status`, `method`, `plugin`, `host`; a hand-edited file may also use `model`, `endpoint`, `session` or `agent` |

List a column only to change it — the twelve are all visible until you hide one, and
List a column only to change it — the thirteen are all visible until you hide one, and
an unrecognised name or sort column is ignored. Inside an entry, always write
`visible:` explicitly: it is optional to the parser but reads as `false`, so
`- name: COST` on its own hides COST rather than showing it.
Expand All @@ -1588,7 +1600,7 @@ Column ids, in display order — the same headers the picker shows:
| `METHOD` | protocol operation: model name, MCP or A2A method |
| `STATUS` | HTTP status of the response |
| `DURATION` | how long the exchange took |
| `BYTES` | bytes an opaque tunnel carried: ↑ sent, ↓ received — off by default |
| `BYTES` | body bytes: ↑ request sent, ↓ response received (tunnels show both); blank when the proxy counted nothing |
| `TOKENS` | tokens used, and what `tool-prune` saved |
| `COST` | estimated cost, and what `tool-prune` saved |
| `HOST` | host the message was sent to |
Expand Down
23 changes: 17 additions & 6 deletions cmd/agentop/tui/events_column_sizing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,11 +244,17 @@ func TestEventsTable_WiderFigureKeepsScrollPosition(t *testing.T) {
}

// The picker's "(no room)" marker is judged against the widths the table is using. At
// 150 columns the default set fits only because TOKENS and COST are sized to their
// this width the default set fits only because TOKENS and COST are sized to their
// figures; judged at their declared widths, COST would be marked as having no room while
// the table beside it showed it.
//
// 169 and not 150: #1309 turned BYTES on by default, which is 19 more columns of
// declared width (17 + cellPadding) and does not size to its content, so the window
// where sizing is what makes the defaults fit moved up by exactly that. The control
// assertion below is what keeps this honest — at declared widths this width must still
// be too narrow, or the test proves nothing.
func TestColumnPicker_NoRoomAgreesWithTheSizedTable(t *testing.T) {
const width = 150
const width = 169
m := sizingModel(t, width,
sizingExchange(t, "r1", "api.anthropic.com", time.Now(), 1_048_576, nil))
if m.eventColsDropped != 0 {
Expand All @@ -269,14 +275,19 @@ func TestColumnPicker_NoRoomAgreesWithTheSizedTable(t *testing.T) {
}

// A widening that crosses a fit boundary changes the column COUNT, not only widths, and
// must not re-anchor the pane either. At 150 columns the fixture's sized defaults fit; the
// first counted saving widens TOKENS and COST past that, a column is dropped, and the
// must not re-anchor the pane either. At the narrow width the fixture's sized defaults fit;
// the first counted saving widens TOKENS and COST past that, a column is dropped, and the
// headings change. Then the reverse: a wider terminal lets the column back on, so the count
// grows. Each direction takes a different SetRows/SetColumns order, and the two together are
// the only ones a toggle or a resize can produce.
//
// Both widths moved up by BYTES' 19 declared columns when #1309 turned it on by default —
// the test needs a width where all thirteen fit and a wider one that still has room after
// the saving, not these particular numbers. The two Fatalf guards below catch it if a
// future column moves the boundary again.
func TestEventsTable_WideningPastTheTerminalKeepsScrollPosition(t *testing.T) {
m := cursorModel(t, 40)
m.width = 150
m.width = 169
m.rebuildEventsTable()
if m.eventColsDropped != 0 {
t.Fatalf("%d columns dropped at %d before any saving; the fixture should start with all of them",
Expand All @@ -297,7 +308,7 @@ func TestEventsTable_WideningPastTheTerminalKeepsScrollPosition(t *testing.T) {
check func(n int) bool
}{
{"a saving pushes a column off", func() {}, func(n int) bool { return n < before }},
{"a wider terminal lets it back", func() { m.width = 200 }, func(n int) bool { return n == before }},
{"a wider terminal lets it back", func() { m.width = 219 }, func(n int) bool { return n == before }},
} {
step.apply()
m.rebuildEventsTable()
Expand Down
19 changes: 14 additions & 5 deletions cmd/agentop/tui/events_columns.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,11 +276,20 @@ var eventColumns = []eventColumn{
desc: "how long the exchange took",
cell: func(c cellContext) string { return durationCell(*c.row.event) },
sortKey: durationSortKey},
// Off by default: only an opaque tunnel's close row has a figure, so a column on for
// everyone would spend columns of every terminal on a mostly blank one. The total is
// the sort key, so a descending sort finds the tunnel that carried the most.
{id: colBytes, width: 17, defaultOn: false, keep: keepLow, rightAlign: true,
desc: "bytes an opaque tunnel carried: ↑ sent, ↓ received",
// On by default since #1309. It was off because only an opaque tunnel's close row had
// a figure, so the column cost every terminal 17 columns of mostly blank — and an
// operator asking how big the request that overran a model's context window was found
// it blank on every row and resorted to measuring the yanked event's JSON, which is
// not the body. Ordinary request and response rows now carry a figure, so the reason
// for opt-in is gone.
//
// 17 is the tunnel close row's pair at its widest ("↑999.9GB ↓999.9GB"); an ordinary
// row reports one side and is strictly narrower. keepLow stays — it is still the
// first column a narrow terminal drops. The total is the sort key, which is also
// right for a one-sided row, so a descending sort finds the biggest payload either
// way.
{id: colBytes, width: 17, defaultOn: true, keep: keepLow, rightAlign: true,
desc: "body bytes: ↑ request sent, ↓ response received (tunnels show both)",
cell: func(c cellContext) string { return bytesCell(*c.row.event) },
sortKey: func(c cellContext) sortValue { return numKey(c.row.event.BytesUp + c.row.event.BytesDown) }},
// 18: sized for a SEVEN-digit prompt with a counted saving, "1,048,576(−12,300)".
Expand Down
7 changes: 4 additions & 3 deletions cmd/agentop/tui/events_columns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -548,9 +548,10 @@ func TestColumnPicker_CursorStaysVisibleWhenClipped(t *testing.T) {
//
// bubbles pads each cell on both sides (Padding(0, 1) on Cell and Header), so a
// column occupies width+2. Modelling it as +1 under-counted by one per column: a
// row of all twelve rendered at 168 against a computed 156, so the table wrapped
// at 80 and — worse, between 156 and 167 — reported dropped==0 while up to twelve
// columns sat off the edge, with no footer count and no "(no room)" marker.
// row of all the columns there were then — twelve, before #1309 turned BYTES on —
// rendered at 168 against a computed 156, so the table wrapped at 80 and — worse,
// between 156 and 167 — reported dropped==0 while up to twelve columns sat off the
// edge, with no footer count and no "(no room)" marker.
func TestFitColumns_RenderedWidthNeverExceedsTerminal(t *testing.T) {
// Several selections, not just all-on. The all-on case alone let a real bug
// through: fitColumns credited back width+1 while columnsWidth charged
Expand Down
Loading
Loading