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

Surfaces per-agent NIP-AM turn-metric usage (tokens, cost, model
breakdown, 7d/30d bucket series) in the desktop app, reading from the
Phase 2 get_agent_usage_series archive command:

- agent-usage/lib/agentUsage.ts: pure client-side helpers -- bucket
  boundary construction, bigint-safe token formatting, and the ranked
  overview-row projection shared by both UI surfaces.
- agent-usage/hooks.ts: react-query wrapper around
  get_agent_usage_series with the app's standard retry:1 default.
- agent-usage/ui/AgentUsageSection.tsx: ranked per-agent usage rows on
  the Agents overview, with a 7d/30d window switch, retry-on-error,
  and a collection-off banner (with or without retained-data coverage
  copy) linking to Local Archive settings.
- agent-usage/ui/AgentUsageFocusedView.tsx: per-agent drill-down
  (bucketed series + model breakdown) reached via an overview row or
  the profile panel's Info-tab usage ingress row; wired into
  AgentsView, UserProfilePanel, and ProfilePanelContext.
- Partial badge on rows whose total is a known lower bound
  (has_unknown_usage), surfaced via the accounting engine's per-field
  completeness contract.

Extends the Rust archive test suite (agent_usage_tests.rs) to close
gaps in the pure accounting engine: the f64 cost ladder had no direct
outcome.cost assertions, assign_bucket_index's start-inclusive/
end-exclusive edges were only exercised indirectly, validate_request's
chrono finite-range rejection was unexercised, and the A2 ranking
tiebreak (pubkey ascending on an exact totalTokens tie) had no test
distinguishing it from insertion order.

