Skip to content

Feat: Scope abctl's sessions list, spend band and drawer to the chosen agent - #1198

Merged
huang195 merged 6 commits into
rossoctl:mainfrom
huang195:feat/agent-scope-everywhere
Sep 30, 2026
Merged

huang195 merged 6 commits into
rossoctl:mainfrom
huang195:feat/agent-scope-everywhere

Conversation

@huang195

@huang195 huang195 commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Summary

Makes abctl's agent scope reach every pane. Today, picking an agent in the AGENTS pane (A, or the
startup picker) narrows only the usage pane: the sessions list and the spend band keep showing
every agent, so IBM Bob and Claude Code cannot be told apart in the two views people use most.

Depends on #1195 (money in its own unit), which is merged into this branch. Until #1195 lands
this diff includes it — review the last four commits:
ec7dd022, afec2cc3, fe1ca095, b05f157b.

Server

  • SessionSummary.Agent (omitempty): the coding agent a session belongs to. Never set
    for default or pending: buckets.
  • /v1/usage?agent=<label> (optional): narrows a window to one agent. Ledger windows filter rows
    on the agent key; ring windows read the per-agent tally. On the ring only group=none is answerable — any other grouping is
    downgraded and reported in group, the existing convention.
  • Snapshot.Agent echoes the filter only when it was applied, which is how a client tells an
    older server that ignored the parameter.

abctl

  • Sessions pane: while scoped, lists only that agent's sessions; hides default, pending:
    and cached-only rows; the title reads · agent=<label>. An AGENT column appears only when the
    listed sessions name two agents.
  • Spend band and drawer: fetched with agent= while scoped; the drawer drops its agent axis.
  • AGENTS pane: gains a SESSIONS column once sessions name their agent.
  • Older servers: if the echo is missing, abctl narrows client-side with usage.ScopeToAgent,
    as the usage pane already does — pinned by TestFetchUsageScoped_NarrowsWhateverTheServerDid.
  • Hides the cached-only pending:<agent> row Feat: Keep each coding agent's header-less requests in its own session #1194 left behind after an adoption.
  • README, help overlay and docs now say what the scope covers.

