mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a134873d47 | ||
|
|
4274b2be0d |
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user