diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 9c35447e8..7a2f08559 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -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"], diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b302f7c3f..76499ad13 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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({ diff --git a/desktop/src-tauri/src/archive/agent_usage_tests.rs b/desktop/src-tauri/src/archive/agent_usage_tests.rs index f28c99a68..4e08c31c7 100644 --- a/desktop/src-tauri/src/archive/agent_usage_tests.rs +++ b/desktop/src-tauri/src/archive/agent_usage_tests.rs @@ -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::::MAX_UTC.timestamp(); + let base = max_ts - 6 * DAY; + let b: Vec = (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 diff --git a/desktop/src/features/agent-usage/hooks.test.mjs b/desktop/src/features/agent-usage/hooks.test.mjs new file mode 100644 index 000000000..64128961d --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.test.mjs @@ -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(); + } +}); diff --git a/desktop/src/features/agent-usage/hooks.ts b/desktop/src/features/agent-usage/hooks.ts new file mode 100644 index 000000000..8a6c09bc9 --- /dev/null +++ b/desktop/src/features/agent-usage/hooks.ts @@ -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({ + queryKey: agentUsageQueryKey(boundaries, agentPubkey), + queryFn: () => + getAgentUsageSeries({ bucketBoundaries: boundaries, agentPubkey }), + enabled, + staleTime: 60_000, + gcTime: 5 * 60_000, + }); +} diff --git a/desktop/src/features/agent-usage/lib/agentUsage.test.mjs b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs new file mode 100644 index 000000000..67ef28447 --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.test.mjs @@ -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"); +}); diff --git a/desktop/src/features/agent-usage/lib/agentUsage.ts b/desktop/src/features/agent-usage/lib/agentUsage.ts new file mode 100644 index 000000000..33fc4977d --- /dev/null +++ b/desktop/src/features/agent-usage/lib/agentUsage.ts @@ -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 = { item: T; totalTokens: bigint | null }; + +function rankByKnownTotal( + items: readonly T[], + totalTokens: (item: T) => UsageField, + tiebreak: (a: T, b: T) => number, +): T[] { + const withTotals: Ranked[] = 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 }; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx new file mode 100644 index 000000000..ec6c8a681 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageDailyBars.tsx @@ -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((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 ( +
+ {buckets.map((bucket) => ( + + ))} +
+ ); +} + +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 ( +
+
+ {kind === "unknown" ? ( +
+ ) : ( +
+ )} +
+ + {trailingText} + +
+ ); +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx new file mode 100644 index 000000000..92709e143 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageFocusedView.tsx @@ -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(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 ( +
+ setDays(value === "30" ? 30 : 7)} + value={String(days)} + > + + + 7d + + + 30d + + + + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageFocusedSkeleton() { + return ( + + + + + + ); +} + +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 ( +

+ No locally archived usage in the last {days} days. Usage appears after + this agent completes a usage-reporting turn. +

+ ); + } + + return ( +
+ {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {agent ? ( + + ) : hasEvidenceOutsideWindow ? ( +

+ No locally archived usage in the last {days} days, but this agent has + reported usage previously. Try the 30-day window. +

+ ) : null} +
+ ); +} + +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 ( + +
+ + + + +
+ + {agent.buckets.length > 0 ? ( +
+

Daily usage

+ +
+ ) : null} + + {models.length > 0 ? ( +
+

By model

+ {models.map((model) => ( +
+ + {model.model ?? "Unknown model"} + + + {isUnknownField(model.usage.totalTokens) + ? "No usage reported" + : formatTokenCountExact( + parseTokenCount(model.usage.totalTokens.value) ?? 0n, + )} + {isPartialField(model.usage.totalTokens) ? ( + + Partial + + ) : null} + +
+ ))} +
+ ) : null} + +
+

+ {agent.reportCount} reported turn{agent.reportCount === 1 ? "" : "s"} + {" · "} + {formatCoverageRange(coverage)} +

+ {explainPartial ? ( +

+ 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. +

+ ) : null} +
+
+ ); +} + +function TokenStat({ + field, + label, +}: { + field: { value: string | null; incomplete: boolean }; + label: string; +}) { + const parsed = parseTokenCount(field.value); + return ( + + ); +} + +function UsageStat({ + display, + isPartial, + label, +}: { + display: string | null; + isPartial: boolean; + label: string; +}) { + return ( +
+

{label}

+

{display ?? "—"}

+ {isPartial ? Partial : null} +
+ ); +} + +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)}`; +} diff --git a/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx new file mode 100644 index 000000000..c0fe6da15 --- /dev/null +++ b/desktop/src/features/agent-usage/ui/AgentUsageSection.tsx @@ -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(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 ( +
+ setDays(value === "30" ? 30 : 7)} + value={String(days)} + > + + + 7d + + + 30d + + + + } + description="Locally archived, agent-reported usage." + title="Usage" + /> + + {query.isLoading ? ( + + ) : query.isError ? ( + + + Couldn't load usage data. + + + + ) : query.data ? ( + + ) : null} +
+ ); +} + +function AgentUsageSkeleton() { + return ( + + + + + + + ); +} + +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((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 ( + + {series.buckets.length > 0 ? ( +
+
+

Daily usage

+ + {overallTotal.knownTotal !== null + ? `${formatTokenCountCompact(overallTotal.knownTotal)} tokens` + : "No usage reported"} + {overallTotal.partial ? ( + + Partial + + ) : null} + +
+ +
+ ) : null} + + {collectionOff ? ( + + + + {hasRetainedData + ? `Collection off · data through ${formatCoverageDate( + series.coverage.lastArchivedAt, + )}` + : "Local usage collection is off."} + + + + + ) : null} + + {hasRows ? ( +
+ {agents.map((agent) => ( + + ))} +
+ ) : ( +

+ {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.`} +

+ )} +
+ ); +} + +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 ( + + ); +} + +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"; +} diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 361199c50..bbb528c5a 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -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], diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f750e266d..5aac0a66b 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -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() { }} /> + { + openProfilePanel?.(pubkey, options); + }} + /> + 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 ? ( + setView("summary", { replace: true })} + /> + ) : null} {view === "info" ? ( ) : null} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 2a46dcc7a..0eb0dc0f1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -68,6 +68,8 @@ export type ProfileSummaryViewProps = { canEditAgent: boolean; canOpenAgentLogs: boolean; canViewActivity: boolean; + canViewUsage: boolean; + usageIngressTrailing: string | undefined; channelCount: number; channelIdToName: Record; 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; 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" ? ( diff --git a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx index 0fe4e1958..3f59b9fbc 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelTabs.tsx @@ -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 ? ( + + ) : null} {hasInfoFields ? : null} {hasInstances ? ( { "memories", "channels", "logs", + "usage", ]) { assert.equal(parseProfilePanelView(view), view); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts index 07f57803b..f547a407d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelUtils.ts +++ b/desktop/src/features/profile/ui/UserProfilePanelUtils.ts @@ -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 = { memories: "Memories", channels: "Channels", logs: "Harness Log", + usage: "Usage", }; const PROFILE_PANEL_VIEWS = new Set( diff --git a/desktop/src/shared/api/tauriArchive.ts b/desktop/src/shared/api/tauriArchive.ts index dc0b704d6..96c2e6d7b 100644 --- a/desktop/src/shared/api/tauriArchive.ts +++ b/desktop/src/shared/api/tauriArchive.ts @@ -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 { + return invokeTauri("get_agent_usage_series", { request }); +} + /** * Read a paginated page of archived raw events for a scope. * diff --git a/desktop/src/shared/context/ProfilePanelContext.tsx b/desktop/src/shared/context/ProfilePanelContext.tsx index 3f47364c5..4127172f0 100644 --- a/desktop/src/shared/context/ProfilePanelContext.tsx +++ b/desktop/src/shared/context/ProfilePanelContext.tsx @@ -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 = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 50985cc6c..d1c74a8f2 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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; 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": { diff --git a/desktop/tests/e2e/agent-usage.spec.ts b/desktop/tests/e2e/agent-usage.spec.ts new file mode 100644 index 000000000..8c4c9c070 --- /dev/null +++ b/desktop/tests/e2e/agent-usage.spec.ts @@ -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 { + return { + agentPubkey, + buckets: [], + hasUnknownUsage: false, + models: [], + reportCount: 1, + usage: reportedUsage({ totalTokens: "1500" }), + ...overrides, + }; +} + +function mockUsageSeries( + overrides: Partial = {}, +): 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 { + 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 => { + const invoke = ( + window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => 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; + }; + } + ).__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) => 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) => 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) => 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(); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 0fd8338ce..87107c85c 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -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. /**