What does not change

  • With no agent picked, every pane renders and fetches as before — the band and drawer send no
    agent=. The new columns need a server that sets SessionSummary.Agent.
  • Keys are unchanged: ↵ toggles the scope, Esc keeps it.
  • The usage pane keeps its client-side narrowing.
  • One pre-existing test changes: the help-overlay test that asserted the old limitation ("not the
    spend band") becomes TestPaneKeys_TheAgentsNoteSaysWhatTheScopeReaches.

Not yet done

  • No independent review yet, and not verified end to end against a live proxy with Bob and
    Claude Code running concurrently.
  • With session.client_affinity off, SessionSummary.Agent relies on each session's first
    known-agent event being its own, which holds in the ordering observed on a laptop.
  • The events pane is not filtered: events belong to their session.
  • The band carries no scope marker of its own; every pane's title names the agent.

Context

Last of three PRs finishing Bob/Claude Code separation in abctl (#1194 merged, #1195 open).
Related: #943, #1116.

Deferred

  • Fold AGENTS rows by agent name. A scope is one versioned label (claude-code/2.1.284), so it
    stops covering an agent once that agent auto-updates. Folding versions is a product decision
    across the AGENTS pane, the usage pane's scope and abctl cost --agent, which must keep giving
    the pane's figures.
  • A per-agent unit tally on the ring. Under agent=, a ring window serves group=currency as
    none, so a two-unit agent's LAST 1H reads (mixed) where ledger-backed spans split it. It would
    extend the ring's labelUnits cross-tabulation, which Feat: Show every abctl money figure in its own billing unit #1195 adds.

Test plan

  • go test ./... per module: core, cmd/abctl, cmd/authbridge-proxy
  • golangci-lint run --new-from-rev=upstream/main: no new issues
  • Each new guard mutation-checked against its own assertion message

Assisted-By: Claude Code

Summary by CodeRabbit

  • New Features
    • Agent selection now filters sessions, usage, and spending views, with clear notices when session data cannot be scoped.
    • Cost displays identify configured billing units across charts, tables, events, and spend breakdowns. Mixed-unit totals are shown by unit where possible; charts and breakdowns are withheld when a single-unit result cannot be represented.
    • Session and usage details now include agent and billing-unit information where available.
  • Documentation
    • Updated API and command-line documentation to describe agent filtering and billing-unit behavior.

SessionSummary.Agent names the coding agent a session belongs to: the affinity owner that
claimed it (rossoctl#1194), else the first event from a known agent (pipeline.EventClient.AffinityName),
first-wins like Title. The default and pending buckets name none.

/v1/usage takes an optional agent=, the label group=agent reports. A ledger window filters its
rows to that agent before folding, so every grouping stays exact. A ring window reads the agent
axis uncapped and narrows through usage.ScopeToAgent, the same narrowing abctl applies client-side;
it keeps group=currency where the agent billed in one unit and serves any other grouping as none,
saying so in group. Snapshot.Agent echoes the filter only when it was applied, so a client can
tell a narrowed answer from a server that ignored the parameter. Without agent= the response is
unchanged.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
The AGENTS pane's scope now reaches every view that shows money or sessions, not just the usage
pane:

- Sessions lists only the chosen agent's sessions (SessionSummary.Agent); the default and pending
  buckets, which belong to no one agent, are hidden, and a scope no session belongs to says so.
  Unscoped, an AGENT column appears when the listed sessions name two agents.
- The spend band and its drawer ask /v1/usage?agent= and, against a server that does not echo
  the filter, narrow the group=agent series with usage.NarrowToAgent — the server's own narrowing.
  A scope change restarts their polls, so no unscoped figure stays under a scoped title. The
  drawer's axis cycle drops the agent axis while scoped.
- Every pane under the band names the scope in its title.
- The AGENTS pane gains a SESSIONS count once sessions name their agent.
- A pending bucket the server no longer lists is not drawn as a cached session: it was adopted.

Unscoped, the band and drawer request exactly what they did, and a server that names no session
agents leaves the sessions and agents tables unchanged. The help note and README now say what the
scope reaches; TestPaneKeys_TheAgentsNoteSaysWhatTheScopeDoesNotReach pinned the old limitation and
is replaced by one pinning the surfaces it reaches.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
… axis

AgentSnapshot finds an agent the response cap would fold away; a scope hides cached-only rows as
well as other agents' sessions; and a scope set while the drawer sat on the agent axis neither
asks for it nor lists it in the hint's cycle.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 requested a review from a team as a code owner September 30, 2026 18:14
@coderabbitai

coderabbitai Bot commented Sep 30, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: de0d94b5-d233-4075-b142-d86da742309f

📥 Commits

Reviewing files that changed from the base of the PR and between 117d9b0 and b05f157.

📒 Files selected for processing (47)
  • CLAUDE.md
  • cmd/abctl/README.md
  • cmd/abctl/apiclient/client.go
  • cmd/abctl/cmd_cost.go
  • cmd/abctl/cmd_cost_test.go
  • cmd/abctl/cmd_pricing.go
  • cmd/abctl/money/money.go
  • cmd/abctl/money/money_test.go
  • cmd/abctl/tui/agent_scope.go
  • cmd/abctl/tui/agent_scope_test.go
  • cmd/abctl/tui/agents_pane.go
  • cmd/abctl/tui/app.go
  • cmd/abctl/tui/cost_event.go
  • cmd/abctl/tui/events_pane.go
  • cmd/abctl/tui/footer.go
  • cmd/abctl/tui/help_overlay.go
  • cmd/abctl/tui/help_overlay_test.go
  • cmd/abctl/tui/keys.go
  • cmd/abctl/tui/money_units_test.go
  • cmd/abctl/tui/sessions_pane.go
  • cmd/abctl/tui/spend.go
  • cmd/abctl/tui/spend_band.go
  • cmd/abctl/tui/spend_drawer.go
  • cmd/abctl/tui/spend_tiers.go
  • cmd/abctl/tui/usage_pane.go
  • cmd/abctl/tui/usage_render.go
  • cmd/abctl/tui/usage_stacked.go
  • core/cost/event/event.go
  • core/cost/event/event_test.go
  • core/cost/ledger/currency_test.go
  • core/cost/ledger/query.go
  • core/cost/ledger/unit_agreement_test.go
  • core/cost/ledger/writer.go
  • core/cost/pricing/describe.go
  • core/cost/settle/settle.go
  • core/cost/usage/agent_snapshot_test.go
  • core/cost/usage/currency_test.go
  • core/cost/usage/scope.go
  • core/cost/usage/snapshot.go
  • core/cost/usage/snapshot_test.go
  • core/cost/usage/usage.go
  • core/session/agent_test.go
  • core/session/currencies_test.go
  • core/session/store.go
  • core/sessionapi/usage.go
  • core/sessionapi/usage_agent_test.go
  • docs/pricing.md
 ______________________________
< Bugs bunny, at your service. >
 ------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@huang195

Copy link
Copy Markdown
Member Author

Review at fe1ca095: 3 must-fix

All three are in this PR's own commits (ec7dd022, afec2cc3, fe1ca095), not in the #1195 work merged into this branch.

1. Sessions and money are scoped by different keys: cmd/abctl/tui/agent_scope.go:34

The scope is the AGENTS row label, which carries a version (EventClient.Label(), e.g. claude-code/2.1.284). The two sides match it differently:

  • Band and drawer send it as agent=, and the server matches it exactly (ledger.FilterAgent compares label == agent, and so does ScopeToAgent). They show only that version's spend.
  • Sessions list and the new SESSIONS column (sessionListed at sessions_pane.go:390, agentSessionsCell at agents_pane.go:396) compare SessionSummary.Agent against agentScopeName(label). That field is AffinityName(), which has no version, and agentScopeName strips the version from the label.

Claude Code auto-updates, so two versions in one today or 7d window is ordinary. When that happens:

  • each version row's SESSIONS cell counts every claude-code session: two sessions read 2 + 2, and a version with no sessions still shows 2;
  • scoping to claude-code/2.1.284 lists sessions from every version under a title reading agent=claude-code/2.1.284, while the band shows only 2.1.284's spend, so the session costs add up to more than the band.

No test covers two versions of one agent.

Fix: pick one key. Either fold the AGENTS rows by name and scope agent= by name on the server, or carry the full label on SessionSummary and match on that.

2. Enter does nothing in the AGENTS picker: cmd/abctl/tui/agents_pane.go:263

When the column set changes, rebuildAgentsTable calls m.agentsTbl.SetRows(nil) before SetColumns(want). bubbles' SetRows clamps the cursor to len(rows)-1, which is −1 for an empty slice, and the next SetRows(rows) only ever clamps downward. So the cursor stays at −1:

  • selectedAgentLabel() (agents_pane.go:356) returns "";
  • Enter is a no-op, while the footer still offers [↵] scope to this agent.

Nothing in the tree calls agentsTbl.SetCursor or GotoTop to restore it.

The column set changes whenever SESSIONS appears or disappears, and on every resize (keys.go:1356 rebuilds on each layout). Against a server that names agents, that is the default path: first open, or any resize with the pane open. j/k brings the cursor back.

Reproduced in a scratch copy: when an agentRowsLoadedMsg arrives after sessions have loaded, the cursor is left at −1, and Enter leaves the scope "".

TestAgentsPane_EnterRestartsTheBandUnderTheScope passes only because its fixture has no sessions, so SESSIONS never appears.

Fix: read the cursor before the swap, clamp it to len(rows)-1, then call SetCursor, as rebuildSessionsTable does through setCursorVisible. Add a test that loads sessions naming agents, opens the pane and presses Enter.

3. Older servers lose the sessions list: cmd/abctl/tui/sessions_pane.go:390 (with app.go:2206)

sessionListed returns scope == "" || s.Agent == scope. A server that predates SessionSummary.Agent sends no agent field, so every row is filtered out. The pane then shows (no session belongs to claude-code/2.1.284), which is untrue: every one of those sessions may belong to that agent.

The AGENTS pane, and so the scope, still works against those servers. The band and drawer have an explicit older-server fallback that the README describes; the sessions list has none.

Fix: when !m.sessionsNameAgents(), don't filter by agent. Show the list with a hint that this proxy does not name sessions' agents, and add a test for it.


The full review, with 26 further suggestions and nits, is kept locally for the fix round.

Fixes review: sessions were scoped by agent name while the band and drawer used the versioned label (agent_scope.go:34)
Fixes review: Enter did nothing in the AGENTS picker after its columns changed (agents_pane.go:263)
Fixes review: a server that names no session's agent emptied the scoped sessions list (sessions_pane.go:390)
Fixes review: the ring's one-unit currency guard under agent= had no test that could fail (scope.go:193)
Fixes review: the ring filed a one-unit agent's plain and unpriced requests under that unit for group=currency (scope.go:196)
Fixes review: a scoped drawer headed an empty BY NONE column and `a` changed nothing visible (scope.go:192)
Fixes review: SessionSummary.Agent was set for a claimed default bucket (store.go:875)
Fixes review: cached pending rows were hidden with no agent picked (sessions_pane.go:311)
Fixes review: the agent= suffix was appended after the events and detail titles were fitted (app.go:2287)
Fixes review: the AGENT cell rendered a server string unsanitised (sessions_pane.go:277)
Fixes review: agent= was missing from the /v1/usage docs and godoc, and agent from the /v1/sessions field list (docs/pricing.md:184)
Fixes review: FilterAgent's unknown-bucket match and the agent= length refusal had no tests
Fixes review: `a` from the agent index under a scope refetched the axis already shown (spend_drawer.go:390)
Fixes review: the SESSIONS placeholder was an en dash, not emptyCell (agents_pane.go:404)
Fixes review: a stale "NO SESSIONS COLUMN" comment (agents_pane.go:203)
Fixes review: sessionListed and sessionsListTwoAgents sat between cachedOnlySessionIDs' godoc and its function
Fixes review: maxAgentLabelLen was 512 against the 96-byte label cap (usage.go:260)
Files:
- CLAUDE.md
- cmd/abctl/README.md
- cmd/abctl/tui/agent_scope.go
- cmd/abctl/tui/agent_scope_test.go
- cmd/abctl/tui/agents_pane.go
- cmd/abctl/tui/app.go
- cmd/abctl/tui/footer.go
- cmd/abctl/tui/sessions_pane.go
- cmd/abctl/tui/spend.go
- cmd/abctl/tui/spend_drawer.go
- core/cost/usage/agent_snapshot_test.go
- core/cost/usage/scope.go
- core/cost/usage/usage.go
- core/session/agent_test.go
- core/session/store.go
- core/sessionapi/usage.go
- core/sessionapi/usage_agent_test.go
- docs/pricing.md

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>

@mrsabath mrsabath left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Reviewed the three commits you flagged (ec7dd022, afec2cc3, fe1ca095) plus the fix commit b05f157; #1195's money-unit work excluded as asked.

No must-fix issues. Two non-blocking comments inline — one asks for a sentence of rationale, one for a clause in a doc comment.

What I checked and found clean

  • Label agreement across paths, the dual-mode risk here. SessionSummary.Agent, the ring's byAgent key and agent= matching all route through usage.AgentLabel. store.go storing AgentLabel(event.Client) while keying the dedup by AffinityName() is the right split: the owner is matched by name, the wire label is what agent= compares. TestListSessions_AgentIsTheGroupAgentLabel pins it, including a label past MaxLabelLen where truncation could have desynced the two.
  • Ledger/ring parity — ledger.FilterAgent reuses labelFor(r, GroupAgent), so the "" → unknown join is the same string from either producer; covered for both windows.
  • Echo semantics — the unconditional snap.Agent = agent on the ledger path is safe via omitempty, and the absent-key case is asserted rather than assumed.
  • Series cap — AgentSnapshot reads the agent axis uncapped, so an agent past MaxSeriesInResponse is findable.
  • Input bounding — agent= capped at usage.MaxLabelLen, matching the storage cap rather than the 512 an earlier round had; both sides of the boundary tested.
  • Escape sanitisation — the new AGENT cell and every title suffix go through sanitizeLabel, with a test scanning for 0x1b.
  • Older-server degradation — sessions list stays whole with a list not scoped footer, band narrows client-side, drawer names the missing breakdown. Three distinct behaviours, three tests.
  • Column/row arity — columns and rows change together in both rebuild paths, cursor visibility survives the column change, and all three arrival orders plus resize are covered.
  • Commits and docs — all signed off, subjects within length; CLAUDE.md, docs/pricing.md, README and the help overlay all updated. The test pinning the old limitation was replaced rather than deleted, which is the right call.

Notes

  • I could not run the tests. No clone of this repo was available to me, so I reviewed the diff and fetched individual files at b05f157 but executed nothing — your go test / golangci-lint claims are unverified by me. I did confirm every helper the new code references (setCursorVisible, sameColumns, sanitizeLabel, formatCount, clipRow) is defined at the head.
  • Adopted is a new /v1/sessions field doing real work (hiding adopted pending: rows) but isn't in the PR body's Server section — worth a line there, since it's a wire-contract change. CLAUDE.md was updated for it.
  • Your own "Not yet done" list is candid, and I'd single out the same item: the first-wins ordering assumption with session.client_affinity off is the one claim no unit test can settle. Worth exercising by hand against a live proxy before merge.

Comment thread core/cost/usage/scope.go
idle.Buckets = narrowBucketsToAgent(snap.Buckets, agent)
scoped = &idle
}
scoped.UngroupedCostMicros, scoped.UngroupedAvoidedMicros = nil, nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — dropping the window residuals here reverses a documented invariant in this same file, silently.

Thirty lines up, ScopeToAgent carries a long comment block explaining that UngroupedCostMicros and UngroupedAvoidedMicros are deliberately kept: "a residual is a fact about the WINDOW, true whichever agent is scoped to" — the same argument that keeps DaysOutsideRetention — with the disclosure duty handed to the caller.

Clearing them unconditionally here is defensible on the merits: /v1/usage?agent= serves group=none, so there's no series left for a residual to be the complement of, and a client summing per-agent figures against a scoped total finds no shortfall needing explanation. But a future reader comparing the two functions has nothing telling them which behaviour is intentional and which is an oversight.

A sentence in NarrowToAgent's doc — that the residual is meaningless once the grouping is downgraded to none — would settle it.

Worth noting the asymmetry isn't total, and the parts that survive are the right ones: Degraded, DaysOutsideRetention and SeriesOvershootMicros all come through both paths, which is consistent with what their docs claim.

if err != nil || snap.Agent == agent {
return snap, err
}
all, err := client.GetUsageWindow(ctx, window, resolution, "", usage.GroupAgent)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit — the doc comment doesn't mention that the caller's group is necessarily lost on this path.

Asking for GroupAgent is correct and unavoidable: it's the only axis carrying the per-agent series there is to narrow from. The consequence is that a drawer asking for group=model under a scope, against a server predating agent=, gets group=none back, and drawerScopeNote then renders "no model breakdown for one agent in …". That's the right user-visible outcome and TestSpendDrawer_SaysWhenOneAgentHasNoBreakdown pins it.

So purely a readability point: the function's doc says it "narrows the group=agent series itself", which reads as though the requested grouping were preserved. One clause saying the grouping degrades to none would save the next reader the trace through NarrowToAgent.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 merged commit c6ef2e9 into rossoctl:main Sep 30, 2026
25 checks passed
@huang195
huang195 deleted the feat/agent-scope-everywhere branch September 30, 2026 19:52

@esnible esnible left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Summary

The scope plumbing is careful work — the shared fetchUsageScoped fallback covers band, drawer and usage pane (the gap the description flagged as unverified is in fact closed), the Snapshot.Agent echo contract holds on all three producer branches, ledger.FilterAgent keys on the same label the ring folds by, and the cols[:1:1] / r[:1:1] insertions correctly avoid aliasing. Three things block merge: the session→agent label can disagree with the group=agent key for Claude Code's WebFetch client, the scoped drawer renders BY NONE over its own header on every ring-path window, and the new AGENT column reintroduces the TITLE width non-monotonicity growSessionsTitle exists to prevent.

Author: huang195 (MEMBER — maintainer)
Areas reviewed: Go (server: session store, sessionapi, cost/usage, cost/ledger; client: abctl TUI, apiclient), Docs
Agent/IDE config (.claude/.vscode): none
Commits: 20 commits; all signed off except the merge commit 538d2c7 (DCO check passes). Reviewed the four commits after the #1195 merge base, per the description.
CI status: DCO pass, CodeRabbit pending


One correction for the description (not a code issue): it says group=currency for an agent billing in one unit is answerable on the ring. It is not — ringSnapshot drops group entirely when agent != "", and NarrowToAgent sets Group = GroupNone unconditionally. The code, TestHandleUsage_AgentAndGroupCurrency, and docs/pricing.md ("falls back to none for every grouping") all agree with each other; only the PR text is stale. Nothing to fix unless the single-unit case was actually intended, in which case it is missing.

The agent=(other) and agent=unknown spoofability is consistent with the existing overflowLabel godoc, so I am not asking for a change there.

Comment thread core/session/store.go
}
}
if agentName != "" && sessionID != DefaultSessionID && !strings.HasPrefix(sessionID, PendingPrefix) && sess.agentLabel(agentName) == "" {
sess.agents = append(sess.agents, sessionAgent{name: agentName, label: usage.AgentLabel(event.Client)})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

must-fix — sess.agents is keyed by event.Client.AffinityName() but stores usage.AgentLabel(event.Client) (= Label()). Those two disagree for exactly the client AffinityName was added for, as its own godoc says ("WIDER THAN Name, AND ONLY HERE").

For Claude Code's WebFetch UA Claude-User (claude-code/2.1.284; +https://support.anthropic.com/), ParseUserAgent leaves Name == "" (the first token claude-user is not a knownClients key, and trailingKnownClient cuts "(claude-code/2.1.284;" to "(claude-code", which does not match). So:

  • AffinityName() trims "();," → "claude-code"
  • Label() / usage.AgentLabel() → the raw UA string, its own group=agent series key

Because first-wins keys on name, not label:

  1. Claude Code hydrates and claims session abc (owners["abc"] = "claude-code"), entry holding no events yet — which Feat: Keep each coding agent's header-less requests in its own session #1194's doc calls the common case, not a race.
  2. A header-less WebFetch call is routed into abc by SessionForClient("claude-code") and appended first → agents = [{name: "claude-code", label: "Claude-User (claude-code/2.1.284; …)"}].
  3. Every later claude-cli/2.1.284 event hits sess.agentLabel("claude-code") != "" and is skipped.
  4. agentOfLocked takes the owner branch → the raw UA.

The session's Agent is then a label holding only its WebFetch calls, while all its LLM spend is filed under claude-code/2.1.284. sessionListed compares s.Agent == scope, so the session is hidden from the claude-code/2.1.284 scope — the row an operator actually picks, since it is the one with the money. Symmetrically, /v1/usage?agent=Claude-User (claude-code/2.1.284; …) (60 bytes, under the 96-byte bound, so accepted) returns the WebFetch sliver.

This is what the last commit ("fix: Match a session's agent by the label agent= uses") claims to close. Neither new test catches it: TestListSessions_NamesTheSessionsAgent puts the WebFetch client in its own fetch-only session (where the raw label is genuinely right, and the assertion blesses it), and TestListSessions_AgentIsTheGroupAgentLabel gives each of its three clients a separate session — so no test ever puts two labels sharing an AffinityName in one session.

Keying first-wins on the label, or preferring the label whose AffinityName matches the owner and that carries a version, would make the SessionSummary.Agent doc ("as usage.AgentLabel names it, which is the label agent= matches") true on the owner path.

Same root cause, milder symptom: first-wins-on-name also pins claude-code/2.1.284 across a self-update to 2.1.285, so post-upgrade spend lands in a different series and is invisible under the session's scope. That one at least follows the documented rule.


func (m *model) drawerLabels() (usage.Group, string) {
axis, window := m.spend.axis(), spanLabelFor(m.spend.window())
axis, window := m.spendAxis(), spanLabelFor(m.spend.window())

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

must-fix — this line is changed to m.spendAxis(), but the snap.Group override fourteen lines down (line 518) is left untouched, and that override now always fires under a scope.

usage.GroupNone is the literal "none" and Snapshot.Group is tagged json:"group" — not omitempty. A scoped fetch over a duration window takes the ring path (ringSnapshot → AgentSnapshot → NarrowToAgent), and NarrowToAgent sets scoped.Group = GroupNone unconditionally. So the reply always carries "group":"none", sanitizeLabel("none") is non-empty, axis becomes "none", and drawerHeaders renders "BY " + ToUpper("none").

Reproduce: scope to any agent (↵ in the AGENTS pane), then $ to expand the drawer. The default window is spanHour (window: "1h" — a duration, so the ring answers), and the header reads BY NONE:

WHERE IT WENT        BY NONE

Note the asymmetry: on the ledger path (today/7d/month), ledgerSnapshotFor filters rows and keeps snap.Group = applied, so a real per-model series comes back and the header is right. Pressing w from the hour to TODAY therefore silently flips the header between BY NONE and BY MODEL for the same scope — the label tracking which storage backend answered is precisely the mislabel this function's own comment says it exists to prevent.

TestSpendDrawer_SaysWhenOneAgentHasNoBreakdown uses a fixture returning "group":"none" — the exact case — but asserts only strings.Contains(view, "no model breakdown for one agent"), never the header, so BY NONE passes.

Suggest skipping the override when the parsed group is GroupNone (or when snap.Agent != ""), falling back to m.spendAxis() — the axis actually requested, which the new hintAxis/asked plumbing already uses correctly for the hint line but not for the header.


// sessionsColumnsWithAgent is sessionsColumnsFor plus an AGENT column after TITLE when agent is set
// and the column fits without narrowing any other; see rebuildSessionsTable for when it is asked.
func sessionsColumnsWithAgent(termWidth int, agent bool) []table.Column {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

must-fix — the AGENT column reintroduces the TITLE width non-monotonicity growSessionsTitle was written to prevent.

Declared base widths sum to 88 + 8×2 padding = 104 rendered. Adding AGENT at sessionsAgentWidth = 19 (+2) makes 125, and the new fits loop requires that no column shrink, so AGENT appears only at termWidth >= 125. growSessionsTitle runs last and hands all slack to TITLE; it reserves room for COST/SAVED (its comment explains why) but knows nothing about AGENT:

termWidth AGENT TITLE
123 no 30
124 no 31
125 yes 11
130 yes 16
145 yes 31

So with two agents listed at 124 columns showing 31 characters of each path, dragging the terminal one column wider truncates every TITLE to 11 characters — a left-truncated leaf fragment — until 145. That is verbatim the harm documented a few lines down:

"27 to 11 as the terminal got WIDER, a legible path becoming a leaf fragment, which is the harm this column's floor exists to prevent. Column PRESENCE was already monotonic; this is what makes width so."

Fix: reserve AGENT's 21 columns in growSessionsTitle below 125 the same way COST/SAVED are reserved below sessionsShowMoney's threshold.

Related, lower severity: because the threshold is 125, at 120 columns — a common width, and the one two of the new tests use — AGENT never appears no matter how many agents are listed, so two agents' sessions interleave with nothing distinguishing them. The README's "an AGENT column appears only when the listed sessions name two agents" is really "…and the terminal is at least 125 columns", which nothing states.

Comment thread cmd/abctl/tui/app.go
// The band above every pane is scoped too, so every pane says so; usage and agents word it
// themselves above, and sessionHeader carries it for the events and detail panes.
if m.agentScope != "" && m.pane != paneUsage && m.pane != paneAgents && m.pane != paneEvents && m.pane != paneDetail {
title += " · agent=" + sanitizeLabel(m.agentScope)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — this gates on m.agentScope != "" rather than m.sessionsScope() != "".

Against a server that never sets SessionSummary.Agent, the sessions list is deliberately not narrowed (sessionsScope() correctly returns "") but the title still reads abctl · <endpoint> · agent=bob-shell/2.0.5 over the full unfiltered list. The only thing correcting it is the new footer marker, which goes through fitStatusLine and truncates from the right, after [paused] / [filter: …] / [sort: …]: at 80 columns with a restored filter active the 44-column marker is dropped entirely, leaving a title asserting a scope the list does not have and nothing on screen to contradict it.

Suggest gating the paneSessions suffix on m.sessionsScope() (or wording it as scoping the band rather than the list).

Second, smaller point: the negative pane list means the suffix is also appended to paneNamespaces, panePods, panePipeline, panePluginDetail and paneCatalog. The plugin catalog and detail panes have nothing to do with an agent scope, and the namespace/pod pickers render before a client exists, so there is no spend band above them for the "the band is scoped too" justification to apply. An allowlist of the panes that actually sit under the band would opt future panes out by default instead of in.

Comment thread core/cost/usage/scope.go
// rather than an error. The narrowed buckets carry no series, so any grouping is served as none,
// and Group says so.
func NarrowToAgent(snap Snapshot, agent string) Snapshot {
scoped, err := ScopeToAgent(&snap, agent, NarrowBuckets)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — NarrowToAgent cannot distinguish "this agent had no traffic" from "this agent's label was not in the folded series", and collapses both into the zeroed idle snapshot with Agent echoed — i.e. asserting "this is that agent's spend".

On the server path this is safe today: AgentSnapshot reads with maxSeries = math.MaxInt, which TestAgentSnapshot_FindsAnAgentPastTheSeriesCap pins. But NarrowToAgent is exported and the client fallback calls it on a capped response — cmd/abctl/tui/agent_scope.go does GetUsageWindow(…, GroupAgent) (server-capped at MaxSeriesInResponse = 16) and then usage.NarrowToAgent(*all, agent). Against an older server that ignores agent=, a deployment with 17+ agents where the selected one is outside the top 16 by cost gets a zeroed snapshot with Agent set — indistinguishable from idle. It under-reports to zero rather than showing another agent's figures, but it is still a wrong number wearing a correct label.

Same shape at record time: ring[slot].byAgent is bounded by maxLabelsPerBucket = 64, so past 63 distinct agents in one minute the label is replaced by (other) and the uncapped read cannot recover it — again a zeroed echo rather than a disclosure. AgentSnapshot's godoc currently claims unqualified that "an agent past MaxSeriesInResponse is still found", which is worth qualifying.

Consider having NarrowToAgent distinguish the two cases (leave Agent unset, or set a capped-series marker, when (other) is present and the agent is not) so a client can fall back instead of rendering a false zero.

Comment thread core/session/store.go
if id == DefaultSessionID || strings.HasPrefix(id, PendingPrefix) {
return ""
}
if owner := s.owners[id]; owner != "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion — the owner branch is taken whenever owners[id] != "", and returns "" when that owner has sent nothing in. Two ways that surfaces as a session hidden from every scope:

Stale owner entry. pruneOwnersLocked sweeps only once len(s.owners) reaches 2*maxSessions, so an entry can outlive its session's eviction (the targeted deletes on the eviction/rekey paths cover the common cases, but not all of them). If the id is later reused: Bob claims abc → abc is evicted → Claude Code creates abc and sends into it → agentOfLocked takes the owner branch, agentLabel("bob-shell") is "", and the session reports no agent even though sess.agents[0] names it correctly. It is then hidden under every scope and gets no AGENT cell.

Just-adopted session. adoptLocked renames the pending:<client> entry into the target id, but every event that accumulated under the pending id was skipped by the prefix guard, so the renamed entry's agents is empty while adoptedByLocked still lists the bucket. ListSessions can return {id: "task-1", adopted: ["pending:bob-shell"], agent: ""} — hidden from the bob-shell/2.0.5 scope while the spend it just absorbed is attributed to that agent in /v1/usage. Transient (the next headered append fixes it), but it is exactly the window in which the TUI refetches after an adoption.

Falling through to agents[0] when agentLabel(owner) == "" would cover both, and would also make a claimed-but-silent session visible under the agent whose traffic is actually in it.

(The Rekey(DefaultSessionID, …) path on A2A traffic has the same "the exclusion guard outlives the exclusion" shape, but coding-agent traffic does not normally take it — not worth blocking on.)

func (m *model) cycleSpendAxis() tea.Cmd {
m.spend.groupIdx = (m.spend.groupIdx + 1) % len(spendDrawerAxes)
axes := m.spendAxes()
next := axes[(slices.Index(axes, m.spendAxis())+1)%len(axes)]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit — axes[(slices.Index(axes, m.spendAxis())+1)%len(axes)] divides by len(axes), and spendAxes() filters GroupAgent out, so if spendDrawerAxes were ever reduced to just {GroupAgent} this panics on % 0 (and spendAxis()'s spendDrawerAxes[0] would be wrong too). Not reachable today — three axes with GroupAgent last, and I confirmed slices.Index cannot return −1 because spendAxis() maps the agent axis to GroupModel, which spendAxes() always contains. A len(axes) == 0 guard or an invariant comment would match how this file already treats its other unreachable-but-guarded cases (e.g. the i < len(tiers) guard).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants