mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
feat(usage): show each agent's own plan usage in its context window details (#5975)
* feat(usage): stream reports for live agent accounts Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately. * fix(usage): retain account scope for open agent sessions Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract. * test(usage): update browser fixtures for scoped report streams * feat(usage): let a usage card show window rows without pins UsageCard takes a required pinnable prop. When false, a window row is a plain bar: no Pressable, hover highlight, or pin glyph. The settings Usage list passes true. * feat(usage): show the agent's usage in the context window popover Reports stream into the query cache as each update arrives, for the host list and for a new per-agent query keyed by agent. The context window popover fetches its agent's reports while open and renders one unpinnable card per report as it lands, a loading sentence before the first, the request error as a sentence, and nothing on hosts without usage reports. * feat(usage): hide the Refresh button on popover usage cards UsageCard takes a required refreshable prop. The popover passes false because its content takes no pointer events, so the button could not be pressed; the card then shows its freshness inline. The settings Usage list keeps the button. * feat(usage): make the sidebar Usage summary opt-in The sidebar Usage item starts hidden on every layout, not only on compact ones, so resolveSidebarNavItems drops its compact input. While the summary is off, the Usage list renders window rows without pins; saved pins stay in preferences and return when it is turned on. Specs that read the summary now turn it on first. * docs(glossary): describe the popover usage card and the opt-in sidebar summary * fix(usage): show a popover request error without retrying A daemon error for an agent's usage, such as an unknown agent, does not heal on retry, and reopening the popover fetches again. Retrying held the loading sentence for about seven seconds before the error. * test(usage): cover agent usage in the context window popover The usage fixture records agentId, streams reports held behind promises, and can switch host support between reloads. The meter spec walks loading, streaming, a report problem, a request error and a host without usage reports, on desktop and compact in both themes. * fix(usage): keep the plain context window tooltip on hosts without usage The popover widened to fit usage cards on every host. Hosts that cannot report usage now keep the tooltip's default padding and width, as before, and mount no usage section. * fix(usage): let a report's Refresh replace it in the agent's list too Refresh wrote the refreshed report only into the host's list, so a card rendered from an agent's list kept the old report. Both lists now sit under one host key and Refresh replaces the report in each that holds it. * feat(usage): open the context window details in a sheet on compact screens Wide screens keep the tooltip. On compact screens pressing the meter opens a content-sized bottom sheet titled Context window. Both render one ContextWindowDetails body; only the sheet's usage cards have Refresh, since the tooltip cannot be pressed. Pins stay off in both. * test(usage): cover the compact context window sheet On the compact viewport the meter opens the sheet; its cards have Refresh, a Refresh replaces the card, and Close dismisses the sheet. The glossary names the sheet. * feat(ui): give tooltips and the context window sheet accessible roles Tooltip content is role=tooltip, and the context window sheet is a dialog named Context window, so assistive tech and tests find them by role. The agent usage message drops its test ID; it is found by its text. * test(usage): reshape the context window and Usage switch specs as journeys The context window spec runs one tooltip journey and one sheet journey per theme, with no layout branches. Its fixture script, meter actions and report builders move to a context-window helper, and it finds the meter, tooltip, sheet and Refresh buttons by role. The theme comes from the system colour scheme. The Usage switch test uses named helpers for the Usage icon and the Summary in sidebar switch. * fix(usage): say an agent's usage request failed instead of showing older reports A failed request keeps whatever reports the query held: those from an earlier open, or those that streamed in before the failure. The context window details showed them as if they were the agent's current usage. The request error now wins over held reports. * test(usage): let a fixture clean up the context window spec's agent The tooltip and sheet tests no longer wrap their steps in try/finally; a spec-local fixture seeds the agent and removes it after the test. * fix(usage): keep a refreshed report when the list it is in finishes Pressing Refresh on one card while another source was still streaming put the pre-refresh report back: the finished list replaced the query's data with the copies the request had fetched when it started. A report now keeps whichever copy was fetched later, both when a streamed copy arrives and when the finished list replaces the streamed one. * fix(usage): show only the login a resumed agent runs under now An agent resumed under another login keeps its ID, so its usage query kept the previous login's reports: reopening the context window showed them while the new login's reports loaded, then both side by side until the request finished. The agent's usage is now dropped as soon as its tooltip or sheet closes, so each open loads the login the agent runs under at that moment. * fix(usage): stop a closed agent usage request from writing reports Closing the context window details cancels the agent's usage request and drops reports still on their way, so reopening after a resume under another login sends a new request instead of joining the old one.
This commit is contained in:
+2
-2
@@ -33,8 +33,8 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **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`).
|
||||
- **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`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.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. Each report renders as one card on the Usage screen, in host Settings, and in an agent's context window details (a tooltip on wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. Only the sheet has Refresh. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`).
|
||||
- **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon still opens the screen); pressing it opens the Usage screen (a bottom sheet on phones). Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/pinned.ts`, `usage/preferences.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`).
|
||||
|
||||
+1
-1
@@ -483,7 +483,7 @@ SVG or URL.
|
||||
|
||||
## Usage sources
|
||||
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Keep discovery independent of agent sessions and provider names: a harness can use a subscription through a proxy or renamed provider. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery and credential-store reads; the daemon owns account grouping, ordered login fallback, and the fetch cache. Scope discovery explicitly: global queries inspect machine stores; session queries inspect only the live harness's selected stores. The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same sanitized SVG file rules as provider icons.
|
||||
|
||||
The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. See the [public usage source reference](../public-docs/plugins/reference.md#usage-sources) for the author contract and minimum version.
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ checkout.forge.set_auto_merge.request;
|
||||
// -> checkout.forge.set_auto_merge.response
|
||||
```
|
||||
|
||||
Most new RPCs should follow this shape. If a request does not have a one-to-one response, call that out in the code near the schema.
|
||||
A finite streaming request can emit `.update` messages before its terminal `.response`.
|
||||
`usage.list_reports.request` uses this shape: each `.update` carries one settled report, and
|
||||
`.response` carries the request-level error or `null`. All three share `requestId`; no updates
|
||||
follow the response. An empty successful stream is an empty list. Register updates as replies
|
||||
of the originating request so source-owned delivery routes them to the requesting connection.
|
||||
|
||||
Most new RPCs should follow these shapes. If a request does not have a one-to-one terminal response, call that out in the code near the schema.
|
||||
|
||||
## Message Shape
|
||||
|
||||
|
||||
@@ -1,7 +1,40 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test as base } from "../support/fixtures";
|
||||
import { expectComposerVisible } from "../support/helpers/composer";
|
||||
import {
|
||||
type AgentUsageScript,
|
||||
closeContextWindowSheet,
|
||||
contextWindowSheet,
|
||||
expectRefreshButtons,
|
||||
expiredLogin,
|
||||
gate,
|
||||
hoverContextWindowMeter,
|
||||
leaveContextWindowMeter,
|
||||
type MockAgentSession,
|
||||
onPersonalLogin,
|
||||
onWorkLogin,
|
||||
openAgent,
|
||||
pressContextWindowMeter,
|
||||
qaScreenshot,
|
||||
refreshUsageCard,
|
||||
reloadAgent,
|
||||
scriptAgentUsage,
|
||||
seedAgentWithContextWindow,
|
||||
usageCard,
|
||||
} from "../support/helpers/context-window";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import { claudeAndCodexReports, expectUnpinnableRows } from "../support/helpers/usage-sidebar-item";
|
||||
|
||||
const test = base.extend<{ agent: MockAgentSession }>({
|
||||
agent: async ({ page: _page }, provide) => {
|
||||
const agent = await seedAgentWithContextWindow();
|
||||
try {
|
||||
await provide(agent);
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Where the progress arc is painted, as its centroid relative to the ring's centre in pixels.
|
||||
// Reads the rendered pixels, so any rotation that does not reach the screen counts as none.
|
||||
@@ -69,3 +102,184 @@ test.describe("context window meter", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const COMPACT = { width: 390, height: 844 };
|
||||
const LOGIN_EXPIRED = /^Login expired .*Run claude to refresh it\.$/;
|
||||
|
||||
type OpenDetails = (page: Page) => Promise<Locator>;
|
||||
type Shot = (state: string) => Promise<void>;
|
||||
|
||||
/** Two reports on the agent's login stream in; the details show each card as it arrives. */
|
||||
async function expectCardsToStreamIn(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<Locator> {
|
||||
const [claude, codex] = claudeAndCodexReports();
|
||||
const first = gate();
|
||||
const second = gate();
|
||||
usage.answerNext({
|
||||
stream: [first.promise, onWorkLogin(claude!), second.promise, onWorkLogin(codex!)],
|
||||
});
|
||||
const details = await openDetails(page);
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await shot("loading");
|
||||
|
||||
first.open();
|
||||
await expect(
|
||||
usageCard(details, "claude:work").getByText("work@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
||||
await expect(usageCard(details, "codex:work")).toHaveCount(0);
|
||||
await shot("streaming");
|
||||
|
||||
second.open();
|
||||
await expect(
|
||||
usageCard(details, "codex:work").getByText("Session", { exact: true }),
|
||||
).toBeVisible();
|
||||
// Only the agent's login, never the host's default one, and no pins.
|
||||
await expect(details.getByText("dev@example.com", { exact: true })).toHaveCount(0);
|
||||
await expectUnpinnableRows(details);
|
||||
await expect(details.getByText("Updated just now", { exact: true })).toHaveCount(2);
|
||||
return details;
|
||||
}
|
||||
|
||||
async function expectFailedRequestSentence(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<void> {
|
||||
usage.answerNext({ error: "Unknown agent" });
|
||||
await reloadAgent(page);
|
||||
const details = await openDetails(page);
|
||||
await expect(
|
||||
details.getByText("Unable to load usage: Unknown agent", { exact: true }),
|
||||
).toBeVisible();
|
||||
await shot("error");
|
||||
}
|
||||
|
||||
async function expectOnlyContextWindowWithoutUsage(
|
||||
page: Page,
|
||||
usage: AgentUsageScript,
|
||||
openDetails: OpenDetails,
|
||||
shot: Shot,
|
||||
): Promise<void> {
|
||||
usage.stopReportingUsage();
|
||||
await reloadAgent(page);
|
||||
const details = await openDetails(page);
|
||||
await expect(details.getByText("25% used", { exact: true })).toBeVisible();
|
||||
await expect(details.getByText("Loading usage...", { exact: true })).toHaveCount(0);
|
||||
await expect(details.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
await shot("unsupported");
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`the context window tooltip shows the agent's usage (${theme})`, async ({ page, agent }) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `tooltip-desktop-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, without Refresh", async () => {
|
||||
const tooltip = await expectCardsToStreamIn(page, usage, hoverContextWindowMeter, shot);
|
||||
// The tooltip cannot be pressed, so its cards have no Refresh.
|
||||
await expectRefreshButtons(tooltip, 0);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("after a resume under another login, only that login shows", async () => {
|
||||
// Closed while the old login's report is still on its way.
|
||||
await leaveContextWindowMeter(page);
|
||||
const slowSource = gate();
|
||||
usage.answerNext({ stream: [slowSource.promise, onWorkLogin(claude!)] });
|
||||
const closedEarly = await hoverContextWindowMeter(page);
|
||||
await expect(closedEarly.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await leaveContextWindowMeter(page);
|
||||
|
||||
const resumed = gate();
|
||||
const finished = gate();
|
||||
usage.answerNext({
|
||||
stream: [resumed.promise, onPersonalLogin(claude!), finished.promise],
|
||||
});
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText("Loading usage...", { exact: true })).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
|
||||
// The old login's report lands during the new request, before the new login's.
|
||||
slowSource.open();
|
||||
resumed.open();
|
||||
await expect(
|
||||
usageCard(tooltip, "claude:personal").getByText("personal@example.com", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(tooltip.getByText("work@example.com", { exact: true })).toHaveCount(0);
|
||||
finished.open();
|
||||
});
|
||||
|
||||
await test.step("a report with a problem shows it on the card", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
await reloadAgent(page);
|
||||
const tooltip = await hoverContextWindowMeter(page);
|
||||
await expect(tooltip.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
await test.step("a failed request says so in a sentence", async () => {
|
||||
await expectFailedRequestSentence(page, usage, hoverContextWindowMeter, shot);
|
||||
});
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, hoverContextWindowMeter, shot);
|
||||
// One request per open that had usage; this one sends none.
|
||||
expect(usage.agentRequests()).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
test(`the context window sheet shows the agent's usage and refreshes it (${theme})`, async ({
|
||||
page,
|
||||
agent,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const [claude] = claudeAndCodexReports();
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.emulateMedia({ colorScheme: theme });
|
||||
await page.setViewportSize(COMPACT);
|
||||
await openAgent(page, agent);
|
||||
const shot: Shot = (state) => qaScreenshot(page, `sheet-compact-${theme}-${state}`);
|
||||
|
||||
await test.step("reports stream in one card at a time, each with Refresh", async () => {
|
||||
const sheet = await expectCardsToStreamIn(page, usage, pressContextWindowMeter, shot);
|
||||
await expectRefreshButtons(sheet, 2);
|
||||
await shot("ready");
|
||||
expect(usage.agentRequests()).toEqual([agent.agentId]);
|
||||
});
|
||||
|
||||
await test.step("Refresh replaces the card with the source's new report", async () => {
|
||||
usage.answerNext([expiredLogin(onWorkLogin(claude!))]);
|
||||
const sheet = contextWindowSheet(page);
|
||||
await refreshUsageCard(sheet, "Claude");
|
||||
await expect(sheet.getByText(LOGIN_EXPIRED)).toBeVisible();
|
||||
expect(usage.refreshedReports()).toEqual([["claude:work"]]);
|
||||
await shot("problem");
|
||||
});
|
||||
|
||||
await test.step("Close dismisses the sheet", async () => {
|
||||
await closeContextWindowSheet(page);
|
||||
});
|
||||
|
||||
await test.step("a failed request says so in a sentence", async () => {
|
||||
await expectFailedRequestSentence(page, usage, pressContextWindowMeter, shot);
|
||||
});
|
||||
|
||||
await test.step("a host without usage reports shows only the context window", async () => {
|
||||
await expectOnlyContextWindowWithoutUsage(page, usage, pressContextWindowMeter, shot);
|
||||
expect(usage.agentRequests()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import {
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
seedSidebarFooterPreferences,
|
||||
setFooterItemVisible,
|
||||
} from "../support/helpers/sidebar-nav-settings";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
@@ -154,8 +155,9 @@ test.describe("Plugin sidebar items", () => {
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
// The Usage item shows only with summary data.
|
||||
// The Usage item shows only with summary data, once it is turned on.
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
const row = headerRow(page, SHOWCASE_PLUGIN_ID, "deploys");
|
||||
|
||||
@@ -34,7 +34,10 @@ import {
|
||||
test("fixed footer line keeps its five icons, Help and Settings at the end", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await seedSidebarFooterPreferences(page, [{ key: "add-project", visible: false }]);
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "add-project", visible: false },
|
||||
{ key: "usage", visible: true },
|
||||
]);
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
@@ -188,6 +191,7 @@ test.describe("Sidebar footer rows in Appearance settings", () => {
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
// Keys for the fixed footer icon buttons are ignored.
|
||||
await seedSidebarFooterPreferences(page, [
|
||||
{ key: "usage", visible: true },
|
||||
{ key: "help", visible: false },
|
||||
{ key: "hosts", visible: false },
|
||||
{ key: "import", visible: false },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import { addConnectedHostAndReload } from "../support/helpers/hosts";
|
||||
import { startIsolatedHostDaemon } from "../support/helpers/isolated-host-daemon";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings";
|
||||
import {
|
||||
installUsageReportsFixture,
|
||||
type UsageListRequest,
|
||||
@@ -57,6 +58,11 @@ test.use({
|
||||
},
|
||||
});
|
||||
|
||||
// These tests read the sidebar summary and pin windows, which both need the Usage item on.
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
});
|
||||
|
||||
function forcedRefreshes(usage: UsageReportsFixture): UsageListRequest[] {
|
||||
return usage.listRequests().filter((request) => request.forceRefresh);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { gotoAppShell, openSettings } from "../support/helpers/app";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { openSettingsHostSection } from "../support/helpers/settings";
|
||||
import {
|
||||
expectFooterItemSetting,
|
||||
expectFooterSeparator,
|
||||
leaveSettings,
|
||||
openSidebarNavSettings,
|
||||
@@ -16,9 +17,13 @@ import {
|
||||
expectNoUsageItem,
|
||||
expectOnUsageScreen,
|
||||
expectPinnedUsage,
|
||||
expectSummaryInSidebar,
|
||||
expectUnpinnableRows,
|
||||
leaveUsageScreen,
|
||||
openCompactSidebar,
|
||||
openUsageScreenFromIcon,
|
||||
pinRow,
|
||||
setSummaryInSidebar,
|
||||
openUsageOptions,
|
||||
showUsageAs,
|
||||
togglePin,
|
||||
@@ -93,18 +98,15 @@ test.describe("Usage item", () => {
|
||||
await gotoAppShell(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
|
||||
await test.step("a fresh device shows default windows, which opens the Usage screen", async () => {
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await test.step("a fresh device shows default windows once the Usage item is on", async () => {
|
||||
// The Usage item starts off on every layout; turning it on stores the choice.
|
||||
await expectNoUsageItem(page);
|
||||
await openSidebarNavSettings(page);
|
||||
await expectFooterItemSetting(page, "usage", false);
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await qaScreenshot(page, "desktop-settings-sidebar-footer");
|
||||
await page.setViewportSize(COMPACT);
|
||||
// Phones start with the Usage item off; turning it on stores the choice.
|
||||
await expect(
|
||||
page.getByTestId("sidebar-nav-section-footer").getByTestId("sidebar-nav-toggle-usage"),
|
||||
).toHaveAttribute("aria-checked", "false");
|
||||
await setFooterItemVisible(page, "usage", true);
|
||||
await expectFooterItemSetting(page, "usage", true);
|
||||
await qaScreenshot(page, "compact-settings-sidebar-footer");
|
||||
await gotoAppShell(page);
|
||||
await openCompactSidebar(page);
|
||||
@@ -112,6 +114,8 @@ test.describe("Usage item", () => {
|
||||
await qaScreenshot(page, "compact-footer-defaults");
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "desktop-footer-defaults", { kind: "footer" });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
await qaScreenshot(page, "default-pins-card");
|
||||
@@ -298,26 +302,49 @@ test("without summary data the footer drops the Usage item and keeps the Usage i
|
||||
await expectOnUsageScreen(page);
|
||||
});
|
||||
|
||||
test("the Usage Settings show and hide the sidebar Usage item", async ({ page }) => {
|
||||
test("the Usage Settings switch turns on the sidebar summary and the pins with it", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] });
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expect(usageItem(page)).toBeVisible({ timeout: 30_000 });
|
||||
await usageItem(page).click();
|
||||
await expectOnUsageScreen(page);
|
||||
const screen = page.getByTestId(`usage-host-${serverId}`);
|
||||
const claude = screen.getByTestId("usage-report-claude:default");
|
||||
|
||||
await openUsageOptions(page);
|
||||
const toggle = page.getByTestId("usage-show-in-sidebar").getByRole("switch");
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "true");
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-testid="sidebar-usage"]:visible')).toHaveCount(0);
|
||||
await test.step("off by default: no Usage item, and rows that do not pin", async () => {
|
||||
await openUsageScreenFromIcon(page);
|
||||
await expectNoUsageItem(page);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await expectUnpinnableRows(screen.getByTestId("usage-report-codex:default"));
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await qaScreenshot(page, "usage-screen-summary-off");
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await openUsageOptions(page);
|
||||
await expect(toggle).toHaveAttribute("aria-checked", "false");
|
||||
await toggle.click();
|
||||
await expect(usageItem(page)).toBeVisible();
|
||||
await test.step("on: the Usage item and the pins appear", async () => {
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).toBeChecked();
|
||||
await togglePin(screen, "Claude", "Weekly");
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await qaScreenshot(page, "usage-screen-summary-on");
|
||||
});
|
||||
|
||||
await test.step("off then on again, after a reload, brings back the saved pins", async () => {
|
||||
await setSummaryInSidebar(page, false);
|
||||
await expectNoUsageItem(page);
|
||||
await expectUnpinnableRows(claude);
|
||||
await page.reload();
|
||||
await expectOnUsageScreen(page);
|
||||
await expectSummaryInSidebar(page, false);
|
||||
await expect(claude.getByText("Session", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expectUnpinnableRows(claude);
|
||||
await setSummaryInSidebar(page, true);
|
||||
await expectPinnedUsage(page, ["31% 5h", "7% 5h", "12% wk"]);
|
||||
await expect(pinRow(screen, "Claude", "Session")).toBeChecked();
|
||||
await expect(pinRow(screen, "Claude", "Weekly")).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
test("released hosts supply source logos through the client conversion", async ({ page }) => {
|
||||
@@ -325,6 +352,7 @@ test("released hosts supply source logos through the client conversion", async (
|
||||
lists: [() => claudeAndCodexReports()],
|
||||
providerUsageListOnly: true,
|
||||
});
|
||||
await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]);
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoAppShell(page);
|
||||
await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]);
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import path from "node:path";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expectComposerVisible } from "./composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "./mock-agent";
|
||||
import { installUsageReportsFixture, type UsageListResponse } from "./usage-reports";
|
||||
import { claudeAndCodexReports } from "./usage-sidebar-item";
|
||||
|
||||
// 32,000 of the mock's 128,000-token window.
|
||||
const METER_NAME = "Context window 25% used";
|
||||
|
||||
export type MockAgentSession = Awaited<ReturnType<typeof seedMockAgentWorkspace>>;
|
||||
|
||||
/** An agent a quarter into its context window. */
|
||||
export function seedAgentWithContextWindow(): Promise<MockAgentSession> {
|
||||
return seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-usage-",
|
||||
title: "Context window usage e2e",
|
||||
initialPrompt: "emit 32000 byte file agent stream payload",
|
||||
});
|
||||
}
|
||||
|
||||
export async function openAgent(page: Page, session: MockAgentSession): Promise<void> {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
export async function reloadAgent(page: Page): Promise<void> {
|
||||
await page.reload({ waitUntil: "commit" });
|
||||
await expectComposerVisible(page);
|
||||
}
|
||||
|
||||
/** Wide screens show the meter's details in a tooltip while the pointer is on it. */
|
||||
export async function hoverContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("img", { name: METER_NAME }).hover({ timeout: 30_000 });
|
||||
const tooltip = page.getByRole("tooltip").filter({ hasText: "Context window" });
|
||||
await expect(tooltip).toBeVisible();
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole("tooltip").filter({ hasText: "Context window" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page): Promise<Locator> {
|
||||
await page.getByRole("button", { name: METER_NAME }).click({ timeout: 30_000 });
|
||||
const sheet = contextWindowSheet(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
const sheet = contextWindowSheet(page);
|
||||
await sheet.getByRole("button", { name: "Close", exact: true }).click();
|
||||
await expect(sheet).toHaveCount(0);
|
||||
}
|
||||
|
||||
export function contextWindowSheet(page: Page): Locator {
|
||||
return page.getByRole("dialog", { name: "Context window" });
|
||||
}
|
||||
|
||||
/** One report card; cards have no role of their own. */
|
||||
export function usageCard(surface: Locator, reportId: string): Locator {
|
||||
return surface.getByTestId(`usage-report-${reportId}`);
|
||||
}
|
||||
|
||||
export async function refreshUsageCard(surface: Locator, source: string): Promise<void> {
|
||||
await surface.getByRole("button", { name: `Refresh ${source}`, exact: true }).click();
|
||||
}
|
||||
|
||||
export async function expectRefreshButtons(surface: Locator, count: number): Promise<void> {
|
||||
await expect(surface.getByRole("button", { name: /^Refresh / })).toHaveCount(count);
|
||||
}
|
||||
|
||||
/** A report on the agent's own login, which is not the host's default one. */
|
||||
export function onWorkLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "work");
|
||||
}
|
||||
|
||||
/** A report on the login an agent resumed under. */
|
||||
export function onPersonalLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return onLogin(report, "personal");
|
||||
}
|
||||
|
||||
function onLogin(report: UsageReportEntry, login: string): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
id: `${report.sourceId}:${login}`,
|
||||
account: { label: `${login}@example.com` },
|
||||
};
|
||||
}
|
||||
|
||||
export function expiredLogin(report: UsageReportEntry): UsageReportEntry {
|
||||
return {
|
||||
...report,
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: {
|
||||
kind: "expired",
|
||||
expiresAt: new Date(Date.now() - 2 * 60 * 60_000).toISOString(),
|
||||
refreshedBy: "claude",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Holds a stream at a point until the test opens it, like a slow source. */
|
||||
export function gate(): { promise: Promise<void>; open: () => void } {
|
||||
let open!: () => void;
|
||||
const promise = new Promise<void>((resolve) => {
|
||||
open = resolve;
|
||||
});
|
||||
return { promise, open };
|
||||
}
|
||||
|
||||
export interface AgentUsageScript {
|
||||
/** The answer to the next agent usage request or report Refresh. */
|
||||
answerNext(response: UsageListResponse): void;
|
||||
/** From the next reload the host no longer reports usage. */
|
||||
stopReportingUsage(): void;
|
||||
/** The agent of each agent usage request, in order. */
|
||||
agentRequests(): string[];
|
||||
/** Each report Refresh, by report ID. */
|
||||
refreshedReports(): string[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Scripts the host's usage: the host's own list is Claude and Codex on its default login; each
|
||||
* agent request and each Refresh takes the next scripted answer.
|
||||
*/
|
||||
export async function scriptAgentUsage(page: Page): Promise<AgentUsageScript> {
|
||||
let supported = true;
|
||||
const answers: UsageListResponse[] = [];
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
usageSupported: () => supported,
|
||||
lists: [
|
||||
(request) => {
|
||||
if (!request.agentId && !request.reportIds) return claudeAndCodexReports();
|
||||
const next = answers.shift();
|
||||
if (!next) throw new Error("The test scripts every agent usage request.");
|
||||
return next;
|
||||
},
|
||||
],
|
||||
});
|
||||
return {
|
||||
answerNext: (response) => answers.push(response),
|
||||
stopReportingUsage: () => {
|
||||
supported = false;
|
||||
},
|
||||
agentRequests: () => usage.listRequests().flatMap((request) => request.agentId ?? []),
|
||||
refreshedReports: () =>
|
||||
usage.listRequests().flatMap((request) => (request.reportIds ? [request.reportIds] : [])),
|
||||
};
|
||||
}
|
||||
|
||||
/** Set PASEO_QA_SCREENSHOT_DIR to keep a QA screenshot. */
|
||||
export async function qaScreenshot(page: Page, name: string): Promise<void> {
|
||||
const directory = process.env.PASEO_QA_SCREENSHOT_DIR;
|
||||
if (!directory) return;
|
||||
// Let the sheet and tooltip animations settle.
|
||||
await page.waitForTimeout(600);
|
||||
await page.addStyleTag({ content: ".__expo_fast_refresh { display: none !important; }" });
|
||||
await page.screenshot({ path: path.join(directory, `${name}.png`) });
|
||||
}
|
||||
@@ -197,18 +197,29 @@ export async function moveFooterItemUp(page: Page, key: string): Promise<void> {
|
||||
.click();
|
||||
}
|
||||
|
||||
function footerItemSwitch(page: Page, key: string): Locator {
|
||||
return page.getByTestId("sidebar-nav-section-footer").getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
}
|
||||
|
||||
export async function setFooterItemVisible(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
const toggle = page
|
||||
.getByTestId("sidebar-nav-section-footer")
|
||||
.getByTestId(`sidebar-nav-toggle-${key}`);
|
||||
const toggle = footerItemSwitch(page, key);
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
/** Whether Settings > Sidebar shows a footer item as on. */
|
||||
export async function expectFooterItemSetting(
|
||||
page: Page,
|
||||
key: string,
|
||||
visible: boolean,
|
||||
): Promise<void> {
|
||||
await expect(footerItemSwitch(page, key)).toHaveAttribute("aria-checked", String(visible));
|
||||
}
|
||||
|
||||
export async function expectFooterOrder(page: Page, keys: string[]): Promise<void> {
|
||||
await expectVerticalOrder(keys, (key) => shellFooterRow(page, key), "sidebar footer rows");
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ import {fetchUsage, discover} from "./server/usage.js";
|
||||
import {inputSchema} from "./shared/input.js";
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({id: "codex-window-qa", label: "Codex", input: inputSchema,
|
||||
discover: () => discover({home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
|
||||
discover: (scope) => discover(scope, {home: ${JSON.stringify(directory)}, env: {CODEX_HOME: ${JSON.stringify(directory)}}}),
|
||||
fetch: (input) => fetchUsage(input, async () => Response.json(JSON.parse(await readFile(${JSON.stringify(path.join(directory, "payload.json"))}, "utf8")))),
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -5,6 +5,7 @@ import { daemonWsRoutePattern, wsRoutePatternForPort } from "./daemon-port";
|
||||
export interface UsageListRequest {
|
||||
forceRefresh: boolean;
|
||||
reportIds?: string[];
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface UsageReportsFixture {
|
||||
@@ -19,15 +20,25 @@ interface UsageReportsFixtureOptions {
|
||||
* `fetchedAt`, or a different answer to a forced refresh).
|
||||
*/
|
||||
lists?: Array<UsageListResponse | ((request: UsageListRequest) => UsageListResponse)>;
|
||||
/** False simulates a host with no usage reporting capability. */
|
||||
usageSupported?: boolean;
|
||||
/**
|
||||
* False simulates a host with no usage reporting capability. A function is read each time the
|
||||
* app connects, so a reload can switch it.
|
||||
*/
|
||||
usageSupported?: boolean | (() => boolean);
|
||||
/** Released hosts expose provider.usage.list with no source icons. */
|
||||
providerUsageListOnly?: boolean;
|
||||
/** The host daemon's port; defaults to the E2E daemon. */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
type UsageListResponse = UsageReportEntry[] | { error: string };
|
||||
/**
|
||||
* A list of reports, a request error, or a stream: reports sent in order, where a promise holds
|
||||
* the rest of the stream and the response until it settles, like a slow source.
|
||||
*/
|
||||
export type UsageListResponse =
|
||||
| UsageReportEntry[]
|
||||
| { error: string }
|
||||
| { stream: Array<UsageReportEntry | Promise<unknown>> };
|
||||
|
||||
type WebSocketMessage = string | Buffer;
|
||||
|
||||
@@ -114,6 +125,8 @@ export async function installUsageReportsFixture(
|
||||
const listRequests: UsageListRequest[] = [];
|
||||
const listCounter = createCounter();
|
||||
const usageSupported = options.usageSupported ?? true;
|
||||
const isUsageSupported = () =>
|
||||
typeof usageSupported === "function" ? usageSupported() : usageSupported;
|
||||
const providerUsageListOnly = options.providerUsageListOnly ?? false;
|
||||
const requestType = providerUsageListOnly
|
||||
? "provider.usage.list.request"
|
||||
@@ -131,6 +144,7 @@ export async function installUsageReportsFixture(
|
||||
const listRequest: UsageListRequest = {
|
||||
forceRefresh: request.forceRefresh === true,
|
||||
reportIds: Array.isArray(request.reportIds) ? (request.reportIds as string[]) : undefined,
|
||||
agentId: typeof request.agentId === "string" ? request.agentId : undefined,
|
||||
};
|
||||
listRequests.push(listRequest);
|
||||
const scripted = pick(options.lists ?? [[]], listRequests.length - 1);
|
||||
@@ -139,22 +153,51 @@ export async function installUsageReportsFixture(
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId,
|
||||
requestType,
|
||||
error: response.error,
|
||||
code: "transport",
|
||||
},
|
||||
},
|
||||
message: providerUsageListOnly
|
||||
? {
|
||||
type: "rpc_error",
|
||||
payload: { requestId, requestType, error: response.error, code: "transport" },
|
||||
}
|
||||
: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: response.error },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
return;
|
||||
}
|
||||
const sendUpdate = (report: UsageReportEntry) =>
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: { type: "usage.list_reports.update", payload: { requestId, report } },
|
||||
}),
|
||||
);
|
||||
if ("stream" in response) {
|
||||
void (async () => {
|
||||
for (const item of response.stream) {
|
||||
if (item instanceof Promise) await item;
|
||||
else sendUpdate(item);
|
||||
}
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
message: {
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId, error: null },
|
||||
},
|
||||
}),
|
||||
);
|
||||
listCounter.increment();
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const ids = Array.isArray(request.reportIds) ? request.reportIds : null;
|
||||
const reports = ids ? response.filter((entry) => ids.includes(entry.id)) : response;
|
||||
if (!providerUsageListOnly) {
|
||||
for (const report of reports) sendUpdate(report);
|
||||
}
|
||||
const reply = providerUsageListOnly
|
||||
? {
|
||||
type: "provider.usage.list.response",
|
||||
@@ -175,7 +218,7 @@ export async function installUsageReportsFixture(
|
||||
})),
|
||||
},
|
||||
}
|
||||
: { type: "usage.list_reports.response", payload: { requestId, reports } };
|
||||
: { type: "usage.list_reports.response", payload: { requestId, error: null } };
|
||||
ws.send(JSON.stringify({ type: "session", message: reply }));
|
||||
listCounter.increment();
|
||||
return;
|
||||
@@ -186,7 +229,7 @@ export async function installUsageReportsFixture(
|
||||
server.onMessage((message) => {
|
||||
const serverInfo =
|
||||
typeof message === "string"
|
||||
? withUsageSupportFeature(message, usageSupported, providerUsageListOnly)
|
||||
? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly)
|
||||
: null;
|
||||
ws.send(serverInfo ?? message);
|
||||
});
|
||||
|
||||
@@ -168,6 +168,19 @@ export function pinRow(scope: Locator, source: string, window: string): Locator
|
||||
return scope.getByRole("checkbox", { name: new RegExp(`^Pin ${source} ${window}, `) });
|
||||
}
|
||||
|
||||
/**
|
||||
* A report card whose window rows do not pin: no pin toggle, no pin glyph, and nothing focusable
|
||||
* but buttons, so no row presses or highlights on hover.
|
||||
*/
|
||||
export async function expectUnpinnableRows(card: Locator): Promise<void> {
|
||||
await expect(card.getByRole("checkbox")).toHaveCount(0);
|
||||
await expect(card.locator('[data-testid^="usage-pin-"]')).toHaveCount(0);
|
||||
const focusable = await card
|
||||
.locator("[tabindex]")
|
||||
.evaluateAll((nodes) => nodes.filter((node) => node.getAttribute("role") !== "button").length);
|
||||
expect(focusable).toBe(0);
|
||||
}
|
||||
|
||||
export async function togglePin(scope: Locator, source: string, window: string) {
|
||||
const row = pinRow(scope, source, window);
|
||||
const pinned = await row.isChecked();
|
||||
@@ -175,6 +188,28 @@ export async function togglePin(scope: Locator, source: string, window: string)
|
||||
await expect(row).toBeChecked({ checked: !pinned });
|
||||
}
|
||||
|
||||
/** The footer's Usage icon, which is there whether or not the Usage item is on. */
|
||||
export async function openUsageScreenFromIcon(page: Page): Promise<void> {
|
||||
await page.getByRole("button", { name: "Usage", exact: true }).click({ timeout: 30_000 });
|
||||
await expectOnUsageScreen(page);
|
||||
}
|
||||
|
||||
function summaryInSidebarSwitch(page: Page): Locator {
|
||||
return page.getByRole("switch", { name: "Summary in sidebar", exact: true });
|
||||
}
|
||||
|
||||
/** Turns the sidebar Usage summary on or off from the Usage screen's Settings. */
|
||||
export async function setSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await summaryInSidebarSwitch(page).click();
|
||||
await expectSummaryInSidebar(page, on);
|
||||
}
|
||||
|
||||
export async function expectSummaryInSidebar(page: Page, on: boolean): Promise<void> {
|
||||
await openUsageOptions(page);
|
||||
await expect(summaryInSidebarSwitch(page)).toBeChecked({ checked: on });
|
||||
}
|
||||
|
||||
/** Expand the inline Settings row when its controls are folded. */
|
||||
export async function openUsageOptions(page: Page): Promise<void> {
|
||||
const toggle = visible(page, "usage-options-toggle");
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AgentUsage } from "@/usage";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
|
||||
interface ContextWindowDetailsProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
percentage: number;
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
sessionCost: string | null;
|
||||
/** The tooltip titles itself; the sheet's header carries the title instead. */
|
||||
showTitle: boolean;
|
||||
/** Only the sheet can be pressed, so only its usage cards have a Refresh button. */
|
||||
refreshable: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the context window meter opens: how full the window is, then the usage of the agent's
|
||||
* account. The wide tooltip and the compact sheet both render it.
|
||||
*/
|
||||
export function ContextWindowDetails({
|
||||
serverId,
|
||||
agentId,
|
||||
percentage,
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
sessionCost,
|
||||
showTitle,
|
||||
refreshable,
|
||||
}: ContextWindowDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<View style={styles.summary}>
|
||||
{showTitle ? <Text style={styles.title}>{t("contextWindow.title")}</Text> : null}
|
||||
<Text style={styles.text}>{t("contextWindow.used", { percentage })}</Text>
|
||||
<Text style={styles.detail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{sessionCost ? (
|
||||
<Text style={styles.detail}>{t("contextWindow.sessionCost", { cost: sessionCost })}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<AgentUsage serverId={serverId} agentId={agentId} refreshable={refreshable} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
summary: {
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 200,
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
detail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
}));
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import Svg, { Circle } from "react-native-svg";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { formatTokenCount } from "./context-window-meter.utils";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostReportsUsage } from "@/usage";
|
||||
import { ContextWindowDetails } from "./context-window-details";
|
||||
import { ContextWindowSheet } from "./context-window-sheet";
|
||||
|
||||
interface ContextWindowMeterProps {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
maxTokens: number | null;
|
||||
usedTokens: number | null;
|
||||
totalCostUsd?: number | null;
|
||||
@@ -91,6 +97,8 @@ function getMeterGeometry(showPercentage: boolean, glyphSize?: number) {
|
||||
}
|
||||
|
||||
export function ContextWindowMeter({
|
||||
serverId,
|
||||
agentId,
|
||||
maxTokens,
|
||||
usedTokens,
|
||||
totalCostUsd,
|
||||
@@ -100,6 +108,15 @@ export function ContextWindowMeter({
|
||||
}: ContextWindowMeterProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
// Usage cards need a wider popover; without them it keeps the plain tooltip shape.
|
||||
const showsUsage = useHostReportsUsage(serverId);
|
||||
const popoverWidth = Math.min(360, width - 24);
|
||||
// Compact screens open the details in a sheet, which can hold a pressable Refresh.
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||
const openSheet = useCallback(() => setIsSheetOpen(true), []);
|
||||
const closeSheet = useCallback(() => setIsSheetOpen(false), []);
|
||||
const percentage =
|
||||
maxTokens !== null && usedTokens !== null ? getUsagePercentage(maxTokens, usedTokens) : null;
|
||||
const geometry = getMeterGeometry(showPercentage, glyphSize);
|
||||
@@ -142,6 +159,71 @@ export function ContextWindowMeter({
|
||||
const formattedSessionCost =
|
||||
typeof totalCostUsd === "number" ? formatSessionCost(totalCostUsd) : null;
|
||||
|
||||
const ring = (
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
const percentageLabel = showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null;
|
||||
const accessibilityLabel = t("contextWindow.accessibility", { percentage: roundedPercentage });
|
||||
|
||||
if (isCompact) {
|
||||
return (
|
||||
<>
|
||||
<Pressable
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
onPress={openSheet}
|
||||
>
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
<ContextWindowSheet open={isSheetOpen} onClose={closeSheet}>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle={false}
|
||||
refreshable
|
||||
/>
|
||||
</ContextWindowSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
@@ -149,62 +231,30 @@ export function ContextWindowMeter({
|
||||
style={containerStyle}
|
||||
testID="context-window-meter"
|
||||
accessibilityRole="image"
|
||||
accessibilityLabel={t("contextWindow.accessibility", {
|
||||
percentage: roundedPercentage,
|
||||
})}
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
>
|
||||
<Svg
|
||||
width={svgSize}
|
||||
height={svgSize}
|
||||
viewBox={`0 0 ${svgSize} ${svgSize}`}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.track}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<Circle
|
||||
cx={center}
|
||||
cy={center}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={colors.progress}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={dashOffset}
|
||||
// SVG strokes start at three o'clock; the ring reads clockwise from twelve.
|
||||
transform={`rotate(-90 ${center} ${center})`}
|
||||
/>
|
||||
</Svg>
|
||||
{showPercentage ? (
|
||||
<Text style={styles.percentageLabel}>{`${roundedPercentage}%`}</Text>
|
||||
) : null}
|
||||
{ring}
|
||||
{percentageLabel}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8} testID="context-window-meter-tooltip">
|
||||
<View style={styles.tooltipContent}>
|
||||
<Text style={styles.tooltipTitle}>{t("contextWindow.title")}</Text>
|
||||
<Text style={styles.tooltipText}>
|
||||
{t("contextWindow.used", { percentage: roundedPercentage })}
|
||||
</Text>
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.tokens", {
|
||||
used: formatTokenCount(usedTokens),
|
||||
max: formatTokenCount(maxTokens),
|
||||
})}
|
||||
</Text>
|
||||
{formattedSessionCost ? (
|
||||
<Text style={styles.tooltipDetail}>
|
||||
{t("contextWindow.sessionCost", { cost: formattedSessionCost })}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="center"
|
||||
offset={8}
|
||||
maxWidth={showsUsage ? popoverWidth : undefined}
|
||||
style={showsUsage ? [styles.popover, { width: popoverWidth }] : undefined}
|
||||
testID="context-window-meter-tooltip"
|
||||
>
|
||||
<ContextWindowDetails
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
percentage={roundedPercentage}
|
||||
usedTokens={usedTokens}
|
||||
maxTokens={maxTokens}
|
||||
sessionCost={formattedSessionCost}
|
||||
showTitle
|
||||
refreshable={false}
|
||||
/>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -237,22 +287,5 @@ const styles = StyleSheet.create((theme) => ({
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.surface3,
|
||||
},
|
||||
tooltipContent: {
|
||||
gap: theme.spacing[1.5],
|
||||
minWidth: 200,
|
||||
},
|
||||
tooltipTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
},
|
||||
tooltipText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
lineHeight: theme.fontSize.base * 1.4,
|
||||
},
|
||||
tooltipDetail: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
lineHeight: theme.fontSize.sm * 1.4,
|
||||
},
|
||||
popover: { padding: theme.spacing[4], gap: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useMemo, type ReactNode } from "react";
|
||||
import { View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BottomSheetScrollView } from "@gorhom/bottom-sheet";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { SheetHeaderView, SHEET_HORIZONTAL_PADDING_SCALE } from "@/components/adaptive-modal-sheet";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
|
||||
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
|
||||
// track, so the sheet is wrapped rather than reading the theme through a hook.
|
||||
// See docs/unistyles.md.
|
||||
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
|
||||
backgroundStyle: {
|
||||
backgroundColor: theme.colors.surface0,
|
||||
borderTopLeftRadius: theme.borderRadius["2xl"],
|
||||
borderTopRightRadius: theme.borderRadius["2xl"],
|
||||
},
|
||||
handleIndicatorStyle: { backgroundColor: theme.colors.palette.zinc[600] },
|
||||
}));
|
||||
|
||||
/**
|
||||
* The context window meter's details on compact screens, in a sheet as tall as its content.
|
||||
* Gorhom mounts the content only while the sheet is presented, so whatever it fetches stops once
|
||||
* the sheet closes.
|
||||
*/
|
||||
export function ContextWindowSheet({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { height } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const header = useMemo(() => ({ title: t("contextWindow.title") }), [t]);
|
||||
const scrollContentStyle = useMemo(() => ({ paddingBottom: insets.bottom }), [insets.bottom]);
|
||||
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
|
||||
visible: open,
|
||||
onClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
// Content-sized rather than fixed snap points: the usage cards stream in one at a time.
|
||||
enableDynamicSizing
|
||||
maxDynamicContentSize={height * 0.8}
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropOpacity={0.45}
|
||||
enablePanDownToClose
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={scrollContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View role="dialog" aria-label={header.title}>
|
||||
<SheetHeaderView header={header} onClose={onClose} />
|
||||
{/* The inset sits on a View: a themed contentContainerStyle resolves to nothing on web. */}
|
||||
<View style={styles.body}>{children}</View>
|
||||
</View>
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
body: {
|
||||
padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE],
|
||||
gap: theme.spacing[4],
|
||||
},
|
||||
}));
|
||||
@@ -523,6 +523,7 @@ export function TooltipContent({
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
role="tooltip"
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
@@ -549,6 +550,7 @@ export function TooltipContent({
|
||||
entering={FadeIn.duration(80)}
|
||||
exiting={FadeOut.duration(80)}
|
||||
collapsable={false}
|
||||
role="tooltip"
|
||||
testID={testID}
|
||||
onLayout={handleLayout}
|
||||
style={contentStyle}
|
||||
|
||||
@@ -279,6 +279,8 @@ function buildAgentStateSelector(serverId: string, agentId: string) {
|
||||
}
|
||||
|
||||
function renderContextWindowMeter(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
contextWindowMaxTokens: number | null,
|
||||
contextWindowUsedTokens: number | null,
|
||||
totalCostUsd: number | null,
|
||||
@@ -292,6 +294,8 @@ function renderContextWindowMeter(
|
||||
}
|
||||
return (
|
||||
<ContextWindowMeter
|
||||
serverId={serverId}
|
||||
agentId={agentId}
|
||||
maxTokens={contextWindowMaxTokens}
|
||||
usedTokens={contextWindowUsedTokens}
|
||||
totalCostUsd={totalCostUsd}
|
||||
@@ -2076,6 +2080,8 @@ function ComposerContentImpl({
|
||||
const contextWindowMeter = useMemo(
|
||||
() =>
|
||||
renderContextWindowMeter(
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
@@ -2084,6 +2090,8 @@ function ComposerContentImpl({
|
||||
contextWindowMeterGlyphSize,
|
||||
),
|
||||
[
|
||||
serverId,
|
||||
agentId,
|
||||
contextWindowMaxTokens,
|
||||
contextWindowUsedTokens,
|
||||
agentState.totalCostUsd,
|
||||
|
||||
@@ -34,7 +34,6 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("yields builtins then plugins, all visible, when nothing is stored", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban, notes],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -59,7 +58,6 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("keeps the stored order and appends newly available items as visible", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [notes, kanban],
|
||||
preferences: [
|
||||
{ key: kanbanKey, visible: false },
|
||||
@@ -81,7 +79,6 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("skips keys that are unknown or not currently available", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: notesKey, visible: false },
|
||||
@@ -101,7 +98,6 @@ describe("resolveSidebarNavItems", () => {
|
||||
it("lets the first of duplicate keys win", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [
|
||||
{ key: "history", visible: false },
|
||||
@@ -118,18 +114,13 @@ describe("resolveSidebarNavItems", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSidebarNavItems on compact layouts", () => {
|
||||
describe("resolveSidebarNavItems for the Usage item", () => {
|
||||
it("hides the Usage item until it is turned on", () => {
|
||||
const resolve = (compact: boolean, preferences: SidebarNavPreference[]) =>
|
||||
summarize(
|
||||
resolveSidebarNavItems({ section: "footer", compact, pluginGroups: [], preferences }),
|
||||
);
|
||||
const resolve = (preferences: SidebarNavPreference[]) =>
|
||||
summarize(resolveSidebarNavItems({ section: "footer", pluginGroups: [], preferences }));
|
||||
|
||||
expect(resolve(true, [])).toEqual([{ key: "usage", visible: false }]);
|
||||
expect(resolve(false, [])).toEqual([{ key: "usage", visible: true }]);
|
||||
expect(resolve(true, [{ key: "usage", visible: true }])).toEqual([
|
||||
{ key: "usage", visible: true },
|
||||
]);
|
||||
expect(resolve([])).toEqual([{ key: "usage", visible: false }]);
|
||||
expect(resolve([{ key: "usage", visible: true }])).toEqual([{ key: "usage", visible: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,7 +128,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
it("toggles one item and writes the full resolved order", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -160,7 +150,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -186,7 +175,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -204,7 +192,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
summarize(
|
||||
resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [notes],
|
||||
preferences: next,
|
||||
}),
|
||||
@@ -215,7 +202,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
it("returns the normalized list unchanged for an unknown key", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -229,7 +215,6 @@ describe("setSidebarNavItemVisible", () => {
|
||||
describe("moveSidebarNavItem", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "header",
|
||||
compact: false,
|
||||
pluginGroups: [kanban],
|
||||
preferences: [],
|
||||
});
|
||||
@@ -307,13 +292,12 @@ describe("footer section", () => {
|
||||
it("resolves the Usage item first, then plugin rows", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: [],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([
|
||||
{ key: "usage", visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
{ key: syncKey, visible: true },
|
||||
]);
|
||||
});
|
||||
@@ -321,18 +305,16 @@ describe("footer section", () => {
|
||||
it("ignores header built-ins stored in footer preferences", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [],
|
||||
preferences: [{ key: "history", visible: false }],
|
||||
});
|
||||
|
||||
expect(summarize(items)).toEqual([{ key: "usage", visible: true }]);
|
||||
expect(summarize(items)).toEqual([{ key: "usage", visible: false }]);
|
||||
});
|
||||
|
||||
it("ignores the footer icon buttons, which are fixed and not items", () => {
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: [
|
||||
{ key: "add-project", visible: false },
|
||||
@@ -350,33 +332,31 @@ describe("footer section", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves and hides footer rows while keeping an unavailable plugin's entry", () => {
|
||||
it("moves and shows footer rows while keeping an unavailable plugin's entry", () => {
|
||||
const notesPreference = { key: notesKey, visible: false };
|
||||
const previous: SidebarNavPreference[] = [notesPreference];
|
||||
const items = resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: previous,
|
||||
});
|
||||
|
||||
const moved = moveSidebarNavItem({ items, key: syncKey, direction: "up", previous });
|
||||
const hidden = setSidebarNavItemVisible({
|
||||
const shown = setSidebarNavItemVisible({
|
||||
items: resolveSidebarNavItems({
|
||||
section: "footer",
|
||||
compact: false,
|
||||
pluginGroups: [sync],
|
||||
preferences: moved,
|
||||
}),
|
||||
key: "usage",
|
||||
visible: false,
|
||||
visible: true,
|
||||
previous: moved,
|
||||
});
|
||||
|
||||
expect(hidden).toEqual([
|
||||
expect(shown).toEqual([
|
||||
notesPreference,
|
||||
{ key: syncKey, visible: true },
|
||||
{ key: "usage", visible: false },
|
||||
{ key: "usage", visible: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,14 +69,11 @@ export function builtinSidebarNavShortcutAction(id: BuiltinSidebarItemId): strin
|
||||
return BUILTIN_SHORTCUT_ACTIONS[id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builtins that start hidden on compact layouts, until the user turns them on. A phone's footer
|
||||
* has no room to spare for the Usage summary.
|
||||
*/
|
||||
const HIDDEN_BY_DEFAULT_ON_COMPACT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
|
||||
/** Builtins that start hidden until the user turns them on: the Usage summary is opt-in. */
|
||||
const HIDDEN_BY_DEFAULT: ReadonlySet<BuiltinSidebarItemId> = new Set(["usage"]);
|
||||
|
||||
function builtinVisibleByDefault(id: BuiltinSidebarItemId, compact: boolean): boolean {
|
||||
return !(compact && HIDDEN_BY_DEFAULT_ON_COMPACT.has(id));
|
||||
function builtinVisibleByDefault(id: BuiltinSidebarItemId): boolean {
|
||||
return !HIDDEN_BY_DEFAULT.has(id);
|
||||
}
|
||||
|
||||
export function pluginSidebarNavKey(
|
||||
@@ -95,8 +92,6 @@ function isBuiltinSidebarItemId<Section extends SidebarSection>(
|
||||
|
||||
export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
|
||||
section: Section;
|
||||
/** Compact layouts start some builtins hidden; a stored preference always wins. */
|
||||
compact: boolean;
|
||||
pluginGroups: readonly PluginSidebarGroup[];
|
||||
preferences: readonly SidebarNavPreference[];
|
||||
}): SidebarNavItem<Section>[] {
|
||||
@@ -131,7 +126,7 @@ export function resolveSidebarNavItems<Section extends SidebarSection>(input: {
|
||||
kind: "builtin",
|
||||
key: id,
|
||||
id,
|
||||
visible: builtinVisibleByDefault(id, input.compact),
|
||||
visible: builtinVisibleByDefault(id),
|
||||
});
|
||||
}
|
||||
for (const [key, group] of groupsByKey) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import type { AppSettings } from "@/hooks/use-settings/storage";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
@@ -28,15 +27,14 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
section: Section,
|
||||
): UseSidebarNavItemsReturn<Section> {
|
||||
const plugins = useInstalledPlugins();
|
||||
const compact = useIsCompactFormFactor();
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const field = PREFERENCE_FIELDS[section];
|
||||
const preferences = settings[field];
|
||||
const pluginGroups = useMemo(() => groupPluginSidebarItems(plugins, section), [plugins, section]);
|
||||
|
||||
const items = useMemo(
|
||||
() => resolveSidebarNavItems({ section, compact, pluginGroups, preferences }),
|
||||
[compact, pluginGroups, preferences, section],
|
||||
() => resolveSidebarNavItems({ section, pluginGroups, preferences }),
|
||||
[pluginGroups, preferences, section],
|
||||
);
|
||||
|
||||
const setVisible = useCallback(
|
||||
@@ -45,7 +43,6 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
compact,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -54,7 +51,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
};
|
||||
});
|
||||
},
|
||||
[compact, field, pluginGroups, section, updateSettings],
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
const move = useCallback(
|
||||
@@ -63,7 +60,6 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
const previous = current[field];
|
||||
const currentItems = resolveSidebarNavItems({
|
||||
section,
|
||||
compact,
|
||||
pluginGroups,
|
||||
preferences: previous,
|
||||
});
|
||||
@@ -72,7 +68,7 @@ export function useSidebarNavItems<Section extends SidebarSection>(
|
||||
};
|
||||
});
|
||||
},
|
||||
[compact, field, pluginGroups, section, updateSettings],
|
||||
[field, pluginGroups, section, updateSettings],
|
||||
);
|
||||
|
||||
return { items, setVisible, move };
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Text, View } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { UsageCard } from "./card";
|
||||
import { usageCopy } from "./copy";
|
||||
import { useUsageDisplay } from "./display";
|
||||
import { useAgentUsage } from "./queries";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/**
|
||||
* The usage of the account an agent runs under, for its context window details: one card per
|
||||
* report as it streams in, without pins. Nothing on hosts that cannot report usage, or for an
|
||||
* agent with no account to report.
|
||||
*/
|
||||
export function AgentUsage({
|
||||
serverId,
|
||||
agentId,
|
||||
refreshable,
|
||||
}: {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
/** Whether each card has a Refresh button; a surface that cannot be pressed hides it. */
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
const view = useAgentUsage(serverId, agentId);
|
||||
if (view.kind === "none") return null;
|
||||
return (
|
||||
<>
|
||||
<View style={styles.divider} />
|
||||
{view.kind === "ready" ? (
|
||||
<AgentUsageCards serverId={serverId} reports={view.reports} refreshable={refreshable} />
|
||||
) : (
|
||||
<Text style={styles.message}>
|
||||
{view.kind === "loading" ? usageCopy.loading : view.message}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentUsageCards({
|
||||
serverId,
|
||||
reports,
|
||||
refreshable,
|
||||
}: {
|
||||
serverId: string;
|
||||
reports: readonly UsageReportEntry[];
|
||||
refreshable: boolean;
|
||||
}) {
|
||||
const { display } = useUsageDisplay(reports);
|
||||
return reports.map((entry) => (
|
||||
<UsageCard
|
||||
key={entry.id}
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
display={display}
|
||||
compact
|
||||
pinnable={false}
|
||||
refreshable={refreshable}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
divider: { height: 1, backgroundColor: theme.colors.borderAccent },
|
||||
message: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
|
||||
}));
|
||||
@@ -47,17 +47,23 @@ export function UsageCard({
|
||||
serverId,
|
||||
entry,
|
||||
display,
|
||||
pinnable,
|
||||
refreshable,
|
||||
compact = false,
|
||||
}: {
|
||||
serverId: string;
|
||||
entry: UsageReportEntry;
|
||||
display: UsageDisplay;
|
||||
/** Whether each window row pins the window to the sidebar. */
|
||||
pinnable: boolean;
|
||||
/** Whether the header has a Refresh button. Without one the freshness shows on the card. */
|
||||
refreshable: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { refresh, refreshState } = useReportRefresh(serverId, entry.id);
|
||||
// Where there is no hover the freshness is printed on the card; elsewhere the Refresh tooltip.
|
||||
const showsFreshnessInline = isNative || isCompact;
|
||||
const showsFreshnessInline = isNative || isCompact || !refreshable;
|
||||
const usage = entry.report;
|
||||
const status = statusText(usage);
|
||||
const footer = entry.account.label ?? null;
|
||||
@@ -93,13 +99,15 @@ export function UsageCard({
|
||||
<Text style={styles.statusLabel}>{status}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
<UsageRefreshButton
|
||||
sourceLabel={entry.sourceLabel}
|
||||
fetchedAt={entry.fetchedAt}
|
||||
refreshState={refreshState}
|
||||
onRefresh={refresh}
|
||||
compact={isCompact}
|
||||
/>
|
||||
{refreshable ? (
|
||||
<UsageRefreshButton
|
||||
sourceLabel={entry.sourceLabel}
|
||||
fetchedAt={entry.fetchedAt}
|
||||
refreshState={refreshState}
|
||||
onRefresh={refresh}
|
||||
compact={isCompact}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
{message ? (
|
||||
@@ -111,7 +119,13 @@ export function UsageCard({
|
||||
{windows.length > 0 || balances.length > 0 ? (
|
||||
<View style={styles.bars}>
|
||||
{windows.map((window) => (
|
||||
<PinnableWindowBar key={window.id} entry={entry} window={window} display={display} />
|
||||
<CardWindowBar
|
||||
key={window.id}
|
||||
entry={entry}
|
||||
window={window}
|
||||
display={display}
|
||||
pinnable={pinnable}
|
||||
/>
|
||||
))}
|
||||
{balances.map((balance) => (
|
||||
<UsageBalanceBar key={balance.id} balance={balance} />
|
||||
@@ -158,14 +172,16 @@ export function UsageCard({
|
||||
);
|
||||
}
|
||||
|
||||
function PinnableWindowBar({
|
||||
function CardWindowBar({
|
||||
entry,
|
||||
window,
|
||||
display,
|
||||
pinnable,
|
||||
}: {
|
||||
entry: UsageReportEntry;
|
||||
window: UsageWindow;
|
||||
display: UsageDisplay;
|
||||
pinnable: boolean;
|
||||
}) {
|
||||
const pin = useMemo(
|
||||
() => ({ sourceId: entry.sourceId, windowId: window.id }),
|
||||
@@ -177,6 +193,7 @@ function PinnableWindowBar({
|
||||
<UsageWindowBar
|
||||
window={window}
|
||||
displayAs={display.displayAs}
|
||||
pinnable={pinnable}
|
||||
pinned={display.isPinned(pin)}
|
||||
onTogglePin={toggle}
|
||||
pinLabel={`${usageCopy.pin} ${entry.sourceLabel} ${window.label}`}
|
||||
|
||||
@@ -25,6 +25,7 @@ export const usageCopy = {
|
||||
empty: "No usage data",
|
||||
noHosts: "No connected hosts",
|
||||
errorTitle: "Unable to load usage",
|
||||
agentError: (reason: string) => `Unable to load usage: ${reason}`,
|
||||
hostUnavailable: (host: string) => `Connect to ${host} to see usage`,
|
||||
hostUpgradeRequired: (host: string) => `Update ${host} to see usage`,
|
||||
clientUnavailable: "Host connection is not ready",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type UsagePin,
|
||||
type UsagePreferences,
|
||||
} from "./preferences";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** What usage views need from the preferences: how to read percents, and the pins. */
|
||||
export interface UsageDisplay {
|
||||
@@ -19,15 +20,21 @@ export interface UsageDisplay {
|
||||
togglePin: (pin: UsagePin) => void;
|
||||
}
|
||||
|
||||
/** The device's usage preferences, and the commands that change them. */
|
||||
export function useUsagePreferences(serverId?: string): {
|
||||
interface UsagePreferencesState {
|
||||
preferences: UsagePreferences;
|
||||
display: UsageDisplay;
|
||||
} {
|
||||
}
|
||||
|
||||
/** The device's usage preferences, with pins resolved against a host's reports. */
|
||||
export function useUsagePreferences(serverId?: string): UsagePreferencesState {
|
||||
const defaultServerId = useUsageHostId();
|
||||
return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId));
|
||||
}
|
||||
|
||||
/** The device's usage preferences, with pins resolved against the given reports. */
|
||||
export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState {
|
||||
const { settings, updateSettings } = useAppSettings();
|
||||
const preferences = settings.usage;
|
||||
const defaultServerId = useUsageHostId();
|
||||
const reports = useUsageHostReports(serverId ?? defaultServerId);
|
||||
const setDisplayAs = useCallback(
|
||||
(displayAs: UsageDisplayAs) => {
|
||||
void updateSettings((current) => ({ usage: setUsageDisplayAs(current.usage, displayAs) }));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useCallback } from "react";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
|
||||
/**
|
||||
* Whether the sidebar footer shows the Usage summary: the same switch as Settings > Sidebar.
|
||||
* Window rows pin to the summary only while it is on.
|
||||
*/
|
||||
export function useUsageInSidebar(): {
|
||||
inSidebar: boolean;
|
||||
setInSidebar: (visible: boolean) => void;
|
||||
} {
|
||||
const { items, setVisible } = useSidebarNavItems("footer");
|
||||
const inSidebar = items.some((item) => item.key === "usage" && item.visible);
|
||||
const setInSidebar = useCallback(
|
||||
(visible: boolean) => setVisible("usage", visible),
|
||||
[setVisible],
|
||||
);
|
||||
return { inSidebar, setInSidebar };
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { AgentUsage } from "./agent-usage";
|
||||
export { HostUsageSection } from "./host-usage-section";
|
||||
export { useHostReportsUsage } from "./queries";
|
||||
export { UsageScreen } from "./usage-screen";
|
||||
export { UsageSidebarItem, useHasUsageSummary, useOpenUsageScreen } from "./sidebar-item";
|
||||
|
||||
@@ -3,9 +3,10 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { UsageCard } from "./card";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageReportEntry } from "./types";
|
||||
|
||||
/** One card per report (source + account). */
|
||||
/** One card per report (source + account). Window rows pin while the sidebar summary is on. */
|
||||
export function UsageList({
|
||||
serverId,
|
||||
reports,
|
||||
@@ -15,11 +16,18 @@ export function UsageList({
|
||||
reports: UsageReportEntry[];
|
||||
display: UsageDisplay;
|
||||
}) {
|
||||
const { inSidebar } = useUsageInSidebar();
|
||||
return (
|
||||
<View style={styles.list}>
|
||||
{reports.map((entry) => (
|
||||
<View key={entry.id} style={settingsStyles.card}>
|
||||
<UsageCard serverId={serverId} entry={entry} display={display} />
|
||||
<UsageCard
|
||||
serverId={serverId}
|
||||
entry={entry}
|
||||
display={display}
|
||||
pinnable={inSidebar}
|
||||
refreshable
|
||||
/>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
@@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
displayPercent,
|
||||
formatUsageFreshness,
|
||||
resolveAgentUsageView,
|
||||
replaceReport,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageHostId,
|
||||
resolveUsageScreenHostId,
|
||||
resolveUsageView,
|
||||
settleReports,
|
||||
upsertReport,
|
||||
usageWindowRowLabel,
|
||||
type UsageHost,
|
||||
type UsageQueryState,
|
||||
@@ -216,3 +219,93 @@ describe("replaceReport", () => {
|
||||
expect(replaceReport([alpha, beta], alpha.id, null)).toEqual([beta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertReport", () => {
|
||||
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
|
||||
const beta = entry({ sourceId: "beta" });
|
||||
|
||||
it("starts a list from the first streamed report", () => {
|
||||
expect(upsertReport(undefined, alpha)).toEqual([alpha]);
|
||||
});
|
||||
|
||||
it("appends a report the list does not have yet", () => {
|
||||
expect(upsertReport([alpha], beta)).toEqual([alpha, beta]);
|
||||
});
|
||||
|
||||
it("replaces a report the list already has, in place", () => {
|
||||
const streamed = entry({ sourceId: "alpha", planLabel: "New" });
|
||||
expect(upsertReport([alpha, beta], streamed)).toEqual([streamed, beta]);
|
||||
});
|
||||
|
||||
it("keeps a copy fetched after the streamed one, such as a Refresh that landed first", () => {
|
||||
const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" };
|
||||
expect(upsertReport([refreshed, beta], alpha)).toEqual([refreshed, beta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("settleReports", () => {
|
||||
const alpha = entry({ sourceId: "alpha", planLabel: "Old" });
|
||||
const beta = entry({ sourceId: "beta" });
|
||||
|
||||
it("takes the finished list, dropping reports the host no longer has", () => {
|
||||
const streamed = entry({ sourceId: "alpha", planLabel: "New" });
|
||||
expect(settleReports([alpha, beta], [streamed])).toEqual([streamed]);
|
||||
});
|
||||
|
||||
it("keeps a report refreshed while the list streamed over its older copy in the list", () => {
|
||||
const refreshed = { ...alpha, planLabel: "Refreshed", fetchedAt: "2026-01-01T00:05:00.000Z" };
|
||||
expect(settleReports([refreshed, beta], [alpha, beta])).toEqual([refreshed, beta]);
|
||||
});
|
||||
|
||||
it("takes the finished list as is when nothing was shown yet", () => {
|
||||
expect(settleReports(undefined, [alpha, beta])).toEqual([alpha, beta]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentUsageView", () => {
|
||||
const report = entry({ sourceId: "claude" });
|
||||
const idle: UsageQueryState = { data: undefined, error: null, isFetching: false };
|
||||
|
||||
it("shows nothing on a host that cannot report usage", () => {
|
||||
expect(resolveAgentUsageView({ canReport: false, query: idle })).toEqual({ kind: "none" });
|
||||
});
|
||||
|
||||
it("loads until the first report streams in", () => {
|
||||
expect(
|
||||
resolveAgentUsageView({ canReport: true, query: { ...idle, isFetching: true } }),
|
||||
).toEqual({ kind: "loading" });
|
||||
});
|
||||
|
||||
it("shows each report as it streams in, before the request finishes", () => {
|
||||
expect(
|
||||
resolveAgentUsageView({
|
||||
canReport: true,
|
||||
query: { data: [report], error: null, isFetching: true },
|
||||
}),
|
||||
).toEqual({ kind: "ready", reports: [report] });
|
||||
});
|
||||
|
||||
it("shows nothing for an agent with no account to report", () => {
|
||||
expect(resolveAgentUsageView({ canReport: true, query: { ...idle, data: [] } })).toEqual({
|
||||
kind: "none",
|
||||
});
|
||||
});
|
||||
|
||||
it("says why a failed request has nothing to show", () => {
|
||||
expect(
|
||||
resolveAgentUsageView({
|
||||
canReport: true,
|
||||
query: { ...idle, error: new Error("Unknown agent") },
|
||||
}),
|
||||
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
|
||||
});
|
||||
|
||||
it("says the request failed instead of showing the reports from before it", () => {
|
||||
expect(
|
||||
resolveAgentUsageView({
|
||||
canReport: true,
|
||||
query: { data: [report], error: new Error("Unknown agent"), isFetching: false },
|
||||
}),
|
||||
).toEqual({ kind: "error", message: "Unable to load usage: Unknown agent" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,40 @@ export function replaceReport(
|
||||
return reports.map((report) => (report.id === reportId ? refreshed : report));
|
||||
}
|
||||
|
||||
/**
|
||||
* The later-fetched of two copies of one report. A list request's copies were fetched when it
|
||||
* started, so a Refresh that lands while it streams is newer than what the request still delivers.
|
||||
*/
|
||||
function laterFetched(shown: UsageReportEntry, arriving: UsageReportEntry): UsageReportEntry {
|
||||
return Date.parse(shown.fetchedAt) > Date.parse(arriving.fetchedAt) ? shown : arriving;
|
||||
}
|
||||
|
||||
/**
|
||||
* A report list with one streamed report in place of its previous copy, or appended if new. A
|
||||
* copy fetched after the streamed one stays.
|
||||
*/
|
||||
export function upsertReport(
|
||||
reports: readonly UsageReportEntry[] | undefined,
|
||||
report: UsageReportEntry,
|
||||
): UsageReportEntry[] {
|
||||
if (!reports?.some((entry) => entry.id === report.id)) return [...(reports ?? []), report];
|
||||
return reports.map((entry) => (entry.id === report.id ? laterFetched(entry, report) : entry));
|
||||
}
|
||||
|
||||
/**
|
||||
* The list a finished request leaves: its reports, dropping any the host no longer has, except
|
||||
* that a copy on screen fetched after the request's copy stays.
|
||||
*/
|
||||
export function settleReports(
|
||||
shown: readonly UsageReportEntry[] | undefined,
|
||||
finished: readonly UsageReportEntry[],
|
||||
): UsageReportEntry[] {
|
||||
return finished.map((report) => {
|
||||
const copy = shown?.find((entry) => entry.id === report.id);
|
||||
return copy ? laterFetched(copy, report) : report;
|
||||
});
|
||||
}
|
||||
|
||||
export interface UsageQueryState {
|
||||
data: UsageReportEntry[] | undefined;
|
||||
error: unknown;
|
||||
@@ -90,6 +124,31 @@ export function resolveUsageView(input: {
|
||||
return { kind: "loading" };
|
||||
}
|
||||
|
||||
/** What a meter popover shows of its agent's usage: nothing while the host cannot say. */
|
||||
export type AgentUsageView =
|
||||
| { kind: "none" }
|
||||
| { kind: "loading" }
|
||||
| { kind: "error"; message: string }
|
||||
| { kind: "ready"; reports: UsageReportEntry[] };
|
||||
|
||||
export function resolveAgentUsageView(input: {
|
||||
canReport: boolean;
|
||||
query: UsageQueryState;
|
||||
}): AgentUsageView {
|
||||
const { canReport, query } = input;
|
||||
if (!canReport) return { kind: "none" };
|
||||
// A failed request keeps the reports from before it, or those that streamed in before it failed;
|
||||
// shown alone they would pass for the agent's complete, current usage.
|
||||
if (query.error) {
|
||||
const reason = query.error instanceof Error ? query.error.message : String(query.error);
|
||||
return { kind: "error", message: usageCopy.agentError(reason) };
|
||||
}
|
||||
if (query.data) {
|
||||
return query.data.length === 0 ? { kind: "none" } : { kind: "ready", reports: query.data };
|
||||
}
|
||||
return { kind: "loading" };
|
||||
}
|
||||
|
||||
export interface UsageHost {
|
||||
serverId: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from "react";
|
||||
import { View } from "react-native";
|
||||
import {
|
||||
SettingsCard,
|
||||
@@ -7,10 +6,10 @@ import {
|
||||
SettingsSwitch,
|
||||
} from "@/components/settings";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { useSidebarNavItems } from "@/sidebar-nav/use-sidebar-nav-items";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { usageCopy } from "./copy";
|
||||
import type { UsageDisplay } from "./display";
|
||||
import { useUsageInSidebar } from "./in-sidebar";
|
||||
import type { UsageDisplayAs } from "./preferences";
|
||||
|
||||
const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
@@ -23,13 +22,7 @@ const DISPLAY_AS_OPTIONS: SegmentedControlOption<UsageDisplayAs>[] = [
|
||||
* switch as Settings > Sidebar) and whether percents read as used or remaining.
|
||||
*/
|
||||
export function UsageOptions({ display }: { display: UsageDisplay }) {
|
||||
const sidebarItems = useSidebarNavItems("footer");
|
||||
const inSidebar = sidebarItems.items.some((item) => item.key === "usage" && item.visible);
|
||||
const { setVisible } = sidebarItems;
|
||||
const setInSidebar = useCallback(
|
||||
(visible: boolean) => setVisible("usage", visible),
|
||||
[setVisible],
|
||||
);
|
||||
const { inSidebar, setInSidebar } = useUsageInSidebar();
|
||||
return (
|
||||
<View style={settingsStyles.section}>
|
||||
<SettingsCard>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { supportsUsageReports } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { skipToken, useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
skipToken,
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useShallow } from "zustand/shallow";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import {
|
||||
@@ -13,11 +19,15 @@ import { useSessionStore, type SessionState } from "@/stores/session-store";
|
||||
import { usageCopy } from "./copy";
|
||||
import {
|
||||
replaceReport,
|
||||
resolveAgentUsageView,
|
||||
resolveUsageRefresh,
|
||||
resolveUsageView,
|
||||
settleReports,
|
||||
type UsageHost,
|
||||
type UsageQueryState,
|
||||
type AgentUsageView,
|
||||
type UsageRefresh,
|
||||
upsertReport,
|
||||
} from "./model";
|
||||
import type { UsageReportEntry, UsageView } from "./types";
|
||||
|
||||
@@ -25,8 +35,17 @@ import type { UsageReportEntry, UsageView } from "./types";
|
||||
// an explicit refresh passes `forceRefresh` and reaches the source's API.
|
||||
const REPORTS_STALE_TIME_MS = 60_000;
|
||||
|
||||
/** Every report list of a host: its own and each agent's. */
|
||||
function hostUsageQueryKey(serverId: string) {
|
||||
return ["usage", serverId] as const;
|
||||
}
|
||||
|
||||
function usageReportsQueryKey(serverId: string) {
|
||||
return ["usage", "reports", serverId] as const;
|
||||
return [...hostUsageQueryKey(serverId), "reports"] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(serverId: string, agentId: string) {
|
||||
return [...hostUsageQueryKey(serverId), "agent", agentId] as const;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
@@ -35,8 +54,44 @@ function requireClient(serverId: string) {
|
||||
return client;
|
||||
}
|
||||
|
||||
async function listReports(serverId: string, forceRefresh = false): Promise<UsageReportEntry[]> {
|
||||
return (await requireClient(serverId).listUsageReports({ forceRefresh })).reports;
|
||||
/**
|
||||
* Lists a host's reports, or one agent's, writing each into `queryKey` as it streams in so a slow
|
||||
* source never holds back the others. The finished list then replaces the streamed one, dropping
|
||||
* any report the host no longer has, but never a report refreshed while the list streamed.
|
||||
*/
|
||||
async function streamReports(input: {
|
||||
queryClient: QueryClient;
|
||||
queryKey: QueryKey;
|
||||
serverId: string;
|
||||
agentId?: string;
|
||||
forceRefresh?: boolean;
|
||||
/** Once aborted, reports still on their way are dropped instead of written. */
|
||||
signal?: AbortSignal;
|
||||
}): Promise<UsageReportEntry[]> {
|
||||
const { queryClient, queryKey, serverId, agentId, forceRefresh = false, signal } = input;
|
||||
const { reports } = await requireClient(serverId).listUsageReports(
|
||||
{ agentId, forceRefresh },
|
||||
(report) => {
|
||||
if (signal?.aborted) return;
|
||||
queryClient.setQueryData<UsageReportEntry[]>(queryKey, (current) =>
|
||||
upsertReport(current, report),
|
||||
);
|
||||
},
|
||||
);
|
||||
return settleReports(queryClient.getQueryData<UsageReportEntry[]>(queryKey), reports);
|
||||
}
|
||||
|
||||
function listReports(
|
||||
queryClient: QueryClient,
|
||||
serverId: string,
|
||||
forceRefresh = false,
|
||||
): Promise<UsageReportEntry[]> {
|
||||
return streamReports({
|
||||
queryClient,
|
||||
queryKey: usageReportsQueryKey(serverId),
|
||||
serverId,
|
||||
forceRefresh,
|
||||
});
|
||||
}
|
||||
|
||||
async function getReport(
|
||||
@@ -57,7 +112,7 @@ 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: () => listReports(queryClient, serverId, true),
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
@@ -77,7 +132,7 @@ 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: () => listReports(queryClient, serverId),
|
||||
enabled: isConnected && isSupported,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
@@ -102,15 +157,51 @@ const NO_REPORTS: UsageReportEntry[] = [];
|
||||
* load, or without a host.
|
||||
*/
|
||||
export function useUsageHostReports(serverId: string | null): UsageReportEntry[] {
|
||||
const queryClient = useQueryClient();
|
||||
const query = useFetchQuery({
|
||||
queryKey: usageReportsQueryKey(serverId ?? ""),
|
||||
queryFn: serverId ? () => listReports(serverId) : skipToken,
|
||||
queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken,
|
||||
dataShape: "list",
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
return query.data ?? NO_REPORTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* The reports of the account an agent runs under, as its meter's tooltip or sheet shows them.
|
||||
* Fetched while either is mounted, so only while it is open.
|
||||
*/
|
||||
export function useAgentUsage(serverId: string, agentId: string): AgentUsageView {
|
||||
const queryClient = useQueryClient();
|
||||
const canReport = useHostReportsUsage(serverId);
|
||||
const queryKey = agentUsageQueryKey(serverId, agentId);
|
||||
const query = useFetchQuery({
|
||||
queryKey,
|
||||
// Consuming the signal cancels the request when the details close, so a reopen sends a new
|
||||
// request instead of joining one scoped to the login the agent ran under before.
|
||||
queryFn: ({ signal }) => streamReports({ queryClient, queryKey, serverId, agentId, signal }),
|
||||
enabled: canReport,
|
||||
// Another agent's reports never stand in while this one's load.
|
||||
dataShape: "value",
|
||||
// Dropped as soon as the details close: an agent resumed under another login keeps its ID, so
|
||||
// reports kept from an earlier open could show the old login. The daemon's cache keeps each
|
||||
// open's request cheap.
|
||||
gcTime: 0,
|
||||
// The daemon's errors (an unknown agent) do not heal on retry, and reopening the details
|
||||
// fetches again; retrying would hold the loading sentence for seconds instead.
|
||||
retry: false,
|
||||
staleTimeMs: REPORTS_STALE_TIME_MS,
|
||||
});
|
||||
return resolveAgentUsageView({ canReport, query: toQueryState(query) });
|
||||
}
|
||||
|
||||
/** Whether a host is connected and reports usage, read without fetching anything. */
|
||||
export function useHostReportsUsage(serverId: string): boolean {
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId]));
|
||||
return isConnected && isSupported;
|
||||
}
|
||||
|
||||
/** Every host with whether it is connected and reports usage, in host order. */
|
||||
export function useUsageHosts(): UsageHost[] {
|
||||
const hosts = useHosts();
|
||||
@@ -133,8 +224,8 @@ export function useUsageHosts(): UsageHost[] {
|
||||
|
||||
/**
|
||||
* Forces the source to fetch one report, and only that report. The result replaces the report
|
||||
* in its host's list, so every surface showing it moves together; until then the previous report
|
||||
* stays on screen.
|
||||
* in every list of its host that holds it, the host's and each agent's, so every surface showing
|
||||
* it moves together; until then the previous report stays on screen.
|
||||
*/
|
||||
export function useReportRefresh(
|
||||
serverId: string,
|
||||
@@ -144,8 +235,9 @@ export function useReportRefresh(
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => getReport(serverId, reportId, true),
|
||||
onSuccess: (report) => {
|
||||
queryClient.setQueryData<UsageReportEntry[]>(usageReportsQueryKey(serverId), (reports) =>
|
||||
reports ? replaceReport(reports, reportId, report) : reports,
|
||||
queryClient.setQueriesData<UsageReportEntry[]>(
|
||||
{ queryKey: hostUsageQueryKey(serverId) },
|
||||
(reports) => (reports ? replaceReport(reports, reportId, report) : reports),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function highlightStyle(pinned: boolean, hovered: boolean) {
|
||||
export function UsageWindowBar({
|
||||
window,
|
||||
displayAs,
|
||||
pinnable,
|
||||
pinned,
|
||||
onTogglePin,
|
||||
pinLabel,
|
||||
@@ -28,6 +29,8 @@ export function UsageWindowBar({
|
||||
}: {
|
||||
window: UsageWindow;
|
||||
displayAs: UsageDisplayAs;
|
||||
/** Whether the row pins the window to the sidebar. When false the row is a plain bar. */
|
||||
pinnable: boolean;
|
||||
pinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
/** What the row pins, naming the source and window: "Pin Claude Session". */
|
||||
@@ -44,6 +47,24 @@ export function UsageWindowBar({
|
||||
|
||||
const value = shownPct != null ? formatDisplayPct(shownPct, displayAs) : "—";
|
||||
const accessibilityState = useMemo(() => ({ checked: pinned }), [pinned]);
|
||||
const content = {
|
||||
label: window.label,
|
||||
value,
|
||||
trailing,
|
||||
isAtRisk,
|
||||
percent: shownPct ?? 0,
|
||||
tone,
|
||||
pinned,
|
||||
};
|
||||
|
||||
// Same padding as the pinnable row, so bars line up in both modes.
|
||||
if (!pinnable) {
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<WindowRowContent {...content} highlight={styles.highlightNone} pinnable={false} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// The whole row pins the window to the sidebar Usage item. Pinned or not, it keeps the same
|
||||
// padding so toggling only changes the background.
|
||||
@@ -59,14 +80,9 @@ export function UsageWindowBar({
|
||||
>
|
||||
{({ hovered }: { hovered?: boolean }) => (
|
||||
<WindowRowContent
|
||||
{...content}
|
||||
highlight={highlightStyle(pinned, Boolean(hovered))}
|
||||
label={window.label}
|
||||
value={value}
|
||||
trailing={trailing}
|
||||
isAtRisk={isAtRisk}
|
||||
percent={shownPct ?? 0}
|
||||
tone={tone}
|
||||
pinned={pinned}
|
||||
pinnable
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -81,6 +97,7 @@ function WindowRowContent({
|
||||
isAtRisk,
|
||||
percent,
|
||||
tone,
|
||||
pinnable,
|
||||
pinned,
|
||||
}: {
|
||||
highlight: StyleProp<ViewStyle>;
|
||||
@@ -90,6 +107,7 @@ function WindowRowContent({
|
||||
isAtRisk: boolean;
|
||||
percent: number;
|
||||
tone: UsageTone;
|
||||
pinnable: boolean;
|
||||
pinned: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -110,7 +128,7 @@ function WindowRowContent({
|
||||
</View>
|
||||
<UsageMeter percent={percent} tone={tone} />
|
||||
</View>
|
||||
<UsagePinGlyph pinned={pinned} />
|
||||
{pinnable ? <UsagePinGlyph pinned={pinned} /> : null}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -7206,11 +7206,15 @@ test("uses modern usage RPC when both capabilities are advertised", async () =>
|
||||
const connected = client.connect();
|
||||
mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } });
|
||||
await connected;
|
||||
const result = client.listUsageReports({
|
||||
requestId: "modern-usage",
|
||||
reportIds: ["claude:work"],
|
||||
forceRefresh: true,
|
||||
});
|
||||
const updates: unknown[] = [];
|
||||
const result = client.listUsageReports(
|
||||
{
|
||||
requestId: "modern-usage",
|
||||
reportIds: ["claude:work"],
|
||||
forceRefresh: true,
|
||||
},
|
||||
(report) => updates.push(report),
|
||||
);
|
||||
expect(parseSentFrame(mock.sent[0])).toEqual({
|
||||
type: "usage.list_reports.request",
|
||||
requestId: "modern-usage",
|
||||
@@ -7230,8 +7234,35 @@ test("uses modern usage RPC when both capabilities are advertised", async () =>
|
||||
},
|
||||
],
|
||||
};
|
||||
mock.triggerMessage(wrapSessionMessage({ type: "usage.list_reports.response", payload }));
|
||||
const report = payload.reports[0];
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "usage.list_reports.update",
|
||||
payload: { requestId: "foreign", report },
|
||||
}),
|
||||
);
|
||||
expect(updates).toEqual([]);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "usage.list_reports.update",
|
||||
payload: { requestId: payload.requestId, report },
|
||||
}),
|
||||
);
|
||||
expect(updates).toEqual([report]);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId: payload.requestId, error: null },
|
||||
}),
|
||||
);
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "usage.list_reports.update",
|
||||
payload: { requestId: payload.requestId, report },
|
||||
}),
|
||||
);
|
||||
expect(await result).toStrictEqual(payload);
|
||||
expect(updates).toEqual([report]);
|
||||
});
|
||||
|
||||
test("rejects usage requests when the host has neither capability", async () => {
|
||||
@@ -7250,3 +7281,47 @@ test("rejects usage requests when the host has neither capability", async () =>
|
||||
await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage.");
|
||||
expect(mock.sent).toEqual([]);
|
||||
});
|
||||
|
||||
test("usage request timeout detaches its update listener", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_unit_test",
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connected = client.connect();
|
||||
mock.triggerOpen({ features: { usageSources: true } });
|
||||
await connected;
|
||||
const updates: unknown[] = [];
|
||||
const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) =>
|
||||
updates.push(report),
|
||||
);
|
||||
const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)");
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
await rejected;
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "usage.list_reports.update",
|
||||
payload: {
|
||||
requestId: "timeout-usage",
|
||||
report: {
|
||||
id: "source:late",
|
||||
account: {},
|
||||
sourceId: "source",
|
||||
sourceLabel: "Source",
|
||||
fetchedAt: "2026-01-01T00:00:00.000Z",
|
||||
report: { status: "available", windows: [] },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(updates).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -95,7 +95,7 @@ import type {
|
||||
RefreshProvidersSnapshotResponseMessage,
|
||||
ProviderDiagnosticResponseMessage,
|
||||
ProviderUsageListResponseMessage,
|
||||
UsageListReportsResponseMessage,
|
||||
UsageReportEntry,
|
||||
DaemonGetStatusResponse,
|
||||
DaemonGetPairingOfferResponse,
|
||||
DaemonConfigReloadResponse,
|
||||
@@ -562,7 +562,10 @@ type GetProvidersSnapshotPayload = GetProvidersSnapshotResponseMessage["payload"
|
||||
type RefreshProvidersSnapshotPayload = RefreshProvidersSnapshotResponseMessage["payload"];
|
||||
type ProviderDiagnosticPayload = ProviderDiagnosticResponseMessage["payload"];
|
||||
type ProviderUsageListPayload = ProviderUsageListResponseMessage["payload"];
|
||||
type UsageListReportsPayload = UsageListReportsResponseMessage["payload"];
|
||||
interface UsageListReportsPayload {
|
||||
requestId: string;
|
||||
reports: UsageReportEntry[];
|
||||
}
|
||||
type DaemonStatusPayload = DaemonGetStatusResponse["payload"];
|
||||
type DaemonPairingOfferPayload = DaemonGetPairingOfferResponse["payload"];
|
||||
type DiagnosticsPayload = DiagnosticsResponse["payload"];
|
||||
@@ -5259,17 +5262,25 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listUsageReports(options?: {
|
||||
requestId?: string;
|
||||
forceRefresh?: boolean;
|
||||
reportIds?: string[];
|
||||
}): Promise<UsageListReportsPayload> {
|
||||
async listUsageReports(
|
||||
options?: {
|
||||
agentId?: string;
|
||||
requestId?: string;
|
||||
forceRefresh?: boolean;
|
||||
reportIds?: string[];
|
||||
},
|
||||
onReport?: (report: UsageReportEntry) => void,
|
||||
): Promise<UsageListReportsPayload> {
|
||||
const features = this.getLastServerInfoMessage()?.features;
|
||||
if (!supportsUsageReports(features)) {
|
||||
throw new Error("Update the host to see usage.");
|
||||
}
|
||||
if (options?.agentId !== undefined && options.reportIds !== undefined)
|
||||
throw new Error("agentId and reportIds cannot be combined");
|
||||
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
|
||||
if (features?.usageSources !== true) {
|
||||
if (options?.agentId !== undefined)
|
||||
return { requestId: this.createRequestId(options.requestId), reports: [] };
|
||||
// Released hosts serve a five-minute cache and have no forceRefresh option.
|
||||
const payload = await this.listProviderUsage({ requestId: options?.requestId });
|
||||
return {
|
||||
@@ -5310,14 +5321,46 @@ export class DaemonClient {
|
||||
}),
|
||||
};
|
||||
}
|
||||
return this.sendNamespacedCorrelatedSessionRequest({
|
||||
requestId: options?.requestId,
|
||||
message: {
|
||||
type: "usage.list_reports.request",
|
||||
forceRefresh: options?.forceRefresh,
|
||||
reportIds: options?.reportIds,
|
||||
},
|
||||
const requestId = this.createRequestId(options?.requestId);
|
||||
const reports: UsageReportEntry[] = [];
|
||||
let active = true;
|
||||
const unsubscribe = this.subscribeRawMessages((message) => {
|
||||
if (
|
||||
!active ||
|
||||
!("payload" in message) ||
|
||||
!("requestId" in message.payload) ||
|
||||
message.payload.requestId !== requestId
|
||||
)
|
||||
return;
|
||||
if (message.type === "usage.list_reports.response" || message.type === "rpc_error") {
|
||||
active = false;
|
||||
return;
|
||||
}
|
||||
if (message.type !== "usage.list_reports.update") return;
|
||||
reports.push(message.payload.report);
|
||||
onReport?.(message.payload.report);
|
||||
});
|
||||
try {
|
||||
const response = await this.sendRequest({
|
||||
requestId,
|
||||
message: {
|
||||
type: "usage.list_reports.request",
|
||||
requestId,
|
||||
forceRefresh: options?.forceRefresh,
|
||||
reportIds: options?.reportIds,
|
||||
agentId: options?.agentId,
|
||||
},
|
||||
select: (message) =>
|
||||
message.type === "usage.list_reports.response" && message.payload.requestId === requestId
|
||||
? message.payload
|
||||
: null,
|
||||
});
|
||||
if (response.error !== null) throw new Error(response.error);
|
||||
return { requestId, reports };
|
||||
} finally {
|
||||
active = false;
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
async listCommands(options: ListCommandsOptions): Promise<ListCommandsPayload>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { ZodType } from "zod";
|
||||
import { z, type ZodType } from "zod";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export interface UsageWindow {
|
||||
@@ -62,13 +62,24 @@ export interface UsageAccount {
|
||||
input: JsonValue;
|
||||
}
|
||||
|
||||
export const UsageScopeSchema = z.discriminatedUnion("kind", [
|
||||
z.object({ kind: z.literal("global") }),
|
||||
z.object({
|
||||
kind: z.literal("session"),
|
||||
provider: z.string(),
|
||||
model: z.string().optional(),
|
||||
env: z.record(z.string(), z.string()),
|
||||
}),
|
||||
]);
|
||||
export type UsageScope = z.infer<typeof UsageScopeSchema>;
|
||||
|
||||
export interface UsageSourceRegistration {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
input: ZodType;
|
||||
/** Every account whose login exists on this machine. Empty when none. */
|
||||
discover(): Promise<UsageAccount[]>;
|
||||
/** Accounts for this scope only. The same key in any scope identifies the same account. */
|
||||
discover(scope: UsageScope): Promise<UsageAccount[]>;
|
||||
/** Re-reads the login store; never writes it. */
|
||||
fetch(input: unknown): Promise<UsageReport>;
|
||||
}
|
||||
|
||||
@@ -1788,6 +1788,7 @@ export const ProviderUsageListRequestMessageSchema = z.object({
|
||||
|
||||
export const UsageListReportsRequestMessageSchema = z.object({
|
||||
type: z.literal("usage.list_reports.request"),
|
||||
agentId: z.string().optional(),
|
||||
requestId: z.string(),
|
||||
reportIds: z.array(z.string()).optional(),
|
||||
forceRefresh: z.boolean().optional(),
|
||||
@@ -6289,9 +6290,13 @@ export const UsageReportEntrySchema = z.object({
|
||||
icon: z.string().optional(),
|
||||
report: UsageReportSchema,
|
||||
});
|
||||
export const UsageListReportsUpdateMessageSchema = z.object({
|
||||
type: z.literal("usage.list_reports.update"),
|
||||
payload: z.object({ requestId: z.string(), report: UsageReportEntrySchema }),
|
||||
});
|
||||
export const UsageListReportsResponseMessageSchema = z.object({
|
||||
type: z.literal("usage.list_reports.response"),
|
||||
payload: z.object({ requestId: z.string(), reports: z.array(UsageReportEntrySchema) }),
|
||||
payload: z.object({ requestId: z.string(), error: z.string().nullable() }),
|
||||
});
|
||||
|
||||
const AgentSlashCommandSchema = z.object({
|
||||
@@ -6979,6 +6984,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
RefreshProvidersSnapshotResponseMessageSchema,
|
||||
ProviderDiagnosticResponseMessageSchema,
|
||||
ProviderUsageListResponseMessageSchema,
|
||||
UsageListReportsUpdateMessageSchema,
|
||||
UsageListReportsResponseMessageSchema,
|
||||
ListCommandsResponseSchema,
|
||||
ListTerminalsResponseSchema,
|
||||
|
||||
@@ -11417,3 +11417,33 @@ test("failed startup history closes the session without registering an agent", a
|
||||
for (const agent of manager.listAgents()) await manager.closeAgent(agent.id);
|
||||
}
|
||||
});
|
||||
|
||||
test("usage session is a pure read of the live adapter and disappears on close", async () => {
|
||||
const descriptor = {
|
||||
provider: "claude",
|
||||
model: "opus",
|
||||
env: { HOME: "/fixture", CLAUDE_CONFIG_DIR: "/fixture/work" },
|
||||
sessionKey: "launch-1",
|
||||
};
|
||||
const client = new (class extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new (class extends TestAgentSession {
|
||||
usageSession() {
|
||||
return descriptor;
|
||||
}
|
||||
})(config);
|
||||
}
|
||||
})();
|
||||
const manager = new AgentManager({ clients: { codex: client }, logger });
|
||||
expect(manager.usageSession("missing")).toBeNull();
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: tmpdir() }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
try {
|
||||
expect(manager.usageSession(agent.id)).toBe(descriptor);
|
||||
expect(manager.usageSession(agent.id)).toBe(descriptor);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
}
|
||||
expect(manager.usageSession(agent.id)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1157,6 +1157,11 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
usageSession(id: string) {
|
||||
const agent = this.agents.get(id);
|
||||
return agent?.session?.usageSession?.() ?? null;
|
||||
}
|
||||
|
||||
getAgent(id: string): ManagedAgent | null {
|
||||
const agent = this.agents.get(id);
|
||||
return agent ? { ...agent } : null;
|
||||
|
||||
@@ -647,7 +647,15 @@ export interface AgentPermissionResult {
|
||||
followUpPrompt?: AgentPromptInput;
|
||||
}
|
||||
|
||||
export interface AgentUsageSession {
|
||||
provider: string;
|
||||
model?: string;
|
||||
env: Record<string, string>;
|
||||
sessionKey: string;
|
||||
}
|
||||
|
||||
export interface AgentSession {
|
||||
usageSession?(): AgentUsageSession | null;
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
@@ -449,6 +449,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
get features() {
|
||||
return inner.features;
|
||||
},
|
||||
usageSession: inner.usageSession?.bind(inner),
|
||||
run: (prompt, options) => inner.run(prompt, options),
|
||||
startTurn: (prompt, options) => inner.startTurn(prompt, options),
|
||||
steerActiveTurn: inner.steerActiveTurn?.bind(inner),
|
||||
|
||||
@@ -90,8 +90,10 @@ describe("Claude SDK env", () => {
|
||||
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
|
||||
expect(capturedEnv?.MCP_TIMEOUT).toBe("claude-startup-timeout");
|
||||
expect(capturedEnv?.MCP_TOOL_TIMEOUT).toBe("claude-tool-timeout");
|
||||
expect(session.usageSession?.()?.env).toBe(capturedEnv);
|
||||
} finally {
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,12 +152,19 @@ describe("Claude SDK env", () => {
|
||||
);
|
||||
|
||||
try {
|
||||
const descriptor = session.usageSession?.();
|
||||
expect(descriptor?.env.PASEO_TEST_FLAG).toBe("resume-launch-value");
|
||||
expect(descriptor?.sessionKey).toEqual(expect.any(String));
|
||||
expect(queryFactory).not.toHaveBeenCalled();
|
||||
const result = await session.run("resume env check");
|
||||
expect(session.usageSession?.()?.sessionKey).toBe(descriptor?.sessionKey);
|
||||
expect(capturedEnv).toBe(descriptor?.env);
|
||||
expect(result.sessionId).toBe("persisted-session");
|
||||
expect(capturedEnv?.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID);
|
||||
expect(capturedEnv?.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG);
|
||||
} finally {
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2053,6 +2053,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private readonly queryFactory?: ClaudeQueryFactory;
|
||||
private readonly resolveBinary: () => Promise<string>;
|
||||
private query: Query | null = null;
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private input: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
/** The exact SDK query/input pair that owns the current foreground turn. */
|
||||
@@ -2128,6 +2130,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.agentId = options.agentId;
|
||||
this.defaults = options.defaults;
|
||||
this.runtimeSettings = options.runtimeSettings;
|
||||
this.harnessEnvironment = this.buildSdkEnv();
|
||||
this.persistSession = options.persistSession;
|
||||
this.logger = options.logger.child({ agentId: this.agentId });
|
||||
this.queryFactory = options.queryFactory;
|
||||
@@ -2422,7 +2425,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
const normalized = isPermissionMode(modeId) ? modeId : "default";
|
||||
assertClaudeModeCanRun(normalized, this.buildSdkEnv());
|
||||
assertClaudeModeCanRun(normalized, this.harnessEnvironment);
|
||||
const previousMode = this.currentMode;
|
||||
const activeQuery = await this.ensureQuery();
|
||||
await activeQuery.setPermissionMode(normalized);
|
||||
@@ -3255,7 +3258,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
);
|
||||
}
|
||||
|
||||
private buildSdkEnv(): NodeJS.ProcessEnv {
|
||||
usageSession() {
|
||||
if (this.closed) return null;
|
||||
return {
|
||||
provider: "claude",
|
||||
model: this.config.model,
|
||||
env: this.harnessEnvironment,
|
||||
sessionKey: this.usageSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
private buildSdkEnv() {
|
||||
return createProviderEnv({
|
||||
baseEnv: process.env,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
@@ -3271,7 +3284,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.buildSdkEnv();
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(this.currentMode, sdkEnv);
|
||||
|
||||
const claudeBinary = await this.resolveBinary();
|
||||
@@ -5064,7 +5077,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private resolveHistoryPath(sessionId: string): string | null {
|
||||
const cwd = this.config.cwd;
|
||||
if (!cwd) return null;
|
||||
const configDir = claudeConfigDir(this.buildSdkEnv());
|
||||
const configDir = claudeConfigDir(this.harnessEnvironment);
|
||||
const candidates = [cwd];
|
||||
try {
|
||||
const realCwd = fs.realpathSync(cwd);
|
||||
|
||||
@@ -698,6 +698,11 @@ process.stdin.on("data", (chunk) => {
|
||||
});
|
||||
|
||||
try {
|
||||
expect(session.usageSession?.()).toMatchObject({
|
||||
provider: "codex",
|
||||
env: { CODEX_HOME: providerCodexHome },
|
||||
});
|
||||
expect(session.usageSession?.()?.sessionKey).toBe(session.usageSession?.()?.sessionKey);
|
||||
return await run({
|
||||
session,
|
||||
readCaptured: () =>
|
||||
@@ -708,6 +713,7 @@ process.stdin.on("data", (chunk) => {
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
vi.unstubAllEnvs();
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -1792,6 +1798,28 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("resumed session exposes usage before connecting and not after close", async () => {
|
||||
let spawns = 0;
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project" }),
|
||||
{ sessionId: "saved-thread" },
|
||||
createTestLogger(),
|
||||
async () => {
|
||||
spawns++;
|
||||
throw new Error("unexpected spawn");
|
||||
},
|
||||
{ environment: { HOME: "/fixture/codex", CODEX_HOME: "/fixture/profile" } },
|
||||
);
|
||||
expect(session.usageSession()).toMatchObject({
|
||||
provider: "codex",
|
||||
env: { CODEX_HOME: "/fixture/profile" },
|
||||
sessionKey: expect.any(String),
|
||||
});
|
||||
expect(spawns).toBe(0);
|
||||
await session.close();
|
||||
expect(session.usageSession()).toBeNull();
|
||||
});
|
||||
|
||||
test("loads archived Codex history without resuming the native thread", async () => {
|
||||
const threadRequests: string[] = [];
|
||||
const appServer = createFakeCodexAppServer({
|
||||
|
||||
@@ -63,7 +63,6 @@ import {
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
resolveProviderLaunch,
|
||||
type ProviderRuntimeSettings,
|
||||
type ResolvedProviderLaunch,
|
||||
@@ -272,6 +271,7 @@ interface CodexAppServerClientLike {
|
||||
}
|
||||
|
||||
interface CodexAppServerAgentDeps {
|
||||
environment?: Record<string, string>;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
customProvider?: {
|
||||
id: string;
|
||||
@@ -3323,7 +3323,7 @@ function toCodexTextInput(text: string): Extract<CodexAppServerUserInput, { type
|
||||
export function buildCodexAppServerEnv(
|
||||
runtimeSettings?: ProviderRuntimeSettings,
|
||||
launchEnv?: Record<string, string>,
|
||||
): NodeJS.ProcessEnv {
|
||||
) {
|
||||
return createProviderEnv({
|
||||
runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
@@ -3521,6 +3521,19 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
} | null = null;
|
||||
private cachedSkills: Array<{ name: string; description: string; path: string }> | null = null;
|
||||
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
private readonly harnessEnvironment: Record<string, string>;
|
||||
|
||||
usageSession() {
|
||||
if (this.closed) return null;
|
||||
return {
|
||||
provider: "codex",
|
||||
model: this.config.model,
|
||||
env: this.harnessEnvironment,
|
||||
sessionKey: this.usageSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
config: AgentSessionConfig,
|
||||
private readonly resumeHandle: { sessionId: string; metadata?: Record<string, unknown> } | null,
|
||||
@@ -3546,6 +3559,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.providerOptions =
|
||||
validateProviderOptions("codex", CodexProviderOptionsSchema, config.providerOptions) ?? {};
|
||||
this.config = config;
|
||||
this.harnessEnvironment = deps.environment ?? buildCodexAppServerEnv();
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
@@ -7211,7 +7225,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
|
||||
private async spawnAppServer(
|
||||
launchEnv?: Record<string, string>,
|
||||
options?: { goalsEnabled?: boolean; agentId?: string },
|
||||
options?: { goalsEnabled?: boolean; agentId?: string; environment?: Record<string, string> },
|
||||
): Promise<ChildProcessWithoutNullStreams> {
|
||||
const launchPrefix = await resolveCodexLaunchPrefix(this.runtimeSettings);
|
||||
const args = [...launchPrefix.args, "app-server"];
|
||||
@@ -7230,10 +7244,7 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const child = spawnProcess(launchPrefix.command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv],
|
||||
}),
|
||||
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
@@ -7254,13 +7265,18 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
const sessionConfig: AgentSessionConfig = { ...config, provider: CODEX_PROVIDER };
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
sessionConfig,
|
||||
null,
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(launchContext?.env),
|
||||
this.spawnAppServer(launchContext?.env, {
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
environment,
|
||||
}),
|
||||
{ ...this.sessionDeps(launchContext?.env), environment },
|
||||
options?.persistSession === false,
|
||||
goalsEnabled,
|
||||
autoReviewEnabled,
|
||||
@@ -7286,13 +7302,18 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
};
|
||||
const goalsEnabled = await this.resolveGoalsEnabled();
|
||||
const autoReviewEnabled = await this.resolveAutoReviewEnabled();
|
||||
const environment = buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env);
|
||||
const session = new CodexAppServerAgentSession(
|
||||
merged,
|
||||
handle,
|
||||
this.logger,
|
||||
() =>
|
||||
this.spawnAppServer(launchContext?.env, { goalsEnabled, agentId: launchContext?.agentId }),
|
||||
this.sessionDeps(launchContext?.env),
|
||||
this.spawnAppServer(launchContext?.env, {
|
||||
goalsEnabled,
|
||||
agentId: launchContext?.agentId,
|
||||
environment,
|
||||
}),
|
||||
{ ...this.sessionDeps(launchContext?.env), environment },
|
||||
false,
|
||||
goalsEnabled,
|
||||
autoReviewEnabled,
|
||||
|
||||
@@ -958,6 +958,9 @@ describe("OMP agent client and session", () => {
|
||||
messageId: "assistant-history",
|
||||
},
|
||||
]);
|
||||
expect(omp.usageSession()).toMatchObject({ provider: "omp", sessionKey: expect.any(String) });
|
||||
await omp.close();
|
||||
expect(omp.usageSession()).toBeNull();
|
||||
});
|
||||
|
||||
test("maps permissions and sends the selected OMP response", async () => {
|
||||
|
||||
@@ -723,6 +723,19 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly emittedUserMessageIds = new Set<string>();
|
||||
private customMessageIndex = 0;
|
||||
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
|
||||
usageSession() {
|
||||
const env = this.runtimeSession.environment;
|
||||
if (this.closed) return null;
|
||||
return {
|
||||
provider: "omp",
|
||||
model: modelToId(this.state.model) ?? undefined,
|
||||
env,
|
||||
sessionKey: this.usageSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(options: OmpAgentSessionOptions) {
|
||||
this.runtimeSession = options.runtimeSession;
|
||||
this.hostTools = options.hostTools;
|
||||
|
||||
@@ -127,7 +127,13 @@ describe("OMP CLI runtime", () => {
|
||||
onOmpCommand(child, (command) => {
|
||||
if (command.type === "steer") pending = command;
|
||||
});
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
const launches: OmpRuntimeLaunch[] = [];
|
||||
const session = await createRuntime(child, launches).startSession({
|
||||
cwd: "/workspace/project",
|
||||
env: { HOME: "/fixture/omp-home" },
|
||||
});
|
||||
expect(session.environment).toBe(launches[0]?.env);
|
||||
expect(session.environment?.HOME).toBe("/fixture/omp-home");
|
||||
const result = session.steer("change direction");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
expect(pending).toMatchObject({ type: "steer", message: "change direction" });
|
||||
@@ -485,7 +491,10 @@ describe("OMP CLI runtime", () => {
|
||||
const child = createOmpChild();
|
||||
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
|
||||
|
||||
const environment = session.environment;
|
||||
child.emit("exit", 1, null);
|
||||
expect(session.environment).toBe(environment);
|
||||
expect(environment).toBeDefined();
|
||||
|
||||
await expect(session.abort()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
@@ -74,6 +75,7 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {});
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
@@ -98,7 +100,7 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
requestTimeoutMs: this.options.requestTimeoutMs,
|
||||
});
|
||||
input.signal?.throwIfAborted();
|
||||
return new OmpCliRuntimeSession(process, this.commandsRpcName);
|
||||
return new OmpCliRuntimeSession(process, this.commandsRpcName, launch.env);
|
||||
} catch (error) {
|
||||
const startupError = error instanceof Error ? error : new Error(String(error));
|
||||
await process.close(startupError);
|
||||
@@ -116,6 +118,7 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
constructor(
|
||||
private readonly process: JsonlRpcProcess,
|
||||
private readonly commandsRpcName: "get_available_commands",
|
||||
private readonly launchEnvironment: Record<string, string>,
|
||||
) {
|
||||
process.onMessage((message) => {
|
||||
const event = OmpRuntimeEventSchema.safeParse(message);
|
||||
@@ -128,6 +131,10 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
|
||||
});
|
||||
}
|
||||
|
||||
get environment(): Record<string, string> {
|
||||
return this.launchEnvironment;
|
||||
}
|
||||
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface OmpStartSessionInput {
|
||||
}
|
||||
|
||||
export interface OmpRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
|
||||
prompt(
|
||||
message: string,
|
||||
|
||||
@@ -115,6 +115,7 @@ export class FakeOmp implements OmpRuntime {
|
||||
}
|
||||
|
||||
export class FakeOmpSession implements OmpRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
fastModeResult = { enabled: false, active: false };
|
||||
readonly setFastModeRequests: boolean[] = [];
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
@@ -174,6 +175,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
null;
|
||||
|
||||
constructor(launch: OmpRuntimeLaunch, sessionId = "omp-session-1") {
|
||||
this.environment = launch.env ?? {};
|
||||
this.state = {
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
|
||||
@@ -566,6 +566,10 @@ export class OmpHarness {
|
||||
return this.events.filter((event) => event.type === "turn_canceled").length;
|
||||
}
|
||||
|
||||
usageSession() {
|
||||
return this.requireSession().usageSession();
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.requireSession().close();
|
||||
await waitForImmediate();
|
||||
|
||||
@@ -1618,6 +1618,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
createDirectEventSource(fakeClient),
|
||||
);
|
||||
@@ -1728,6 +1729,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
createDirectEventSource(fakeClient),
|
||||
);
|
||||
@@ -1812,6 +1814,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
createDirectEventSource(fakeClient),
|
||||
);
|
||||
@@ -1853,6 +1856,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
undefined,
|
||||
undefined,
|
||||
@@ -1881,6 +1885,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
await session.close();
|
||||
@@ -1905,6 +1910,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
events,
|
||||
);
|
||||
@@ -1977,6 +1983,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
@@ -2067,6 +2074,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
@@ -2119,6 +2127,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
@@ -2232,6 +2241,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
const history: AgentStreamEvent[] = [];
|
||||
@@ -2347,6 +2357,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -3146,6 +3157,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
sdkClient,
|
||||
"ses_sync_abort_failure",
|
||||
createTestLogger(),
|
||||
{},
|
||||
);
|
||||
|
||||
try {
|
||||
@@ -3744,7 +3756,10 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
try {
|
||||
await session.startTurn("first");
|
||||
await session.interrupt();
|
||||
const descriptor = session.usageSession?.();
|
||||
openCode.emitEvent({ type: "server-exited", error: new Error("OpenCode exited") });
|
||||
expect(session.usageSession?.()).toEqual(descriptor);
|
||||
expect(descriptor).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
openCode.sessionPromptAsyncEvents = [
|
||||
@@ -3799,6 +3814,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
openCode.asSdkClient(),
|
||||
"ses_readiness_timeout",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
{
|
||||
ready: () => new Promise<void>(() => undefined),
|
||||
@@ -3852,6 +3868,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
openCode.asSdkClient(),
|
||||
"ses_readiness_slow_stream",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
{
|
||||
ready: () => streamReady.promise,
|
||||
@@ -3884,6 +3901,7 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
openCode.asSdkClient(),
|
||||
"ses_readiness_retry",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
{
|
||||
ready: () => streamReady.promise,
|
||||
@@ -5562,6 +5580,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
fakeClient,
|
||||
"ses_parent",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
createDirectEventSource(fakeClient),
|
||||
);
|
||||
@@ -5720,6 +5739,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
fakeClient,
|
||||
"ses_parent",
|
||||
createTestLogger(),
|
||||
{},
|
||||
new Map(),
|
||||
createDirectEventSource(fakeClient),
|
||||
);
|
||||
@@ -6976,9 +6996,14 @@ describe("OpenCode session permission rules", () => {
|
||||
metadata: { cwd },
|
||||
});
|
||||
try {
|
||||
expect(session.usageSession?.()).toMatchObject({
|
||||
provider: "opencode",
|
||||
sessionKey: expect.any(String),
|
||||
});
|
||||
expect(openCode.calls.sessionUpdate).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import fs from "node:fs/promises";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import pLimit from "p-limit";
|
||||
@@ -1492,6 +1492,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
session.id,
|
||||
this.logger,
|
||||
connection.environment,
|
||||
new Map(this.modelContextWindows),
|
||||
connection.events,
|
||||
connection.release,
|
||||
@@ -1546,6 +1547,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
client,
|
||||
handle.sessionId,
|
||||
this.logger,
|
||||
connection.environment,
|
||||
new Map(this.modelContextWindows),
|
||||
connection.events,
|
||||
connection.release,
|
||||
@@ -1594,6 +1596,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
return {
|
||||
client: this.createOpenCodeClient({ baseUrl: acquisition.server.url, directory }),
|
||||
events: acquisition.events,
|
||||
environment: acquisition.environment,
|
||||
url: acquisition.server.url,
|
||||
release: acquisition.release,
|
||||
};
|
||||
@@ -3357,6 +3360,7 @@ async function listOpenCodeChildSessions(
|
||||
|
||||
/** One OpenCode server generation a session talks to. */
|
||||
interface OpenCodeServerConnection {
|
||||
environment: Record<string, string>;
|
||||
client: OpencodeClient;
|
||||
events: OpenCodeEventSource;
|
||||
url: string;
|
||||
@@ -3437,11 +3441,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
private closed = false;
|
||||
private readonly persistSession: boolean;
|
||||
private deletedFromProvider = false;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
constructor(
|
||||
config: OpenCodeAgentConfig,
|
||||
client: OpencodeClient,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
private readonly harnessEnvironment: Record<string, string>,
|
||||
modelContextWindowsByModelKey: ReadonlyMap<string, number> = new Map(),
|
||||
events: OpenCodeEventSource = EMPTY_OPENCODE_EVENT_SOURCE,
|
||||
releaseServer: () => Promise<void> = async () => undefined,
|
||||
@@ -3453,7 +3459,13 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
connectServer?: () => Promise<OpenCodeServerConnection>,
|
||||
) {
|
||||
this.config = config;
|
||||
this.server = { client, events, url: serverUrl ?? "", release: releaseServer };
|
||||
this.server = {
|
||||
client,
|
||||
events,
|
||||
url: serverUrl ?? "",
|
||||
release: releaseServer,
|
||||
environment: this.harnessEnvironment,
|
||||
};
|
||||
this.connectServer = connectServer ?? null;
|
||||
this.sessionId = sessionId;
|
||||
this.logger = logger.child({ agentId: this.agentId });
|
||||
@@ -3468,6 +3480,16 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.subscribeServerEvents();
|
||||
}
|
||||
|
||||
usageSession() {
|
||||
if (this.closed) return null;
|
||||
return {
|
||||
provider: "opencode",
|
||||
model: this.config.model,
|
||||
env: this.harnessEnvironment,
|
||||
sessionKey: this.usageSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
private get client(): OpencodeClient {
|
||||
return this.server.client;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ describe("OpenCodeServerManager generations", () => {
|
||||
const acquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(runtime.spawnCalls[0]?.options.baseEnv).toEqual(baseEnv);
|
||||
expect(acquisition.environment).toBe(runtime.spawnCalls[0]?.options.baseEnv);
|
||||
await acquisition.release();
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { spawnProcess, type SpawnProcessOptions } from "../../../../utils/spawn.
|
||||
import { terminateWithTreeKill, type ProcessTerminator } from "../../../../utils/tree-kill.js";
|
||||
import type { ManagedProcessRegistry } from "../../../managed-processes/managed-processes.js";
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
createProviderEnv,
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
@@ -30,6 +30,7 @@ const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
|
||||
export interface OpenCodeServerAcquisition {
|
||||
environment: Record<string, string>;
|
||||
server: { port: number; url: string };
|
||||
events: OpenCodeEventSource;
|
||||
release: () => Promise<void>;
|
||||
@@ -44,6 +45,7 @@ export interface OpenCodeServerManagerLike {
|
||||
}
|
||||
|
||||
export interface OpenCodeServerGeneration {
|
||||
environment: Record<string, string>;
|
||||
process: ChildProcess;
|
||||
port: number;
|
||||
url: string;
|
||||
@@ -211,6 +213,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
return {
|
||||
server: { port: server.port, url: server.url },
|
||||
events: server.events,
|
||||
environment: server.environment,
|
||||
release: async () => {
|
||||
if (releasePromise) {
|
||||
return releasePromise;
|
||||
@@ -332,15 +335,16 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
const bridgeEnv = this.decorateServerEnv?.(
|
||||
existingConfigContent ? { OPENCODE_CONFIG_CONTENT: existingConfigContent } : {},
|
||||
);
|
||||
const environment = createProviderEnv({
|
||||
baseEnv: this.baseEnv,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv, bridgeEnv],
|
||||
});
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...createProviderEnvSpec({
|
||||
baseEnv: this.baseEnv,
|
||||
runtimeSettings: this.runtimeSettings,
|
||||
overlays: [launchEnv, bridgeEnv],
|
||||
}),
|
||||
baseEnv: environment,
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
@@ -353,6 +357,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
resolveProcessExit = resolve;
|
||||
});
|
||||
const server: OpenCodeServerGeneration = {
|
||||
environment,
|
||||
process: serverProcess,
|
||||
port,
|
||||
url,
|
||||
|
||||
@@ -46,6 +46,7 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
this.acquisitions.push(acquisition);
|
||||
return {
|
||||
server: this.server,
|
||||
environment: input.env ?? {},
|
||||
events,
|
||||
release: async () => {
|
||||
acquisition.released = true;
|
||||
|
||||
+1
@@ -74,6 +74,7 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
|
||||
return {
|
||||
server: this.server,
|
||||
events: this.events,
|
||||
environment: input.env ?? {},
|
||||
release: async () => {
|
||||
acquisition.releaseCount += 1;
|
||||
},
|
||||
|
||||
@@ -1495,7 +1495,7 @@ describe("PiRpcAgentSession", () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await client.resumeSession(
|
||||
const session = await client.resumeSession(
|
||||
{
|
||||
provider: "pi",
|
||||
sessionId: "pi-session-1",
|
||||
@@ -1510,6 +1510,12 @@ describe("PiRpcAgentSession", () => {
|
||||
{ env: { RESUME_PROBE: "expected" } },
|
||||
);
|
||||
|
||||
expect(session.usageSession?.()).toMatchObject({
|
||||
provider: "pi",
|
||||
sessionKey: expect.any(String),
|
||||
});
|
||||
await session.close();
|
||||
expect(session.usageSession?.()).toBeNull();
|
||||
expect(pi.recordedLaunches).toHaveLength(1);
|
||||
const actualLaunch = pi.recordedLaunches[0]!;
|
||||
expect(actualLaunch).toMatchObject({
|
||||
|
||||
@@ -1202,6 +1202,19 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
// turn ID; retain their errors too until the cancellation request settles.
|
||||
private interruptingTurn: { turnId: string | undefined; error: string | null } | null = null;
|
||||
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
|
||||
usageSession() {
|
||||
const env = this.runtimeSession.environment;
|
||||
if (this.closed) return null;
|
||||
return {
|
||||
provider: "pi",
|
||||
model: modelToId(this.state.model) ?? undefined,
|
||||
env,
|
||||
sessionKey: this.usageSessionKey,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(options: PiRpcAgentSessionOptions) {
|
||||
this.runtimeSession = options.runtimeSession;
|
||||
this.config = options.config;
|
||||
|
||||
@@ -146,7 +146,12 @@ describe("PiCliRuntime", () => {
|
||||
const launches: PiRuntimeLaunch[] = [];
|
||||
const runtime = createRuntime(child, launches);
|
||||
|
||||
const session = await runtime.startSession({ cwd: "/workspace/project" });
|
||||
const session = await runtime.startSession({
|
||||
cwd: "/workspace/project",
|
||||
env: { HOME: "/fixture/pi-home" },
|
||||
});
|
||||
expect(session.environment).toBe(launches[0]?.env);
|
||||
expect(session.environment?.HOME).toBe("/fixture/pi-home");
|
||||
|
||||
await expect(session.getState()).resolves.toMatchObject({
|
||||
sessionId: "pi-session-1",
|
||||
@@ -287,7 +292,10 @@ describe("PiCliRuntime", () => {
|
||||
|
||||
const state = session.getState();
|
||||
child.stderr.write("boom");
|
||||
const environment = session.environment;
|
||||
child.emit("exit", 1, null);
|
||||
expect(session.environment).toBe(environment);
|
||||
expect(environment).toBeDefined();
|
||||
|
||||
await expect(state).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
@@ -56,6 +57,7 @@ export class PiCliRuntime implements PiRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {});
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
@@ -76,7 +78,7 @@ export class PiCliRuntime implements PiRuntime {
|
||||
await process.close(input.signal.reason);
|
||||
input.signal.throwIfAborted();
|
||||
}
|
||||
return new PiCliRuntimeSession(process, this.commandsRpcName);
|
||||
return new PiCliRuntimeSession(process, this.commandsRpcName, launch.env);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +88,7 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
constructor(
|
||||
private readonly process: JsonlRpcProcess,
|
||||
private readonly commandsRpcName: string,
|
||||
private readonly launchEnvironment: Record<string, string>,
|
||||
) {
|
||||
process.onMessage((message) => {
|
||||
this.emit(message as PiRuntimeEvent);
|
||||
@@ -95,6 +98,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
|
||||
});
|
||||
}
|
||||
|
||||
get environment(): Record<string, string> {
|
||||
return this.launchEnvironment;
|
||||
}
|
||||
|
||||
onEvent(callback: (event: PiRuntimeEvent) => void): () => void {
|
||||
this.subscribers.add(callback);
|
||||
return () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface PiStartSessionInput {
|
||||
}
|
||||
|
||||
export interface PiRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
|
||||
prompt(
|
||||
message: string,
|
||||
|
||||
@@ -93,6 +93,7 @@ export class FakePi implements PiRuntime {
|
||||
}
|
||||
|
||||
export class FakePiSession implements PiRuntimeSession {
|
||||
readonly environment: Record<string, string>;
|
||||
readonly prompts: Array<{ message: string; imageCount: number }> = [];
|
||||
readonly steerCalls: Array<{ message: string; imageCount: number }> = [];
|
||||
steerError: Error | null = null;
|
||||
@@ -145,6 +146,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
null;
|
||||
|
||||
constructor(launch: PiRuntimeLaunch) {
|
||||
this.environment = launch.env ?? {};
|
||||
this.state = {
|
||||
model: null,
|
||||
thinkingLevel: "medium",
|
||||
|
||||
@@ -374,6 +374,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"project.update": "workspace.read",
|
||||
project_icon_response: "workspace.read",
|
||||
"provider.usage.list.response": "daemon.read",
|
||||
"usage.list_reports.update": "daemon.read",
|
||||
"usage.list_reports.response": "daemon.read",
|
||||
provider_diagnostic_response: "daemon.read",
|
||||
providers_snapshot_update: ["daemon.read", "hub.execute"],
|
||||
|
||||
@@ -618,7 +618,11 @@ export async function createPaseoDaemon(
|
||||
});
|
||||
const browserToolsPolicy = new DaemonConfigBrowserToolsPolicy(daemonConfigStore);
|
||||
const browserToolsBroker = new BrowserToolsBroker({});
|
||||
const pluginRuntime = new PluginService(logger, daemonConfigStore, daemonVersion, {
|
||||
const pluginRuntime: PluginService = new PluginService(logger, daemonConfigStore, daemonVersion, {
|
||||
usageAgents: {
|
||||
hasAgent: (id) => agentManager.getAgent(id) !== null,
|
||||
usageSession: (id) => agentManager.usageSession(id),
|
||||
},
|
||||
managedSources: new ManagedPluginSources(config.paseoHome, {
|
||||
registries: config.pluginRegistries,
|
||||
defaultUrl: config.pluginRegistryUrl,
|
||||
|
||||
@@ -26,7 +26,11 @@ import { PluginRuntime } from "./runtime.js";
|
||||
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
|
||||
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
||||
import { readPluginProviderIcon } from "./provider-icon.js";
|
||||
import { UsageSourceRegistry } from "./usage-sources/index.js";
|
||||
import {
|
||||
type AgentUsageLookup,
|
||||
type ListUsageReportsOptions,
|
||||
UsageSourceRegistry,
|
||||
} from "./usage-sources/index.js";
|
||||
import type { PluginUsageSourceMetadata } from "./plugin-process-protocol.js";
|
||||
|
||||
const BUILTIN_PROVIDER_ID_SET: ReadonlySet<string> = new Set(BUILTIN_PROVIDER_IDS);
|
||||
@@ -56,6 +60,7 @@ interface PluginRuntimePort {
|
||||
}
|
||||
|
||||
interface PluginServiceDependencies {
|
||||
usageAgents?: AgentUsageLookup;
|
||||
settingsDirectory?: string;
|
||||
runtime?: PluginRuntimePort;
|
||||
managedSources?: ManagedPluginSources;
|
||||
@@ -96,7 +101,12 @@ export class PluginService {
|
||||
private readonly dependencies: PluginServiceDependencies = {},
|
||||
) {
|
||||
this.logger = logger.child({ module: "plugin-service" });
|
||||
this.usageSources = new UsageSourceRegistry(Date.now, 300_000, this.logger);
|
||||
this.usageSources = new UsageSourceRegistry(
|
||||
Date.now,
|
||||
300_000,
|
||||
this.logger,
|
||||
dependencies.usageAgents,
|
||||
);
|
||||
this.runtime =
|
||||
dependencies.runtime ??
|
||||
new PluginRuntime(logger, daemonVersion, {
|
||||
@@ -149,7 +159,7 @@ export class PluginService {
|
||||
return [...this.providers.values()].sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
listUsageReports(options?: { forceRefresh?: boolean; reportIds?: string[] }) {
|
||||
listUsageReports(options?: ListUsageReportsOptions) {
|
||||
return this.usageSources.listReports(options);
|
||||
}
|
||||
|
||||
@@ -573,8 +583,8 @@ export class PluginService {
|
||||
id: source.id,
|
||||
label: source.label,
|
||||
icon: source.icon,
|
||||
discover: async () => {
|
||||
const result = await this.runtime.discoverUsage(pluginId, source.id);
|
||||
discover: async (scope) => {
|
||||
const result = await this.runtime.discoverUsage(pluginId, source.id, scope);
|
||||
if (!Array.isArray(result))
|
||||
throw new Error(`Invalid usage discovery from ${source.id}`);
|
||||
return result;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import type {
|
||||
ProviderConnectRequest,
|
||||
ProviderCatalogOptions,
|
||||
@@ -52,7 +53,7 @@ export type PluginProcessRequest =
|
||||
| { type: "hook"; requestId: string; kind: "event" | "before"; name: string; input: unknown }
|
||||
| { type: "hook.cancel"; requestId: string }
|
||||
| { type: "usage.fetch"; requestId: string; sourceId: string; input: unknown }
|
||||
| { type: "usage.discover"; requestId: string; sourceId: string }
|
||||
| { type: "usage.discover"; requestId: string; sourceId: string; scope: UsageScope }
|
||||
| { type: "invoke"; requestId: string; method: string; input: unknown }
|
||||
| {
|
||||
type: "provider.connect";
|
||||
@@ -199,7 +200,12 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({ type: z.literal("usage.discover"), requestId: z.string(), sourceId: z.string() })
|
||||
.object({
|
||||
type: z.literal("usage.discover"),
|
||||
requestId: z.string(),
|
||||
sourceId: z.string(),
|
||||
scope: UsageScopeSchema,
|
||||
})
|
||||
.strict(),
|
||||
z
|
||||
.object({
|
||||
|
||||
@@ -344,7 +344,8 @@ export function createPluginWorker(options: {
|
||||
void (async () => {
|
||||
const source = usageSources.get(message.sourceId);
|
||||
if (!source) throw new Error(`Unknown usage source: ${message.sourceId}`);
|
||||
if (message.type === "usage.discover") return jsonTransportValue(await source.discover());
|
||||
if (message.type === "usage.discover")
|
||||
return jsonTransportValue(await source.discover(message.scope));
|
||||
const input = await source.input.parseAsync(message.input);
|
||||
return jsonTransportValue(await source.fetch(input));
|
||||
})().then(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import {
|
||||
ProviderStatusSchema,
|
||||
type ProviderStatus,
|
||||
@@ -402,10 +403,15 @@ export class PluginRuntime {
|
||||
return this.request(loaded, { type: "usage.fetch", requestId: randomUUID(), sourceId, input });
|
||||
}
|
||||
|
||||
discoverUsage(pluginId: string, sourceId: string): Promise<unknown> {
|
||||
discoverUsage(pluginId: string, sourceId: string, scope: UsageScope): Promise<unknown> {
|
||||
const loaded = this.plugins.get(pluginId);
|
||||
if (!loaded) throw new Error(`Plugin is not available: ${pluginId}`);
|
||||
return this.request(loaded, { type: "usage.discover", requestId: randomUUID(), sourceId });
|
||||
return this.request(loaded, {
|
||||
type: "usage.discover",
|
||||
requestId: randomUUID(),
|
||||
sourceId,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
|
||||
async getProviderStatus(
|
||||
|
||||
@@ -21,7 +21,12 @@ test("lists built-in and subprocess usage; validates input and isolates fetch er
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.9.2" });
|
||||
try {
|
||||
await client.connect();
|
||||
const first = await client.listUsageReports();
|
||||
const updates: string[] = [];
|
||||
const first = await client.listUsageReports({}, (report) => updates.push(report.id));
|
||||
expect(updates).toEqual(first.reports.map((report) => report.id));
|
||||
await expect(client.listUsageReports({ agentId: "missing-agent" })).rejects.toThrow(
|
||||
"Unknown agent",
|
||||
);
|
||||
expect(first.reports).toHaveLength(4);
|
||||
expect(first.reports[0]?.icon).toContain("<svg");
|
||||
expect(first.reports[0]?.fetchedAt).toMatch(/^\d{4}-/);
|
||||
@@ -220,7 +225,7 @@ async function fetchApi(url, init) {
|
||||
}
|
||||
export default function contribute(server) {
|
||||
server.registerUsageSource({ id: '${source}', label: '${source}', input: inputSchema,
|
||||
discover: () => discover(lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
discover: (scope) => discover(scope, lookup${source === "claude" ? ", fetchApi" : ""}),
|
||||
fetch: (input) => fetchUsage(inputSchema.parse(input), fetchApi, lookup),
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -126,7 +126,7 @@ test("rediscovery removes an old account from targeted refreshes", async () => {
|
||||
expect(await registry.listReports({ reportIds: ["source:old"] })).toEqual([]);
|
||||
});
|
||||
|
||||
test("discovery failures log once and produce no card", async () => {
|
||||
test("discovery failures log once while other sources still report", async () => {
|
||||
const warnings: unknown[][] = [];
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, {
|
||||
warn: (...args: unknown[]) => {
|
||||
@@ -142,7 +142,11 @@ test("discovery failures log once and produce no card", async () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(await registry.listReports()).toEqual([]);
|
||||
registry.register(source({ id: "healthy", discover: async () => [{ key: "one", input: {} }] }));
|
||||
const updates: string[] = [];
|
||||
const reports = await registry.listReports({ onReport: (report) => updates.push(report.id) });
|
||||
expect(reports.map((report) => report.id)).toEqual(["healthy:one"]);
|
||||
expect(updates).toEqual(["healthy:one"]);
|
||||
expect(warnings).toEqual([[{ sourceId: "kimi", err }, "Usage source discovery failed"]]);
|
||||
});
|
||||
|
||||
@@ -279,3 +283,93 @@ test("failed fallbacks preserve the final problem", async () => {
|
||||
problem: { kind: "rejected", status: 403 },
|
||||
});
|
||||
});
|
||||
|
||||
test("session discovery reuses accounts, separates launches, and retains live agent reports globally", async () => {
|
||||
const sessions = new Map([
|
||||
["one", { provider: "claude", env: { HOME: "/one" }, sessionKey: "launch-1" }],
|
||||
["two", { provider: "claude", env: { HOME: "/two" }, sessionKey: "launch-2" }],
|
||||
]);
|
||||
const existing = new Set(["one", "two", "closed"]);
|
||||
const discoveries: string[] = [];
|
||||
let fetches = 0;
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, {
|
||||
hasAgent: (id) => existing.has(id),
|
||||
usageSession: (id) => sessions.get(id) ?? null,
|
||||
});
|
||||
registry.register({
|
||||
id: "claude",
|
||||
label: "Claude",
|
||||
discover: async (scope) => {
|
||||
discoveries.push(scope.kind === "global" ? "global" : scope.env.HOME);
|
||||
if (scope.kind === "global") return [{ key: "default", input: "default" }];
|
||||
return [{ key: scope.env.HOME === "/new" ? "new" : "same", input: scope.env.HOME }];
|
||||
},
|
||||
fetch: async () => {
|
||||
fetches++;
|
||||
return { status: "available", windows: [] };
|
||||
},
|
||||
});
|
||||
expect(await registry.listReports({ agentId: "closed" })).toEqual([]);
|
||||
expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual([
|
||||
"claude:same",
|
||||
]);
|
||||
await registry.listReports({ agentId: "one" });
|
||||
await registry.listReports({ agentId: "two" });
|
||||
expect(discoveries).toEqual(["/one", "/two"]);
|
||||
expect(fetches).toBe(1);
|
||||
expect((await registry.listReports()).map((r) => r.id)).toEqual([
|
||||
"claude:default",
|
||||
"claude:same",
|
||||
]);
|
||||
sessions.set("one", { provider: "claude", env: { HOME: "/new" }, sessionKey: "launch-3" });
|
||||
expect((await registry.listReports({ agentId: "one" })).map((r) => r.id)).toEqual(["claude:new"]);
|
||||
existing.delete("two");
|
||||
sessions.delete("two");
|
||||
expect((await registry.listReports()).map((r) => r.id)).toEqual(["claude:default", "claude:new"]);
|
||||
await expect(registry.listReports({ agentId: "missing" })).rejects.toThrow("Unknown agent");
|
||||
await expect(registry.listReports({ agentId: "one", reportIds: [] })).rejects.toThrow("agentId");
|
||||
});
|
||||
|
||||
test("reports arrive independently and a hung fetch settles as an error before completion", async () => {
|
||||
const registry = new UsageSourceRegistry(Date.now, 300_000, undefined, undefined, 25);
|
||||
let finishSlow!: (value: unknown) => void;
|
||||
const slow = new Promise<unknown>((resolve) => {
|
||||
finishSlow = resolve;
|
||||
});
|
||||
const reports: string[] = [];
|
||||
let firstReport!: () => void;
|
||||
const first = new Promise<void>((resolve) => {
|
||||
firstReport = resolve;
|
||||
});
|
||||
for (const id of ["slow", "fast", "also-fast"]) {
|
||||
registry.register(
|
||||
source({
|
||||
id,
|
||||
discover: async () => [{ key: "account", input: {} }],
|
||||
fetch: async () => (id === "slow" ? slow : { status: "available", windows: [] }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
let complete = false;
|
||||
const request = registry
|
||||
.listReports({
|
||||
onReport: (entry) => {
|
||||
reports.push(entry.id);
|
||||
firstReport();
|
||||
},
|
||||
})
|
||||
.then((entries) => {
|
||||
complete = true;
|
||||
return entries;
|
||||
});
|
||||
await first;
|
||||
expect(reports).toEqual(["fast:account", "also-fast:account"]);
|
||||
expect(complete).toBe(false);
|
||||
const entries = await request;
|
||||
expect(reports).toEqual(["fast:account", "also-fast:account", "slow:account"]);
|
||||
expect(entries[0]?.report).toEqual({ status: "error", error: "Usage fetch timed out" });
|
||||
finishSlow({ status: "available", windows: [] });
|
||||
expect((await registry.listReports({ reportIds: ["slow:account"] }))[0]?.report).toEqual(
|
||||
entries[0]?.report,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentUsageSession } from "../../agent/agent-sdk-types.js";
|
||||
import type { UsageScope } from "@getpaseo/plugin/server/usage";
|
||||
import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
@@ -12,7 +14,7 @@ export interface UsageSource {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
discover(): Promise<unknown>;
|
||||
discover(scope: UsageScope): Promise<unknown>;
|
||||
fetch(input: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -22,10 +24,31 @@ interface KnownReport {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface AgentUsageLookup {
|
||||
hasAgent(id: string): boolean;
|
||||
usageSession(id: string): AgentUsageSession | null;
|
||||
}
|
||||
|
||||
export interface ListUsageReportsOptions {
|
||||
forceRefresh?: boolean;
|
||||
reportIds?: string[];
|
||||
agentId?: string;
|
||||
onReport?: (report: UsageReportEntry) => void;
|
||||
}
|
||||
|
||||
interface AgentReports {
|
||||
provider: string;
|
||||
model?: string;
|
||||
sessionKey: string;
|
||||
reports: Map<string, KnownReport>;
|
||||
}
|
||||
|
||||
/** Owns account identity, ordered logins for each account, and the five-minute fetch cache. */
|
||||
export class UsageSourceRegistry {
|
||||
private readonly sources = new Map<string, UsageSource>();
|
||||
private readonly known = new Map<string, KnownReport>();
|
||||
private defaults = new Map<string, KnownReport>();
|
||||
private readonly byAgent = new Map<string, AgentReports>();
|
||||
private readonly cache = new Map<string, { at: number; entry: UsageReportEntry }>();
|
||||
private readonly pending = new Map<string, Promise<UsageReportEntry>>();
|
||||
|
||||
@@ -33,6 +56,8 @@ export class UsageSourceRegistry {
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly ttlMs = 300_000,
|
||||
private readonly logger: Pick<Logger, "warn"> = { warn: console.warn },
|
||||
private readonly agents: AgentUsageLookup = { hasAgent: () => false, usageSession: () => null },
|
||||
private readonly deadlineMs = 20_000,
|
||||
) {}
|
||||
|
||||
register(source: UsageSource): void {
|
||||
@@ -42,25 +67,98 @@ export class UsageSourceRegistry {
|
||||
|
||||
unregister(id: string): void {
|
||||
this.sources.delete(id);
|
||||
for (const key of this.defaults.keys()) if (key.startsWith(`${id}:`)) this.defaults.delete(key);
|
||||
// Re-discover on the next session query when a source is replaced.
|
||||
for (const [agentId, mapping] of this.byAgent) {
|
||||
if ([...mapping.reports.values()].some((report) => report.source.id === id))
|
||||
this.byAgent.delete(agentId);
|
||||
}
|
||||
for (const key of this.known.keys()) if (key.startsWith(`${id}:`)) this.known.delete(key);
|
||||
for (const key of this.cache.keys()) if (key.startsWith(`${id}:`)) this.cache.delete(key);
|
||||
}
|
||||
|
||||
async listReports(
|
||||
options: { forceRefresh?: boolean; reportIds?: string[] } = {},
|
||||
): Promise<UsageReportEntry[]> {
|
||||
const ids = options.reportIds ?? (await this.discoverReportIds());
|
||||
async listReports(options: ListUsageReportsOptions = {}): Promise<UsageReportEntry[]> {
|
||||
if (options.agentId !== undefined && options.reportIds !== undefined)
|
||||
throw new Error("agentId and reportIds cannot be combined");
|
||||
this.pruneAgents();
|
||||
let ids: string[];
|
||||
if (options.agentId !== undefined) {
|
||||
ids = await this.discoverAgent(options.agentId);
|
||||
} else if (options.reportIds !== undefined) {
|
||||
ids = options.reportIds;
|
||||
} else {
|
||||
this.defaults = await this.discover({ kind: "global" });
|
||||
this.mergeKnown();
|
||||
ids = [...this.known.keys()];
|
||||
}
|
||||
return Promise.all(
|
||||
[...new Set(ids)].flatMap((id) => {
|
||||
const known = this.known.get(id);
|
||||
return known ? [this.fetchId(id, known, options.forceRefresh)] : [];
|
||||
if (!known) return [];
|
||||
return [
|
||||
this.fetchId(id, known, options.forceRefresh).then((entry) => {
|
||||
options.onReport?.(entry);
|
||||
return entry;
|
||||
}),
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async discoverReportIds(): Promise<string[]> {
|
||||
private pruneAgents(): void {
|
||||
for (const id of this.byAgent.keys()) {
|
||||
if (!this.agents.hasAgent(id)) this.byAgent.delete(id);
|
||||
}
|
||||
this.mergeKnown();
|
||||
}
|
||||
|
||||
private async discoverAgent(agentId: string): Promise<string[]> {
|
||||
if (!this.agents.hasAgent(agentId)) throw new Error(`Unknown agent: ${agentId}`);
|
||||
const session = this.agents.usageSession(agentId);
|
||||
if (!session) return [];
|
||||
const previous = this.byAgent.get(agentId);
|
||||
const sameScope =
|
||||
previous?.sessionKey === session.sessionKey &&
|
||||
previous.provider === session.provider &&
|
||||
previous.model === session.model;
|
||||
if (sameScope) return [...previous.reports.keys()];
|
||||
const reports = await this.discover({
|
||||
kind: "session",
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
env: session.env,
|
||||
});
|
||||
// A query that finishes after a resume must not publish the previous launch's mapping.
|
||||
if (this.agents.usageSession(agentId)?.sessionKey !== session.sessionKey) return [];
|
||||
this.byAgent.set(agentId, {
|
||||
sessionKey: session.sessionKey,
|
||||
provider: session.provider,
|
||||
model: session.model,
|
||||
reports,
|
||||
});
|
||||
this.mergeKnown();
|
||||
return [...reports.keys()];
|
||||
}
|
||||
|
||||
private mergeKnown(): void {
|
||||
this.known.clear();
|
||||
for (const reports of [
|
||||
this.defaults,
|
||||
...[...this.byAgent.values()].map((agent) => agent.reports),
|
||||
]) {
|
||||
for (const [id, report] of reports) {
|
||||
if (this.sources.get(report.source.id) !== report.source) continue;
|
||||
const known = this.known.get(id);
|
||||
if (known) known.inputs.push(...report.inputs);
|
||||
else this.known.set(id, { ...report, inputs: [...report.inputs] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async discover(scope: UsageScope): Promise<Map<string, KnownReport>> {
|
||||
const discovered = await Promise.all(
|
||||
[...this.sources.values()].map(async (source) => {
|
||||
const reports = new Map<string, KnownReport>();
|
||||
try {
|
||||
const accounts = z
|
||||
.array(
|
||||
@@ -70,31 +168,24 @@ export class UsageSourceRegistry {
|
||||
input: z.json(),
|
||||
}),
|
||||
)
|
||||
.parse(await source.discover());
|
||||
const knownReports = new Map<string, KnownReport>();
|
||||
const ids: string[] = [];
|
||||
.parse(await source.discover(scope));
|
||||
for (const account of accounts) {
|
||||
const id = `${source.id}:${account.key}`;
|
||||
ids.push(id);
|
||||
const known = knownReports.get(id);
|
||||
const known = reports.get(id);
|
||||
if (known) known.inputs.push(account.input);
|
||||
else knownReports.set(id, { source, inputs: [account.input], label: account.label });
|
||||
else reports.set(id, { source, inputs: [account.input], label: account.label });
|
||||
}
|
||||
for (const key of this.known.keys()) {
|
||||
if (key.startsWith(`${source.id}:`)) this.known.delete(key);
|
||||
}
|
||||
for (const [id, known] of knownReports) this.known.set(id, known);
|
||||
return ids;
|
||||
} catch (error) {
|
||||
for (const key of this.known.keys()) {
|
||||
if (key.startsWith(`${source.id}:`)) this.known.delete(key);
|
||||
}
|
||||
this.logger.warn({ sourceId: source.id, err: error }, "Usage source discovery failed");
|
||||
return [];
|
||||
}
|
||||
return reports;
|
||||
}),
|
||||
);
|
||||
return discovered.flat();
|
||||
const merged = new Map<string, KnownReport>();
|
||||
for (const reports of discovered) {
|
||||
for (const [id, report] of reports) merged.set(id, report);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
// COMPAT(providerUsageList): added in v0.1.98, remove after 2027-03-26.
|
||||
@@ -136,7 +227,7 @@ export class UsageSourceRegistry {
|
||||
icon: known.source.icon,
|
||||
account: { label: known.label },
|
||||
fetchedAt: new Date(this.now()).toISOString(),
|
||||
report: await this.fetchWithFallback(known),
|
||||
report: await this.fetchBeforeDeadline(known),
|
||||
};
|
||||
this.writeCache(id, entry);
|
||||
return entry;
|
||||
@@ -156,6 +247,21 @@ export class UsageSourceRegistry {
|
||||
this.cache.set(id, { at, entry });
|
||||
}
|
||||
|
||||
private async fetchBeforeDeadline(known: KnownReport): Promise<UsageReport> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const deadline = new Promise<UsageReport>((resolve) => {
|
||||
timer = setTimeout(
|
||||
() => resolve({ status: "error", error: "Usage fetch timed out" }),
|
||||
this.deadlineMs,
|
||||
);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([this.fetchWithFallback(known), deadline]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchWithFallback(known: KnownReport): Promise<UsageReport> {
|
||||
let report = await this.fetchLogin(known.source, known.inputs[0]);
|
||||
for (const input of known.inputs.slice(1)) {
|
||||
|
||||
@@ -31,6 +31,7 @@ const exceptions = {
|
||||
agent_deleted: ["delete_agent_request"],
|
||||
agent_permission_resolved: ["agent_permission_response"],
|
||||
terminals_changed: ["subscribe_terminals_request"],
|
||||
"usage.list_reports.update": ["usage.list_reports.request"],
|
||||
"daemon.update.progress": ["daemon.update.request"],
|
||||
// Transcription belongs to the explicitly retained voice operation, not a request ID.
|
||||
transcription_result: [],
|
||||
|
||||
@@ -18,7 +18,8 @@ test("lists reports from usage sources", async () => {
|
||||
emit: (message) => emitted.push(message),
|
||||
runtime: {
|
||||
async listUsageReports(options) {
|
||||
requested.push(options);
|
||||
requested.push({ forceRefresh: options.forceRefresh, reportIds: options.reportIds });
|
||||
options.onReport?.(entry);
|
||||
return [entry];
|
||||
},
|
||||
async listLegacyUsage() {
|
||||
@@ -31,7 +32,8 @@ test("lists reports from usage sources", async () => {
|
||||
await usage.handleListReports({ type: "usage.list_reports.request", requestId: "list" });
|
||||
expect(requested).toEqual([{ forceRefresh: undefined, reportIds: undefined }]);
|
||||
expect(emitted).toEqual([
|
||||
{ type: "usage.list_reports.response", payload: { requestId: "list", reports: [entry] } },
|
||||
{ type: "usage.list_reports.update", payload: { requestId: "list", report: entry } },
|
||||
{ type: "usage.list_reports.response", payload: { requestId: "list", error: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -55,3 +57,18 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
|
||||
payload: { requestId: "u1", code: "provider_usage_list_failed" },
|
||||
});
|
||||
});
|
||||
|
||||
test("request failures terminate with an error response and no updates", async () => {
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const usage = new UsageSession({
|
||||
emit: (message) => emitted.push(message),
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
await usage.handleListReports({ type: "usage.list_reports.request", requestId: "failed" });
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId: "failed", error: "Plugin runtime is unavailable" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ListUsageReportsOptions } from "../../plugins/usage-sources/index.js";
|
||||
import type pino from "pino";
|
||||
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
@@ -5,10 +6,7 @@ import type { SessionInboundMessage, SessionOutboundMessage } from "../../messag
|
||||
export interface UsageSessionOptions {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
runtime?: {
|
||||
listUsageReports(options: {
|
||||
forceRefresh?: boolean;
|
||||
reportIds?: string[];
|
||||
}): Promise<UsageReportEntry[]>;
|
||||
listUsageReports(options: ListUsageReportsOptions): Promise<UsageReportEntry[]>;
|
||||
listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>;
|
||||
};
|
||||
logger: pino.Logger;
|
||||
@@ -22,16 +20,28 @@ export class UsageSession {
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
|
||||
const reports = await this.options.runtime.listUsageReports({
|
||||
await this.options.runtime.listUsageReports({
|
||||
forceRefresh: msg.forceRefresh,
|
||||
reportIds: msg.reportIds,
|
||||
agentId: msg.agentId,
|
||||
onReport: (report) =>
|
||||
this.options.emit({
|
||||
type: "usage.list_reports.update",
|
||||
payload: { requestId: msg.requestId, report },
|
||||
}),
|
||||
});
|
||||
this.options.emit({
|
||||
type: "usage.list_reports.response",
|
||||
payload: { requestId: msg.requestId, reports },
|
||||
payload: { requestId: msg.requestId, error: null },
|
||||
});
|
||||
} catch (error) {
|
||||
this.emitError(msg, error, "usage_list_reports_failed");
|
||||
this.options.emit({
|
||||
type: "usage.list_reports.response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,20 +74,4 @@ export class UsageSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private emitError(
|
||||
msg: Extract<SessionInboundMessage, { type: "usage.list_reports.request" }>,
|
||||
error: unknown,
|
||||
code: string,
|
||||
): void {
|
||||
this.options.emit({
|
||||
type: "rpc_error",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
requestType: msg.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
code,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Claude",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => discover(scope),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -134,6 +134,7 @@ describe("Claude credential routes", () => {
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "fixture-default" } }),
|
||||
);
|
||||
const accounts = await discover(
|
||||
{ kind: "global" },
|
||||
{ home: dir, env: { CLAUDE_CONFIG_DIR: dir }, platform: "linux" },
|
||||
fixtureFetch("fixture-default"),
|
||||
);
|
||||
|
||||
@@ -29,15 +29,15 @@ async function accountIdentity(
|
||||
input: Parameters<typeof fetchUsage>[0],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
) {
|
||||
const accounts = await discover(lookup(env), profile);
|
||||
const accounts = await discover({ kind: "global" }, lookup(env), profile);
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
if (!account) throw new Error("Expected discovered login");
|
||||
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
||||
}
|
||||
async function logins(options: Parameters<typeof discover>[0]) {
|
||||
return (await discover(options, profile)).map(
|
||||
async function logins(options: Parameters<typeof discover>[1]) {
|
||||
return (await discover({ kind: "global" }, options, profile)).map(
|
||||
(account) => account.input as Parameters<typeof fetchUsage>[0],
|
||||
);
|
||||
}
|
||||
@@ -204,9 +204,12 @@ test("Claude Keychain login replaces the fallback file", async () => {
|
||||
const inputs = await logins({
|
||||
...lookup(),
|
||||
platform: "darwin",
|
||||
env: { USER: "fixture-user" },
|
||||
readKeychainCredentials: async () => ({ claudeAiOauth: { accessToken: "fixture-keychain" } }),
|
||||
});
|
||||
expect(inputs).toEqual([{ route: { store: "keychain" } }]);
|
||||
expect(inputs).toEqual([
|
||||
{ route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Claude account metadata alone does not discover a login", async () => {
|
||||
@@ -221,16 +224,23 @@ test("keychain identity belongs to its token even when Claude Code metadata name
|
||||
oauthAccount: { accountUuid: "stale", organizationUuid: "stale-org" },
|
||||
});
|
||||
const [identity] = await discover(
|
||||
{ kind: "global" },
|
||||
{
|
||||
...lookup(),
|
||||
platform: "darwin",
|
||||
env: { USER: "fixture-user" },
|
||||
readKeychainCredentials: async () => ({
|
||||
claudeAiOauth: { accessToken: "fixture-keychain-other" },
|
||||
}),
|
||||
},
|
||||
profile,
|
||||
);
|
||||
expect(identity).toEqual({ key: "pi-account.pi-org", input: { route: { store: "keychain" } } });
|
||||
expect(identity).toEqual({
|
||||
key: "pi-account.pi-org",
|
||||
input: {
|
||||
route: { store: "keychain", service: "Claude Code-credentials", account: "fixture-user" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("OMP fetch re-reads its row after rotation and skips a newly disabled login", async () => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
@@ -193,7 +194,9 @@ describe("claude usage source", () => {
|
||||
"discovers no Claude logins in %s",
|
||||
async (scenario) => {
|
||||
if (scenario === "unrelated files") writeFileSync(join(homeDir, "unrelated.json"), "{}");
|
||||
expect(await discover({ home: homeDir, env: {}, platform: "linux" })).toEqual([]);
|
||||
expect(
|
||||
await discover({ kind: "global" }, { home: homeDir, env: {}, platform: "linux" }),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -757,6 +760,7 @@ it("discovery and fetch share the explicit macOS keychain route", async () => {
|
||||
try {
|
||||
const lookup = {
|
||||
platform: "darwin" as const,
|
||||
env: { USER: "fixture", CLAUDE_CONFIG_DIR: directory },
|
||||
claudeHome: directory,
|
||||
home: directory,
|
||||
readKeychainCredentials: async () => ({
|
||||
@@ -765,7 +769,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => {
|
||||
};
|
||||
let usageRequested = false;
|
||||
await fetchUsage(
|
||||
{ route: { store: "keychain" } },
|
||||
{
|
||||
route: {
|
||||
store: "keychain",
|
||||
account: "fixture",
|
||||
service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`,
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
usageRequested = true;
|
||||
return new Response(null, { status: 401 });
|
||||
@@ -774,7 +784,13 @@ it("discovery and fetch share the explicit macOS keychain route", async () => {
|
||||
);
|
||||
expect(usageRequested).toBe(true);
|
||||
const account = await accountFor(
|
||||
{ route: { store: "keychain" } },
|
||||
{
|
||||
route: {
|
||||
store: "keychain",
|
||||
account: "fixture",
|
||||
service: `Claude Code-credentials-${hashAccountKey(directory).slice(0, 8)}`,
|
||||
},
|
||||
},
|
||||
async () =>
|
||||
jsonResponse({
|
||||
account: { uuid: "keychain-account" },
|
||||
@@ -864,7 +880,10 @@ it("discovers a Pi OAuth login independently of the CLI login", async () => {
|
||||
anthropic: { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
|
||||
}),
|
||||
);
|
||||
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
|
||||
const inputs = await discover(
|
||||
{ kind: "global" },
|
||||
{ home: directory, env: {}, platform: "linux" },
|
||||
);
|
||||
expect(inputs.map((account) => account.input)).toContainEqual({
|
||||
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
|
||||
});
|
||||
@@ -880,7 +899,7 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc
|
||||
const lookup = {
|
||||
home: directory,
|
||||
claudeHome: directory,
|
||||
env: {},
|
||||
env: { USER: "fixture" },
|
||||
platform: "darwin" as const,
|
||||
now: () => 10_800_000,
|
||||
readKeychainCredentials: async () => ({
|
||||
@@ -892,9 +911,14 @@ it("keeps an expired Keychain login without calling the network and prefers Keyc
|
||||
calls++;
|
||||
throw new Error("must not call network");
|
||||
};
|
||||
const accounts = await discover(lookup, network);
|
||||
const accounts = await discover({ kind: "global" }, lookup, network);
|
||||
expect(accounts).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: { route: { store: "keychain" } } },
|
||||
{
|
||||
key: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
input: {
|
||||
route: { store: "keychain", service: "Claude Code-credentials", account: "fixture" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(await fetchUsage(accounts[0]!.input as UsageInput, network, lookup)).toEqual({
|
||||
status: "unavailable",
|
||||
@@ -912,10 +936,8 @@ async function accountFor(
|
||||
now: () => number,
|
||||
lookup: Parameters<typeof discover>[0] = {},
|
||||
) {
|
||||
const accounts = await discover({ ...lookup, now }, fetchApi);
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
const accounts = await discover({ kind: "global" }, { ...lookup, now }, fetchApi);
|
||||
const account = accounts.find((candidate) => isDeepStrictEqual(candidate.input, input));
|
||||
if (!account) throw new Error("Expected discovered login");
|
||||
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
||||
}
|
||||
@@ -936,7 +958,7 @@ it.each([401, 403])("fetch reports rejected usage HTTP %i", async (status) => {
|
||||
it.each([401, 403])("a rejected profile HTTP %i keeps a locator-keyed account", async (status) => {
|
||||
const lookup = tokenLookup(`profile-rejection-${status}`);
|
||||
const api: typeof fetch = async () => new Response(null, { status });
|
||||
const accounts = await discover(lookup, api);
|
||||
const accounts = await discover({ kind: "global" }, lookup, api);
|
||||
expect(accounts).toEqual([
|
||||
{ key: expect.stringMatching(/^[a-f0-9]{64}$/), input: credentialInput(lookup.claudeHome) },
|
||||
]);
|
||||
@@ -969,7 +991,7 @@ it.each([
|
||||
if (networkError) throw new Error(error);
|
||||
return new Response(null, { status: 500 });
|
||||
};
|
||||
const accounts = await discover(lookup, api);
|
||||
const accounts = await discover({ kind: "global" }, lookup, api);
|
||||
expect(accounts).toEqual([{ key: hashAccountKey(JSON.stringify(input.route)), input }]);
|
||||
await expect(fetchUsage(accounts[0]!.input as UsageInput, api, lookup)).rejects.toThrow(error);
|
||||
},
|
||||
@@ -981,7 +1003,7 @@ it("re-reads a refreshed login at the original locator", async () => {
|
||||
join(lookup.claudeHome, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "expiring-token", expiresAt: 0 } }),
|
||||
);
|
||||
const [account] = await discover(lookup, async () => {
|
||||
const [account] = await discover({ kind: "global" }, lookup, async () => {
|
||||
throw new Error("expired token must not fetch");
|
||||
});
|
||||
expect(await fetchUsage(account!.input as UsageInput, fetch, lookup)).toEqual({
|
||||
@@ -999,3 +1021,75 @@ it("re-reads a refreshed login at the original locator", async () => {
|
||||
);
|
||||
expect(report.status).toBe("available");
|
||||
});
|
||||
|
||||
it("session discovery reads only its Claude home and excludes foreign sessions", async () => {
|
||||
const home = mkdtempSync(join(tmpdir(), "claude-session-scope-"));
|
||||
try {
|
||||
writeClaudeCredentials(home, "scoped-unique-token");
|
||||
const scope = {
|
||||
kind: "session" as const,
|
||||
provider: "claude",
|
||||
env: { HOME: home, CLAUDE_CONFIG_DIR: home },
|
||||
};
|
||||
const calls: string[] = [];
|
||||
const api: typeof fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
return jsonResponse({
|
||||
account: { uuid: "session-account", email: "session@example.test" },
|
||||
organization: { uuid: "org" },
|
||||
});
|
||||
};
|
||||
const lookup = { platform: "linux" as const, home: "/unused-default-home", env: {} };
|
||||
const accounts = await discover(scope, lookup, api);
|
||||
expect(accounts).toEqual([
|
||||
{ key: "session-account.org", label: "session@example.test", input: credentialInput(home) },
|
||||
]);
|
||||
await discover(scope, lookup, api);
|
||||
expect(calls).toEqual(["https://api.anthropic.com/api/oauth/profile"]);
|
||||
for (const env of [
|
||||
{ CLAUDE_CODE_USE_BEDROCK: "1" },
|
||||
{ CLAUDE_CODE_USE_VERTEX: "1" },
|
||||
{ ANTHROPIC_BASE_URL: "https://other.test" },
|
||||
]) {
|
||||
expect(await discover({ ...scope, env: { ...scope.env, ...env } }, lookup, api)).toEqual([]);
|
||||
}
|
||||
expect(await discover({ ...scope, provider: "codex" }, lookup, api)).toEqual([]);
|
||||
expect(
|
||||
await discover(
|
||||
{ ...scope, env: { ...scope.env, CLAUDE_CONFIG_DIR: join(home, "missing") } },
|
||||
lookup,
|
||||
api,
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("session Keychain input retains the selected config-directory locator through fetch", async () => {
|
||||
const reads: string[] = [];
|
||||
const scope = {
|
||||
kind: "session" as const,
|
||||
provider: "claude",
|
||||
env: { HOME: "/fixture", USER: "fixture-user", CLAUDE_CONFIG_DIR: "/fixture/work" },
|
||||
};
|
||||
const service = `Claude Code-credentials-${hashAccountKey(scope.env.CLAUDE_CONFIG_DIR).slice(0, 8)}`;
|
||||
const lookup = {
|
||||
platform: "darwin" as const,
|
||||
readKeychainCredentials: async (selected: string, account: string) => {
|
||||
reads.push(`${selected}:${account}`);
|
||||
return { claudeAiOauth: { accessToken: "scoped-keychain-fixture" } };
|
||||
},
|
||||
};
|
||||
const accounts = await discover(scope, lookup, async () =>
|
||||
jsonResponse({ account: { uuid: "keychain-scoped" }, organization: { uuid: "org" } }),
|
||||
);
|
||||
expect(accounts).toHaveLength(1);
|
||||
const input = inputSchema.parse(accounts[0]?.input);
|
||||
expect(input.route).toEqual({ store: "keychain", service, account: "fixture-user" });
|
||||
await fetchUsage(input, async () => jsonResponse(makeClaudeResponse()), {
|
||||
...lookup,
|
||||
env: { CLAUDE_CONFIG_DIR: "/somewhere-else" },
|
||||
});
|
||||
expect(reads).toEqual(Array(3).fill(`${service}:fixture-user`));
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
toneFromUsedPct,
|
||||
unavailable,
|
||||
type UsageAccount,
|
||||
type UsageScope,
|
||||
windowFromUsedPct,
|
||||
type UsageReport,
|
||||
type UsageWindow,
|
||||
@@ -315,10 +316,11 @@ async function runSecurityCommand(args: string[]): Promise<string | null> {
|
||||
export async function readClaudeKeychainCredentials(
|
||||
run: ClaudeKeychainCommandRunner = runSecurityCommand,
|
||||
account: string = claudeKeychainAccount(),
|
||||
service: string = CLAUDE_KEYCHAIN_SERVICE,
|
||||
): Promise<unknown | null> {
|
||||
const lookups = [
|
||||
["find-generic-password", "-a", account, "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
||||
["find-generic-password", "-w", "-s", CLAUDE_KEYCHAIN_SERVICE],
|
||||
["find-generic-password", "-a", account, "-w", "-s", service],
|
||||
["find-generic-password", "-w", "-s", service],
|
||||
];
|
||||
|
||||
for (const args of lookups) {
|
||||
@@ -337,7 +339,7 @@ export async function readClaudeKeychainCredentials(
|
||||
}
|
||||
|
||||
interface ClaudeCredentialLookup extends StoreLookup {
|
||||
readKeychainCredentials?: () => Promise<unknown | null>;
|
||||
readKeychainCredentials?: (service: string, account: string) => Promise<unknown | null>;
|
||||
claudeHome?: string;
|
||||
}
|
||||
|
||||
@@ -350,27 +352,30 @@ function claudeCredentialPath(lookup: ClaudeCredentialLookup): string {
|
||||
|
||||
async function keychainCredentialRecord(
|
||||
lookup: ClaudeCredentialLookup,
|
||||
route = claudeKeychainRoute(lookup),
|
||||
): Promise<ClaudeCredentialRecord | null> {
|
||||
if ((lookup.platform ?? process.platform) !== "darwin") return null;
|
||||
const parsed = ClaudeCredentialsSchema.safeParse(
|
||||
await (lookup.readKeychainCredentials ?? readClaudeKeychainCredentials)(),
|
||||
await (lookup.readKeychainCredentials
|
||||
? lookup.readKeychainCredentials(route.service, route.account)
|
||||
: readClaudeKeychainCredentials(undefined, route.account, route.service)),
|
||||
);
|
||||
return parsed.success ? toCredentialRecord(parsed.data) : null;
|
||||
}
|
||||
|
||||
export async function discover(
|
||||
scope: UsageScope,
|
||||
lookup: ClaudeCredentialLookup = {},
|
||||
fetchApi: typeof fetch = fetch,
|
||||
): Promise<UsageAccount[]> {
|
||||
// Claude Code only reads the file when its Keychain login is empty.
|
||||
const primary: UsageInput = (await keychainCredentialRecord(lookup))
|
||||
? { route: { store: "keychain" } }
|
||||
: { route: { store: "claude", path: claudeCredentialPath(lookup) } };
|
||||
const candidates: UsageInput[] = [
|
||||
primary,
|
||||
{ route: { store: "pi", path: piAuthPath(lookup) } },
|
||||
...discoverOmp(lookup).map((route) => ({ route })),
|
||||
];
|
||||
if (scope.kind === "session")
|
||||
lookup = {
|
||||
...lookup,
|
||||
env: scope.env,
|
||||
home: scope.env.HOME || scope.env.USERPROFILE || homedir(),
|
||||
};
|
||||
const candidates =
|
||||
scope.kind === "global" ? await globalRoutes(lookup) : await sessionRoutes(scope, lookup);
|
||||
const accounts: UsageAccount[] = [];
|
||||
for (const input of candidates) {
|
||||
const credentials = await resolveClaudeCredentials(input, lookup);
|
||||
@@ -395,6 +400,59 @@ export async function discover(
|
||||
return accounts;
|
||||
}
|
||||
|
||||
function claudeKeychainRoute(lookup: ClaudeCredentialLookup) {
|
||||
const env = lookup.env ?? process.env;
|
||||
const configDir = env.CLAUDE_CONFIG_DIR;
|
||||
// macOS still uses Keychain with CLAUDE_CONFIG_DIR, with an entry keyed to that directory:
|
||||
// https://code.claude.com/docs/en/authentication#credential-management
|
||||
// Verified in @anthropic-ai/claude-code 2.1.59, package/cli.js: _c() appends the first
|
||||
// 8 SHA-256 hex characters of HA() (the NFC-normalized config directory).
|
||||
const suffix = configDir ? `-${hashAccountKey(configDir.normalize("NFC")).slice(0, 8)}` : "";
|
||||
return {
|
||||
store: "keychain" as const,
|
||||
service: `${CLAUDE_KEYCHAIN_SERVICE}${suffix}`,
|
||||
account: claudeKeychainAccount(env.USER),
|
||||
};
|
||||
}
|
||||
|
||||
async function claudeRoute(lookup: ClaudeCredentialLookup): Promise<UsageInput> {
|
||||
const route = claudeKeychainRoute(lookup);
|
||||
return (await keychainCredentialRecord(lookup, route))
|
||||
? { route }
|
||||
: { route: { store: "claude", path: claudeCredentialPath(lookup) } };
|
||||
}
|
||||
|
||||
async function globalRoutes(lookup: ClaudeCredentialLookup): Promise<UsageInput[]> {
|
||||
return [
|
||||
await claudeRoute(lookup),
|
||||
{ route: { store: "pi", path: piAuthPath(lookup) } },
|
||||
...discoverOmp(lookup).map((route) => ({ route })),
|
||||
];
|
||||
}
|
||||
|
||||
async function sessionRoutes(
|
||||
scope: Extract<UsageScope, { kind: "session" }>,
|
||||
lookup: ClaudeCredentialLookup,
|
||||
): Promise<UsageInput[]> {
|
||||
if (scope.provider === "claude") {
|
||||
const env = scope.env;
|
||||
const foreign =
|
||||
env.ANTHROPIC_BASE_URL &&
|
||||
env.ANTHROPIC_BASE_URL.replace(/\/+$/, "") !== "https://api.anthropic.com";
|
||||
if (enabled(env.CLAUDE_CODE_USE_BEDROCK) || enabled(env.CLAUDE_CODE_USE_VERTEX) || foreign)
|
||||
return [];
|
||||
return [await claudeRoute(lookup)];
|
||||
}
|
||||
if (!scope.model?.startsWith("anthropic/")) return [];
|
||||
if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }];
|
||||
if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route }));
|
||||
return [];
|
||||
}
|
||||
|
||||
function enabled(value: string | undefined): boolean {
|
||||
return value === "1" || value?.toLowerCase() === "true" || value?.toLowerCase() === "yes";
|
||||
}
|
||||
|
||||
/** Re-read the selected login; the harness owns token refresh. */
|
||||
export async function resolveClaudeCredentials(
|
||||
input: UsageInput,
|
||||
@@ -402,7 +460,12 @@ export async function resolveClaudeCredentials(
|
||||
): Promise<ClaudeCredentialRecord | null> {
|
||||
const route = input.route;
|
||||
if (route.store === "claude") return readCredentialFile(route.path);
|
||||
if (route.store === "keychain") return keychainCredentialRecord(lookup);
|
||||
if (route.store === "keychain")
|
||||
return keychainCredentialRecord(lookup, {
|
||||
store: "keychain",
|
||||
service: route.service ?? CLAUDE_KEYCHAIN_SERVICE,
|
||||
account: route.account ?? claudeKeychainAccount(),
|
||||
});
|
||||
const oauth = await readHarness(route, lookup);
|
||||
return oauth ? { oauth: { accessToken: oauth.access }, expires: oauth.expires } : null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,13 @@ export const routeSchema = z.discriminatedUnion("store", [
|
||||
credentialId: z.number().int().positive(),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ store: z.literal("keychain") }).strict(),
|
||||
z
|
||||
.object({
|
||||
store: z.literal("keychain"),
|
||||
service: z.string().optional(),
|
||||
account: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
]);
|
||||
export const inputSchema = z.object({ route: routeSchema }).strict();
|
||||
export type UsageInput = z.infer<typeof inputSchema>;
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Codex",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => discover(scope),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -23,15 +23,15 @@ async function accountIdentity(
|
||||
input: Parameters<typeof fetchUsage>[0],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
) {
|
||||
const accounts = await discover(lookup(env));
|
||||
const accounts = await discover({ kind: "global" }, lookup(env));
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
if (!account) throw new Error("Expected discovered login");
|
||||
return { key: account.key, ...(account.label ? { label: account.label } : {}) };
|
||||
}
|
||||
async function logins(options: Parameters<typeof discover>[0]) {
|
||||
return (await discover(options)).map(
|
||||
async function logins(options: Parameters<typeof discover>[1]) {
|
||||
return (await discover({ kind: "global" }, options)).map(
|
||||
(account) => account.input as Parameters<typeof fetchUsage>[0],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,10 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
|
||||
"openai-codex": { type: "oauth", access: "fixture-pi", accountId: "pi-account" },
|
||||
}),
|
||||
);
|
||||
const inputs = await discover({ home: directory, env: {}, platform: "linux" });
|
||||
const inputs = await discover(
|
||||
{ kind: "global" },
|
||||
{ home: directory, env: {}, platform: "linux" },
|
||||
);
|
||||
expect(inputs.map((account) => account.input)).toContainEqual({
|
||||
route: { store: "pi", path: join(directory, ".pi", "agent", "auth.json") },
|
||||
});
|
||||
@@ -227,16 +230,19 @@ test("discovers a Pi OAuth login independently of the CLI login", async () => {
|
||||
});
|
||||
|
||||
async function accountFor(input: CodexUsageInput) {
|
||||
const accounts = await discover({
|
||||
home: fixtureHome,
|
||||
env: {
|
||||
CODEX_HOME:
|
||||
input.route.store === "codex"
|
||||
? (await import("node:path")).dirname(input.route.path)
|
||||
: fixtureHome,
|
||||
const accounts = await discover(
|
||||
{ kind: "global" },
|
||||
{
|
||||
home: fixtureHome,
|
||||
env: {
|
||||
CODEX_HOME:
|
||||
input.route.store === "codex"
|
||||
? (await import("node:path")).dirname(input.route.path)
|
||||
: fixtureHome,
|
||||
},
|
||||
platform: "linux",
|
||||
},
|
||||
platform: "linux",
|
||||
});
|
||||
);
|
||||
const account = accounts.find(
|
||||
(candidate) => JSON.stringify(candidate.input) === JSON.stringify(input),
|
||||
);
|
||||
@@ -250,7 +256,7 @@ test.each(["empty home", "unrelated files"])(
|
||||
const home = await mkdtemp(join(tmpdir(), "codex-no-login-"));
|
||||
try {
|
||||
if (scenario === "unrelated files") await writeFile(join(home, "unrelated.json"), "{}");
|
||||
expect(await discover({ home, env: {}, platform: "linux" })).toEqual([]);
|
||||
expect(await discover({ kind: "global" }, { home, env: {}, platform: "linux" })).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
@@ -379,3 +385,30 @@ test("unknown lengths and code review never get a duration from their slot", asy
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("session discovery isolates CODEX_HOME and excludes foreign routes", async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), "codex-session-scope-"));
|
||||
try {
|
||||
await writeFile(
|
||||
join(home, "auth.json"),
|
||||
JSON.stringify({ tokens: { access_token: "session-token", account_id: "session-account" } }),
|
||||
);
|
||||
const scope = {
|
||||
kind: "session" as const,
|
||||
provider: "codex",
|
||||
env: { HOME: home, CODEX_HOME: home },
|
||||
};
|
||||
const accounts = await discover(scope, { home: "/unused-default-home", env: {} });
|
||||
expect(accounts).toHaveLength(1);
|
||||
expect(accounts[0]?.input).toEqual(authInput(home));
|
||||
expect(
|
||||
await discover({ ...scope, env: { ...scope.env, OPENAI_BASE_URL: "https://other.test" } }),
|
||||
).toEqual([]);
|
||||
expect(await discover({ ...scope, provider: "claude" })).toEqual([]);
|
||||
expect(
|
||||
await discover({ ...scope, env: { HOME: home, CODEX_HOME: join(home, "missing") } }),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
hashAccountKey,
|
||||
unavailable,
|
||||
type UsageAccount,
|
||||
type UsageScope,
|
||||
toneFromUsedPct,
|
||||
windowFromReportedDuration,
|
||||
type UsageReport,
|
||||
@@ -57,7 +58,26 @@ interface Auth {
|
||||
expires?: number;
|
||||
}
|
||||
|
||||
export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]> {
|
||||
export async function discover(
|
||||
scope: UsageScope,
|
||||
lookup: StoreLookup = {},
|
||||
): Promise<UsageAccount[]> {
|
||||
if (scope.kind === "session")
|
||||
lookup = {
|
||||
...lookup,
|
||||
env: scope.env,
|
||||
home: scope.env.HOME || scope.env.USERPROFILE || homedir(),
|
||||
};
|
||||
const candidates = scope.kind === "global" ? globalRoutes(lookup) : sessionRoutes(scope, lookup);
|
||||
const present: UsageAccount[] = [];
|
||||
for (const input of candidates) {
|
||||
const auth = await readAuth(input, lookup);
|
||||
if (auth) present.push({ ...accountIdentity(auth, input), input });
|
||||
}
|
||||
return present;
|
||||
}
|
||||
|
||||
function globalRoutes(lookup: StoreLookup): CodexUsageInput[] {
|
||||
const env = lookup.env ?? process.env;
|
||||
const home = lookup.home ?? homedir();
|
||||
const paths = [
|
||||
@@ -77,12 +97,36 @@ export async function discover(lookup: StoreLookup = {}): Promise<UsageAccount[]
|
||||
{ route: { store: "pi", path: piAuthPath(lookup) } },
|
||||
...discoverOmp(lookup).map((route) => ({ route })),
|
||||
);
|
||||
const present: UsageAccount[] = [];
|
||||
for (const input of candidates) {
|
||||
const auth = await readAuth(input, lookup);
|
||||
if (auth) present.push({ ...accountIdentity(auth, input), input });
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function sessionRoutes(
|
||||
scope: Extract<UsageScope, { kind: "session" }>,
|
||||
lookup: StoreLookup,
|
||||
): CodexUsageInput[] {
|
||||
const env = scope.env;
|
||||
const home = lookup.home ?? homedir();
|
||||
if (scope.provider === "codex") {
|
||||
if (env.OPENAI_BASE_URL) return [];
|
||||
return [
|
||||
{
|
||||
route: { store: "codex", path: join(env.CODEX_HOME || join(home, ".codex"), "auth.json") },
|
||||
},
|
||||
];
|
||||
}
|
||||
return present;
|
||||
if (!scope.model?.startsWith("openai/")) return [];
|
||||
if (scope.provider === "pi") return [{ route: { store: "pi", path: piAuthPath(lookup) } }];
|
||||
if (scope.provider === "omp") return discoverOmp(lookup).map((route) => ({ route }));
|
||||
if (scope.provider === "opencode")
|
||||
return [
|
||||
{
|
||||
route: {
|
||||
store: "opencode",
|
||||
path: join(env.XDG_DATA_HOME || join(home, ".local", "share"), "opencode", "auth.json"),
|
||||
},
|
||||
},
|
||||
];
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function readAuth(
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "GitHub Copilot",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Cursor",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Grok",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Kimi",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "MiniMax",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "OpenCode Go",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover,
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
label: "Z.ai",
|
||||
icon: "icon.svg",
|
||||
input: inputSchema,
|
||||
discover: () => discover(),
|
||||
discover: (scope) => (scope.kind === "global" ? discover() : Promise.resolve([])),
|
||||
fetch: fetchUsage,
|
||||
});
|
||||
return () => {};
|
||||
|
||||
@@ -345,12 +345,16 @@ interface UsageAccount {
|
||||
input: JsonValue;
|
||||
}
|
||||
|
||||
type UsageScope =
|
||||
| { kind: "global" }
|
||||
| { kind: "session"; provider: string; model?: string; env: Record<string, string> };
|
||||
|
||||
interface UsageSourceRegistration {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
input: ZodType;
|
||||
discover(): Promise<UsageAccount[]>;
|
||||
discover(scope: UsageScope): Promise<UsageAccount[]>;
|
||||
fetch(input: unknown): Promise<UsageReport>;
|
||||
}
|
||||
|
||||
@@ -371,10 +375,28 @@ type UsageProblem =
|
||||
| { kind: "no_quota"; detail: string };
|
||||
```
|
||||
|
||||
Return every account whose login exists on the machine from `discover()`, including expired
|
||||
logins. Return `[]` when none exists. Discovery takes no arguments and must work independently of
|
||||
agent sessions and provider names. Inputs name credential stores; never put credentials in inputs
|
||||
or reports. Paseo validates each input against your schema before calling `fetch()`.
|
||||
`discover({ kind: "global" })` queries machine login stores, including expired logins. Session
|
||||
scope queries only the login stores selected by that harness's resolved launch environment.
|
||||
Return `[]` when no login exists or the session does not use your source. Never scan default stores
|
||||
from session discovery or scan agents from global discovery. Discovery is a query, with no agent
|
||||
lifecycle hooks. Closed agents have no session scope until resumed.
|
||||
|
||||
Inputs name credential stores; never put credentials in inputs or reports. Paseo validates each
|
||||
input against your schema before calling `fetch()`. The same key in any scope is the same report;
|
||||
agents sharing an account share the fetch cache. Fetches have a 20-second deadline.
|
||||
|
||||
Built-in session routes:
|
||||
|
||||
| Source | Session | Login store |
|
||||
| ------------- | --------------------------------------------- | ---------------------------------------------------------------------------------------------- |
|
||||
| Claude | `claude` | `CLAUDE_CONFIG_DIR`, or the default; on macOS, the directory's Keychain entry takes precedence |
|
||||
| Claude | `pi`, `omp` with `anthropic/…` model | That harness's Anthropic login store |
|
||||
| Codex | `codex` | `CODEX_HOME/auth.json`, or the default |
|
||||
| Codex | `pi`, `opencode`, `omp` with `openai/…` model | That harness's OpenAI login store |
|
||||
| Other sources | Any | No session discovery |
|
||||
|
||||
Claude excludes Bedrock, Vertex, and foreign `ANTHROPIC_BASE_URL` sessions. Codex excludes sessions
|
||||
with `OPENAI_BASE_URL` set.
|
||||
|
||||
Use a stable account key: 1–128 characters from `[A-Za-z0-9._-]`. It identifies the account or
|
||||
organization whose quota is metered and survives token rotation. Never use a credential or raw
|
||||
|
||||
Reference in New Issue
Block a user