From 86c24a6507f54422c94ffbe23251ee4659f1d7be Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 27 Sep 2026 12:20:37 +0200 Subject: [PATCH] feat(app): read usage reports through report IDs Share report data between agent pills and host views while preserving the existing popover interaction for the next UI phase. --- docs/glossary.md | 3 +- .../e2e/browser/usage-composer-pill.spec.ts | 31 +++++++----- packages/app/e2e/browser/usage-screen.spec.ts | 14 ++++-- .../app/e2e/browser/usage-settings.spec.ts | 5 +- .../app/e2e/support/helpers/usage-reports.ts | 31 +++++++----- packages/app/src/usage/card.tsx | 2 +- packages/app/src/usage/list.tsx | 2 +- packages/app/src/usage/model.test.ts | 4 +- packages/app/src/usage/queries.ts | 50 ++++++++++++++----- public-docs/plugins/reference.md | 11 ++-- 10 files changed, 104 insertions(+), 49 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index c29e741824..4e0111475c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -32,7 +32,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Terminal profile** — A named shell command a terminal can launch. Stored host-wide in daemon config as `terminalProfiles`. UI: "Terminal profiles". Code: `TerminalProfile` (`packages/protocol/src/messages.ts:135`), `resolveTerminalProfiles` (`packages/protocol/src/terminal-profiles.ts`). Also selectable as a New Workspace launch target, where the alternative is the chat agent. - **Provider** — Agent backend (Claude Code, Codex, Copilot, OpenCode, Pi, Oh My Pi). UI: "Provider". Code: `ProviderSnapshotEntry` (`packages/protocol/src/messages.ts:198`). - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). -- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`). +- **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). +- **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`). - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). - **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`). diff --git a/packages/app/e2e/browser/usage-composer-pill.spec.ts b/packages/app/e2e/browser/usage-composer-pill.spec.ts index beb9468925..72f9a7a6d3 100644 --- a/packages/app/e2e/browser/usage-composer-pill.spec.ts +++ b/packages/app/e2e/browser/usage-composer-pill.spec.ts @@ -6,11 +6,13 @@ import { installUsageReportsFixture } from "../support/helpers/usage-reports"; function agentEntry(usedPct: number): UsageReportEntry { return { + id: "fixture:fixture-account", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "fixture", sourceLabel: "Fixture plan", icon: '', report: { - account: { key: "fixture-account" }, status: "available", planLabel: "Test plan", windows: [ @@ -38,18 +40,19 @@ test.describe("usage composer pill", () => { }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { - agentEntries: [agentEntry(42), agentEntry(64)], + agentReportIds: ["fixture:fixture-account"], + lists: [[agentEntry(42)], [agentEntry(64)]], }); const session = await openMockAgent(page); try { const pill = page.getByTestId("usage-composer-pill"); await expect(pill).toContainText("42%", { timeout: 30_000 }); - expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId, forceRefresh: false }); + expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId }); - const requestsBeforeOpen = usage.agentRequests().length; + const requestsBeforeOpen = usage.listRequests().length; await pill.click(); - await usage.waitForAgentRequests(requestsBeforeOpen + 1); - expect(usage.agentRequests().at(-1)?.forceRefresh).toBe(true); + await usage.waitForListRequests(requestsBeforeOpen + 1); + expect(usage.listRequests().at(-1)?.forceRefresh).toBe(true); const popover = page.getByTestId("usage-composer-popover"); await expect(popover.getByText("Fixture plan", { exact: true })).toBeVisible(); @@ -61,25 +64,27 @@ test.describe("usage composer pill", () => { } }); - test("refetches the agent's report when a turn completes", async ({ page }) => { + test("re-resolves the report ID after a turn and reuses the cached report", async ({ page }) => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { - agentEntries: [agentEntry(42), agentEntry(77)], + agentReportIds: ["fixture:fixture-account"], + lists: [[agentEntry(42)]], }); const session = await openMockAgent(page); try { const pill = page.getByTestId("usage-composer-pill"); await expect(pill).toContainText("42%", { timeout: 30_000 }); const requestsBeforeTurn = usage.agentRequests().length; + const listRequestsBeforeTurn = usage.listRequests().length; await submitMessage(page, "emit 1 coalesced agent stream update for usage composer pill."); await usage.waitForAgentRequests(requestsBeforeTurn + 1); expect(usage.agentRequests().at(-1)).toEqual({ agentId: session.agentId, - forceRefresh: false, }); - await expect(pill).toContainText("77%"); + await expect(pill).toContainText("42%"); + expect(usage.listRequests()).toHaveLength(listRequestsBeforeTurn); } finally { await session.cleanup(); } @@ -87,11 +92,12 @@ test.describe("usage composer pill", () => { test("is hidden when the agent has no usage report", async ({ page }) => { test.setTimeout(180_000); - const usage = await installUsageReportsFixture(page, { agentEntries: [null] }); + const usage = await installUsageReportsFixture(page, { agentReportIds: [null] }); const session = await openMockAgent(page); try { await usage.waitForAgentRequests(1); await expect(page.getByTestId("usage-composer-pill")).toHaveCount(0); + expect(usage.listRequests()).toHaveLength(0); } finally { await session.cleanup(); } @@ -101,7 +107,8 @@ test.describe("usage composer pill", () => { test.setTimeout(180_000); const usage = await installUsageReportsFixture(page, { usageSources: false, - agentEntries: [agentEntry(42)], + agentReportIds: ["fixture:fixture-account"], + lists: [[agentEntry(42)]], }); const session = await openMockAgent(page); try { diff --git a/packages/app/e2e/browser/usage-screen.spec.ts b/packages/app/e2e/browser/usage-screen.spec.ts index 3ffa9a65e0..0158beff5e 100644 --- a/packages/app/e2e/browser/usage-screen.spec.ts +++ b/packages/app/e2e/browser/usage-screen.spec.ts @@ -11,18 +11,23 @@ test.describe("usage screen", () => { lists: [ [ { + id: "alpha:a", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "alpha", sourceLabel: "Alpha plan", report: { - account: { key: "a" }, status: "available", windows: [{ id: "weekly", label: "Weekly", usedPct: 31, headline: true }], }, }, { + id: "beta:b", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "beta", sourceLabel: "Beta plan", - report: { account: { key: "b" }, status: "unavailable", windows: [] }, + report: { status: "unavailable", windows: [] }, }, ], ], @@ -47,9 +52,12 @@ test.describe("usage screen", () => { lists: [ [ { + id: "alpha:a", + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: "alpha", sourceLabel: "Alpha plan", - report: { account: { key: "a" }, status: "available", windows: [] }, + report: { status: "available", windows: [] }, }, ], ], diff --git a/packages/app/e2e/browser/usage-settings.spec.ts b/packages/app/e2e/browser/usage-settings.spec.ts index c825f76c3c..623bc51e67 100644 --- a/packages/app/e2e/browser/usage-settings.spec.ts +++ b/packages/app/e2e/browser/usage-settings.spec.ts @@ -13,11 +13,13 @@ function report(input: { report: Partial; }): UsageReportEntry { return { + id: `${input.sourceId}:account`, + account: { label: input.sourceId === "alpha" ? "dev@example.com" : undefined }, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: input.sourceId, sourceLabel: input.sourceLabel, icon: ICON, report: { - account: { key: `${input.sourceId}-account` }, status: "available", windows: [], ...input.report, @@ -37,7 +39,6 @@ test.describe("usage settings", () => { sourceLabel: "Alpha plan", report: { planLabel: "Max", - account: { key: "a", label: "dev@example.com" }, windows: [{ id: "session", label: "Session", usedPct: 7, headline: true }], }, }), diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 5b5938c157..6b2cbbc1c1 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -3,8 +3,8 @@ import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import { daemonWsRoutePattern } from "./daemon-port"; export interface UsageReportsFixture { - listRequests(): Array<{ forceRefresh: boolean }>; - agentRequests(): Array<{ agentId: string; forceRefresh: boolean }>; + listRequests(): Array<{ forceRefresh: boolean; reportIds?: string[] }>; + agentRequests(): Array<{ agentId: string }>; waitForListRequests(count: number): Promise; waitForAgentRequests(count: number): Promise; } @@ -12,8 +12,8 @@ export interface UsageReportsFixture { interface UsageReportsFixtureOptions { /** Successive `usage.list_reports` responses; the last one repeats. */ lists?: UsageReportEntry[][]; - /** Successive `agent.get_usage_report` responses; the last one repeats. */ - agentEntries?: Array; + /** Successive agent report IDs; the last one repeats. */ + agentReportIds?: Array; /** Advertise `features.usageSources`. False simulates a host from before usage sources. */ usageSources?: boolean; } @@ -89,8 +89,8 @@ export async function installUsageReportsFixture( page: Page, options: UsageReportsFixtureOptions, ): Promise { - const listRequests: Array<{ forceRefresh: boolean }> = []; - const agentRequests: Array<{ agentId: string; forceRefresh: boolean }> = []; + const listRequests: Array<{ forceRefresh: boolean; reportIds?: string[] }> = []; + const agentRequests: Array<{ agentId: string }> = []; const listCounter = createCounter(); const agentCounter = createCounter(); const usageSources = options.usageSources ?? true; @@ -102,8 +102,13 @@ export async function installUsageReportsFixture( const request = getSessionMessage(message); const requestId = request?.requestId; if (request?.type === "usage.list_reports.request" && typeof requestId === "string") { - listRequests.push({ forceRefresh: request.forceRefresh === true }); - const reports = pick(options.lists ?? [[]], listRequests.length - 1); + listRequests.push({ + forceRefresh: request.forceRefresh === true, + reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined, + }); + const available = pick(options.lists ?? [[]], listRequests.length - 1); + const ids = Array.isArray(request.reportIds) ? request.reportIds : null; + const reports = ids ? available.filter((entry) => ids.includes(entry.id)) : available; ws.send( JSON.stringify({ type: "session", @@ -113,16 +118,18 @@ export async function installUsageReportsFixture( listCounter.increment(); return; } - if (request?.type === "agent.get_usage_report.request" && typeof requestId === "string") { + if (request?.type === "agent.resolve_usage_report.request" && typeof requestId === "string") { agentRequests.push({ agentId: String(request.agentId), - forceRefresh: request.forceRefresh === true, }); - const entry = pick(options.agentEntries ?? [null], agentRequests.length - 1); + const reportId = pick(options.agentReportIds ?? [null], agentRequests.length - 1); ws.send( JSON.stringify({ type: "session", - message: { type: "agent.get_usage_report.response", payload: { requestId, entry } }, + message: { + type: "agent.resolve_usage_report.response", + payload: { requestId, reportId }, + }, }), ); agentCounter.increment(); diff --git a/packages/app/src/usage/card.tsx b/packages/app/src/usage/card.tsx index 4728b4a94a..d432f80cdf 100644 --- a/packages/app/src/usage/card.tsx +++ b/packages/app/src/usage/card.tsx @@ -21,7 +21,7 @@ export function UsageCard({ }) { const usage = entry.report; const status = statusText(usage); - const footer = usage.account.label ?? null; + const footer = entry.account.label ?? null; const balances = usage.balances ?? []; const details = usage.details ?? []; diff --git a/packages/app/src/usage/list.tsx b/packages/app/src/usage/list.tsx index 24f8ccd67b..8cb0b9f5bc 100644 --- a/packages/app/src/usage/list.tsx +++ b/packages/app/src/usage/list.tsx @@ -9,7 +9,7 @@ export function UsageList({ reports }: { reports: UsageReportEntry[] }) { return ( {reports.map((entry, index) => ( - + {index > 0 ? : null} diff --git a/packages/app/src/usage/model.test.ts b/packages/app/src/usage/model.test.ts index ad73ea761b..e08155fcfc 100644 --- a/packages/app/src/usage/model.test.ts +++ b/packages/app/src/usage/model.test.ts @@ -15,11 +15,13 @@ function entry(input: { sourceId?: string; }): UsageReportEntry { return { + id: `${input.sourceId ?? "fixture"}:account-1`, + account: {}, + fetchedAt: "2026-01-01T00:00:00.000Z", sourceId: input.sourceId ?? "fixture", sourceLabel: "Fixture source", ...(input.icon ? { icon: input.icon } : {}), report: { - account: { key: "account-1" }, status: "available", ...(input.planLabel ? { planLabel: input.planLabel } : {}), windows: input.windows ?? [], diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 8937e37f9b..905458ecce 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -42,12 +42,19 @@ async function listReports(serverId: string, forceRefresh = false): Promise { - return (await requireClient(serverId).getAgentUsageReport({ agentId, forceRefresh })).entry; + return ( + (await requireClient(serverId).listUsageReports({ reportIds: [reportId], forceRefresh })) + .reports[0] ?? null + ); } function supportsUsage(session: SessionState | undefined): boolean { @@ -57,7 +64,12 @@ function supportsUsage(session: SessionState | undefined): boolean { async function refreshReports(queryClient: QueryClient, serverId: string): Promise { await queryClient.fetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId, true), + queryFn: async () => { + const reports = await listReports(serverId, true); + for (const report of reports) + queryClient.setQueryData(reportQueryKey(serverId, report.id), report); + return reports; + }, staleTime: 0, }); } @@ -77,7 +89,12 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), - queryFn: () => listReports(serverId), + queryFn: async () => { + const reports = await listReports(serverId); + for (const report of reports) + queryClient.setQueryData(reportQueryKey(serverId, report.id), report); + return reports; + }, enabled: isConnected && isSupported, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, @@ -156,23 +173,32 @@ export function useAgentUsage( return { model: agent?.model ?? null, isRunning: agent?.status === "running" }; }), ); - const queryKey = agentUsageQueryKey(serverId, agentId, model); - const query = useFetchQuery({ - queryKey, - queryFn: () => getAgentReport(serverId, agentId), + const identity = useFetchQuery({ + queryKey: agentUsageQueryKey(serverId, agentId, model), + queryFn: async () => + (await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId, enabled: isConnected && isSupported && !isRunning, dataShape: "value", staleTimeMs: 0, }); + const reportId = identity.data ?? null; + const query = useFetchQuery({ + queryKey: reportQueryKey(serverId, reportId ?? "none"), + queryFn: () => getReport(serverId, reportId!), + enabled: isConnected && isSupported && reportId !== null, + dataShape: "value", + staleTimeMs: REPORTS_STALE_TIME_MS, + }); const refresh = useCallback(() => { + if (!reportId) return; void queryClient .fetchQuery({ - queryKey: agentUsageQueryKey(serverId, agentId, model), - queryFn: () => getAgentReport(serverId, agentId, true), + queryKey: reportQueryKey(serverId, reportId), + queryFn: () => getReport(serverId, reportId, true), staleTime: 0, }) .catch(() => undefined); - }, [agentId, model, queryClient, serverId]); + }, [queryClient, reportId, serverId]); const entry = isSupported ? (query.data ?? null) : null; return { pill: resolveUsagePill({ supportsUsage: isSupported, entry }), entry, refresh }; } diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index d9c9d87b33..5e7e7a030f 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -306,7 +306,7 @@ SVG or URL. ### Usage sources -**Requires Paseo 0.9.3 or newer.** Server plugins can register a usage source with `server.registerUsageSource()` and import its types and helpers from `@getpaseo/plugin/server/usage`. +**Requires Paseo 0.9.3 or newer.** Server plugins register a usage source with `server.registerUsageSource()` and import types and helpers from `@getpaseo/plugin/server/usage`. ```ts import { z } from "zod"; @@ -321,16 +321,19 @@ export default function contribute(server: PluginServerContext) { icon: "icon.svg", input, discover: async () => [{ account: "default" }], - fetch: async (value) => { + identify: async (value) => { const { account } = input.parse(value); - return { account: { key: account }, status: "available", windows: [] }; + return { key: account }; }, + fetch: async () => ({ status: "available", windows: [] }), }); return () => {}; } ``` -`discover()` supplies host accounts for `usage.list_reports`; return `[]` when none are configured. The daemon validates input against `input` inside the plugin process, caches each source/input result for five minutes, and deduplicates reports by source and `account.key`. `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on the window to show first. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. +`discover()` supplies configured inputs; return `[]` when no account is configured. The daemon also passes each live agent's `session.usage_reference` input to `identify()`. That method returns a stable account key and optional display label without fetching usage, or `null` when there are no credentials. The daemon combines the source ID and key as `:`. The key must be 1–128 characters from `[A-Za-z0-9._-]`, remain stable across token rotation and input routes, and identify the account or organization whose quota is metered. Never use a credential or raw email as the key; use `hashAccountKey(value)` when the only stable identity is sensitive. + +`usage.list_reports` discovers reports when called without IDs, or reads only the requested known IDs. It caches each report for five minutes and `forceRefresh` refreshes only the returned IDs. Each entry carries `id`, `account.label`, and `fetchedAt`; `fetch()` returns a `UsageReport` with `status` (`available`, `unavailable`, or `error`), optional `planLabel`, and generic `windows`, `balances`, and `details`. Set `headline: true` on a window to show it first. The icon is a path to a self-contained SVG under the plugin directory and follows the provider icon restrictions above. ## Entry point and cleanup