mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
Will Pfleger
parent
4f41319220
commit
a073cc8b84
@@ -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"],
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>(
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user