Skip to content

feat(desktop): add Agent Usage UI backed by the NIP-AM local archive - #2035

Open
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/agent-usage-archive
Open

feat(desktop): add Agent Usage UI backed by the NIP-AM local archive#2035
wpfleger96 wants to merge 1 commit into
mainfrom
duncan/agent-usage-archive

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Jul 17, 2026

Copy link
Copy Markdown
Member

Adds a rolling token/cost usage view for agents, sourced from the local NIP-AM metrics archive. Covers both the Agents overview and a focused per-agent subview in the profile panel, with the per-agent breakdown grouped by (harness, model) so the same model running under two different harnesses (e.g. claude-sonnet-4-5 via goose vs claude-code) renders as two distinct rows.

Backend

  • get_agent_usage_series Tauri command (archive/agent_usage.rs): reads the local SQLite archive over a caller-supplied window of local-midnight bucket boundaries, returns per-agent token and cost totals broken down by (harness, model), with partial/unknown-field flags when evidence is incomplete. Sort tiebreak: harness ascending → model ascending, None last in each.
  • Boundary arity is a bounded range rather than a fixed pair: MIN_BOUNDARIES = 2 (the single-bucket 1d case) through MAX_BOUNDARIES = 367 (366 daily buckets, one leap year). The frontend picker clamps to the same span, so this fail-closed check is defense in depth and never the UX error path. MAX_INTERVAL_SECS still bounds each individual bucket at 48h.
  • agent_metric_index: nullable harness TEXT column, parsed from AgentTurnMetricPayload.harness and written/read through all store paths. Schema migration M1 runs as a single SQLite transaction — schema-guarded ALTER TABLE, full index rebuild from the canonical archived_events store (ingest and backfill share the same from_payload parser), completion marker written last — so a crash mid-migration can never leave a half-built index marked complete.
  • Wired persistedAgentMetrics notifier through the archive sync path so new metric events are picked up without a full resync.

Frontend

  • AgentUsageSection (features/agent-usage/ui/): ranked agent list in AgentsView, rendered below the agent cards and the teams section. Row click-through opens the profile panel focused on usage.
  • AgentUsageDailyBars: each bar carries its date on the x-axis and its compact token total above it, plus a per-bar hover tooltip with the exact total/input/output split. Each tooltip field reports "unknown" independently rather than collapsing an uncounted value to zero. Above 14 buckets the chart drops on-bar value text and thins date ticks instead of overflowing; the tooltip stays complete.
  • AgentUsageRangeTabs: 1d/7d/30d presets plus a Custom tab whose popover takes an arbitrary inclusive start/end date pair. Selecting Custom opens the picker without moving the active range, so an in-progress edit never issues a query. Validation is local — the user sees "Pick a range of 366 days or fewer", not a backend arity error.
  • Custom endpoints are civil dates parsed to local midnight and walked by distinct local midnights, so DST transitions and skipped civil dates cannot produce duplicate or non-increasing bucket boundaries.
  • AgentUsageFocusedView: per-agent totals with a by-(harness, model) breakdown, rendered as a focused profile-panel subview (same pattern as Memories/Diagnostics) rather than a new tab. Each breakdown row shows the harness as a dimmed sub-label next to the model name; null harness (pre-migration or unknown data) renders no label, so single-harness data is visually unchanged.
  • UserProfilePanel/UserProfilePanelSections/UserProfilePanelTabs: threaded canViewUsage/onOpenUsage (owner-only, bot profiles only) and added a BarChart3 ingress row in the Info tab.
  • agentUsage.ts sortModelsByKnownTotal: ordinal compound (harness, model) tiebreak matching the Rust ordering (identifier domain is ASCII; documented).
  • Loading skeleton, query-error retry, empty state, and a collection-off banner (with retained-data coverage copy when historical data still exists) are all covered.

Agent-side first-turn accounting

UsageTracker::seed_zero_baseline() (crates/buzz-acp/src/usage.rs) seeds a zero baseline for sessions this process just spawned, so the first turn of a fresh session reports reliable token deltas instead of failing closed. Sessions the process re-attaches to are deliberately not seeded — their true prior cumulative is unknown, and fabricating a baseline there would misattribute another process's tokens to the first observed turn.

last_cost seeds to Some(0.0) so a first-turn cost delta computes against a real zero; last_total stays None because NIP-AM forbids fabricating a total baseline for providers that never report one. The call sites in pool.rs sit immediately after create_session_and_apply_model() on both the channel and heartbeat paths, keyed off is_new_session — the only authoritative spawned-vs-attached signal. Seeding is idempotent via entry().or_insert().

