Compare commits

...
Author SHA1 Message Date
Mohamed Boudra a134873d47 fix(usage): resolve saved provider accounts without sessions
Default-model chats retain their effective model in saved runtime info, and the persistence handle retains the session ID needed for credential attribution. Pass both through the provider client so closed chats resolve usage after restart.
2026-09-29 13:47:57 +02:00
Mohamed Boudra 4274b2be0d fix(usage): show account reports in reopened chats
Stored agents have no loaded session after a daemon restart, so session-owned reference lookup returned null. Resolve through provider clients using stored config, and request the report when a chat opens even during a turn.
2026-09-29 13:17:31 +02:00
24 changed files with 606 additions and 231 deletions
@@ -1,7 +1,11 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
openAgentRoute,
seedMockAgentWorkspace,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
@@ -42,6 +46,30 @@ async function openMockAgent(page: Page) {
}
test.describe("usage composer pill", () => {
test("shows the report when an existing running chat opens", async ({ page }) => {
test.setTimeout(180_000);
const usage = await installUsageReportsFixture(page, {
agentReportIds: [REPORT_ID],
lists: [[agentEntry(42)]],
});
const session = await seedRunningMockAgentWorkspace({
repoPrefix: "usage-running-",
title: "Running usage pill",
initialPrompt: "Show usage during the turn.",
model: "one-minute-stream",
});
try {
await openAgentRoute(page, session);
await expectComposerVisible(page);
await expect(page.getByTestId("usage-composer-pill")).toContainText("42%", {
timeout: 20_000,
});
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
} finally {
await session.cleanup();
}
});
test("opens the cached report without a request and shows when it was fetched", async ({
page,
}) => {
+10 -6
View File
@@ -31,8 +31,13 @@ function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
}
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
return ["usage", "agent", serverId, agentId, model] as const;
function agentUsageQueryKey(
serverId: string,
agentId: string,
model: string | null,
isRunning: boolean,
) {
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
}
function requireClient(serverId: string) {
@@ -160,8 +165,7 @@ export function useUsageByHost(): {
/**
* The usage report for the account an agent is spending. The daemon resolves the
* report ID, which is re-resolved when the model changes (a new key) and when a
* turn completes (the query is paused while the agent runs, and its data is always
* stale, so resuming refetches). The report itself is read from the shared cache.
* turn completes (a new query key). The report itself is read from the shared cache.
*/
export function useAgentUsage(
serverId: string,
@@ -176,10 +180,10 @@ export function useAgentUsage(
}),
);
const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model),
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported && !isRunning,
enabled: isConnected && isSupported,
dataShape: "value",
staleTimeMs: 0,
});
@@ -50,6 +50,7 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type UsageReference,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
@@ -1167,6 +1168,24 @@ export class AgentManager {
return agent ? { ...agent } : null;
}
async resolveUsageReference(
id: string,
): Promise<{ found: boolean; reference: UsageReference | null }> {
const agent = this.getAgent(id);
const stored = agent?.session ? null : await this.registry?.get(id);
const source = stored ?? agent;
if (!source) return { found: false, reference: null };
const config = stored ? buildStoredAgentConfig(stored) : agent!.config;
const reference =
(await this.clients.get(source.provider)?.resolveUsageReference?.({
config,
runtimeInfo: source.runtimeInfo ?? null,
persistence: source.persistence ?? null,
session: agent?.session ?? null,
})) ?? null;
return { found: true, reference };
}
async waitForAgentClose(agentId: string): Promise<void> {
// Loading during reload must wait for the replacement, not resume another writer.
await this.lifecycleMutationTails.get(agentId);
@@ -673,8 +673,6 @@ export interface AgentSession {
/** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
/** Resolved at fetch time because model and credentials may change during a session. */
getUsageReference?(): Promise<UsageReference | null>;
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -750,6 +748,13 @@ export interface ResolveAgentDefaultModeInput {
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
/** Account used by the agent. Resolving this must not start or load a session. */
resolveUsageReference?(input: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
persistence: AgentPersistenceHandle | null;
session: AgentSession | null;
}): Promise<UsageReference | null>;
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -406,7 +406,12 @@ describe("PluginAgentClientRegistry", () => {
cwd: "/workspace",
});
try {
expect(await session.getUsageReference?.()).toBeNull();
expect(
await client.resolveUsageReference?.({
config: { provider: harness.registration.id, cwd: "/workspace" },
session,
}),
).toBeNull();
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally {
await session.close();
@@ -897,6 +897,15 @@ class PluginAgentClient implements AgentClient {
return agentCapabilities(this.runtime.negotiatedCapabilities);
}
async resolveUsageReference({
session,
}: {
config: AgentSessionConfig;
session: AgentSession | null;
}) {
return session instanceof PluginAgentSession ? session.getPluginUsageReference() : null;
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -1096,7 +1105,7 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities);
}
getUsageReference(): Promise<UsageReference | null> {
getPluginUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference();
}
@@ -458,7 +458,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
}
},
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
getUsageReference: inner.getUsageReference?.bind(inner),
getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId),
@@ -490,13 +489,28 @@ function wrapClientProvider(
const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
// An alias's live session can carry launch env that is fresher than stored config.
const sessions = new WeakMap<AgentSession, AgentSession>();
const wrapSession = (session: AgentSession) => {
const wrapped = wrapSessionProvider(provider, session);
sessions.set(wrapped, session);
return wrapped;
};
return {
provider,
capabilities: inner.capabilities,
resolveUsageReference: inner.resolveUsageReference
? ({ config, runtimeInfo, persistence, session }) =>
inner.resolveUsageReference!({
config: { ...config, provider: inner.provider },
runtimeInfo,
persistence,
session: session ? (sessions.get(session) ?? null) : null,
})
: undefined,
createSession: async (config, launchContext) =>
wrapSessionProvider(
provider,
wrapSession(
await inner.createSession(
{
...config,
@@ -506,8 +520,7 @@ function wrapClientProvider(
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
wrapSessionProvider(
provider,
wrapSession(
await inner.resumeSession(
{
...handle,
@@ -569,7 +582,7 @@ function wrapClientProvider(
}
return {
...imported,
session: wrapSessionProvider(provider, imported.session),
session: wrapSession(imported.session),
config: {
...imported.config,
provider,
@@ -224,8 +224,17 @@ function createSessionWithConfig(
test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
const session = createSessionWithConfig({ provider });
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
const client = new ACPAgentClient({
provider,
logger: createTestLogger(),
defaultCommand: ["unused"],
});
expect(
await client.resolveUsageReference({
config: { provider, cwd: "/tmp/paseo-acp-test" },
session: null,
}),
).toEqual({ source: provider, input: {} });
}
});
@@ -882,6 +882,9 @@ function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): b
export class ACPAgentClient implements AgentClient {
readonly provider: string;
readonly capabilities: AgentCapabilityFlags;
async resolveUsageReference() {
return { source: this.provider, input: {} };
}
readonly resolveCreateConfig = resolveACPCreateConfig;
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
@@ -1633,10 +1636,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
readonly provider: string;
readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
return { source: this.provider, input: {} };
}
private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]];
@@ -531,16 +531,16 @@ describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger();
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
);
expect(await session.getUsageReference?.()).toEqual({
const config = { provider: "claude", cwd: process.cwd() };
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
resolveBinary: async () => "/test/claude/bin",
});
expect(await client.resolveUsageReference?.({ config, session: null })).toEqual({
source: "claude",
input: { configDir: "/accounts/second" },
});
await session.close();
});
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
@@ -548,14 +548,15 @@ describe("ClaudeAgentClient binary resolution", () => {
async (name) => {
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { [name]: "override" } },
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { [name]: "override" } },
);
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
expect(
await client.resolveUsageReference?.({
config: { provider: "claude", cwd: process.cwd() },
session: null,
}),
).toBeNull();
},
);
@@ -569,15 +570,16 @@ describe("ClaudeAgentClient binary resolution", () => {
},
},
});
const session = await registry["work-claude"].createClient(logger).createSession({
provider: "work-claude",
cwd: process.cwd(),
});
expect(await session.getUsageReference?.()).toEqual({
const client = registry["work-claude"].createClient(logger);
expect(
await client.resolveUsageReference?.({
config: { provider: "work-claude", cwd: process.cwd() },
session: null,
}),
).toEqual({
source: "claude",
input: { configDir: "/accounts/work" },
});
await session.close();
});
test("resolves the installed Claude Code version", async () => {
@@ -1519,6 +1519,15 @@ export class ClaudeAgentClient implements AgentClient {
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
}
async resolveUsageReference() {
const env = createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings });
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
return {
source: "claude",
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
};
}
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
return resolveConfiguredClaudeModel(model);
}
@@ -2042,15 +2051,6 @@ class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
async getUsageReference() {
const env = this.buildSdkEnv();
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
return {
source: "claude",
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
};
}
private readonly config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string;
@@ -172,41 +172,18 @@ function createSession(
}
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
const session = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second" },
);
expect(await session.getUsageReference()).toEqual({
const config = createConfig();
const client = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second" },
});
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
source: "codex",
input: { codexHome: "/accounts/second" },
});
const custom = new CodexAppServerAgentSession(
createConfig(),
null,
createTestLogger(),
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
);
expect(await custom.getUsageReference()).toBeNull();
const custom = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
});
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
});
function createProviderWithFakeAppServer(
@@ -3456,7 +3456,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
) {
this.logger = logger.child({
module: "agent",
@@ -3486,17 +3485,6 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async getUsageReference() {
if (this.usageEnv.OPENAI_BASE_URL) return null;
return {
source: "codex",
input: {
codexHome:
this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"),
},
};
}
get id(): string | null {
return this.currentThreadId;
}
@@ -7040,6 +7028,15 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {},
) {}
async resolveUsageReference() {
const env = buildCodexAppServerEnv(this.runtimeSettings);
if (env.OPENAI_BASE_URL) return null;
return {
source: "codex",
input: { codexHome: env.CODEX_HOME || path.join(env.HOME || os.homedir(), ".codex") },
};
}
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return {
...this.deps,
@@ -7161,7 +7158,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
"interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -7194,7 +7190,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -194,14 +194,20 @@ describe("OMP agent client and session", () => {
expires: Date.now() + 60_000,
}),
);
db.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)").run(
"session:sticky:openai-codex:omp-session-1",
JSON.stringify({ type: "oauth", credentialId: 1 }),
Math.floor(Date.now() / 1000) + 100,
);
db.close();
try {
const omp = new OmpHarness();
await omp.start({}, undefined, {
const env = {
OMP_PROFILE: "",
PI_CODING_AGENT_DIR: agentDir,
XDG_DATA_HOME: "",
});
};
const omp = new OmpHarness({ env });
await omp.start({}, undefined, env);
const runtime = omp.runtime();
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
const requestsBefore = runtime.getStateRequestCount;
@@ -216,6 +222,20 @@ describe("OMP agent client and session", () => {
input: { accessToken: "claude-fixture" },
});
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2);
const beforeClosed = runtime.getStateRequestCount;
expect(
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1"),
).toEqual({
source: "codex",
input: { accessToken: "codex-fixture", accountId: "codex-account" },
});
expect(
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1", null),
).toEqual({
source: "codex",
input: { accessToken: "codex-fixture", accountId: "codex-account" },
});
expect(runtime.getStateRequestCount).toBe(beforeClosed);
await omp.close();
} finally {
rmSync(home, { recursive: true, force: true });
@@ -969,9 +969,8 @@ export class OmpAgentSession implements AgentSession {
private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
async getUsageReference() {
const state = await this.runtimeSession.getState();
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
getUsageEnvironment() {
return this.usageEnv;
}
get id(): string | null {
@@ -2274,6 +2273,27 @@ export class OmpAgentClient implements AgentClient {
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
}
async resolveUsageReference({
config,
runtimeInfo,
persistence,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
persistence: AgentPersistenceHandle | null;
session: AgentSession | null;
}) {
const liveInfo = session ? await session.getRuntimeInfo() : null;
const model = liveInfo?.model ?? config.model ?? runtimeInfo?.model;
const sessionId = liveInfo?.sessionId ?? persistence?.sessionId;
const env =
session instanceof OmpAgentSession
? session.getUsageEnvironment()
: { ...process.env, ...this.runtimeSettings?.env };
return resolveOmpUsageReference(sessionId ?? "", model?.split("/")[0] ?? "", env);
}
private async configureNativePaseoTools(
runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog | undefined,
@@ -71,11 +71,13 @@ export class OmpHarness {
providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler;
env?: Record<string, string>;
} = {},
) {
this.client = new OmpAgentClient({
logger: pino({ level: "silent" }),
runtime: this.omp,
runtimeSettings: options.env ? { env: options.env } : undefined,
providerIdleScheduler: options.providerIdleScheduler,
noTurnScheduler: options.noTurnScheduler,
usagePollScheduler: options.usagePollScheduler,
@@ -484,7 +486,24 @@ export class OmpHarness {
}
async getUsageReference() {
return this.requireSession().getUsageReference();
return this.client.resolveUsageReference({
config: { provider: "omp", cwd: CWD },
session: this.requireSession(),
});
}
async getStoredUsageReference(
model: string,
sessionId: string,
configuredModel: string | null = model,
) {
const input = {
config: { provider: "omp", cwd: CWD, model: configuredModel },
runtimeInfo: { provider: "omp", sessionId, model },
persistence: { provider: "omp", sessionId },
session: null,
};
return this.client.resolveUsageReference(input);
}
runningToolCallIds(): string[] {
@@ -420,19 +420,33 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
{ serverManager: runtime, createClient: runtime.createClient },
);
expect(
await client.resolveUsageReference({
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
session: null,
}),
).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
const session = await client.createSession(buildConfig(cwd));
await session.setModel?.("openai/gpt-5");
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
await session.setModel?.("openai/gpt-5");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("other/model");
expect(await session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId;
@@ -448,7 +462,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
} as OpenCodeEvent);
await vi.waitFor(async () =>
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
}),
@@ -468,6 +482,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true });
});
test("usage reference survives a model switch and default-model restart", async () => {
const cwd = tmpCwd();
const client = new OpenCodeAgentClient(logger, {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" },
"opencode-go": { type: "api", key: "go-key" },
}),
},
});
try {
const runtimeInfo = {
provider: "opencode",
sessionId: "saved-session",
model: "openai/gpt-5",
};
const closed = {
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
runtimeInfo,
persistence: null,
session: null,
};
expect(await client.resolveUsageReference(closed)).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
expect(
await client.resolveUsageReference({
...closed,
config: { ...buildConfig(cwd), model: null },
}),
).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
test("usage reference ignores another auth entry with a different type", async () => {
const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness();
@@ -487,7 +541,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
const session = await client.createSession(buildConfig(cwd));
try {
await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
@@ -1445,6 +1445,26 @@ export class OpenCodeAgentClient implements AgentClient {
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
}
async resolveUsageReference({
config,
runtimeInfo,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
session: AgentSession | null;
}): Promise<UsageReference | null> {
const model = session
? (await session.getRuntimeInfo()).model
: (config.model ?? runtimeInfo?.model);
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const env =
session instanceof OpenCodeAgentSession
? session.getUsageEnvironment()
: { ...process.env, ...this.runtimeSettings?.env };
return resolveOpenCodeUsageReference(model, await readOpenCodeUsageAuth(env));
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -3620,11 +3640,8 @@ class OpenCodeAgentSession implements AgentSession {
};
}
async getUsageReference(): Promise<UsageReference | null> {
const model = this.config.model;
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const auth = await readOpenCodeUsageAuth(this.usageEnv);
return resolveOpenCodeUsageReference(model, auth);
getUsageEnvironment(): NodeJS.ProcessEnv {
return this.usageEnv;
}
async setModel(modelId: string | null): Promise<void> {
@@ -229,15 +229,16 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
runtime = sessionRuntime;
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
});
const session = await createClient(pi).createSession(createConfig(), {
const client = createClient(pi);
const session = await client.createSession(createConfig(), {
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "claude",
input: { accessToken: "claude-token" },
});
@@ -249,7 +250,7 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
}),
);
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
expect(await session.getUsageReference?.()).toEqual({
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "codex",
input: { accessToken: "codex-token" },
});
@@ -258,15 +259,65 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
path.join(agentDir, "auth.json"),
JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }),
);
expect(await session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
runtime.state.model = { provider: "other", id: "other", name: "Other" };
expect(await session.getUsageReference?.()).toBeNull();
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
await session.close();
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("Pi resolves a stored model without opening a session", async () => {
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-stored-usage-"));
try {
writeFileSync(
path.join(agentDir, "auth.json"),
JSON.stringify({
anthropic: { type: "oauth", access: "claude-token", refresh: "refresh" },
"openai-codex": {
type: "oauth",
access: "codex-token",
accountId: "account-1",
refresh: "refresh",
},
}),
);
const client = new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtime: new FakePi(),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
});
expect(
await client.resolveUsageReference({
config: createConfig({ model: "anthropic/claude-sonnet-4" }),
session: null,
}),
).toEqual({ source: "claude", input: { accessToken: "claude-token" } });
const runtimeInfo = {
provider: "pi",
sessionId: "saved-session",
model: "openai-codex/gpt-5",
};
const closed = {
config: createConfig({ model: "openai-codex/gpt-5" }),
runtimeInfo,
persistence: null,
session: null,
};
expect(await client.resolveUsageReference(closed)).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
expect(await client.resolveUsageReference({ ...closed, config: createConfig() })).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
} finally {
rmSync(agentDir, { recursive: true, force: true });
}
});
test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi();
const client = createClient(pi);
@@ -1141,37 +1141,39 @@ function createRuntime(
});
}
async function resolvePiUsageReference(provider: string | undefined, env?: Record<string, string>) {
let source: string | null = null;
if (provider === "openai-codex") source = "codex";
if (provider === "anthropic") source = "claude";
if (!source || !provider) return null;
try {
const auth: unknown = JSON.parse(
await readFile(join(resolvePiAgentDir(env), "auth.json"), "utf8"),
);
if (provider === "openai-codex") {
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
if (!credential) return null;
return {
source,
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
return credential ? { source, input: { accessToken: credential.access } } : null;
} catch {
return null;
}
}
export class PiRpcAgentSession implements AgentSession {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
await this.refreshState();
const provider = this.state.model?.provider;
let source: string | null = null;
if (provider === "openai-codex") source = "codex";
if (provider === "anthropic") source = "claude";
if (!source || !provider) return null;
try {
const auth: unknown = JSON.parse(
await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"),
);
if (provider === "openai-codex") {
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
if (!credential) return null;
return {
source,
input: {
accessToken: credential.access,
...(credential.accountId ? { accountId: credential.accountId } : {}),
},
};
}
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
return credential ? { source, input: { accessToken: credential.access } } : null;
} catch {
return null;
}
getUsageEnvironment() {
return this.usageEnv;
}
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -2477,6 +2479,25 @@ export class PiRpcAgentClient implements AgentClient {
this.usagePollScheduler = options.usagePollScheduler;
}
async resolveUsageReference({
config,
runtimeInfo,
session,
}: {
config: AgentSessionConfig;
runtimeInfo: AgentRuntimeInfo | null;
session: AgentSession | null;
}) {
const model = session
? (await session.getRuntimeInfo()).model
: (config.model ?? runtimeInfo?.model);
const env =
session instanceof PiRpcAgentSession
? session.getUsageEnvironment()
: this.runtimeSettings?.env;
return resolvePiUsageReference(model?.split("/")[0], env);
}
async createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
@@ -1,10 +1,13 @@
import { fileURLToPath } from "node:url";
import { randomUUID } from "node:crypto";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ACPAgentSession } from "../agent/providers/acp-agent.js";
import type { UsageReference } from "../agent/agent-sdk-types.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { AgentStorage } from "../agent/agent-storage.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -53,75 +56,21 @@ test("resolves a provider plugin session reference through its usage source", as
}
}, 60_000);
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
test("agent.resolve_usage_report resolves source IDs from provider clients", async () => {
const directory = fileURLToPath(
new URL("./test-fixtures/session-usage-reference/", import.meta.url),
);
const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const;
const logger = createTestLogger();
const references = new Map<string, UsageReference | null>();
const claude = await new ClaudeAgentClient({
logger,
resolveBinary: async () => "/test/claude/bin",
}).createSession(
{ provider: "claude", cwd: directory },
{
env: {
HOME: directory,
CLAUDE_CONFIG_DIR: "",
ANTHROPIC_BASE_URL: "",
ANTHROPIC_API_KEY: "",
ANTHROPIC_AUTH_TOKEN: "",
},
},
);
references.set("claude", (await claude.getUsageReference?.()) ?? null);
await claude.close();
const codex = new CodexAppServerAgentSession(
{ provider: "codex", cwd: directory },
null,
logger,
() => {
throw new Error("Codex runtime should not start");
},
{},
false,
false,
false,
undefined,
"interactive",
{ HOME: directory, OPENAI_BASE_URL: "" },
);
references.set("codex", await codex.getUsageReference());
for (const provider of providers.slice(2)) {
const session = new ACPAgentSession(
{ provider, cwd: directory },
{
provider,
logger,
defaultCommand: ["unused"],
defaultModes: [],
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: true,
supportsMcpServers: true,
supportsReasoningStream: true,
supportsToolInvocations: true,
},
},
);
references.set(provider, await session.getUsageReference());
}
const agentClients = Object.fromEntries(
providers.map((provider) => {
const client = createTestAgentClient(provider);
const createSession = client.createSession.bind(client);
client.createSession = async (...args) => {
const session = await createSession(...args);
session.getUsageReference = async () => references.get(provider) ?? null;
return session;
};
let input: Record<string, string> = {};
if (provider === "claude") input = { configDir: directory };
if (provider === "codex") input = { codexHome: directory };
client.resolveUsageReference = async () => ({
source: provider,
input,
});
return [provider, client];
}),
);
@@ -148,3 +97,156 @@ test("agent.resolve_usage_report resolves source IDs from default built-in and A
await daemon.close();
}
}, 60_000);
test("stored Claude and Codex agents resolve without opening a provider session", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-closed-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const sessionsOpened = new Map<string, number>();
const agentClients = Object.fromEntries(
["claude", "codex"].map((provider) => {
const providerClient = createTestAgentClient(provider, {
beforeCreateSession: async () => {
sessionsOpened.set(provider, (sessionsOpened.get(provider) ?? 0) + 1);
},
});
providerClient.resolveUsageReference = async ({ session, runtimeInfo, persistence }) => {
expect(session).toBeNull();
expect(runtimeInfo?.sessionId).toBeTruthy();
expect(runtimeInfo?.model).toBe(`${provider}/saved-model`);
expect(persistence?.sessionId).toBe(runtimeInfo?.sessionId);
return { source: "fixture-session-usage", input: { account: "from-session" } };
};
return [provider, providerClient];
}),
);
const options = {
paseoHomeRoot: root,
staticDir,
cleanup: false,
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
agentClients,
};
let daemon = await createTestPaseoDaemon(options);
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const ids: string[] = [];
for (const provider of ["claude", "codex"]) {
const agent = await client.createAgent({ provider, cwd: root });
ids.push(agent.id);
await client.sendMessage(agent.id, "Say OK.");
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle");
expect(sessionsOpened.get(provider)).toBe(1);
}
await client.close();
await daemon.close();
const storage = new AgentStorage(
path.join(root, ".paseo", "agents"),
pino({ level: "silent" }),
);
for (const [index, provider] of ["claude", "codex"].entries()) {
const record = await storage.get(ids[index]!);
expect(record).not.toBeNull();
await storage.upsert({
...record!,
runtimeInfo: { ...record!.runtimeInfo!, model: `${provider}/saved-model` },
});
}
daemon = await createTestPaseoDaemon(options);
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, provider] of ["claude", "codex"].entries()) {
const result = await client.resolveAgentUsageReport({ agentId: ids[index]! });
expect(result.reportId).toBe("fixture-session-usage:from-session");
expect(
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports,
).toHaveLength(1);
expect(sessionsOpened.get(provider)).toBe(1);
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
test("stored default-model OpenCode and Pi agents resolve their saved runtime model", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-saved-model-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const piDir = path.join(root, "pi-agent");
await mkdir(piDir);
await writeFile(
path.join(piDir, "auth.json"),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "codex-fixture-token",
accountId: "fixture-account",
refresh: "fixture-refresh",
},
}),
);
const storage = new AgentStorage(path.join(root, ".paseo", "agents"), pino({ level: "silent" }));
const ids: string[] = [];
const now = new Date().toISOString();
for (const provider of ["opencode", "pi"]) {
const id = randomUUID();
ids.push(id);
await storage.upsert({
id,
provider,
cwd: root,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
config: { model: null },
runtimeInfo: {
provider,
sessionId: `${provider}-saved-session`,
model: provider === "opencode" ? "openai/gpt-5" : "openai-codex/gpt-5",
},
persistence: { provider, sessionId: `${provider}-saved-session` },
});
}
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: root,
staticDir,
cleanup: false,
agentClients: {
opencode: new OpenCodeAgentClient(pino({ level: "silent" }), {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "codex-fixture-token", accountId: "fixture-account" },
}),
},
}),
pi: new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: piDir } },
}),
},
builtinPlugins: new BuiltinPluginLoader(undefined, ["codex-usage-source"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, id] of ids.entries()) {
expect(
(await client.resolveAgentUsageReport({ agentId: id })).reportId,
["opencode", "pi"][index],
).not.toBeNull();
}
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
+1 -1
View File
@@ -1000,7 +1000,7 @@ export class Session {
this.usageSession = new UsageSession({
emit: (msg) => this.emit(msg),
listAgents: () => this.agentManager.listAgents(),
getAgent: (agentId) => this.agentManager.getAgent(agentId),
resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
runtime: pluginRuntime,
logger: this.sessionLogger,
});
@@ -1,6 +1,5 @@
import pino from "pino";
import { expect, test } from "vitest";
import type { AgentSession } from "../../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../../messages.js";
import { UsageSession } from "./usage-session.js";
@@ -9,9 +8,8 @@ test("collects live references for usage reports and resolves an agent report",
const references: unknown[] = [];
const reference = { source: "fixture", input: { account: "one" } };
const agent = {
session: {
getUsageReference: async () => reference,
} as AgentSession,
id: "one",
session: {},
};
const entry = {
id: "fixture:one",
@@ -23,8 +21,8 @@ test("collects live references for usage reports and resolves an agent report",
};
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [agent, { session: null }],
getAgent: (id) => (id === "one" ? agent : null),
listAgents: () => [agent, { id: "closed", session: null }],
resolveAgentReference: async (id) => ({ found: id === "one", reference }),
runtime: {
async listUsageReports(options) {
references.push(options.references);
@@ -59,7 +57,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
getAgent: () => null,
resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: {
async listUsageReports() {
return [];
@@ -85,7 +83,7 @@ test("unknown agent returns agent_not_found", async () => {
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
getAgent: () => null,
resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: {
listUsageReports: async () => [],
resolveUsageReference: async () => null,
@@ -1,16 +1,20 @@
import type pino from "pino";
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js";
import type { UsageReference } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
interface UsageAgent {
session: AgentSession | null;
id: string;
session: object | null;
}
export interface UsageSessionOptions {
emit(message: SessionOutboundMessage): void;
listAgents(): UsageAgent[];
getAgent(agentId: string): UsageAgent | null;
resolveAgentReference(agentId: string): Promise<{
found: boolean;
reference: UsageReference | null;
}>;
runtime?: {
listUsageReports(options: {
forceRefresh?: boolean;
@@ -37,8 +41,12 @@ export class UsageSession {
await Promise.all(
this.options
.listAgents()
.map(
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null,
.filter((agent) => agent.session !== null)
.map(async (agent) =>
this.options
.resolveAgentReference(agent.id)
.then((resolved) => resolved.reference)
.catch(() => null),
),
)
).filter((reference): reference is UsageReference => reference !== null)
@@ -62,12 +70,12 @@ export class UsageSession {
): Promise<void> {
try {
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
const agent = this.options.getAgent(msg.agentId);
if (!agent) {
const resolved = await this.options.resolveAgentReference(msg.agentId);
if (!resolved.found) {
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
return;
}
const reference = (await agent.session?.getUsageReference?.()) ?? null;
const reference = resolved.reference;
const reportId = reference
? await this.options.runtime.resolveUsageReference(reference)
: null;