mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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.
This commit is contained in:
+2
-1
@@ -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: `<sourceId>:<accountKey>`. 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`).
|
||||
|
||||
@@ -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: '<svg viewBox="0 0 24 24"><rect width="24" height="24" fill="currentColor"/></svg>',
|
||||
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 {
|
||||
|
||||
@@ -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: [] },
|
||||
},
|
||||
],
|
||||
],
|
||||
|
||||
@@ -13,11 +13,13 @@ function report(input: {
|
||||
report: Partial<UsageReportEntry["report"]>;
|
||||
}): 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 }],
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<void>;
|
||||
waitForAgentRequests(count: number): Promise<void>;
|
||||
}
|
||||
@@ -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<UsageReportEntry | null>;
|
||||
/** Successive agent report IDs; the last one repeats. */
|
||||
agentReportIds?: Array<string | null>;
|
||||
/** 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<UsageReportsFixture> {
|
||||
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();
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export function UsageList({ reports }: { reports: UsageReportEntry[] }) {
|
||||
return (
|
||||
<View style={settingsStyles.card}>
|
||||
{reports.map((entry, index) => (
|
||||
<Fragment key={`${entry.sourceId}:${entry.report.account.key}`}>
|
||||
<Fragment key={entry.id}>
|
||||
{index > 0 ? <View style={styles.divider} /> : null}
|
||||
<UsageCard entry={entry} />
|
||||
</Fragment>
|
||||
|
||||
@@ -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 ?? [],
|
||||
|
||||
@@ -42,12 +42,19 @@ async function listReports(serverId: string, forceRefresh = false): Promise<Usag
|
||||
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
|
||||
}
|
||||
|
||||
async function getAgentReport(
|
||||
function reportQueryKey(serverId: string, reportId: string) {
|
||||
return ["usage", "report", serverId, reportId] as const;
|
||||
}
|
||||
|
||||
async function getReport(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
reportId: string,
|
||||
forceRefresh = false,
|
||||
): Promise<UsageReportEntry | null> {
|
||||
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<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 `<sourceId>:<accountKey>`. 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user