Tests

  • Rust: unit tests for bucket-boundary math, boundary-arity acceptance and rejection at both edges of the supported range, cost-ladder aggregation (direct/cumulative/decrease-taint), ranking tie-breaks (including harness), and the same-model/two-harness collapse fix; integration tests exercising the command against a real SQLite archive (fresh ingest, pubkey filtering, pre-existing-row backfill); store_migration_tests.rs covering M1 through the real open_archive_db path (legacy-file backfill, idempotent reopen, crash-before-commit recovery).
  • agentUsage.test.mjs / hooks.test.mjs: pure-helper unit tests for formatting, partial/unknown-field detection, compound-key sorting, custom-range validation, and local-midnight boundary construction across DST and skipped civil dates.
  • tests/e2e/agent-usage.spec.ts: loading → resolved, preset and custom window switching, both click-through paths into the focused view, dated axis labels and on-bar values, tooltip contents (including unknown fields as unknown, not zero), error/retry, empty state, collection-off banner, partial badge rendering in row/bar/ingress for incomplete-provenance totals, focused-view unknown-intervals and invalid-reports caveats under their independent gate conditions, invalid-only window rendering the uncountable state in both overview and focused view (distinguished from ordinary-empty and outside-window), harness sub-label rendering, and the usage section's position below the agents and teams sections.
  • crates/buzz-acp/src/usage.rs: regression tests covering the spawned first turn reporting reliable deltas, the re-attached first turn staying fail-closed, both paths going reliable by the second turn, every wire field on the first-turn payload, and the seed being a no-op when a baseline already exists.

@wpfleger96
wpfleger96 requested a review from a team as a code owner July 17, 2026 17:08
@wpfleger96
wpfleger96 marked this pull request as draft July 17, 2026 17:10
@wpfleger96
wpfleger96 force-pushed the duncan/agent-usage-archive branch 4 times, most recently from b213a00 to 122b043 Compare July 21, 2026 08:04
wpfleger96 pushed a commit that referenced this pull request Jul 21, 2026
wpfleger96 pushed a commit that referenced this pull request Jul 23, 2026
wpfleger96 added a commit that referenced this pull request Jul 23, 2026
Four Playwright tests covering the new Agent Usage UI surfaces:
01 overview card (default/collapsed), 02 focused usage subview,
03 multi-day bars chart, 04 empty state (collection on, no data).

Also adds agent-usage-screenshots.spec.ts to the smoke project
testMatch so CI can discover and run it alongside the other
screenshot specs.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wpfleger96 added a commit that referenced this pull request Jul 25, 2026
Stack: #2035 → this PR

## What

Group the per-agent model breakdown by `(harness, model)` instead of
`model` alone, so the same model running under two different harnesses
(e.g. `claude-sonnet` via `goose` vs `claude-code`) produces two
distinct rows rather than collapsing into one.

## Changes

**Rust / SQLite**
- `agent_metric_index`: add nullable `harness TEXT` column. Schema
migration M1: `ALTER TABLE ... ADD COLUMN` + delete-then-backfill
rebuild so all existing rows get `harness` populated from retained
`archived_events.raw_json` — no data loss; ingest and backfill share the
same `from_payload` parser (frozen-plan requirement preserved).
- `AgentMetricIndexRow`: parse `harness` from
`AgentTurnMetricPayload.harness` (REQUIRED field per NIP-AM), write/read
through all store paths (`ROW_COLUMNS`, INSERT, `row_from_sql`).
- `AgentScope.models` grouping key widened from `Option<String>` to
`(Option<String>, Option<String>)` i.e. `(harness, model)`. Sort
tiebreak: harness ascending → model ascending, `None` last in each.
- `ModelUsage` wire type: add `harness: Option<String>` field.

**Frontend**
- `tauriArchive.ts` / `bridge.ts`: add `harness` to `AgentUsageModel`
type.
- `AgentUsageFocusedView`: render `harness` as a dimmed sub-label next
to each model name on breakdown rows. `null` harness (pre-migration data
or unknown) renders no label — single-harness data is visually
unchanged.
- `agentUsage.ts` `sortModelsByKnownTotal`: tiebreak updated for
compound `(harness, model)` key.

**Tests**
- Rust: collapse-fix test (same model / two harnesses → two rows),
migration test (old-shape rows get harness populated after rebuild),
harness sort coverage.
- TS: `sortModelsByKnownTotal` harness tiebreak tests, same-model
two-harness sort test.
- E2E: `bridge.ts` fixture type updated, `agent-usage.spec.ts` harness
assertion, `agent-usage-screenshots.spec.ts` shot 02 harness-label
visibility check.