tests/e2e/agent-usage.spec.ts (wired into playwright.config.ts's smoke
project) exercises both UI surfaces against a mocked
get_agent_usage_series: ranked rows, window switching, click-through
to the focused view from both the overview row and the profile
panel's ingress row, retry recovery from a query error, the
collection-off banner and its settings deep link, and the Partial
badge.

Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-07-20 23:28:50 -07:00
co-authored by Will Pfleger
parent 4f41319220
commit a073cc8b84
22 changed files with 3332 additions and 4 deletions
+1
View File
@@ -114,6 +114,7 @@ export default defineConfig({
"**/inbox-live-update.spec.ts",
"**/mesh-compute.spec.ts",
"**/observer-archive-policy.spec.ts",
"**/agent-usage.spec.ts",
],
use: {
...devices["Desktop Chrome"],
+11 -1
View File
@@ -347,7 +347,10 @@ const overrides = new Map([
["src/features/profile/ui/UserProfilePanelSections.tsx", 1140],
// +14 for openEditAgent event subscription (config-nudge card "Open Edit Agent" action).
// +11 for editAgentFocus state + initialFocus prop threading (deep-link granularity).
["src/features/profile/ui/UserProfilePanel.tsx", 1025],
// +1 for A13 fail-closed canViewUsage wiring + AgentUsageFocusedView mount.
// +14 for query-derived usageIngressTrailing summary threaded into the
// Info-tab ingress row (replaces the hardcoded "View" label, plan:328).
["src/features/profile/ui/UserProfilePanel.tsx", 1040],
// PersistBackend enum + marker-on-keyring-success plumbing and its three
// fail-closed regression tests (silent identity rotation on keyring outage).
// A small overage from load-bearing security plumbing on a file already at
@@ -524,6 +527,13 @@ const overrides = new Map([
// runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix);
// hideProviderIds computation for Databricks v1 gate. Queued to split.
["src/features/agents/ui/AgentDefinitionDialog.tsx", 1035],
// agent-usage-archive (Rev 3): Phase 2's single invoke_handler registration
// line for get_agent_usage_series (826d79221) pre-existed under the prior
// 1000-line ceiling; rebasing this branch onto a later main (which grew
// lib.rs by 2 lines upstream, unrelated to this feature) tipped the file
// to 1001. Not generic debt growth from this PR — one line, pre-existing.
// Queued to split with the rest of this list.
["src-tauri/src/lib.rs", 1001],
]);
await runFileSizeCheck({
@@ -176,6 +176,59 @@ fn decrease_taints_only_the_affected_field() {
assert!(matches!(outcome.output, FieldValue::Known(50)));
}
// ── Cost ladder (mirrors the token ladder tests above, f64-specific) ───────
#[test]
fn ladder_cost_direct_value_used_when_no_baseline() {
let r = AgentMetricIndexRow {
turn_cost_usd: Some(0.05),
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 1, 0)
};
let __probe_rows = [r.clone()];
let probes = probe_map(&__probe_rows);
let outcome = compute_event_outcome(&r, &probes);
assert!(matches!(outcome.cost, FieldValue::Known(v) if v == 0.05));
}
#[test]
fn ladder_cost_adjacent_cumulative_preferred_over_direct() {
let prev = AgentMetricIndexRow {
cumulative_cost_usd: Some(1.0),
..row("e0", "agent1", "s1", 1, 0)
};
let cur = AgentMetricIndexRow {
cumulative_cost_usd: Some(1.3),
turn_cost_usd: Some(999.0), // deliberately wrong direct value
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 2, 10)
};
let __probe_rows = [prev, cur.clone()];
let probes = probe_map(&__probe_rows);
let outcome = compute_event_outcome(&cur, &probes);
assert!(matches!(outcome.cost, FieldValue::Known(v) if (v - 0.3).abs() < f64::EPSILON));
}
#[test]
fn ladder_cost_adjacent_decrease_is_unknown_not_direct() {
// A1 applies identically to the cost field: a decreasing cumulative
// pair is terminal-unknown even with a present direct value.
let prev = AgentMetricIndexRow {
cumulative_cost_usd: Some(5.0),
..row("e0", "agent1", "s1", 1, 0)
};
let cur = AgentMetricIndexRow {
cumulative_cost_usd: Some(3.0), // decreased
turn_cost_usd: Some(1.0), // present, but must NOT be used
delta_reliable: Some(true),
..row("e1", "agent1", "s1", 2, 10)
};
let __probe_rows = [prev, cur.clone()];
let probes = probe_map(&__probe_rows);
let outcome = compute_event_outcome(&cur, &probes);
assert!(matches!(outcome.cost, FieldValue::Unknown));
}
// ── A4/A11: duplicate sequence quarantine ───────────────────────────────────
#[test]
@@ -395,6 +448,24 @@ fn window_probe_keys_skips_rows_without_session_or_seq() {
assert!(keys.is_empty());
}
// ── assign_bucket_index: boundary edges ─────────────────────────────────────
#[test]
fn assign_bucket_index_start_is_inclusive_end_is_exclusive() {
let boundaries = boundaries_7();
// Exactly on bucket 1's start boundary → bucket 1, not bucket 0.
assert_eq!(assign_bucket_index(&boundaries, DAY), Some(1));
// One second before bucket 1's start → still bucket 0 (end-exclusive).
assert_eq!(assign_bucket_index(&boundaries, DAY - 1), Some(0));
}
#[test]
fn assign_bucket_index_returns_none_outside_every_bucket() {
let boundaries = boundaries_7();
assert_eq!(assign_bucket_index(&boundaries, -1), None);
assert_eq!(assign_bucket_index(&boundaries, boundaries[7]), None); // last boundary is exclusive end
}
// ── compute_series: bucketing, overflow, ranking, models ────────────────────
#[test]
@@ -477,6 +548,27 @@ fn compute_series_ranks_known_total_before_unknown_total() {
assert_eq!(series.agents[1].agent_pubkey, "agent_unknown");
}
#[test]
fn compute_series_ties_on_known_total_break_by_pubkey_ascending() {
// Equal totalTokens for two agents: the A2 tiebreak must be
// deterministic pubkey order, not insertion/hash order.
let agent_z = AgentMetricIndexRow {
turn_total_tokens: Some(100),
delta_reliable: Some(true),
..row("e1", "agent_zzz", "s1", 1, 0)
};
let agent_a = AgentMetricIndexRow {
turn_total_tokens: Some(100),
delta_reliable: Some(true),
..row("e2", "agent_aaa", "s1", 1, 0)
};
let boundaries = boundaries_7();
let rows = vec![agent_z, agent_a];
let series = compute_series(&rows, &rows, 0, &boundaries, None, true);
assert_eq!(series.agents[0].agent_pubkey, "agent_aaa");
assert_eq!(series.agents[1].agent_pubkey, "agent_zzz");
}
#[test]
fn compute_series_model_breakdown_attributes_per_event_model() {
let r1 = AgentMetricIndexRow {
@@ -567,6 +659,22 @@ fn validate_request_rejects_interval_over_48h() {
assert!(validate_request(&req(b, None)).is_err());
}
#[test]
fn validate_request_rejects_boundary_out_of_chrono_representable_range() {
// All 7 intervals stay well within the 48h band (exactly one day each)
// so only the finite-range check can be responsible for the rejection;
// the window is shifted to straddle chrono's actual max representable
// instant rather than a hardcoded guess.
let max_ts = chrono::DateTime::<chrono::Utc>::MAX_UTC.timestamp();
let base = max_ts - 6 * DAY;
let b: Vec<i64> = (0..=7).map(|i| base + i * DAY).collect();
assert!(
b[7] > max_ts,
"test setup must push the last boundary out of range"
);
assert!(validate_request(&req(b, None)).is_err());
}
#[test]
fn validate_request_accepts_30_minute_dst_interval() {
// Lord Howe Island: 30-minute DST offset. A day-boundary pair differing
@@ -0,0 +1,283 @@
/**
* Fake-timer proof for the M4-frozen midnight-rollover contract:
* `useLocalDayBoundaries` schedules exactly one `setTimeout` per local
* midnight, and each fire both rebuilds the boundary set (so the query key
* changes) AND reschedules the next fire — never `setInterval`, which would
* drift across DST.
*
* Uses the same minimal DOM shim + `react-dom/client` `createRoot`/`act`
* harness pattern as `useAnchoredScroll.lifecycle.test.mjs`, combined with
* `node:test`'s `mock.timers` (as used in `activeAgentTurnsStore.test.mjs`)
* to drive the wall clock deterministically across the rollover boundary.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { mock } from "node:test";
function installDOMShim() {
class EventTargetShim {
constructor() {
this.listeners = new Map();
}
addEventListener(type, listener) {
this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]);
}
removeEventListener(type, listener) {
this.listeners.set(
type,
(this.listeners.get(type) ?? []).filter(
(current) => current !== listener,
),
);
}
dispatchEvent(event) {
for (const listener of this.listeners.get(event.type) ?? [])
listener(event);
return true;
}
}
class NodeShim extends EventTargetShim {
constructor(tagName) {
super();
this.tagName = tagName;
this.nodeName = tagName.toUpperCase();
this.nodeType = 1;
this.namespaceURI = "http://www.w3.org/1999/xhtml";
this.children = [];
this.childNodes = [];
this.style = {};
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children.at(-1) ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((current) => current !== child);
this.childNodes = this.childNodes.filter((current) => current !== child);
child.parentNode = null;
return child;
}
insertBefore(child, reference) {
if (!reference) return this.appendChild(child);
const index = this.children.indexOf(reference);
if (index < 0) return this.appendChild(child);
this.children.splice(index, 0, child);
this.childNodes.splice(index, 0, child);
child.parentNode = this;
return child;
}
contains(node) {
return (
this === node || this.children.some((child) => child.contains(node))
);
}
}
class DocumentShim extends EventTargetShim {
constructor() {
super();
this.nodeType = 9;
this.defaultView = globalThis;
}
createElement(tagName) {
return new NodeShim(tagName);
}
createTextNode(value) {
const node = new NodeShim("#text");
node.nodeType = 3;
node.nodeValue = value;
return node;
}
createComment(value) {
const node = new NodeShim("#comment");
node.nodeType = 8;
node.nodeValue = value;
return node;
}
get activeElement() {
return null;
}
}
globalThis.document = new DocumentShim();
globalThis.HTMLIFrameElement = NodeShim;
globalThis.HTMLElement = NodeShim;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
Object.defineProperty(globalThis, "window", {
configurable: true,
value: globalThis,
});
// Never route through the mocked `setTimeout` — React's scheduler falls
// back to a real `MessageChannel` (present natively in Node), so mocking
// only `setTimeout`/`Date` below cannot stall a commit.
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.CSS = { escape: (value) => value };
}
installDOMShim();
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useLocalDayBoundaries } from "./hooks.ts";
function Harness({ days, onBoundaries }) {
const boundaries = useLocalDayBoundaries(days);
onBoundaries(boundaries);
return null;
}
test("useLocalDayBoundaries reschedules across two local-midnight rollovers, rebuilding boundaries each time", async () => {
// One minute before local midnight, so the first scheduled `setTimeout`
// fires quickly under `mock.timers`.
const beforeMidnight = new Date(2026, 5, 15, 23, 59, 0);
mock.timers.enable({
apis: ["setTimeout", "Date"],
now: beforeMidnight.getTime(),
});
try {
let latest = null;
const captured = [];
const onBoundaries = (boundaries) => {
latest = boundaries;
};
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(Harness, { days: 7, onBoundaries }));
});
assert.equal(latest.length, 8, "7-day window yields 8 boundaries");
captured.push(latest);
// Advance 1 minute — crosses the Jun 16 local midnight. The single
// scheduled `setTimeout` must fire, bump `rolloverTick`, and rebuild the
// boundary set (React flushes the resulting state update inside `act`).
await act(async () => {
mock.timers.tick(60_000);
});
assert.notDeepEqual(
latest,
captured[0],
"boundaries must rebuild after the first midnight rollover fires",
);
assert.equal(latest.length, 8, "boundary count is unchanged by a rollover");
const juneSeventeenTomorrow = Math.floor(
new Date(2026, 5, 17, 0, 0, 0, 0).getTime() / 1_000,
);
assert.equal(
latest.at(-1),
juneSeventeenTomorrow,
"newest boundary must shift forward to tomorrow of the new window",
);
captured.push(latest);
// Advance a full day — crosses the Jun 17 local midnight. This only
// fires if the first rollover's effect RESCHEDULED a fresh `setTimeout`
// rather than firing once and going silent.
await act(async () => {
mock.timers.tick(24 * 60 * 60 * 1000);
});
assert.notDeepEqual(
latest,
captured[1],
"boundaries must rebuild again after the second midnight rollover fires, proving the timer rescheduled itself",
);
const juneEighteenTomorrow = Math.floor(
new Date(2026, 5, 18, 0, 0, 0, 0).getTime() / 1_000,
);
assert.equal(
latest.at(-1),
juneEighteenTomorrow,
"newest boundary must shift forward again after the rescheduled rollover",
);
await act(async () => {
root.unmount();
});
} finally {
mock.timers.reset();
}
});
test("useLocalDayBoundaries clears its scheduled timeout on unmount (no post-unmount rollover)", async () => {
const beforeMidnight = new Date(2026, 5, 15, 23, 59, 0);
mock.timers.enable({
apis: ["setTimeout", "Date"],
now: beforeMidnight.getTime(),
});
try {
let renderCount = 0;
const onBoundaries = () => {
renderCount++;
};
const root = createRoot(document.createElement("div"));
await act(async () => {
root.render(React.createElement(Harness, { days: 7, onBoundaries }));
});
const countAtUnmount = renderCount;
await act(async () => {
root.unmount();
});
// Crossing the midnight the pending timeout targeted must not throw or
// invoke a setState-after-unmount path — `clearTimeout` in the effect's
// cleanup must have already cancelled it.
await act(async () => {
mock.timers.tick(60_000);
});
assert.equal(
renderCount,
countAtUnmount,
"no render (and no error) after the component unmounted",
);
} finally {
mock.timers.reset();
}
});
+102
View File
@@ -0,0 +1,102 @@
import * as React from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
getAgentUsageSeries,
onAgentMetricsChanged,
type AgentUsageSeries,
} from "@/shared/api/tauriArchive";
import {
buildLocalDayBoundaries,
msUntilNextLocalMidnight,
type UsageWindowDays,
} from "./lib/agentUsage";
/** Root query key for the whole `agent-usage` family — invalidated en masse on any agent-metric change (M4/A13). */
export const agentUsageQueryKeyRoot = ["agent-usage"] as const;
/**
* Stable key incorporating the exact boundary set (so a midnight rollover or
* 7d/30d switch produces a new cache entry) and the optional author filter.
*/
export function agentUsageQueryKey(
boundaries: readonly number[],
agentPubkey?: string,
) {
return [
...agentUsageQueryKeyRoot,
boundaries.join(","),
agentPubkey ?? null,
] as const;
}
/**
* Local-day boundaries for the given window that recompute automatically at
* every local midnight (M4) — no `setInterval` (which would drift across
* DST), just a single scheduled `setTimeout` that reschedules itself each
* time it fires. Split out of {@link useAgentUsageSeries} so the rollover
* mechanics are testable without a `QueryClientProvider`.
*/
export function useLocalDayBoundaries(days: UsageWindowDays): number[] {
// Bumped once per local midnight so `boundaries` below recomputes even
// though `days` hasn't changed.
const [rolloverTick, setRolloverTick] = React.useState(0);
// `rolloverTick` is the only intended dependency: each fire reschedules
// against a freshly computed `Date.now()`, never a fixed interval that
// would drift across DST.
// biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick is read to reschedule, not to avoid a stale closure
React.useEffect(() => {
const timeoutId = setTimeout(() => {
setRolloverTick((tick) => tick + 1);
}, msUntilNextLocalMidnight());
return () => clearTimeout(timeoutId);
}, [rolloverTick]);
// `rolloverTick` intentionally forces a recompute at local midnight even
// though it carries no boundary data itself.
// biome-ignore lint/correctness/useExhaustiveDependencies: rolloverTick drives recompute, not boundary data
return React.useMemo(
() => buildLocalDayBoundaries(days),
[days, rolloverTick],
);
}
/**
* Local NIP-AM usage series for the Agents overview or a single agent's
* profile drill-in. Rebuilds boundaries once per local midnight (M4, no
* polling) and invalidates on `onAgentMetricsChanged` — new archived
* metrics, or a kind-44200 subscription toggle — instead of a refetch
* interval.
*/
export function useAgentUsageSeries({
agentPubkey,
days,
enabled = true,
}: {
agentPubkey?: string;
days: UsageWindowDays;
enabled?: boolean;
}) {
const queryClient = useQueryClient();
const boundaries = useLocalDayBoundaries(days);
React.useEffect(
() =>
onAgentMetricsChanged(() => {
void queryClient.invalidateQueries({
queryKey: agentUsageQueryKeyRoot,
});
}),
[queryClient],
);
return useQuery<AgentUsageSeries>({
queryKey: agentUsageQueryKey(boundaries, agentPubkey),
queryFn: () =>
getAgentUsageSeries({ bucketBoundaries: boundaries, agentPubkey }),
enabled,
staleTime: 60_000,
gcTime: 5 * 60_000,
});
}
@@ -0,0 +1,529 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
bigintRatio,
buildLocalDayBoundaries,
deriveUsageIngressTrailing,
formatEstimatedCostUsd,
formatTokenCountCompact,
formatTokenCountExact,
isPartialField,
isUnknownField,
msUntilNextLocalMidnight,
parseTokenCount,
sortAgentsByKnownTotal,
sortModelsByKnownTotal,
sumKnownBucketTotals,
} from "./agentUsage.ts";
// ── Fixtures ─────────────────────────────────────────────────────────────────
function usageField(overrides = {}) {
return { value: null, incomplete: false, ...overrides };
}
function reportedUsage(overrides = {}) {
return {
inputTokens: usageField(),
outputTokens: usageField(),
totalTokens: usageField(),
estimatedCostUsd: usageField(),
...overrides,
};
}
function agentUsage(pubkey, totalTokensValue, overrides = {}) {
return {
agentPubkey: pubkey,
usage: reportedUsage({
totalTokens: usageField({ value: totalTokensValue }),
}),
buckets: [],
models: [],
reportCount: 0,
hasUnknownUsage: false,
...overrides,
};
}
function modelUsage(model, totalTokensValue, overrides = {}) {
return {
model,
usage: reportedUsage({
totalTokens: usageField({ value: totalTokensValue }),
}),
reportCount: 0,
hasUnknownUsage: false,
...overrides,
};
}
// ── buildLocalDayBoundaries ──────────────────────────────────────────────────
test("buildLocalDayBoundaries returns 8 boundaries for a 7-day window", () => {
const now = new Date(2026, 5, 15, 14, 30, 0); // June 15, 2026, 14:30 local
const boundaries = buildLocalDayBoundaries(7, now);
assert.equal(boundaries.length, 8);
});
test("buildLocalDayBoundaries returns 31 boundaries for a 30-day window", () => {
const now = new Date(2026, 5, 15, 14, 30, 0);
const boundaries = buildLocalDayBoundaries(30, now);
assert.equal(boundaries.length, 31);
});
test("buildLocalDayBoundaries produces strictly increasing boundaries ending at tomorrow's local midnight", () => {
const now = new Date(2026, 5, 15, 14, 30, 0);
const boundaries = buildLocalDayBoundaries(7, now);
for (let i = 1; i < boundaries.length; i++) {
assert.ok(
boundaries[i] > boundaries[i - 1],
`boundary ${i} must exceed boundary ${i - 1}`,
);
}
const tomorrowMidnight = new Date(2026, 5, 16, 0, 0, 0, 0);
assert.equal(
boundaries.at(-1),
Math.floor(tomorrowMidnight.getTime() / 1000),
);
});
test("buildLocalDayBoundaries is independent of time-of-day within the reference day", () => {
const morning = buildLocalDayBoundaries(7, new Date(2026, 5, 15, 0, 0, 1));
const night = buildLocalDayBoundaries(7, new Date(2026, 5, 15, 23, 59, 59));
assert.deepEqual(morning, night);
});
// ── buildLocalDayBoundaries / msUntilNextLocalMidnight explicit-TZ coverage ──
//
// `process.env.TZ` is read by the JS engine on every `Date` field access
// (not just pinned at process start), so each test below mutates it
// directly and restores the original value in a `finally` — no subprocess
// needed, but evidence (`Date#toString()`) is asserted so a future runtime
// that DOES pin `TZ` at startup fails loudly instead of silently passing
// against the wrong offset.
function withTz(tz, fn) {
const original = process.env.TZ;
process.env.TZ = tz;
try {
return fn();
} finally {
if (original === undefined) delete process.env.TZ;
else process.env.TZ = original;
}
}
function assertStrictlyIncreasing(boundaries) {
for (let i = 1; i < boundaries.length; i++) {
assert.ok(
boundaries[i] > boundaries[i - 1],
`boundary ${i} (${boundaries[i]}) must exceed boundary ${i - 1} (${boundaries[i - 1]})`,
);
}
}
test("buildLocalDayBoundaries stays strictly increasing across an ordinary US spring-forward DST transition", () => {
withTz("America/New_York", () => {
const now = new Date(2024, 2, 12, 10, 0, 0); // Mar 12, after Mar 10 spring-forward
assert.equal(
now.toString().includes("Daylight"),
true,
"sanity: DST active",
);
const boundaries = buildLocalDayBoundaries(7, now);
assert.equal(boundaries.length, 8);
assertStrictlyIncreasing(boundaries);
});
});
test("buildLocalDayBoundaries stays strictly increasing across an ordinary US fall-back DST transition", () => {
withTz("America/New_York", () => {
const now = new Date(2024, 10, 6, 10, 0, 0); // Nov 6, after Nov 3 fall-back
assert.equal(
now.toString().includes("Standard"),
true,
"sanity: standard time active",
);
const boundaries = buildLocalDayBoundaries(7, now);
assert.equal(boundaries.length, 8);
assertStrictlyIncreasing(boundaries);
});
});
test("buildLocalDayBoundaries stays strictly increasing across Lord Howe Island's 30-minute DST shift", () => {
withTz("Australia/Lord_Howe", () => {
const now = new Date(2024, 9, 8, 10, 0, 0); // Oct 8, after Oct 6 spring-forward (+30min)
const boundaries = buildLocalDayBoundaries(7, now);
assert.equal(boundaries.length, 8);
assertStrictlyIncreasing(boundaries);
// The transition day is a 23.5h day, not the ordinary 24h.
const deltas = [];
for (let i = 1; i < boundaries.length; i++) {
deltas.push(boundaries[i] - boundaries[i - 1]);
}
assert.ok(
deltas.some((d) => d === 23.5 * 3600),
`expected a 23.5h transition delta, got ${JSON.stringify(deltas)}`,
);
});
});
test("buildLocalDayBoundaries stays strictly increasing across Pacific/Apia's skipped 2011-12-30 civil date", () => {
withTz("Pacific/Apia", () => {
// Samoa skipped 2011-12-30 entirely moving across the International
// Date Line; Dec 29 was immediately followed by Dec 31. The UTC
// offset itself jumped by exactly 24h (UTC-11 -> UTC+13) at that
// instant, so real elapsed time across the 2-civil-day jump is only
// 24h, not 48h — but the boundary construction must not collapse the
// two distinct local midnights (Dec 29, Dec 31) into one duplicate.
const now = new Date(2011, 11, 31, 12, 0, 0);
const boundaries = buildLocalDayBoundaries(7, now);
assert.equal(boundaries.length, 8);
assertStrictlyIncreasing(boundaries);
const dec29 = Math.floor(new Date(2011, 11, 29, 0, 0, 0).getTime() / 1000);
const dec31 = Math.floor(new Date(2011, 11, 31, 0, 0, 0).getTime() / 1000);
assert.ok(
boundaries.includes(dec29),
"expected Dec 29 local midnight as a boundary",
);
assert.ok(
boundaries.includes(dec31),
"expected Dec 31 local midnight as a boundary",
);
});
});
test("buildLocalDayBoundaries produces days+1 boundaries even when a civil date is skipped", () => {
withTz("Pacific/Apia", () => {
const boundaries7 = buildLocalDayBoundaries(
7,
new Date(2011, 11, 31, 12, 0, 0),
);
assert.equal(boundaries7.length, 8);
const boundaries30 = buildLocalDayBoundaries(
30,
new Date(2011, 11, 31, 12, 0, 0),
);
assert.equal(boundaries30.length, 31);
assertStrictlyIncreasing(boundaries30);
});
});
// ── msUntilNextLocalMidnight ─────────────────────────────────────────────────
test("msUntilNextLocalMidnight returns the exact gap to the next local midnight", () => {
const now = new Date(2026, 5, 15, 23, 0, 0, 0);
const ms = msUntilNextLocalMidnight(now);
assert.equal(ms, 60 * 60 * 1000);
});
test("msUntilNextLocalMidnight is always positive, even called exactly at midnight", () => {
const now = new Date(2026, 5, 15, 0, 0, 0, 0);
const ms = msUntilNextLocalMidnight(now);
assert.equal(ms, 24 * 60 * 60 * 1000);
});
test("msUntilNextLocalMidnight is positive and lands on a real local midnight across DST/date-line TZs", () => {
const cases = [
["America/New_York", new Date(2024, 2, 9, 23, 30, 0)], // eve of spring-forward
["Australia/Lord_Howe", new Date(2024, 9, 5, 23, 45, 0)], // eve of 30-min shift
["Pacific/Apia", new Date(2011, 11, 29, 23, 0, 0)], // eve of the skipped date
];
for (const [tz, now] of cases) {
withTz(tz, () => {
const ms = msUntilNextLocalMidnight(now);
assert.ok(ms > 0, `${tz}: expected positive ms, got ${ms}`);
const landed = new Date(now.getTime() + ms);
assert.equal(
landed.getHours(),
0,
`${tz}: expected to land on local midnight, got ${landed.toString()}`,
);
assert.equal(landed.getMinutes(), 0, `${tz}: expected :00 minutes`);
});
}
});
// ── parseTokenCount ──────────────────────────────────────────────────────────
test("parseTokenCount parses a plain decimal string to bigint", () => {
assert.equal(parseTokenCount("12345"), 12345n);
});
test("parseTokenCount preserves u64::MAX precision beyond Number.MAX_SAFE_INTEGER", () => {
assert.equal(parseTokenCount("18446744073709551615"), 18446744073709551615n);
});
test("parseTokenCount returns null for null input", () => {
assert.equal(parseTokenCount(null), null);
});
test("parseTokenCount fails closed on malformed wire data instead of throwing", () => {
for (const malformed of ["", "-1", "1.5", "abc", "1e10", " 1", "1 "]) {
assert.equal(
parseTokenCount(malformed),
null,
`expected null for ${JSON.stringify(malformed)}`,
);
}
});
test("parseTokenCount accepts zero", () => {
assert.equal(parseTokenCount("0"), 0n);
});
// ── formatTokenCountCompact / formatTokenCountExact ─────────────────────────
test("formatTokenCountCompact abbreviates thousands/millions/billions", () => {
assert.equal(formatTokenCountCompact(999n), "999");
assert.equal(formatTokenCountCompact(1_234n), "1.2K");
assert.equal(formatTokenCountCompact(1_000_000n), "1M");
assert.equal(formatTokenCountCompact(1_500_000_000n), "1.5B");
});
test("formatTokenCountCompact handles negative magnitudes symmetrically", () => {
assert.equal(formatTokenCountCompact(-1_234n), "-1.2K");
});
test("formatTokenCountExact renders full grouped digits, never abbreviated", () => {
assert.equal(formatTokenCountExact(1_234_567n), "1,234,567");
assert.equal(formatTokenCountExact(0n), "0");
});
// ── formatEstimatedCostUsd ───────────────────────────────────────────────────
test("formatEstimatedCostUsd renders two-decimal USD currency", () => {
assert.equal(formatEstimatedCostUsd(1.5), "$1.50");
assert.equal(formatEstimatedCostUsd(0), "$0.00");
});
// ── bigintRatio ──────────────────────────────────────────────────────────────
test("bigintRatio computes a bounded ratio without losing bigint precision on large magnitudes", () => {
const whole = 18_446_744_073_709_551_614n; // largest even value near u64::MAX
assert.equal(bigintRatio(whole / 2n, whole), 0.5);
});
test("bigintRatio returns 0 for a zero or negative whole (divide-by-zero guard)", () => {
assert.equal(bigintRatio(5n, 0n), 0);
assert.equal(bigintRatio(5n, -10n), 0);
});
test("bigintRatio clamps part to [0, whole]", () => {
assert.equal(bigintRatio(-5n, 100n), 0);
assert.equal(bigintRatio(200n, 100n), 1);
});
// ── sortAgentsByKnownTotal / sortModelsByKnownTotal ─────────────────────────
test("sortAgentsByKnownTotal ranks known totals descending", () => {
const agents = [
agentUsage("a1", "100"),
agentUsage("a2", "300"),
agentUsage("a3", "200"),
];
const sorted = sortAgentsByKnownTotal(agents);
assert.deepEqual(
sorted.map((a) => a.agentPubkey),
["a2", "a3", "a1"],
);
});
test("sortAgentsByKnownTotal lists unknown-total agents after all known-total agents, never interleaved", () => {
const agents = [
agentUsage("unknown-b", null),
agentUsage("known", "50"),
agentUsage("unknown-a", null),
];
const sorted = sortAgentsByKnownTotal(agents);
assert.equal(sorted[0].agentPubkey, "known");
// Unknown-total agents tiebreak by normalized pubkey.
assert.deepEqual(
sorted.slice(1).map((a) => a.agentPubkey),
["unknown-a", "unknown-b"],
);
});
test("sortAgentsByKnownTotal tiebreaks equal known totals by pubkey", () => {
const agents = [agentUsage("b", "100"), agentUsage("a", "100")];
const sorted = sortAgentsByKnownTotal(agents);
assert.deepEqual(
sorted.map((a) => a.agentPubkey),
["a", "b"],
);
});
test("sortModelsByKnownTotal sorts null model ('Unknown model') last among ties", () => {
const models = [
modelUsage(null, "100"),
modelUsage("gpt-4", "100"),
modelUsage("claude", "100"),
];
const sorted = sortModelsByKnownTotal(models);
assert.deepEqual(
sorted.map((m) => m.model),
["claude", "gpt-4", null],
);
});
// ── isPartialField / isUnknownField ──────────────────────────────────────────
test("isPartialField is true only for a known value flagged incomplete", () => {
assert.equal(
isPartialField(usageField({ value: "10", incomplete: true })),
true,
);
assert.equal(
isPartialField(usageField({ value: "10", incomplete: false })),
false,
);
assert.equal(
isPartialField(usageField({ value: null, incomplete: true })),
false,
);
});
test("isUnknownField is true only when there is no known value at all", () => {
assert.equal(isUnknownField(usageField({ value: null })), true);
assert.equal(isUnknownField(usageField({ value: "0" })), false);
});
// ── sumKnownBucketTotals ──────────────────────────────────────────────────────
function bucket(overrides = {}) {
return {
start: 1_700_000_000,
end: 1_700_086_400,
usage: reportedUsage(),
reportCount: 0,
hasUnknownUsage: false,
...overrides,
};
}
test("sumKnownBucketTotals returns knownTotal null and partial false for an all-empty window", () => {
const result = sumKnownBucketTotals([
bucket({ reportCount: 0 }),
bucket({ reportCount: 0 }),
]);
assert.equal(result.knownTotal, null);
assert.equal(result.partial, false);
});
test("sumKnownBucketTotals sums all known totals when every bucket is fully known", () => {
const result = sumKnownBucketTotals([
bucket({
usage: reportedUsage({ totalTokens: usageField({ value: "100" }) }),
reportCount: 1,
}),
bucket({
usage: reportedUsage({ totalTokens: usageField({ value: "200" }) }),
reportCount: 1,
}),
]);
assert.equal(result.knownTotal, 300n);
assert.equal(result.partial, false);
});
test("sumKnownBucketTotals marks partial true when any bucket has an incomplete (known lower-bound) total", () => {
const result = sumKnownBucketTotals([
bucket({
usage: reportedUsage({
totalTokens: usageField({ value: "100", incomplete: true }),
}),
reportCount: 1,
}),
bucket({
usage: reportedUsage({ totalTokens: usageField({ value: "200" }) }),
reportCount: 1,
}),
]);
assert.equal(result.knownTotal, 300n);
assert.equal(result.partial, true);
});
test("sumKnownBucketTotals marks partial true when any bucket has reports but null total (activity not fully counted)", () => {
const result = sumKnownBucketTotals([
bucket({
usage: reportedUsage({ totalTokens: usageField({ value: "100" }) }),
reportCount: 1,
}),
bucket({ usage: reportedUsage(), reportCount: 1, hasUnknownUsage: true }),
]);
assert.equal(result.knownTotal, 100n);
assert.equal(result.partial, true);
});
// ── deriveUsageIngressTrailing ────────────────────────────────────────────────
function baseSeries(overrides = {}) {
return {
collectionEnabled: true,
buckets: [],
agents: [],
coverage: {
firstArchivedAt: null,
firstReportedAt: null,
hasUnknownUsage: false,
invalidReportCount: 0,
lastArchivedAt: null,
lastReportedAt: null,
reportCount: 0,
},
hasArchivedEvidence: null,
...overrides,
};
}
test("deriveUsageIngressTrailing returns 'Collection off' when collection is disabled", () => {
const series = baseSeries({ collectionEnabled: false });
assert.equal(deriveUsageIngressTrailing(series), "Collection off");
});
test("deriveUsageIngressTrailing returns 'No recent data' when collection is on but no agents present", () => {
const series = baseSeries({ agents: [] });
assert.equal(deriveUsageIngressTrailing(series), "No recent data");
});
test("deriveUsageIngressTrailing returns compact token count when a known non-partial total is available", () => {
const series = baseSeries({
agents: [agentUsage("a", "1500")],
});
assert.equal(deriveUsageIngressTrailing(series), "1.5K");
});
test("deriveUsageIngressTrailing appends '· Partial' when the total is a known lower bound", () => {
const series = baseSeries({
agents: [
agentUsage("a", null, {
usage: reportedUsage({
totalTokens: usageField({ value: "1500", incomplete: true }),
}),
}),
],
});
assert.equal(deriveUsageIngressTrailing(series), "1.5K · Partial");
});
test("deriveUsageIngressTrailing returns 'Input/output reported' when only input or output is known", () => {
const series = baseSeries({
agents: [
agentUsage("a", null, {
usage: reportedUsage({
inputTokens: usageField({ value: "800" }),
outputTokens: usageField({ value: "200" }),
}),
}),
],
});
assert.equal(deriveUsageIngressTrailing(series), "Input/output reported");
});
test("deriveUsageIngressTrailing returns 'No recent data' when all usage fields are unknown", () => {
const series = baseSeries({
agents: [agentUsage("a", null)],
});
assert.equal(deriveUsageIngressTrailing(series), "No recent data");
});
@@ -0,0 +1,312 @@
//! Frontend-owned local-day boundary construction, bigint-safe token
//! handling, and truthful-state derivation for the NIP-AM local agent usage
//! feature.
//!
//! Rust request validation (`agent_usage.rs::validate_request`) only bounds
//! query span and shape — per M5, the trusted frontend is the single source
//! of local-midnight civil-day construction. Every consumer of
//! `AgentUsageSeriesRequest.bucketBoundaries` must build them here.
import type {
AgentUsage,
AgentUsageModel,
AgentUsageSeries,
AgentUsageSeriesBucket,
CostField,
UsageField,
} from "@/shared/api/tauriArchive";
// ── Local-day boundary construction (M5, A9) ─────────────────────────────────
export type UsageWindowDays = 7 | 30;
const DISTINCT_MIDNIGHT_MAX_STEP = 3;
/**
* The local midnight strictly before `from` (which must itself be a local
* midnight), found via `Date#setDate` day-arithmetic so ordinary DST
* transitions land on the correct calendar day. `Date#setDate` normalizes a
* *nonexistent* local date (a full civil day dropped by a date-line move,
* e.g. `Pacific/Apia`'s 2011-12-30) forward to the next real one, which can
* renormalize back to `from` itself — so this widens the step by one
* calendar day at a time until it actually lands on a distinct instant.
*/
function previousDistinctLocalMidnight(from: Date): Date {
let probe = from;
for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) {
probe = new Date(from);
probe.setDate(probe.getDate() - step);
probe.setHours(0, 0, 0, 0);
if (probe.getTime() !== from.getTime()) return probe;
}
return probe;
}
/** The local midnight strictly after `from`; see {@link previousDistinctLocalMidnight}. */
function nextDistinctLocalMidnight(from: Date): Date {
let probe = from;
for (let step = 1; step <= DISTINCT_MIDNIGHT_MAX_STEP; step++) {
probe = new Date(from);
probe.setDate(probe.getDate() + step);
probe.setHours(0, 0, 0, 0);
if (probe.getTime() !== from.getTime()) return probe;
}
return probe;
}
/**
* Build `days + 1` exact local-midnight Unix-second boundaries ending at the
* start of tomorrow's local day, covering the trailing `days` calendar days
* (today plus `days - 1` prior days).
*
* Walks to each boundary's *distinct* local midnight one civil day at a
* time (never independent `Date#setDate` offsets from one shared base date,
* and never `N * 86_400`), so boundaries stay correct across DST
* transitions — including 30-minute offset zones (e.g. Lord Howe Island),
* where a "day" is 23.5h or 24.5h — and across a skipped local civil date
* (e.g. `Pacific/Apia`'s 2011 date-line move), where independently offsetting
* from one base date would normalize the nonexistent date forward and emit
* a duplicate boundary. A skipped date instead produces one interval
* spanning the elapsed real time between the two surviving distinct
* midnights (which can exceed the ordinary 24h, up to the 48h band
* `validate_request`'s `MAX_INTERVAL_SECS` (A9) admits) rather than a
* duplicate. `referenceNow` is injectable for deterministic tests and the
* midnight-rollover timer (M4).
*/
export function buildLocalDayBoundaries(
days: UsageWindowDays,
referenceNow: Date = new Date(),
): number[] {
const todayMidnight = new Date(referenceNow);
todayMidnight.setHours(0, 0, 0, 0);
const tomorrowMidnight = nextDistinctLocalMidnight(todayMidnight);
// Oldest boundary is `days - 1` distinct local midnights before today's;
// the window covers today plus the (days - 1) preceding calendar days.
const priorMidnights: Date[] = [];
let cursor = todayMidnight;
for (let i = 0; i < days - 1; i++) {
cursor = previousDistinctLocalMidnight(cursor);
priorMidnights.push(cursor);
}
priorMidnights.reverse();
return [...priorMidnights, todayMidnight, tomorrowMidnight].map((d) =>
Math.floor(d.getTime() / 1_000),
);
}
/**
* Milliseconds until the next local midnight after `referenceNow`, for the
* single-`setTimeout` rollover (M4). Recompute and reschedule each time the
* timer fires — never use `setInterval`, which drifts across DST.
*/
export function msUntilNextLocalMidnight(
referenceNow: Date = new Date(),
): number {
const nextMidnight = new Date(referenceNow);
nextMidnight.setHours(24, 0, 0, 0);
return nextMidnight.getTime() - referenceNow.getTime();
}
// ── Bigint-safe token parsing/formatting ─────────────────────────────────────
/**
* Parse a decimal token-count string to `bigint`, fail-closed. The wire
* sends token counters as decimal strings specifically so the full valid
* `u64` range survives the Tauri boundary — never round-trip through
* `Number(...)`, which loses precision above 2^53.
*
* Returns `null` for a null/missing value or a string that isn't a plain
* non-negative decimal integer (defensive: malformed wire data becomes
* "unknown", not a thrown parse error that would crash the panel).
*/
export function parseTokenCount(value: string | null): bigint | null {
if (value === null) return null;
if (!/^\d+$/.test(value)) return null;
try {
return BigInt(value);
} catch {
return null;
}
}
/** Compact display, e.g. `1234` -> "1.2K", `1_000_000` -> "1M". Never lossy for exact copy — use `formatTokenCountExact` for that. */
export function formatTokenCountCompact(value: bigint): string {
const abs = value < 0n ? -value : value;
const units: Array<[bigint, string]> = [
[1_000_000_000n, "B"],
[1_000_000n, "M"],
[1_000n, "K"],
];
for (const [threshold, suffix] of units) {
if (abs >= threshold) {
// One decimal place, computed in bigint math to stay exact until the
// final float division (bounded to a single small ratio, not the
// original magnitude, so no precision loss that matters visually).
const scaled = Number((value * 10n) / threshold) / 10;
return `${scaled}${suffix}`;
}
}
return value.toString();
}
/** Exact grouped display, e.g. `1234567` -> "1,234,567". Safe for arbitrary `bigint` magnitude. */
export function formatTokenCountExact(value: bigint): string {
return value.toLocaleString("en-US");
}
/** Exact USD display, e.g. `1.5` -> "$1.50". `null` callers should render "Estimated" copy elsewhere, never "$0.00". */
export function formatEstimatedCostUsd(value: number): string {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
}).format(value);
}
/**
* Bigint-safe ratio in `[0, 1]` for a relative bar, e.g. `part` tokens against
* `whole` tokens. Never converts the full magnitude through `Number(...)`;
* only the final small ratio is a float. Returns `0` when `whole` is zero or
* negative (guards a divide-by-zero, not a real data case).
*/
export function bigintRatio(part: bigint, whole: bigint): number {
if (whole <= 0n) return 0;
const clampedPart = part < 0n ? 0n : part > whole ? whole : part;
// Scale into an integer permille before the single float division so the
// division only ever operates on bounded small integers.
const permille = (clampedPart * 1000n) / whole;
return Number(permille) / 1000;
}
// ── Ranking (A2: known lower-bound totals rank; null totals list after) ─────
type Ranked<T> = { item: T; totalTokens: bigint | null };
function rankByKnownTotal<T>(
items: readonly T[],
totalTokens: (item: T) => UsageField,
tiebreak: (a: T, b: T) => number,
): T[] {
const withTotals: Ranked<T>[] = items.map((item) => ({
item,
totalTokens: parseTokenCount(totalTokens(item).value),
}));
return withTotals
.sort((a, b) => {
if (a.totalTokens !== null && b.totalTokens !== null) {
if (a.totalTokens !== b.totalTokens) {
return a.totalTokens > b.totalTokens ? -1 : 1;
}
return tiebreak(a.item, b.item);
}
// Known-total rows rank before unknown-total rows; never interleave.
if (a.totalTokens !== null) return -1;
if (b.totalTokens !== null) return 1;
return tiebreak(a.item, b.item);
})
.map((ranked) => ranked.item);
}
/** Agents sort by known `totalTokens` descending, then normalized pubkey (A2/plan). Unknown-total agents list after all known-total agents, unranked among themselves beyond the pubkey tiebreak. */
export function sortAgentsByKnownTotal(
agents: readonly AgentUsage[],
): AgentUsage[] {
return rankByKnownTotal(
agents,
(agent) => agent.usage.totalTokens,
(a, b) => a.agentPubkey.localeCompare(b.agentPubkey),
);
}
/** Model rows use the same ranking rule as agents, tiebroken by model name (`null` model sorts last as "Unknown model"). */
export function sortModelsByKnownTotal(
models: readonly AgentUsageModel[],
): AgentUsageModel[] {
return rankByKnownTotal(
models,
(model) => model.usage.totalTokens,
(a, b) => {
if (a.model === b.model) return 0;
if (a.model === null) return 1;
if (b.model === null) return -1;
return a.model.localeCompare(b.model);
},
);
}
// ── Coverage / partial-state copy helpers ────────────────────────────────────
/** A field is a "Partial" lower bound when it has a known value that is flagged incomplete. Distinct from fully unknown (`value === null`), which renders as an omitted/unknown state, never zero. */
export function isPartialField(field: UsageField | CostField): boolean {
return field.value !== null && field.incomplete;
}
/** True when a field has no known value at all — omit from totals/bars, never render as zero. */
export function isUnknownField(field: UsageField | CostField): boolean {
return field.value === null;
}
/**
* Truthful trailing summary for the profile Info-tab Usage ingress row
* (plan:328): the viewer's own agent's 7-day known total, `Partial` when
* incomplete, `Input/output reported` when only those fields are known,
* or `No recent data` when nothing in the window is known. Never renders
* the placeholder `"View"` the ingress row used to show unconditionally.
*/
export function deriveUsageIngressTrailing(series: AgentUsageSeries): string {
if (!series.collectionEnabled) return "Collection off";
const agent = series.agents[0];
if (agent === undefined) return "No recent data";
const { inputTokens, outputTokens, totalTokens } = agent.usage;
const knownTotal = parseTokenCount(totalTokens.value);
if (knownTotal !== null) {
const compact = formatTokenCountCompact(knownTotal);
return isPartialField(totalTokens) ? `${compact} · Partial` : compact;
}
if (
parseTokenCount(inputTokens.value) !== null ||
parseTokenCount(outputTokens.value) !== null
) {
return "Input/output reported";
}
return "No recent data";
}
/**
* Bigint-safe sum of each bucket's known `totalTokens` across a daily
* series, for the overview/focused-view header total. `partial` is true
* when any bucket has a known-but-incomplete total OR any bucket's total is
* fully unknown (activity happened somewhere in the window that this exact
* sum cannot include) — never silently reported as a complete figure.
* Returns `knownTotal: null` only when every bucket has zero reports (a
* true empty window, not partial data).
*/
export function sumKnownBucketTotals(
buckets: readonly AgentUsageSeriesBucket[],
): {
knownTotal: bigint | null;
partial: boolean;
} {
let sum = 0n;
let sawKnown = false;
let partial = false;
for (const bucket of buckets) {
const total = bucket.usage.totalTokens;
const known = parseTokenCount(total.value);
if (known !== null) {
sum += known;
sawKnown = true;
}
if (isPartialField(total) || (bucket.reportCount > 0 && known === null)) {
partial = true;
}
}
return { knownTotal: sawKnown ? sum : null, partial };
}
@@ -0,0 +1,179 @@
import * as React from "react";
import { cn } from "@/shared/lib/cn";
import type { AgentUsageSeriesBucket } from "@/shared/api/tauriArchive";
import {
bigintRatio,
formatTokenCountCompact,
isPartialField,
parseTokenCount,
} from "../lib/agentUsage";
const BAR_TRACK_HEIGHT_PX = 56;
const UNKNOWN_BASELINE_HEIGHT_PX = 10;
const KNOWN_MIN_HEIGHT_PX = 3;
// A hatched, non-zero baseline for "activity happened but the total
// couldn't be counted" — deliberately never a zero-height bar, so unknown
// usage is never visually indistinguishable from a day with no activity
// (plan:306/329: "does not encode unknown as zero").
const UNKNOWN_BAR_STYLE: React.CSSProperties = {
backgroundImage:
"repeating-linear-gradient(45deg, var(--muted-foreground) 0, var(--muted-foreground) 1px, transparent 1px, transparent 5px)",
backgroundColor: "transparent",
height: UNKNOWN_BASELINE_HEIGHT_PX,
opacity: 0.35,
};
function dateLabelOf(unixSeconds: number): string {
return new Date(unixSeconds * 1000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
/**
* One bar's derived render state, computed from backend truth rather than
* the field's `value` alone: `reportCount === 0` is a genuine zero-activity
* day (the field is `null` because nothing happened), which is a different
* state from `hasUnknownUsage` (activity happened but the total could not
* be fully counted) even though both leave `usage.totalTokens.value` null.
*/
function deriveBarState(bucket: AgentUsageSeriesBucket) {
const total = bucket.usage.totalTokens;
const known = parseTokenCount(total.value);
const dateLabel = dateLabelOf(bucket.start);
if (bucket.reportCount === 0) {
return {
accessibleLabel: `${dateLabel} · no usage reported`,
kind: "empty" as const,
knownTokens: 0n,
};
}
if (known === null) {
return {
accessibleLabel: `${dateLabel} · unknown usage`,
kind: "unknown" as const,
knownTokens: null,
};
}
const partial = isPartialField(total);
return {
accessibleLabel: `${dateLabel} · ${formatTokenCountCompact(known)} reported tokens${
partial ? " (partial)" : ""
}`,
kind: partial ? ("partial" as const) : ("known" as const),
knownTokens: known,
};
}
/**
* CSS-only daily bar row for a usage series (plan:305-306/329). Columns are
* equal CSS-grid fractions of the container so the row never causes
* horizontal overflow regardless of window width or bucket count (8 or 31).
* Each bar carries an accessible `title`/`aria-label` (`date · reported
* tokens` or `date · unknown usage`) and a visible textual total beneath
* it; a day with reported-but-uncountable usage renders a fixed hatched
* baseline, never a zero-height bar.
*/
export function AgentUsageDailyBars({
buckets,
}: {
buckets: AgentUsageSeriesBucket[];
}) {
const maxKnownTotal = React.useMemo(
() =>
buckets.reduce<bigint>((max, bucket) => {
const total = parseTokenCount(bucket.usage.totalTokens.value);
return total !== null && total > max ? total : max;
}, 0n),
[buckets],
);
if (buckets.length === 0) return null;
return (
<div
className="grid items-end gap-1"
data-testid="agent-usage-daily-bars"
style={{
gridTemplateColumns: `repeat(${buckets.length}, minmax(0, 1fr))`,
}}
>
{buckets.map((bucket) => (
<DailyBar
bucket={bucket}
key={bucket.start}
maxKnownTotal={maxKnownTotal}
/>
))}
</div>
);
}
function DailyBar({
bucket,
maxKnownTotal,
}: {
bucket: AgentUsageSeriesBucket;
maxKnownTotal: bigint;
}) {
const { accessibleLabel, kind, knownTokens } = deriveBarState(bucket);
const knownHeightPx =
knownTokens !== null && maxKnownTotal > 0n
? Math.max(
Math.round(
bigintRatio(knownTokens, maxKnownTotal) * BAR_TRACK_HEIGHT_PX,
),
knownTokens > 0n ? KNOWN_MIN_HEIGHT_PX : 1,
)
: KNOWN_MIN_HEIGHT_PX;
const trailingText =
kind === "unknown"
? "—"
: kind === "partial"
? `≥${formatTokenCountCompact(knownTokens ?? 0n)}`
: formatTokenCountCompact(knownTokens ?? 0n);
return (
<div
className="flex flex-col items-center gap-1"
data-testid={`agent-usage-daily-bar-${bucket.start}`}
title={accessibleLabel}
>
<div
className="flex w-full items-end justify-center"
style={{ height: BAR_TRACK_HEIGHT_PX }}
>
{kind === "unknown" ? (
<div
aria-label={accessibleLabel}
className="w-full rounded-t-sm"
role="img"
style={UNKNOWN_BAR_STYLE}
/>
) : (
<div
aria-label={accessibleLabel}
className={cn(
"w-full rounded-t-sm",
kind === "partial"
? "bg-primary/50"
: kind === "empty"
? "bg-muted/40"
: "bg-primary",
)}
role="img"
style={{ height: knownHeightPx }}
/>
)}
</div>
<span className="max-w-full truncate text-2xs text-muted-foreground">
{trailingText}
</span>
</div>
);
}
@@ -0,0 +1,322 @@
import * as React from "react";
import { RefreshCw } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import type { AgentUsageSeries } from "@/shared/api/tauriArchive";
import { Alert, AlertDescription } from "@/shared/ui/alert";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { useAgentUsageSeries } from "../hooks";
import {
formatEstimatedCostUsd,
formatTokenCountExact,
isPartialField,
isUnknownField,
parseTokenCount,
sortModelsByKnownTotal,
type UsageWindowDays,
} from "../lib/agentUsage";
import { AgentUsageDailyBars } from "./AgentUsageDailyBars";
/**
* Per-agent Usage focused subview, rendered from the profile panel when
* `view === 'usage'` (M4/A9/A13, frozen Rev 3 plan). Owns its own 7d/30d
* selector and author-filtered query — independent of the Agents overview.
*
* A13 fail-closed: eligibility is ownership (`canViewUsage`) OR archived
* evidence for a historical/deleted agent (`hasArchivedEvidence === true`).
* A hand-authored `?profileView=usage` URL with neither falls back to the
* summary view via `onIneligible` — but only once the query resolves, so a
* still-loading owner-eligible or evidence-eligible agent is never bounced.
*/
export function AgentUsageFocusedView({
agentPubkey,
canViewUsage,
onIneligible,
}: {
agentPubkey: string;
canViewUsage: boolean;
onIneligible: () => void;
}) {
const [days, setDays] = React.useState<UsageWindowDays>(7);
const query = useAgentUsageSeries({ agentPubkey, days });
const { onOpenSettings } = useAppShell();
React.useEffect(() => {
if (canViewUsage || !query.data) return;
if (query.data.hasArchivedEvidence !== true) onIneligible();
}, [canViewUsage, onIneligible, query.data]);
return (
<div className="space-y-4 pt-4" data-testid="agent-usage-focused-view">
<Tabs
onValueChange={(value) => setDays(value === "30" ? 30 : 7)}
value={String(days)}
>
<TabsList>
<TabsTrigger data-testid="agent-usage-focused-window-7" value="7">
7d
</TabsTrigger>
<TabsTrigger data-testid="agent-usage-focused-window-30" value="30">
30d
</TabsTrigger>
</TabsList>
</Tabs>
{query.isLoading ? (
<AgentUsageFocusedSkeleton />
) : query.isError ? (
<Alert data-testid="agent-usage-focused-error" variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>Couldn't load usage data.</span>
<Button
onClick={() => void query.refetch()}
size="sm"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</Button>
</AlertDescription>
</Alert>
) : query.data ? (
<AgentUsageFocusedContent
days={days}
onOpenSettings={onOpenSettings}
series={query.data}
/>
) : null}
</div>
);
}
function AgentUsageFocusedSkeleton() {
return (
<Card className="space-y-4 p-6" data-testid="agent-usage-focused-skeleton">
<Skeleton className="h-6 w-32" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-12 w-full" />
</Card>
);
}
function AgentUsageFocusedContent({
days,
onOpenSettings,
series,
}: {
days: UsageWindowDays;
onOpenSettings: ((section: "local-archive") => void) | null;
series: AgentUsageSeries;
}) {
const agent = series.agents[0];
const collectionOff = !series.collectionEnabled;
const hasRetainedData = series.coverage.reportCount > 0;
const hasEvidenceOutsideWindow =
agent === undefined && series.hasArchivedEvidence === true;
if (!collectionOff && agent === undefined && !hasEvidenceOutsideWindow) {
return (
<p
className="text-sm text-muted-foreground"
data-testid="agent-usage-focused-empty"
>
No locally archived usage in the last {days} days. Usage appears after
this agent completes a usage-reporting turn.
</p>
);
}
return (
<div className="space-y-4">
{collectionOff ? (
<Alert data-testid="agent-usage-focused-collection-off">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
{hasRetainedData
? `Collection off · data through ${formatCoverageDate(
series.coverage.lastArchivedAt,
)}`
: "Local usage collection is off."}
</span>
<Button
onClick={() => onOpenSettings?.("local-archive")}
size="sm"
variant="outline"
>
Open Local Archive settings
</Button>
</AlertDescription>
</Alert>
) : null}
{agent ? (
<AgentUsageFocusedTotals agent={agent} coverage={series.coverage} />
) : hasEvidenceOutsideWindow ? (
<p
className="text-sm text-muted-foreground"
data-testid="agent-usage-focused-outside-window"
>
No locally archived usage in the last {days} days, but this agent has
reported usage previously. Try the 30-day window.
</p>
) : null}
</div>
);
}
function AgentUsageFocusedTotals({
agent,
coverage,
}: {
agent: AgentUsageSeries["agents"][number];
coverage: AgentUsageSeries["coverage"];
}) {
const { estimatedCostUsd, inputTokens, outputTokens, totalTokens } =
agent.usage;
const models = sortModelsByKnownTotal(agent.models);
const explainPartial =
agent.hasUnknownUsage || coverage.invalidReportCount > 0;
return (
<Card className="space-y-4 p-6" data-testid="agent-usage-focused-totals">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<TokenStat field={totalTokens} label="Total tokens" />
<TokenStat field={inputTokens} label="Input tokens" />
<TokenStat field={outputTokens} label="Output tokens" />
<UsageStat
display={
estimatedCostUsd.value !== null
? `Est. ${formatEstimatedCostUsd(estimatedCostUsd.value)}`
: null
}
isPartial={isPartialField(estimatedCostUsd)}
label="Estimated cost"
/>
</div>
{agent.buckets.length > 0 ? (
<div
className="space-y-2 border-t border-border pt-4"
data-testid="agent-usage-focused-daily-bars"
>
<h3 className="text-sm font-medium text-foreground">Daily usage</h3>
<AgentUsageDailyBars buckets={agent.buckets} />
</div>
) : null}
{models.length > 0 ? (
<div
className="space-y-2 border-t border-border pt-4"
data-testid="agent-usage-focused-models"
>
<h3 className="text-sm font-medium text-foreground">By model</h3>
{models.map((model) => (
<div
className="flex items-center justify-between gap-3 text-sm"
key={model.model ?? "unknown"}
>
<span className="min-w-0 truncate text-muted-foreground">
{model.model ?? "Unknown model"}
</span>
<span className="shrink-0 font-medium text-foreground">
{isUnknownField(model.usage.totalTokens)
? "No usage reported"
: formatTokenCountExact(
parseTokenCount(model.usage.totalTokens.value) ?? 0n,
)}
{isPartialField(model.usage.totalTokens) ? (
<Badge className="ml-2" variant="outline">
Partial
</Badge>
) : null}
</span>
</div>
))}
</div>
) : null}
<div
className="space-y-1 border-t border-border pt-4 text-xs text-muted-foreground"
data-testid="agent-usage-focused-coverage"
>
<p>
{agent.reportCount} reported turn{agent.reportCount === 1 ? "" : "s"}
{" · "}
{formatCoverageRange(coverage)}
</p>
{explainPartial ? (
<p data-testid="agent-usage-focused-partial-explanation">
Some usage could not be counted: reports with an unreadable
timestamp or a cumulative total missing its session are excluded,
and unknown intervals are omitted rather than shown as zero.
</p>
) : null}
</div>
</Card>
);
}
function TokenStat({
field,
label,
}: {
field: { value: string | null; incomplete: boolean };
label: string;
}) {
const parsed = parseTokenCount(field.value);
return (
<UsageStat
display={parsed !== null ? formatTokenCountExact(parsed) : null}
isPartial={isPartialField(field)}
label={label}
/>
);
}
function UsageStat({
display,
isPartial,
label,
}: {
display: string | null;
isPartial: boolean;
label: string;
}) {
return (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-lg font-semibold text-foreground">{display ?? "—"}</p>
{isPartial ? <Badge variant="outline">Partial</Badge> : null}
</div>
);
}
function formatCoverageDate(unixSeconds: number | null): string {
if (unixSeconds === null) return "unknown";
return new Date(unixSeconds * 1000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
/**
* Human-readable coverage range for the focused view's footer, from the
* exact first/last reported timestamps the backend already computes
* (plan:329's "coverage dates"). `null` on either end means no reported row
* fell in this window (the caller only renders this once `agent` exists,
* so both are actually set in practice, but the fallback stays honest).
*/
function formatCoverageRange(coverage: AgentUsageSeries["coverage"]): string {
const { firstReportedAt, lastReportedAt } = coverage;
if (firstReportedAt === null || lastReportedAt === null) {
return "coverage unknown";
}
if (firstReportedAt === lastReportedAt) {
return `reported ${formatCoverageDate(firstReportedAt)}`;
}
return `${formatCoverageDate(firstReportedAt)} – ${formatCoverageDate(lastReportedAt)}`;
}
@@ -0,0 +1,320 @@
import * as React from "react";
import { RefreshCw } from "lucide-react";
import { useAppShell } from "@/app/AppShellContext";
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import {
resolveUserLabel,
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import type { AgentUsage, AgentUsageSeries } from "@/shared/api/tauriArchive";
import { Alert, AlertDescription } from "@/shared/ui/alert";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card";
import { SectionHeader } from "@/shared/ui/PageHeader";
import { Progress } from "@/shared/ui/progress";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { useAgentUsageSeries } from "../hooks";
import {
bigintRatio,
formatTokenCountCompact,
isPartialField,
isUnknownField,
parseTokenCount,
sortAgentsByKnownTotal,
sumKnownBucketTotals,
type UsageWindowDays,
} from "../lib/agentUsage";
import { AgentUsageDailyBars } from "./AgentUsageDailyBars";
/**
* Compact "Usage" section on the Agents page: local NIP-AM usage totals for
* the last 7 or 30 days, broken down per agent, with a click-through to the
* per-agent focused view in the profile panel (M4/A9/A13, frozen Rev 3 plan).
*/
export function AgentUsageSection({
onOpenAgentProfile,
}: {
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
}) {
const [days, setDays] = React.useState<UsageWindowDays>(7);
const query = useAgentUsageSeries({ days });
const { onOpenSettings } = useAppShell();
const agents = React.useMemo(
() => sortAgentsByKnownTotal(query.data?.agents ?? []),
[query.data?.agents],
);
const pubkeys = React.useMemo(
() => agents.map((agent) => agent.agentPubkey),
[agents],
);
const usersBatchQuery = useUsersBatchQuery(pubkeys, {
enabled: pubkeys.length > 0,
});
return (
<section className="relative space-y-4" data-testid="agents-usage-section">
<SectionHeader
action={
<Tabs
onValueChange={(value) => setDays(value === "30" ? 30 : 7)}
value={String(days)}
>
<TabsList>
<TabsTrigger data-testid="agent-usage-window-7" value="7">
7d
</TabsTrigger>
<TabsTrigger data-testid="agent-usage-window-30" value="30">
30d
</TabsTrigger>
</TabsList>
</Tabs>
}
description="Locally archived, agent-reported usage."
title="Usage"
/>
{query.isLoading ? (
<AgentUsageSkeleton />
) : query.isError ? (
<Alert data-testid="agent-usage-error" variant="destructive">
<AlertDescription className="flex items-center justify-between gap-3">
<span>Couldn't load usage data.</span>
<Button
onClick={() => void query.refetch()}
size="sm"
variant="outline"
>
<RefreshCw className="h-3.5 w-3.5" />
Retry
</Button>
</AlertDescription>
</Alert>
) : query.data ? (
<AgentUsageCard
agents={agents}
days={days}
onOpenAgentProfile={onOpenAgentProfile}
onOpenSettings={onOpenSettings}
profiles={usersBatchQuery.data?.profiles}
series={query.data}
/>
) : null}
</section>
);
}
function AgentUsageSkeleton() {
return (
<Card className="space-y-4 p-6" data-testid="agent-usage-skeleton">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</Card>
);
}
function AgentUsageCard({
agents,
days,
onOpenAgentProfile,
onOpenSettings,
profiles,
series,
}: {
agents: AgentUsage[];
days: UsageWindowDays;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
onOpenSettings: ((section: "local-archive") => void) | null;
profiles: UserProfileLookup | undefined;
series: AgentUsageSeries;
}) {
const hasRows = agents.length > 0;
const collectionOff = !series.collectionEnabled;
const hasRetainedData = series.coverage.reportCount > 0;
// Relative bars are decorative (aria-hidden, per plan) — scale each agent's
// known total against the largest known total in the current window so the
// sorted-by-total list also reads as a bar chart.
const maxKnownTotal = React.useMemo(
() =>
agents.reduce<bigint>((max, agent) => {
const total = parseTokenCount(agent.usage.totalTokens.value);
return total !== null && total > max ? total : max;
}, 0n),
[agents],
);
const overallTotal = React.useMemo(
() => sumKnownBucketTotals(series.buckets),
[series.buckets],
);
return (
<Card className="space-y-4 p-6" data-testid="agent-usage-card">
{series.buckets.length > 0 ? (
<div className="space-y-2" data-testid="agent-usage-overall-bars">
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-sm font-medium text-foreground">Daily usage</h3>
<span className="text-sm text-muted-foreground">
{overallTotal.knownTotal !== null
? `${formatTokenCountCompact(overallTotal.knownTotal)} tokens`
: "No usage reported"}
{overallTotal.partial ? (
<Badge className="ml-2" variant="outline">
Partial
</Badge>
) : null}
</span>
</div>
<AgentUsageDailyBars buckets={series.buckets} />
</div>
) : null}
{collectionOff ? (
<Alert data-testid="agent-usage-collection-off">
<AlertDescription className="flex flex-wrap items-center justify-between gap-2">
<span>
{hasRetainedData
? `Collection off · data through ${formatCoverageDate(
series.coverage.lastArchivedAt,
)}`
: "Local usage collection is off."}
</span>
<Button
onClick={() => onOpenSettings?.("local-archive")}
size="sm"
variant="outline"
>
Open Local Archive settings
</Button>
</AlertDescription>
</Alert>
) : null}
{hasRows ? (
<div className="space-y-3">
{agents.map((agent) => (
<AgentUsageRow
agent={agent}
days={days}
key={agent.agentPubkey}
label={resolveUserLabel({ profiles, pubkey: agent.agentPubkey })}
maxKnownTotal={maxKnownTotal}
onOpenAgentProfile={onOpenAgentProfile}
profileAvatarUrl={
profiles?.[agent.agentPubkey]?.avatarUrl ?? null
}
/>
))}
</div>
) : (
<p
className="text-sm text-muted-foreground"
data-testid="agent-usage-empty"
>
{collectionOff
? "Turn on collection to start tracking agent usage."
: `No locally archived usage in the last ${days} days. Usage appears after an agent completes a usage-reporting turn.`}
</p>
)}
</Card>
);
}
function formatCoverageDate(unixSeconds: number | null): string {
if (unixSeconds === null) return "unknown";
return new Date(unixSeconds * 1000).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
function AgentUsageRow({
agent,
days,
label,
maxKnownTotal,
onOpenAgentProfile,
profileAvatarUrl,
}: {
agent: AgentUsage;
days: UsageWindowDays;
label: string;
maxKnownTotal: bigint;
onOpenAgentProfile: (
pubkey: string,
options?: ProfilePanelOpenOptions,
) => void;
profileAvatarUrl: string | null;
}) {
const total = agent.usage.totalTokens;
const knownTotal = parseTokenCount(total.value);
const partial = isPartialField(total);
const unknown = isUnknownField(total);
const trailing =
knownTotal !== null
? formatTokenCountCompact(knownTotal)
: formatIndependentFields(agent);
return (
<button
aria-label={`Open ${label} usage for the last ${days} days`}
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-3 text-left transition-colors hover:bg-muted/40"
data-testid={`agent-usage-row-${agent.agentPubkey}`}
onClick={() => onOpenAgentProfile(agent.agentPubkey, { view: "usage" })}
type="button"
>
<ProfileAvatar
avatarUrl={profileAvatarUrl}
className="h-9 w-9"
label={label}
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-foreground">
{label}
</span>
{!unknown ? (
<Progress
aria-hidden="true"
className="mt-1.5 h-1.5"
value={
knownTotal !== null && maxKnownTotal > 0n
? bigintRatio(knownTotal, maxKnownTotal) * 100
: null
}
/>
) : null}
</span>
<span className="flex shrink-0 items-center gap-2 text-sm text-muted-foreground">
{partial ? <Badge variant="outline">Partial</Badge> : null}
{trailing}
</span>
</button>
);
}
function formatIndependentFields(agent: AgentUsage): string {
const input = parseTokenCount(agent.usage.inputTokens.value);
const output = parseTokenCount(agent.usage.outputTokens.value);
if (input !== null || output !== null) {
const parts: string[] = [];
if (input !== null) parts.push(`in ${formatTokenCountCompact(input)}`);
if (output !== null) parts.push(`out ${formatTokenCountCompact(output)}`);
return parts.join(" · ");
}
return "No usage reported";
}
@@ -72,7 +72,8 @@ export function AgentsScreen() {
profile: pubkey,
profilePersona: null,
profileTab: options?.tab === "info" ? null : (options?.tab ?? null),
profileView: null,
profileView:
options?.view === "summary" ? null : (options?.view ?? null),
});
},
[applyPatch],
@@ -4,6 +4,7 @@ import {
consumePendingSnapshotImport,
subscribeSnapshotImport,
} from "@/features/agents/openSnapshotImportFromUrlEvent";
import { AgentUsageSection } from "@/features/agent-usage/ui/AgentUsageSection";
import { AddAgentToChannelDialog } from "./AddAgentToChannelDialog";
import { AddTeamToChannelDialog } from "./AddTeamToChannelDialog";
import { AgentDefaultsDialog } from "./AgentDefaultsDialog";
@@ -202,6 +203,12 @@ export function AgentsView() {
}}
/>
<AgentUsageSection
onOpenAgentProfile={(pubkey, options) => {
openProfilePanel?.(pubkey, options);
}}
/>
<TeamsSection
error={
teamActions.teamsQuery.error instanceof Error
@@ -71,6 +71,9 @@ import {
ProfileSummaryView,
} from "@/features/profile/ui/UserProfilePanelSections";
import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails";
import { AgentUsageFocusedView } from "@/features/agent-usage/ui/AgentUsageFocusedView";
import { useAgentUsageSeries } from "@/features/agent-usage/hooks";
import { deriveUsageIngressTrailing } from "@/features/agent-usage/lib/agentUsage";
import { UserProfileAgentSettingsMenuSlot } from "@/features/profile/ui/UserProfileAgentActions";
import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion";
import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields";
@@ -342,6 +345,18 @@ export function UserProfilePanel({
viewerIsOwner &&
Boolean(effectivePubkey) &&
canOpenAgentActivity(effectivePubkey);
const canViewUsage = viewerIsOwner && isBot && Boolean(effectivePubkey);
// Info-tab ingress row's trailing text (plan:328) needs its own 7-day
// query — independent of the focused view's own 7d/30d selector, and
// gated off entirely when the row won't render.
const usageIngressQuery = useAgentUsageSeries({
agentPubkey: effectivePubkey ?? undefined,
days: 7,
enabled: canViewUsage,
});
const usageIngressTrailing = usageIngressQuery.data
? deriveUsageIngressTrailing(usageIngressQuery.data)
: undefined;
const canOpenAgentLogs =
isOwner === true && managedAgent?.backend.type === "local";
const canInstantiateAgent =
@@ -819,6 +834,8 @@ export function UserProfilePanel({
canInstantiateAgent={canInstantiateAgent}
canOpenAgentLogs={canOpenAgentLogs}
canViewActivity={canViewActivity}
canViewUsage={canViewUsage}
usageIngressTrailing={usageIngressTrailing}
callerChannelId={callerChannelId}
channelCount={profileChannels.length}
channelIdToName={channelIdToName}
@@ -853,6 +870,7 @@ export function UserProfilePanel({
onOpenChannel={handleOpenChannel}
onOpenDiagnostics={() => setView("diagnostics")}
onOpenInstructions={() => setView("instructions")}
onOpenUsage={() => setView("usage")}
onTabChange={setTab}
onOpenDm={onOpenDm}
presenceStatus={presenceStatus}
@@ -870,6 +888,13 @@ export function UserProfilePanel({
viewerIsOwner={viewerIsOwner}
/>
) : null}
{view === "usage" && effectivePubkey ? (
<AgentUsageFocusedView
agentPubkey={effectivePubkey}
canViewUsage={canViewUsage}
onIneligible={() => setView("summary", { replace: true })}
/>
) : null}
{view === "info" ? (
<AgentInfoFocusedView metadataFields={agentInfoFields} />
) : null}
@@ -68,6 +68,8 @@ export type ProfileSummaryViewProps = {
canEditAgent: boolean;
canOpenAgentLogs: boolean;
canViewActivity: boolean;
canViewUsage: boolean;
usageIngressTrailing: string | undefined;
channelCount: number;
channelIdToName: Record<string, string>;
channels: ProfileChannelLink[];
@@ -101,6 +103,7 @@ export type ProfileSummaryViewProps = {
onOpenChannel: (channelId: string) => void;
onOpenDiagnostics: () => void;
onOpenInstructions: () => void;
onOpenUsage: () => void;
onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void;
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
presenceStatus: "online" | "away" | "offline" | undefined;
@@ -180,6 +183,8 @@ export function ProfileSummaryView({
canEditAgent,
canOpenAgentLogs,
canViewActivity,
canViewUsage,
usageIngressTrailing,
channelCount,
channelIdToName,
channels,
@@ -213,6 +218,7 @@ export function ProfileSummaryView({
onOpenChannel,
onOpenDiagnostics,
onOpenInstructions,
onOpenUsage,
onTabChange,
onOpenDm,
presenceStatus,
@@ -250,11 +256,13 @@ export function ProfileSummaryView({
diagnosticsFields.some((field) => field.label !== "Status") ||
canOpenAgentLogs;
const showActivityIngress = canViewActivity;
const showUsageIngress = canViewUsage;
const showInfoTab =
agentInfoFields.length > 0 ||
instances.length > 1 ||
isArchived ||
showActivityIngress ||
showUsageIngress ||
!showRuntimeTab;
const diagnosticsErrorField = diagnosticsFields.find(
@@ -397,8 +405,11 @@ export function ProfileSummaryView({
isArchived={isArchived}
onOpenActivity={onOpenActivity}
onOpenInstance={onOpenInstance}
onOpenUsage={onOpenUsage}
pubkey={pubkey}
showActivityIngress={showActivityIngress}
showUsageIngress={showUsageIngress}
usageIngressTrailing={usageIngressTrailing}
/>
) : null}
{activeTab === "runtime" ? (
@@ -3,6 +3,7 @@ import type { LucideIcon } from "lucide-react";
import {
Activity,
Archive,
BarChart3,
ChevronRight,
Info,
RefreshCw,
@@ -287,8 +288,11 @@ export function ProfileInfoTabContent({
isArchived,
onOpenActivity,
onOpenInstance,
onOpenUsage,
pubkey,
showActivityIngress,
showUsageIngress,
usageIngressTrailing,
}: {
activeTurns: ActiveTurnSummary[];
activityAgent: ProfileActivityAgent | null;
@@ -299,8 +303,11 @@ export function ProfileInfoTabContent({
isArchived: boolean;
onOpenActivity: (channelId?: string | null) => void;
onOpenInstance: (pubkey: string) => void;
onOpenUsage: () => void;
pubkey: string | null;
showActivityIngress: boolean;
showUsageIngress: boolean;
usageIngressTrailing: string | undefined;
}) {
const infoFields: ProfileField[] = isArchived
? [
@@ -320,7 +327,12 @@ export function ProfileInfoTabContent({
const showLiveActivityEmbed =
showActivityIngress && (feedScope.isLive || feedScope.hasFeedContent);
if (!hasInfoFields && !showActivityIngress && !hasInstances) {
if (
!hasInfoFields &&
!showActivityIngress &&
!hasInstances &&
!showUsageIngress
) {
return null;
}
@@ -346,6 +358,15 @@ export function ProfileInfoTabContent({
/>
)
) : null}
{showUsageIngress ? (
<ProfileIngressRow
icon={BarChart3}
label="Usage"
onClick={onOpenUsage}
testId={`user-profile-view-usage-${pubkey}`}
trailing={usageIngressTrailing}
/>
) : null}
{hasInfoFields ? <ProfileFieldGroup fields={infoFields} /> : null}
{hasInstances ? (
<ProfileInstancesSection
@@ -161,6 +161,7 @@ test("parseProfilePanelView accepts all profile panel subviews", () => {
"memories",
"channels",
"logs",
"usage",
]) {
assert.equal(parseProfilePanelView(view), view);
}
@@ -25,7 +25,8 @@ export type ProfilePanelView =
| "diagnostics"
| "memories"
| "channels"
| "logs";
| "logs"
| "usage";
export type ProfilePanelTab = "info" | "runtime" | "channels" | "memories";
@@ -38,6 +39,7 @@ export const PROFILE_PANEL_VIEW_TITLES: Record<ProfilePanelView, string> = {
memories: "Memories",
channels: "Channels",
logs: "Harness Log",
usage: "Usage",
};
const PROFILE_PANEL_VIEWS = new Set<ProfilePanelView>(
+90
View File
@@ -2,6 +2,84 @@ import { KIND_AGENT_TURN_METRIC } from "@/shared/constants/kinds";
import { invokeTauri } from "./tauri";
// ── Agent usage wire types (NIP-AM, `get_agent_usage_series`) ────────────────
//
// Mirrors `desktop-src-tauri/src/archive/agent_usage.rs` field-for-field.
// Token counters cross the Tauri boundary as decimal strings (JS cannot
// exactly represent the full `u64` range); parse with `BigInt(...)` in the
// feature slice, never `Number(...)`.
export type UsageField = { value: string | null; incomplete: boolean };
export type CostField = { value: number | null; incomplete: boolean };
export type ReportedUsage = {
inputTokens: UsageField;
outputTokens: UsageField;
totalTokens: UsageField;
estimatedCostUsd: CostField;
};
export type AgentUsageSeriesBucket = {
start: number;
end: number;
usage: ReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
export type AgentUsageModel = {
model: string | null;
usage: ReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
export type AgentUsage = {
agentPubkey: string;
usage: ReportedUsage;
buckets: AgentUsageSeriesBucket[];
models: AgentUsageModel[];
reportCount: number;
hasUnknownUsage: boolean;
};
export type AgentUsageCoverage = {
firstArchivedAt: number | null;
lastArchivedAt: number | null;
firstReportedAt: number | null;
lastReportedAt: number | null;
reportCount: number;
invalidReportCount: number;
hasUnknownUsage: boolean;
};
export type AgentUsageSeries = {
collectionEnabled: boolean;
buckets: AgentUsageSeriesBucket[];
agents: AgentUsage[];
coverage: AgentUsageCoverage;
/**
* A13: `null` when the request had no `agentPubkey` filter; otherwise
* `true` iff at least one surviving `agent_metric_index` row (either
* `parseStatus`) exists for that author, independent of the requested
* bucket window. Drives profile focused-view eligibility for historical
* agents whose only evidence falls outside the current 7d/30d window.
*/
hasArchivedEvidence: boolean | null;
};
export type AgentUsageSeriesRequest = {
/**
* Exact local-midnight Unix-second boundaries, inclusive start/exclusive
* end per adjacent pair. Exactly 8 entries (7 buckets) or 31 entries (30
* buckets) — build with the feature slice's DST-safe boundary helper,
* never `N * 86_400`.
*/
bucketBoundaries: number[];
/** Normalized 64-hex author filter for the profile drill-in, or omit for the overview. */
agentPubkey?: string;
};
// ── Wire-shape types (raw Tauri responses) ───────────────────────────────────
/**
@@ -364,6 +442,18 @@ export async function readUnindexedObserverRows(): Promise<
}));
}
/**
* Read the locally archived NIP-AM usage series for the active identity +
* relay (Rev 3 frozen contract). Rust owns identity/relay scoping, request
* validation, backfill-before-read, and the accounting ladder — this is a
* thin typed wrapper with no client-side logic.
*/
export async function getAgentUsageSeries(
request: AgentUsageSeriesRequest,
): Promise<AgentUsageSeries> {
return invokeTauri<AgentUsageSeries>("get_agent_usage_series", { request });
}
/**
* Read a paginated page of archived raw events for a scope.
*
@@ -1,9 +1,11 @@
import * as React from "react";
import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanelUtils";
import type { AgentPersona } from "@/shared/api/types";
export type ProfilePanelOpenOptions = {
tab?: "info" | "runtime" | "channels" | "memories";
view?: ProfilePanelView;
};
type ProfilePanelContextValue = {
+118
View File
@@ -123,6 +123,77 @@ type MockSearchProfileSeed = {
isAgent?: boolean;
};
// ── Agent usage (NIP-AM) mock wire shapes ────────────────────────────────────
// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types
// field-for-field, kept independent of that module so the bridge has no
// runtime dependency on the feature slice it's mocking for.
type RawUsageField = { value: string | null; incomplete: boolean };
type RawCostField = { value: number | null; incomplete: boolean };
type RawReportedUsage = {
inputTokens: RawUsageField;
outputTokens: RawUsageField;
totalTokens: RawUsageField;
estimatedCostUsd: RawCostField;
};
type RawAgentUsageSeriesBucket = {
start: number;
end: number;
usage: RawReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
type RawAgentUsageModel = {
model: string | null;
usage: RawReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
type RawAgentUsage = {
agentPubkey: string;
usage: RawReportedUsage;
buckets: RawAgentUsageSeriesBucket[];
models: RawAgentUsageModel[];
reportCount: number;
hasUnknownUsage: boolean;
};
type RawAgentUsageSeries = {
collectionEnabled: boolean;
buckets: RawAgentUsageSeriesBucket[];
agents: RawAgentUsage[];
coverage: {
firstArchivedAt: number | null;
lastArchivedAt: number | null;
firstReportedAt: number | null;
lastReportedAt: number | null;
reportCount: number;
invalidReportCount: number;
hasUnknownUsage: boolean;
};
hasArchivedEvidence: boolean | null;
};
const DEFAULT_MOCK_AGENT_USAGE_SERIES: RawAgentUsageSeries = {
collectionEnabled: true,
buckets: [],
agents: [],
coverage: {
firstArchivedAt: null,
lastArchivedAt: null,
firstReportedAt: null,
lastReportedAt: null,
reportCount: 0,
invalidReportCount: 0,
hasUnknownUsage: false,
},
hasArchivedEvidence: null,
};
type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
@@ -296,6 +367,23 @@ type E2eConfig = {
*/
observerArchiveDefaultEnabledError?: string;
agentMetricArchiveDefaultEnabled?: boolean;
/**
* Response for `get_agent_usage_series` (NIP-AM local agent usage,
* `desktop/src/features/agent-usage`). Mirrors
* `desktop/src/shared/api/tauriArchive.ts`'s `AgentUsageSeries` wire
* shape field-for-field so specs can seed exact fixtures without a real
* SQLite archive. Omitted → an empty, collection-enabled series (no
* agents, no coverage, `hasArchivedEvidence: null`), which renders the
* "no locally archived usage" empty state.
*/
agentUsageSeries?: RawAgentUsageSeries;
/** Sequenced `get_agent_usage_series` failures, call-count indexed
* (mirrors `addChannelMembersErrors`): a string rejects that call;
* `null` succeeds. When exhausted, the last entry repeats. Drives the
* retry error state without deleting mock config mid-test. */
agentUsageErrors?: (string | null)[];
/** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */
agentUsageDelayMs?: number;
saveSubscriptions?: Array<{
scope_type: string;
scope_value: string;
@@ -6851,6 +6939,7 @@ async function handleConnectAcpRuntime(
// re-evaluated via addInitScript, so the counter starts at 0 for every test.
let installCallCount = 0;
let addChannelMembersCallCount = 0;
let agentUsageSeriesCallCount = 0;
let mockGlobalAgentConfig: {
env_vars: Record<string, string>;
provider: string | null;
@@ -10487,6 +10576,35 @@ export function maybeInstallE2eTauriMocks() {
}
return activeConfig?.mock?.observerArchiveDefaultEnabled ?? false;
}
case "get_agent_usage_series": {
// `AgentUsageSeriesRequest` is validated Rust-side; the mock trusts
// the seeded fixture as-is and ignores `bucketBoundaries`/`agentPubkey`
// filtering — specs seed the exact series they want per window/agent.
const configuredErrors = activeConfig?.mock?.agentUsageErrors;
if (configuredErrors && configuredErrors.length > 0) {
const index = Math.min(
agentUsageSeriesCallCount,
configuredErrors.length - 1,
);
agentUsageSeriesCallCount += 1;
const error = configuredErrors[index];
if (error) {
throw new Error(error);
}
} else {
agentUsageSeriesCallCount += 1;
}
const usageDelayMs = activeConfig?.mock?.agentUsageDelayMs ?? 0;
if (usageDelayMs > 0) {
await new Promise((resolve) =>
window.setTimeout(resolve, usageDelayMs),
);
}
return (
activeConfig?.mock?.agentUsageSeries ??
DEFAULT_MOCK_AGENT_USAGE_SERIES
);
}
case "agent_metric_archive_default_enabled":
return activeConfig?.mock?.agentMetricArchiveDefaultEnabled ?? false;
case "merge_save_subscription_kinds": {
+815
View File
@@ -0,0 +1,815 @@
import { expect, test, type Page } from "@playwright/test";
import {
installMockBridge,
TEST_IDENTITIES,
type MockAgentUsage,
type MockAgentUsageSeries,
} from "../helpers/bridge";
/**
* A non-owned, non-managed agent (declared owner is `outsider`, not the mock
* viewer) — used by the A13 fail-closed tests where eligibility must come
* from archived evidence alone, not ownership.
*/
const HISTORICAL_AGENT_PUBKEY = "6".repeat(64);
function getHashSearchParam(page: Page, name: string) {
const hash = new URL(page.url()).hash.replace(/^#/, "");
const queryStart = hash.indexOf("?");
if (queryStart === -1) {
return null;
}
return new URLSearchParams(hash.slice(queryStart + 1)).get(name);
}
async function expectHashSearchParam(
page: Page,
name: string,
value: string | null,
) {
await expect.poll(() => getHashSearchParam(page, name)).toBe(value);
}
function usageField(value: string | null, incomplete = false) {
return { value, incomplete };
}
function costField(value: number | null, incomplete = false) {
return { value, incomplete };
}
function reportedUsage(
overrides: Partial<{
inputTokens: string | null;
outputTokens: string | null;
totalTokens: string | null;
estimatedCostUsd: number | null;
}> = {},
) {
return {
estimatedCostUsd: costField(overrides.estimatedCostUsd ?? null),
inputTokens: usageField(overrides.inputTokens ?? null),
outputTokens: usageField(overrides.outputTokens ?? null),
totalTokens: usageField(overrides.totalTokens ?? null),
};
}
function mockAgentUsage(
agentPubkey: string,
overrides: Partial<MockAgentUsage> = {},
): MockAgentUsage {
return {
agentPubkey,
buckets: [],
hasUnknownUsage: false,
models: [],
reportCount: 1,
usage: reportedUsage({ totalTokens: "1500" }),
...overrides,
};
}
function mockUsageSeries(
overrides: Partial<MockAgentUsageSeries> = {},
): MockAgentUsageSeries {
return {
agents: [],
buckets: [],
collectionEnabled: true,
coverage: {
firstArchivedAt: null,
firstReportedAt: null,
hasUnknownUsage: false,
invalidReportCount: 0,
lastArchivedAt: null,
lastReportedAt: null,
reportCount: 0,
},
hasArchivedEvidence: null,
...overrides,
};
}
async function addGenericAgent(
page: Page,
channelName: string,
agentName: string,
): Promise<string> {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
const channelId = await page
.getByTestId(`channel-${channelName}`)
.getAttribute("data-channel-id");
if (!channelId) {
throw new Error(`Channel ${channelName} is missing a data-channel-id.`);
}
await page.waitForFunction(() => {
return Boolean(
(window as Window & { __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown })
.__BUZZ_E2E_INVOKE_MOCK_COMMAND__,
);
});
return page.evaluate(
async ({ agentName, channelId }): Promise<string> => {
const invoke = (
window as Window & {
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
command: string,
payload?: Record<string, unknown>,
) => Promise<{ agent?: { pubkey: string } }>;
}
).__BUZZ_E2E_INVOKE_MOCK_COMMAND__;
if (!invoke) {
throw new Error("Mock bridge is not installed.");
}
const created = (await invoke("create_managed_agent", {
input: {
name: agentName,
spawnAfterCreate: true,
systemPrompt: "Watch the channel and help when asked.",
},
})) as { agent?: { pubkey: string } };
const pubkey = created.agent?.pubkey;
if (!pubkey) {
throw new Error("Mock managed agent creation did not return a pubkey.");
}
await invoke("add_channel_members", {
channelId,
pubkeys: [pubkey],
role: "bot",
});
await (
window as Window & {
__BUZZ_E2E_QUERY_CLIENT__?: {
invalidateQueries: () => Promise<void>;
};
}
).__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries();
return pubkey;
},
{ agentName, channelId },
);
}
async function openAgentsView(page: Page) {
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-usage-section")).toBeVisible({
timeout: 10_000,
});
}
test("shows a loading skeleton while the usage series is in flight", async ({
page,
}) => {
await installMockBridge(page, {
agentUsageSeries: mockUsageSeries(),
agentUsageDelayMs: 2_000,
});
await openAgentsView(page);
await expect(page.getByTestId("agent-usage-skeleton")).toBeVisible();
await expect(page.getByTestId("agent-usage-card")).toBeVisible({
timeout: 5_000,
});
await expect(page.getByTestId("agent-usage-skeleton")).toHaveCount(0);
});
test("renders ranked agent rows and switches between the 7d and 30d windows", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Token Bot");
await page.evaluate(
(series) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
mockUsageSeries({
agents: [
mockAgentUsage(agentPubkey, {
usage: reportedUsage({
inputTokens: "1200",
outputTokens: "300",
totalTokens: "1500",
}),
}),
],
coverage: {
firstArchivedAt: 1_700_000_000,
firstReportedAt: 1_700_000_000,
hasUnknownUsage: false,
invalidReportCount: 0,
lastArchivedAt: 1_700_086_400,
lastReportedAt: 1_700_086_400,
reportCount: 1,
},
}),
);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agents-usage-section")).toBeVisible();
const row = page.getByTestId(`agent-usage-row-${agentPubkey}`);
await expect(row).toBeVisible();
await expect(row).toContainText("1.5K");
await expect(row).toContainText("Token Bot");
await page.getByTestId("agent-usage-window-30").click();
await expect(page.getByTestId("agent-usage-window-30")).toHaveAttribute(
"data-state",
"active",
);
});
test("clicking an agent row opens the profile panel's Usage focused view", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Drilldown Bot");
await page.evaluate(
({ series }) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
{
series: mockUsageSeries({
agents: [
mockAgentUsage(agentPubkey, {
models: [
{
hasUnknownUsage: false,
model: "claude-opus",
reportCount: 1,
usage: reportedUsage({ totalTokens: "1500" }),
},
],
usage: reportedUsage({
estimatedCostUsd: 0.42,
inputTokens: "1200",
outputTokens: "300",
totalTokens: "1500",
}),
}),
],
}),
},
);
await page.getByTestId("open-agents-view").click();
await expect(
page.getByTestId(`agent-usage-row-${agentPubkey}`),
).toBeVisible();
await page.getByTestId(`agent-usage-row-${agentPubkey}`).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible();
await expect(page.getByTestId("agent-usage-focused-totals")).toContainText(
"1,500",
);
await expect(page.getByTestId("agent-usage-focused-models")).toContainText(
"claude-opus",
);
// Reaching the same view via the Info-tab ingress row lands on the same
// subview (covers the ProfileIngressRow entry point, not just the
// row-click shortcut).
await page.getByTestId("user-profile-panel-back").click();
await expect(page.getByTestId("user-profile-tab-info")).toBeVisible();
await page.getByTestId(`user-profile-view-usage-${agentPubkey}`).click();
await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible();
});
// A non-owned, non-managed agent seeded purely via `searchProfiles` (no
// managed-agent creation) — used by the ingress-visibility test's
// non-owner leg, which must be hidden regardless of archived evidence.
const NON_OWNER_AGENT_PUBKEY = "7".repeat(64);
test("Info-tab Usage ingress is visible for an owner-viewed agent, and absent for a human or a non-owner agent", async ({
page,
}) => {
await installMockBridge(page, {
agentUsageSeries: mockUsageSeries(),
searchProfiles: [
{
pubkey: NON_OWNER_AGENT_PUBKEY,
displayName: "Someone Else's Bot",
isAgent: true,
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
},
],
});
// Owner case: a locally-managed agent is owned by the mock viewer by
// construction, so `canViewUsage` (viewerIsOwner && isBot) is true.
await openAgentsView(page);
const ownedAgentPubkey = await addGenericAgent(page, "general", "Own Bot");
await page.evaluate(
(series) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
mockUsageSeries({ agents: [mockAgentUsage(ownedAgentPubkey)] }),
);
await page.getByTestId("open-agents-view").click();
await page.getByTestId(`agent-usage-row-${ownedAgentPubkey}`).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await page.getByTestId("user-profile-panel-back").click();
await expect(page.getByTestId("user-profile-tab-info")).toBeVisible();
await expect(
page.getByTestId(`user-profile-view-usage-${ownedAgentPubkey}`),
).toBeVisible();
// Human case: `canViewUsage` requires `isBot`, so a plain human profile
// never renders the row, regardless of ownership.
await page.evaluate((pubkey) => {
(
window as Window & {
__TSR_ROUTER__?: { navigate: (opts: Record<string, unknown>) => void };
}
).__TSR_ROUTER__?.navigate({
to: "/agents",
search: { profile: pubkey },
});
}, TEST_IDENTITIES.bob.pubkey);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await expect(
page.getByTestId(`user-profile-view-usage-${TEST_IDENTITIES.bob.pubkey}`),
).toHaveCount(0);
// Non-owner agent case: `isBot` is true but `viewerIsOwner` is false (the
// declared owner is `outsider`, not the mock viewer) — the row stays
// hidden even though the agent is eligible for the focused view via
// archived evidence (A13 fail-closed is a separate axis from ingress
// visibility).
await page.evaluate((pubkey) => {
(
window as Window & {
__TSR_ROUTER__?: { navigate: (opts: Record<string, unknown>) => void };
}
).__TSR_ROUTER__?.navigate({
to: "/agents",
search: { profile: pubkey },
});
}, NON_OWNER_AGENT_PUBKEY);
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
await expect(
page.getByTestId(`user-profile-view-usage-${NON_OWNER_AGENT_PUBKEY}`),
).toHaveCount(0);
});
test("the usage card fits a compact viewport without horizontal overflow, and its controls stay keyboard-focusable", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Compact Bot");
await page.evaluate(
(series) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
mockUsageSeries({
agents: [mockAgentUsage(agentPubkey)],
buckets: Array.from({ length: 8 }, (_, i) => ({
start: 1_700_000_000 + i * 86_400,
end: 1_700_000_000 + (i + 1) * 86_400,
usage: reportedUsage({ totalTokens: String((i + 1) * 100) }),
reportCount: 1,
hasUnknownUsage: false,
})),
}),
);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agent-usage-card")).toBeVisible();
await page.setViewportSize({ width: 520, height: 900 });
await expect(page.getByTestId("agent-usage-card")).toBeVisible();
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth > window.innerWidth,
);
expect(overflow).toBe(false);
// Window-selector tabs and the agent row remain keyboard-focusable at
// this width — Tab forward from the 7d control must reach 30d, and the
// row must remain reachable and clickable via focus + Enter.
const window7 = page.getByTestId("agent-usage-window-7");
const window30 = page.getByTestId("agent-usage-window-30");
await window7.focus();
await expect(window7).toBeFocused();
// Radix's `Tabs` uses a roving tabindex: only the active tab is in the
// Tab order, and arrow keys move focus (and selection) between tabs.
await page.keyboard.press("ArrowRight");
await expect(window30).toBeFocused();
const row = page.getByTestId(`agent-usage-row-${agentPubkey}`);
await row.focus();
await expect(row).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible();
});
test("surfaces a retry affordance when the usage query fails, and recovers on retry", async ({
page,
}) => {
// React Query's global `retry: 1` (queryClient.ts) auto-retries once
// before the UI ever sees an error, silently consuming one entry of the
// sequence — so two failures are needed before the UI's own error state
// (and its Retry button) appears; the third entry is the button click.
await installMockBridge(page, {
agentUsageErrors: ["archive unavailable", "archive unavailable", null],
});
await openAgentsView(page);
const error = page.getByTestId("agent-usage-error");
await expect(error).toBeVisible();
await expect(error).toContainText("Couldn't load usage data.");
await error.getByRole("button", { name: "Retry" }).click();
await expect(page.getByTestId("agent-usage-card")).toBeVisible();
await expect(page.getByTestId("agent-usage-error")).toHaveCount(0);
});
test("shows the empty state when collection is on but nothing has been archived yet", async ({
page,
}) => {
await installMockBridge(page, {
agentUsageSeries: mockUsageSeries(),
});
await openAgentsView(page);
const empty = page.getByTestId("agent-usage-empty");
await expect(empty).toBeVisible();
await expect(empty).toContainText("No locally archived usage");
await expect(page.getByTestId("agent-usage-collection-off")).toHaveCount(0);
});
test("shows the collection-off banner with a settings deep link, with and without retained data", async ({
page,
}) => {
await installMockBridge(page, {
agentUsageSeries: mockUsageSeries({ collectionEnabled: false }),
});
await openAgentsView(page);
const banner = page.getByTestId("agent-usage-collection-off");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Local usage collection is off.");
await expect(page.getByTestId("agent-usage-empty")).toContainText(
"Turn on collection",
);
await banner
.getByRole("button", { name: "Open Local Archive settings" })
.click();
await expect(page.getByTestId("settings-view")).toBeVisible();
await expect(page.getByTestId("settings-local-archive")).toBeVisible({
timeout: 10_000,
});
await page.goBack();
await expect(page.getByTestId("agents-usage-section")).toBeVisible();
});
test("shows retained-data coverage copy when collection is off but usage was previously archived", async ({
page,
}) => {
await installMockBridge(page, {
agentUsageSeries: mockUsageSeries({
collectionEnabled: false,
coverage: {
firstArchivedAt: 1_700_000_000,
firstReportedAt: 1_700_000_000,
hasUnknownUsage: false,
invalidReportCount: 0,
lastArchivedAt: 1_700_086_400,
lastReportedAt: 1_700_086_400,
reportCount: 3,
},
}),
});
await openAgentsView(page);
await expect(page.getByTestId("agent-usage-collection-off")).toContainText(
"Collection off · data through",
);
});
// A13 fail-closed contract (Rev 3): the focused view's eligibility is
// ownership OR archived evidence, decided only once the author-filtered
// query resolves. Both tests below deep-link straight to
// `?profileView=usage` for a non-owned, non-managed agent (declared owner is
// `outsider`, not the mock viewer) so neither test depends on ownership.
async function openUsageViewForHistoricalAgent(
page: Page,
agentUsageSeries: MockAgentUsageSeries,
) {
await installMockBridge(page, {
agentUsageSeries,
searchProfiles: [
{
pubkey: HISTORICAL_AGENT_PUBKEY,
displayName: "Historical Bot",
isAgent: true,
ownerPubkey: TEST_IDENTITIES.outsider.pubkey,
},
],
});
await page.goto("/#/agents", { waitUntil: "domcontentloaded" });
await expect(page.getByTestId("agents-usage-section")).toBeVisible({
timeout: 10_000,
});
await page.evaluate((pubkey) => {
(
window as Window & {
__TSR_ROUTER__?: {
navigate: (opts: Record<string, unknown>) => void;
};
}
).__TSR_ROUTER__?.navigate({
to: "/agents",
search: { profile: pubkey, profileView: "usage" },
});
}, HISTORICAL_AGENT_PUBKEY);
await expect(page.getByTestId("user-profile-panel")).toBeVisible({
timeout: 10_000,
});
}
test("a historical author with 30d-only archived evidence gets a valid empty 7d focused view, not a redirect", async ({
page,
}) => {
await openUsageViewForHistoricalAgent(
page,
mockUsageSeries({ agents: [], hasArchivedEvidence: true }),
);
const outsideWindow = page.getByTestId("agent-usage-focused-outside-window");
await expect(outsideWindow).toBeVisible();
await expect(outsideWindow).toContainText("Try the 30-day window.");
// Eligible via archived evidence alone (no ownership) — never bounced.
await expectHashSearchParam(page, "profileView", "usage");
const panel = page.getByTestId("user-profile-panel");
await expect(
panel.getByRole("heading", { level: 2, name: "Usage" }),
).toBeVisible();
});
test("a hand-authored usage URL with no ownership and no archived evidence falls back to summary", async ({
page,
}) => {
await openUsageViewForHistoricalAgent(
page,
mockUsageSeries({ agents: [], hasArchivedEvidence: null }),
);
// The redirect from "usage" → null may fire before the first poll
// observes profileView=usage — skip that transient assertion; the null
// landing + summary heading carry the contract.
await expectHashSearchParam(page, "profileView", null);
await expect(page.getByTestId("agent-usage-focused-view")).toHaveCount(0);
const panel = page.getByTestId("user-profile-panel");
await expect(
panel.getByRole("heading", { level: 2, name: "Profile" }),
).toBeVisible();
});
test("renders a Partial badge for an agent whose total is a known lower bound", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Partial Bot");
await page.evaluate(
({ series }) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
{
series: mockUsageSeries({
agents: [
mockAgentUsage(agentPubkey, {
hasUnknownUsage: true,
usage: {
estimatedCostUsd: costField(null),
inputTokens: usageField(null),
outputTokens: usageField(null),
totalTokens: usageField("900", true),
},
}),
],
}),
},
);
await page.getByTestId("open-agents-view").click();
const row = page.getByTestId(`agent-usage-row-${agentPubkey}`);
await expect(row).toBeVisible();
await expect(row.getByText("Partial", { exact: true })).toBeVisible();
});
// ── F4: daily bars behavioral coverage ───────────────────────────────────────
test("overview card renders daily bars with correct accessible labels distinguishing known, unknown, and empty days", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Bar Bot");
// Three top-level buckets: one known (100 tokens), one unknown (reports but
// null total), one empty (zero reports). The daily bar component must never
// encode "unknown" as "zero" — confirmed via aria-label inspection.
const knownStart = 1_700_000_000;
const unknownStart = knownStart + 86_400;
const emptyStart = unknownStart + 86_400;
await page.evaluate(
({ series }) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
{
series: mockUsageSeries({
agents: [mockAgentUsage(agentPubkey, { buckets: [] })],
buckets: [
{
start: knownStart,
end: knownStart + 86_400,
usage: reportedUsage({ totalTokens: "100" }),
reportCount: 1,
hasUnknownUsage: false,
},
{
start: unknownStart,
end: unknownStart + 86_400,
// reportCount > 0 but totalTokens.value null → unknown, not zero
usage: reportedUsage({ totalTokens: null }),
reportCount: 1,
hasUnknownUsage: true,
},
{
start: emptyStart,
end: emptyStart + 86_400,
usage: reportedUsage({ totalTokens: null }),
reportCount: 0,
hasUnknownUsage: false,
},
],
}),
},
);
await page.getByTestId("open-agents-view").click();
await expect(page.getByTestId("agent-usage-overall-bars")).toBeVisible();
const knownBar = page.getByTestId(`agent-usage-daily-bar-${knownStart}`);
const unknownBar = page.getByTestId(`agent-usage-daily-bar-${unknownStart}`);
const emptyBar = page.getByTestId(`agent-usage-daily-bar-${emptyStart}`);
await expect(knownBar).toBeVisible();
await expect(unknownBar).toBeVisible();
await expect(emptyBar).toBeVisible();
// Unknown day must have a distinct label — NOT "no usage reported" and NOT
// a token count. Known and empty must each carry their own label too.
const knownLabel = await knownBar
.locator("[aria-label]")
.first()
.getAttribute("aria-label");
const unknownLabel = await unknownBar
.locator("[aria-label]")
.first()
.getAttribute("aria-label");
const emptyLabel = await emptyBar
.locator("[aria-label]")
.first()
.getAttribute("aria-label");
expect(knownLabel).toMatch(/reported tokens/i);
expect(unknownLabel).toMatch(/unknown usage/i);
expect(emptyLabel).toMatch(/no usage reported/i);
// Verify the three labels are all distinct — unknown must not collapse to zero.
expect(unknownLabel).not.toBe(emptyLabel);
expect(unknownLabel).not.toBe(knownLabel);
});
test("focused view shows daily bars, coverage dates, and a partial explanation when usage is incomplete", async ({
page,
}) => {
await installMockBridge(page);
await openAgentsView(page);
const agentPubkey = await addGenericAgent(page, "general", "Coverage Bot");
const bucketStart = 1_700_000_000;
await page.evaluate(
({ series }) => {
const testWindow = window as Window & {
__BUZZ_E2E__?: { mock?: { agentUsageSeries?: unknown } };
};
testWindow.__BUZZ_E2E__ ??= {};
testWindow.__BUZZ_E2E__.mock ??= {};
testWindow.__BUZZ_E2E__.mock.agentUsageSeries = series;
},
{
series: mockUsageSeries({
agents: [
mockAgentUsage(agentPubkey, {
hasUnknownUsage: true,
reportCount: 3,
buckets: [
{
start: bucketStart,
end: bucketStart + 86_400,
// incomplete=true → triggers partial explanation
usage: reportedUsage({ totalTokens: "500" }),
reportCount: 2,
hasUnknownUsage: true,
},
],
usage: reportedUsage({ totalTokens: "500" }),
}),
],
coverage: {
firstArchivedAt: 1_700_000_000,
firstReportedAt: 1_700_000_000,
hasUnknownUsage: true,
invalidReportCount: 1,
lastArchivedAt: 1_700_086_400,
lastReportedAt: 1_700_086_400,
reportCount: 3,
},
}),
},
);
// Navigate to the focused view via the row click shortcut.
await page.getByTestId("open-agents-view").click();
await expect(
page.getByTestId(`agent-usage-row-${agentPubkey}`),
).toBeVisible();
await page.getByTestId(`agent-usage-row-${agentPubkey}`).click();
await expect(page.getByTestId("agent-usage-focused-view")).toBeVisible();
// Daily bars section must appear in the focused view.
await expect(
page.getByTestId("agent-usage-focused-daily-bars"),
).toBeVisible();
// Coverage section must contain the report count.
const coverage = page.getByTestId("agent-usage-focused-coverage");
await expect(coverage).toBeVisible();
await expect(coverage).toContainText("reported turn");
// The partial explanation must appear when usage is known-incomplete.
// (explainPartial = hasUnknownUsage || invalidReportCount > 0 — both true here.)
await expect(
page.getByTestId("agent-usage-focused-partial-explanation"),
).toBeVisible();
});
+69
View File
@@ -126,6 +126,61 @@ export type MockAgentMemoryListing = {
fetchedAt: number;
};
// ── Agent usage (NIP-AM) mock wire shapes ────────────────────────────────────
// Mirrors `desktop/src/shared/api/tauriArchive.ts`'s camelCase types
// field-for-field, kept local (no cross-file type import) to match this
// file's existing Mock* type convention.
export type MockUsageField = { value: string | null; incomplete: boolean };
export type MockCostField = { value: number | null; incomplete: boolean };
export type MockReportedUsage = {
inputTokens: MockUsageField;
outputTokens: MockUsageField;
totalTokens: MockUsageField;
estimatedCostUsd: MockCostField;
};
export type MockAgentUsageSeriesBucket = {
start: number;
end: number;
usage: MockReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
export type MockAgentUsageModel = {
model: string | null;
usage: MockReportedUsage;
reportCount: number;
hasUnknownUsage: boolean;
};
export type MockAgentUsage = {
agentPubkey: string;
usage: MockReportedUsage;
buckets: MockAgentUsageSeriesBucket[];
models: MockAgentUsageModel[];
reportCount: number;
hasUnknownUsage: boolean;
};
export type MockAgentUsageSeries = {
collectionEnabled: boolean;
buckets: MockAgentUsageSeriesBucket[];
agents: MockAgentUsage[];
coverage: {
firstArchivedAt: number | null;
lastArchivedAt: number | null;
firstReportedAt: number | null;
lastReportedAt: number | null;
reportCount: number;
invalidReportCount: number;
hasUnknownUsage: boolean;
};
hasArchivedEvidence: boolean | null;
};
type MockBridgeOptions = {
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
builderlabAuth?: { email?: string; name?: string; expiresAt: string } | null;
@@ -258,6 +313,20 @@ type MockBridgeOptions = {
* drives the fail-closed path when the policy check itself fails.
*/
observerArchiveDefaultEnabledError?: string;
/**
* Response for the mocked `get_agent_usage_series` command (NIP-AM local
* agent usage). Mirrors `desktop/src/shared/api/tauriArchive.ts`'s
* `AgentUsageSeries` wire shape field-for-field; see e2eBridge mock config
* for the same shape and default (empty, collection-enabled series).
*/
agentUsageSeries?: MockAgentUsageSeries;
/** Sequenced `get_agent_usage_series` failures, call-count indexed
* (mirrors `addChannelMembersErrors`): a string rejects that call; `null`
* succeeds. When exhausted, the last entry repeats. Drives the retry
* error state without deleting mock config mid-test. */
agentUsageErrors?: (string | null)[];
/** Delay (ms) before `get_agent_usage_series` resolves; drives the loading-skeleton state. */
agentUsageDelayMs?: number;
// NIP-IA gate inputs — drive the archive-button gate matrix in
// tests/e2e/identity-archive.spec.ts.
/**