## Gates

All green locally:
- `just desktop-check` ✓
- `just desktop-typecheck` ✓
- `just desktop-build` ✓
- `just desktop-test` (3487 pass, 0 fail) ✓
- `cargo test` in `desktop/src-tauri` (1575 pass, 0 fail) ✓

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Jul 25, 2026
@wpfleger96
wpfleger96 force-pushed the duncan/agent-usage-archive branch from 0d52530 to 0a5b070 Compare July 25, 2026 20:43
wpfleger96 added a commit that referenced this pull request Jul 29, 2026
…ain (#3593)

## What

Wires genuine provider-reported `total_tokens` through the full
buzz-agent → buzz-acp publish chain so kind-44200 events carry real
per-turn and cumulative totals for OpenAI-backed models, while
preserving all existing behaviour for Anthropic and external harnesses
(goose, claude-code).

## Why

Live prod data showed 0 of 1,934 archived reports carry `totalTokens`.
Both hardcoded `total_tokens: None` in `pool.rs` and the absent field in
`buzz-agent`'s parser are root causes. This is the backend half of a
two-track fix; the display-fallback half lands in
[#2035](#2035).

## Changes

**`crates/buzz-agent/src/types.rs`**
- Added `total_tokens: Option<u64>` to `LlmResponse` with an explicit
doc comment that NIP-AM forbids deriving it.
- Added `TurnTotalState` enum (`Unseen | Exact(u64) | Unknown`) with
`fold()` and `exact_value()` — the tri-state accumulator that
distinguishes not-yet-observed from permanently poisoned.

**`crates/buzz-agent/src/llm.rs`**
- `parse_responses` and `parse_openai`: read `usage.total_tokens` from
OpenAI Chat Completions (including Databricks routes) and the Responses
API via `sum_usage`.
- Anthropic: explicit `total_tokens: None` — no genuine total available;
NIP-AM forbids summing categories.

**`crates/buzz-agent/src/agent.rs`**
- Added `turn_total_state: &'a mut TurnTotalState` to `RunCtx`.
- Fold `response.total_tokens` into the accumulator after each
usage-bearing response; non-usage-bearing responses (keepalive/stream
frames) do not poison.

**`crates/buzz-agent/src/lib.rs`**
- Added `accumulated_total_state: TurnTotalState` to `Session` (default
`Unseen`).
- Per-turn state passed to `RunCtx`, folded into session cumulative
after each turn.
- Emits `accumulatedTotalTokens` in `usage_update` only when cumulative
is `Exact(n)`.

**`crates/buzz-acp/src/usage.rs`**
- Added `accumulated_total_tokens: Option<u64>` (serde default) to
`UsageUpdatePayload` — optional for goose compat.
- Added `last_total: Option<u64>` to `SessionState`.
- Added `turn_total_tokens` and `cumulative_total_tokens` to `TurnUsage`
(field-local — never affect `delta_reliable`).
- Derive turn-total delta only when prev and current are both `Some` and
monotonic; absence, decrease, or no baseline leaves only the total delta
null without touching input/output reliability.

**`crates/buzz-acp/src/pool.rs`**
- Replaced both hardcoded `total_tokens: None` in
`publish_agent_turn_metric` with `usage.turn_total_tokens` and
`usage.cumulative_total_tokens`.

## Tests

20 new tests across the four touched files:

| File | Tests |
|------|-------|
| `types.rs` | `TurnTotalState` fold, accumulation, exact_value, default
(7 tests) |
| `llm.rs` | Chat present/absent, Responses present/absent, Anthropic
always-None (5 tests) |
| `usage.rs` | First turn no baseline, second-turn delta, cumulative
decrease (field-local), current absent, goose-shaped deserialization,
baseline absent (6 tests) |
| `pool.rs` | Exact turn+cumulative mapping, null totals never derived
(2 tests) |

`cargo test -p buzz-acp -p buzz-agent` — all passing, 0 failures.

## Scope

Boundary: `crates/buzz-agent/**` + `crates/buzz-acp/**` only. Desktop
unchanged.
`costUsd` explicitly out of scope.

Related: [#2035](#2035)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
wpfleger96 pushed a commit that referenced this pull request Jul 30, 2026
@wpfleger96

Copy link
Copy Markdown
Member Author

Screenshots for the dated-bars chart, the range filter, and the nav reorder.

Overview usage section

The default collapsed card on the Agents page: daily bars plus the per-agent totals row.
01-overview-usage-section

Focused usage view

Per-agent drilldown in the profile panel, with the model/harness breakdown.
02-focused-usage-view

Dated daily bars

Each bar now carries its date on the x-axis and its compact token total above it; a zero-token day renders a labeled 0 and a day with no reports renders no bar at all.
03-daily-bars-multi-day

Empty state

Collection is on but nothing has been archived in the window yet.
04-empty-state

Bar hover tooltip

Hovering a bar shows the exact total/input/output split for that day. Each field reports "unknown" independently rather than collapsing an uncounted value to zero.
05-bar-hover-tooltip

Custom range picker

The Custom tab opens an inclusive start/end date picker. The range is capped at 366 days in the picker itself, so the backend's fail-closed arity check is never the UX error path.
06-custom-range-picker

Usage section last in the nav order

The usage section now renders below the agent cards and the teams section.
07-usage-section-last-in-nav

@wpfleger96
wpfleger96 marked this pull request as ready for review July 30, 2026 21:02
@wpfleger96
wpfleger96 force-pushed the duncan/agent-usage-archive branch from 7855777 to 41b77f3 Compare July 31, 2026 15:43
wpfleger96 added a commit that referenced this pull request Jul 31, 2026
Add the NIP-AM agent-usage archive backend (query engine, SQLite
migrations, ingestion pipeline, buzz-acp first-turn fix) from
PR #2035. This is the backend-only base of a stacked split; the
UI lives in the companion stack PR.

Backend surface:
- `desktop/src-tauri/src/archive/`: archive query engine, metric
  index store, store migrations, pipeline integration, and full
  Rust test suite (agent_usage, metric_store, store, migration tests)
- `desktop/src/shared/api/tauriArchive.ts`: wire types + Tauri
  command bridge (`get_agent_usage_series`)
- `desktop/src/features/local-archive/archiveSyncManager.ts`:
  `persistedAgentMetrics` notifier wired to the archiveSyncManager
- `crates/buzz-acp/`: first-turn baseline fix (Task E from #2035)

Cache-read schema addition (M2 migration):
Adds `turn_cache_read_tokens` and `cumulative_cache_read_tokens`
columns to `agent_metric_index` via a new one-shot additive migration
(M2). These columns persist the optional NIP-AM `cacheReadTokens`
fields so that Hayt's Phase-0 cache-threading events (crates/buzz-acp,
separate PR) start being persisted the day both PRs merge. All
pre-migration rows receive NULL (fail-closed). M2 runs before M1 in
the migration chain so the M1 rebuild always operates against the
full schema. Three M2 migration tests: fresh DB, post-M1 upgrade, and
idempotency.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Adds a rolling token/cost usage view for agents, sourced from the local
NIP-AM metrics archive. Covers the Agents overview and a focused
per-agent subview in the profile panel, with the per-agent breakdown
grouped by (harness, model) so the same model under two harnesses
renders as two distinct rows.

Backend:
- get_agent_usage_series Tauri command (archive/agent_usage.rs): reads
  the local SQLite archive over a sliding 7/30-day window, aggregates
  by (agent_pubkey, session_id, harness, model), and returns a
  serialized AgentUsageSeries response.
- archive/metric_store.rs: MetricIndexRow + insert_metric_index_row
  with turn/cumulative token + cost + cache-read fields; query layer
  produces AgentMetricRow slices.
- store_migrations.rs: M1 (harness column + index rebuild) and M2
  (cache-read columns: turn_cache_read_tokens,
  cumulative_cache_read_tokens). M2 runs before M1 so the rebuild
  always operates against the full schema. All migrations are
  idempotent via marker guard.
- archive/mod.rs: pipeline integration — AgentMetricRow ingestion,
  persistedAgentMetrics notifier, archiveSyncManager wiring.
- tauriArchive.ts: wire types + Tauri command bridge.
- archiveSyncManager.ts: persistedAgentMetrics notifier.

Agent-side first-turn accounting (buzz-acp):
- UsageTracker::seed_zero_baseline() seeds a zero baseline for
  sessions this process just spawned, so the first turn of a fresh
  session reports reliable deltas. Re-attached sessions are not seeded;
  their true prior cumulative is unknown. Seeding is idempotent via
  entry().or_insert(). acp.rs call sites are immediately after
  create_session_and_apply_model() on both channel and heartbeat paths.

UI:
- desktop/src/features/agent-usage/: AgentUsageDailyBars (recharts),
  AgentUsageRangeTabs (7/30-day preset + custom), AgentUsageFocusedView
  (per-agent breakdown by harness/model), AgentUsageSection (wires
  overview + focused view). hooks.ts: data fetch and memoised series.
  lib/agentUsage.ts: bucket math, formatter, caveat detection,
  DisplayTotal, sortModelsByKnownTotal.
- Profile panel: canViewUsage/onOpenUsage threaded; BarChart3 ingress
  row in the Info tab. AgentsView/AgentsScreen reordered.
- ProfilePanelContext: usage navigation state.
- e2eBridge.ts + tests/helpers/bridge.ts: get_agent_usage_series mock
  handler + fixture types.
- agent-usage.spec.ts: 19-test Playwright spec (loading, empty, bars,
  range switch, error/retry, focused view, caveats, cache-invalidation).
- agent-usage-screenshots.spec.ts: 3-test screenshot spec.
- playwright.config.ts: agent-usage specs added to suite.
- package.json: recharts dependency.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
@wpfleger96
wpfleger96 force-pushed the duncan/agent-usage-archive branch from 3d6c9c4 to 4d18b86 Compare August 1, 2026 00:23
calvadev pushed a commit to shopstr-eng/buzz that referenced this pull request Aug 3, 2026
…ain (block#3593)

## What

Wires genuine provider-reported `total_tokens` through the full
buzz-agent → buzz-acp publish chain so kind-44200 events carry real
per-turn and cumulative totals for OpenAI-backed models, while
preserving all existing behaviour for Anthropic and external harnesses
(goose, claude-code).

## Why

Live prod data showed 0 of 1,934 archived reports carry `totalTokens`.
Both hardcoded `total_tokens: None` in `pool.rs` and the absent field in
`buzz-agent`'s parser are root causes. This is the backend half of a
two-track fix; the display-fallback half lands in
[block#2035](block#2035).

## Changes

**`crates/buzz-agent/src/types.rs`**
- Added `total_tokens: Option<u64>` to `LlmResponse` with an explicit
doc comment that NIP-AM forbids deriving it.
- Added `TurnTotalState` enum (`Unseen | Exact(u64) | Unknown`) with
`fold()` and `exact_value()` — the tri-state accumulator that
distinguishes not-yet-observed from permanently poisoned.

**`crates/buzz-agent/src/llm.rs`**
- `parse_responses` and `parse_openai`: read `usage.total_tokens` from
OpenAI Chat Completions (including Databricks routes) and the Responses
API via `sum_usage`.
- Anthropic: explicit `total_tokens: None` — no genuine total available;
NIP-AM forbids summing categories.

**`crates/buzz-agent/src/agent.rs`**
- Added `turn_total_state: &'a mut TurnTotalState` to `RunCtx`.
- Fold `response.total_tokens` into the accumulator after each
usage-bearing response; non-usage-bearing responses (keepalive/stream
frames) do not poison.

**`crates/buzz-agent/src/lib.rs`**
- Added `accumulated_total_state: TurnTotalState` to `Session` (default
`Unseen`).
- Per-turn state passed to `RunCtx`, folded into session cumulative
after each turn.
- Emits `accumulatedTotalTokens` in `usage_update` only when cumulative
is `Exact(n)`.

**`crates/buzz-acp/src/usage.rs`**
- Added `accumulated_total_tokens: Option<u64>` (serde default) to
`UsageUpdatePayload` — optional for goose compat.
- Added `last_total: Option<u64>` to `SessionState`.
- Added `turn_total_tokens` and `cumulative_total_tokens` to `TurnUsage`
(field-local — never affect `delta_reliable`).
- Derive turn-total delta only when prev and current are both `Some` and
monotonic; absence, decrease, or no baseline leaves only the total delta
null without touching input/output reliability.

**`crates/buzz-acp/src/pool.rs`**
- Replaced both hardcoded `total_tokens: None` in
`publish_agent_turn_metric` with `usage.turn_total_tokens` and
`usage.cumulative_total_tokens`.

## Tests

20 new tests across the four touched files:

| File | Tests |
|------|-------|
| `types.rs` | `TurnTotalState` fold, accumulation, exact_value, default
(7 tests) |
| `llm.rs` | Chat present/absent, Responses present/absent, Anthropic
always-None (5 tests) |
| `usage.rs` | First turn no baseline, second-turn delta, cumulative
decrease (field-local), current absent, goose-shaped deserialization,
baseline absent (6 tests) |
| `pool.rs` | Exact turn+cumulative mapping, null totals never derived
(2 tests) |

`cargo test -p buzz-acp -p buzz-agent` — all passing, 0 failures.

## Scope

Boundary: `crates/buzz-agent/**` + `crates/buzz-acp/**` only. Desktop
unchanged.
`costUsd` explicitly out of scope.

Related: [block#2035](block#2035)

---------

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant