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
paseo-bot[bot] b9ac1d4982 fix(app): add a Back to plugins link to plugin settings screens on desktop (#5620)
On the wide settings layout, a plugin settings screen showed only its title
and the plugin's content, so getting back to the Plugins page meant finding
Plugins in the sidebar again. The compact layout already had a Back header
and project settings already had a desktop "Back to projects" link. Plugin
settings screens now render a "Back to plugins" link on desktop the same
way, including when the screen is offline or unavailable.

Closes #5518

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-29 09:10:21 +02:00
30 changed files with 659 additions and 234 deletions
@@ -14,6 +14,7 @@ import {
expectConflictPreservesDraft, expectConflictPreservesDraft,
discardTitle, discardTitle,
returnAndReopenSettings, returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable, expectSettingsUnavailable,
} from "../support/helpers/plugin-settings"; } from "../support/helpers/plugin-settings";
@@ -43,6 +44,11 @@ test("another client's save preserves a conflicting draft", async ({ page }) =>
await discardTitle(page); await discardTitle(page);
}); });
test("Back to plugins returns from a plugin settings screen", async ({ page }) => {
await openDisplaySettings(page);
await returnToPluginsPage(page);
});
test.describe("compact plugin settings", () => { test.describe("compact plugin settings", () => {
test.use({ viewport: { width: 390, height: 844 } }); test.use({ viewport: { width: 390, height: 844 } });
@@ -1,7 +1,11 @@
import type { UsageReportEntry } from "@getpaseo/protocol/messages"; import type { UsageReportEntry } from "@getpaseo/protocol/messages";
import { expect, test, type Page } from "../support/fixtures"; import { expect, test, type Page } from "../support/fixtures";
import { expectComposerVisible, submitMessage } from "../support/helpers/composer"; 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"; import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account"; const REPORT_ID = "fixture:fixture-account";
@@ -42,6 +46,30 @@ async function openMockAgent(page: Page) {
} }
test.describe("usage composer pill", () => { 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 ({ test("opens the cached report without a request and shows when it was fetched", async ({
page, page,
}) => { }) => {
@@ -3,6 +3,8 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app"; import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings"; import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id"; import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace"; import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>; type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -105,6 +107,13 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click(); await page.getByRole("menuitem", { name: "Display", exact: true }).click();
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible(); await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
} }
export async function returnToPluginsPage(page: Page) {
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
await expectAppRoute(page, buildSettingsHostSectionRoute(getServerId(), "plugins"));
await expect(
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
).toBeVisible();
}
export async function expectSettingsUnavailable(page: Page) { export async function expectSettingsUnavailable(page: Page) {
await expect( await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }), page.getByText("This plugin settings screen is unavailable.", { exact: true }),
+1
View File
@@ -2071,6 +2071,7 @@ export const en = {
offline: "Connect to this host to open plugin settings.", offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.", update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.", unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
}, },
trustedTitle: "Plugins are trusted code", trustedTitle: "Plugins are trusted code",
trustedDescription: trustedDescription:
@@ -18,6 +18,7 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.", offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.", update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.", unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
}, },
title: "الإضافات", title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة", trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -72,6 +73,7 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.", offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.", update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.", unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
}, },
title: "Plugins", title: "Plugins",
trustedTitle: "Los plugins son código de confianza", trustedTitle: "Los plugins son código de confianza",
@@ -126,6 +128,7 @@ export const pluginSettings = {
offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.", offline: "Connectez-vous à cet hôte pour ouvrir les paramètres du plugin.",
update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.", update: "Mettez à jour cet hôte pour utiliser les paramètres du plugin.",
unavailable: "Cet écran de paramètres du plugin est indisponible.", unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
}, },
title: "Plugins", title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance", trustedTitle: "Les plugins sont du code de confiance",
@@ -180,6 +183,7 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。", offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。", update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。", unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
}, },
title: "プラグイン", title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです", trustedTitle: "プラグインは信頼済みコードです",
@@ -234,6 +238,7 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.", offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.", update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.", unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
}, },
title: "플러그인", title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다", trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -287,6 +292,7 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir as configurações do plugin.", offline: "Conecte-se a este host para abrir as configurações do plugin.",
update: "Atualize este host para usar as configurações do plugin.", update: "Atualize este host para usar as configurações do plugin.",
unavailable: "Esta tela de configurações do plugin está indisponível.", unavailable: "Esta tela de configurações do plugin está indisponível.",
backToPlugins: "Voltar para plugins",
}, },
title: "Plugins", title: "Plugins",
trustedTitle: "Plugins são código confiável", trustedTitle: "Plugins são código confiável",
@@ -341,6 +347,7 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.", offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.", update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.", unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
}, },
title: "Плагины", title: "Плагины",
trustedTitle: "Плагины — доверенный код", trustedTitle: "Плагины — доверенный код",
@@ -395,6 +402,7 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。", offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。", update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。", unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
}, },
title: "插件", title: "插件",
trustedTitle: "插件是受信任的代码", trustedTitle: "插件是受信任的代码",
+27 -3
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native"; import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router"; import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client"; import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -127,7 +128,30 @@ function SettingsContent({
} }
const ThemedSettingsContent = withUnistyles(SettingsContent); const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent(props: SettingsIdentity) { export function PluginSettingsContent({
return <ThemedSettingsContent {...props} uniProps={themeMapping} />; onBackToPlugins,
showBackToPlugins,
...identity
}: SettingsIdentity & { onBackToPlugins: () => void; showBackToPlugins: boolean }) {
const { t } = useTranslation();
return (
<View>
{showBackToPlugins ? (
<Button
onPress={onBackToPlugins}
variant="ghost"
size="sm"
leftIcon={ArrowLeft}
style={styles.backButton}
>
{t("settings.plugins.screens.backToPlugins")}
</Button>
) : null}
<ThemedSettingsContent {...identity} uniProps={themeMapping} />
</View>
);
} }
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } })); const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
@@ -1333,6 +1333,8 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId} serverId={view.serverId}
pluginId={view.pluginId} pluginId={view.pluginId}
screenId={view.screenId} screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/> />
); );
if (view.kind === "host") { if (view.kind === "host") {
+10 -6
View File
@@ -31,8 +31,13 @@ function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const; return ["usage", "reports", serverId] as const;
} }
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) { function agentUsageQueryKey(
return ["usage", "agent", serverId, agentId, model] as const; serverId: string,
agentId: string,
model: string | null,
isRunning: boolean,
) {
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
} }
function requireClient(serverId: string) { 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 * 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 * 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 * turn completes (a new query key). The report itself is read from the shared cache.
* stale, so resuming refetches). The report itself is read from the shared cache.
*/ */
export function useAgentUsage( export function useAgentUsage(
serverId: string, serverId: string,
@@ -176,10 +180,10 @@ export function useAgentUsage(
}), }),
); );
const identity = useFetchQuery({ const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model), queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
queryFn: async () => queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId, (await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported && !isRunning, enabled: isConnected && isSupported,
dataShape: "value", dataShape: "value",
staleTimeMs: 0, staleTimeMs: 0,
}); });
@@ -50,6 +50,7 @@ import {
type AgentTimelineItem, type AgentTimelineItem,
type AgentUsage, type AgentUsage,
type AgentRuntimeInfo, type AgentRuntimeInfo,
type UsageReference,
type ImportedTimelineEntry, type ImportedTimelineEntry,
type ImportableProviderSession, type ImportableProviderSession,
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
@@ -1167,6 +1168,24 @@ export class AgentManager {
return agent ? { ...agent } : null; 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> { async waitForAgentClose(agentId: string): Promise<void> {
// Loading during reload must wait for the replacement, not resume another writer. // Loading during reload must wait for the replacement, not resume another writer.
await this.lifecycleMutationTails.get(agentId); await this.lifecycleMutationTails.get(agentId);
@@ -673,8 +673,6 @@ export interface AgentSession {
/** New provider-owned rows to commit on registration. streamHistory must also /** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */ * replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[]; 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>; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -750,6 +748,13 @@ export interface ResolveAgentDefaultModeInput {
export interface AgentClient { export interface AgentClient {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags; 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( createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
@@ -406,7 +406,12 @@ describe("PluginAgentClientRegistry", () => {
cwd: "/workspace", cwd: "/workspace",
}); });
try { 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); expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally { } finally {
await session.close(); await session.close();
@@ -897,6 +897,15 @@ class PluginAgentClient implements AgentClient {
return agentCapabilities(this.runtime.negotiatedCapabilities); return agentCapabilities(this.runtime.negotiatedCapabilities);
} }
async resolveUsageReference({
session,
}: {
config: AgentSessionConfig;
session: AgentSession | null;
}) {
return session instanceof PluginAgentSession ? session.getPluginUsageReference() : null;
}
async createSession( async createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
@@ -1096,7 +1105,7 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities); return agentCapabilities(this.bridge.negotiatedCapabilities);
} }
getUsageReference(): Promise<UsageReference | null> { getPluginUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference(); return this.bridge.getUsageReference();
} }
@@ -458,7 +458,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
} }
}, },
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()), getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
getUsageReference: inner.getUsageReference?.bind(inner),
getAvailableModes: () => inner.getAvailableModes(), getAvailableModes: () => inner.getAvailableModes(),
getCurrentMode: () => inner.getCurrentMode(), getCurrentMode: () => inner.getCurrentMode(),
setMode: (modeId) => inner.setMode(modeId), setMode: (modeId) => inner.setMode(modeId),
@@ -490,13 +489,28 @@ function wrapClientProvider(
const listFeatures = inner.listFeatures?.bind(inner); const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.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 { return {
provider, provider,
capabilities: inner.capabilities, 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) => createSession: async (config, launchContext) =>
wrapSessionProvider( wrapSession(
provider,
await inner.createSession( await inner.createSession(
{ {
...config, ...config,
@@ -506,8 +520,7 @@ function wrapClientProvider(
), ),
), ),
resumeSession: async (handle, overrides, launchContext, options) => resumeSession: async (handle, overrides, launchContext, options) =>
wrapSessionProvider( wrapSession(
provider,
await inner.resumeSession( await inner.resumeSession(
{ {
...handle, ...handle,
@@ -569,7 +582,7 @@ function wrapClientProvider(
} }
return { return {
...imported, ...imported,
session: wrapSessionProvider(provider, imported.session), session: wrapSession(imported.session),
config: { config: {
...imported.config, ...imported.config,
provider, provider,
@@ -224,8 +224,17 @@ function createSessionWithConfig(
test("ACP usage reference uses the provider ID", async () => { test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) { for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
const session = createSessionWithConfig({ provider }); const client = new ACPAgentClient({
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} }); 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 { export class ACPAgentClient implements AgentClient {
readonly provider: string; readonly provider: string;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
async resolveUsageReference() {
return { source: this.provider, input: {} };
}
readonly resolveCreateConfig = resolveACPCreateConfig; readonly resolveCreateConfig = resolveACPCreateConfig;
readonly isCreateConfigUnattended = isACPCreateConfigUnattended; readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
@@ -1633,10 +1636,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
readonly provider: string; readonly provider: string;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
async getUsageReference() {
return { source: this.provider, input: {} };
}
private readonly logger: Logger; private readonly logger: Logger;
private readonly runtimeSettings?: ProviderRuntimeSettings; private readonly runtimeSettings?: ProviderRuntimeSettings;
private readonly defaultCommand: [string, ...string[]]; private readonly defaultCommand: [string, ...string[]];
@@ -531,16 +531,16 @@ describe("ClaudeAgentClient binary resolution", () => {
const logger = createTestLogger(); const logger = createTestLogger();
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => { test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const config = { provider: "claude", cwd: process.cwd() };
const session = await client.createSession( const client = new ClaudeAgentClient({
{ provider: "claude", cwd: process.cwd() }, logger,
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } }, runtimeSettings: { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
); resolveBinary: async () => "/test/claude/bin",
expect(await session.getUsageReference?.()).toEqual({ });
expect(await client.resolveUsageReference?.({ config, session: null })).toEqual({
source: "claude", source: "claude",
input: { configDir: "/accounts/second" }, input: { configDir: "/accounts/second" },
}); });
await session.close();
}); });
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])( test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
@@ -548,14 +548,15 @@ describe("ClaudeAgentClient binary resolution", () => {
async (name) => { async (name) => {
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
logger, logger,
runtimeSettings: { env: { [name]: "override" } },
resolveBinary: async () => "/test/claude/bin", resolveBinary: async () => "/test/claude/bin",
}); });
const session = await client.createSession( expect(
{ provider: "claude", cwd: process.cwd() }, await client.resolveUsageReference?.({
{ env: { [name]: "override" } }, config: { provider: "claude", cwd: process.cwd() },
); session: null,
expect(await session.getUsageReference?.()).toBeNull(); }),
await session.close(); ).toBeNull();
}, },
); );
@@ -569,15 +570,16 @@ describe("ClaudeAgentClient binary resolution", () => {
}, },
}, },
}); });
const session = await registry["work-claude"].createClient(logger).createSession({ const client = registry["work-claude"].createClient(logger);
provider: "work-claude", expect(
cwd: process.cwd(), await client.resolveUsageReference?.({
}); config: { provider: "work-claude", cwd: process.cwd() },
expect(await session.getUsageReference?.()).toEqual({ session: null,
}),
).toEqual({
source: "claude", source: "claude",
input: { configDir: "/accounts/work" }, input: { configDir: "/accounts/work" },
}); });
await session.close();
}); });
test("resolves the installed Claude Code version", async () => { test("resolves the installed Claude Code version", async () => {
@@ -1519,6 +1519,15 @@ export class ClaudeAgentClient implements AgentClient {
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk; 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 { resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
return resolveConfiguredClaudeModel(model); return resolveConfiguredClaudeModel(model);
} }
@@ -2042,15 +2051,6 @@ class ClaudeAgentSession implements AgentSession {
readonly provider = "claude" as const; readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES; 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 config: ClaudeAgentConfig;
private readonly launchEnv?: Record<string, string>; private readonly launchEnv?: Record<string, string>;
private readonly agentId?: string; private readonly agentId?: string;
@@ -172,41 +172,18 @@ function createSession(
} }
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => { test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
const session = new CodexAppServerAgentSession( const config = createConfig();
createConfig(), const client = new CodexAppServerAgentClient(createTestLogger(), {
null, env: { CODEX_HOME: "/accounts/second" },
createTestLogger(), });
() => { expect(await client.resolveUsageReference({ config, session: null })).toEqual({
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second" },
);
expect(await session.getUsageReference()).toEqual({
source: "codex", source: "codex",
input: { codexHome: "/accounts/second" }, input: { codexHome: "/accounts/second" },
}); });
const custom = new CodexAppServerAgentSession( const custom = new CodexAppServerAgentClient(createTestLogger(), {
createConfig(), env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
null, });
createTestLogger(), expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
() => {
throw new Error("unused");
},
{},
false,
false,
false,
undefined,
"interactive",
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
);
expect(await custom.getUsageReference()).toBeNull();
}); });
function createProviderWithFakeAppServer( function createProviderWithFakeAppServer(
@@ -3456,7 +3456,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly autoReviewEnabled: boolean = false, private readonly autoReviewEnabled: boolean = false,
private readonly agentId?: string, private readonly agentId?: string,
private readonly initialResumePurpose: "interactive" | "history" = "interactive", private readonly initialResumePurpose: "interactive" | "history" = "interactive",
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
) { ) {
this.logger = logger.child({ this.logger = logger.child({
module: "agent", 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 { get id(): string | null {
return this.currentThreadId; return this.currentThreadId;
} }
@@ -7040,6 +7028,15 @@ export class CodexAppServerAgentClient implements AgentClient {
private readonly deps: CodexAppServerAgentDeps = {}, 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 { private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
return { return {
...this.deps, ...this.deps,
@@ -7161,7 +7158,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled, autoReviewEnabled,
launchContext?.agentId, launchContext?.agentId,
"interactive", "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
); );
await session.connect(); await session.connect();
return session; return session;
@@ -7194,7 +7190,6 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled, autoReviewEnabled,
launchContext?.agentId, launchContext?.agentId,
options?.purpose ?? "interactive", options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
); );
await session.connect(); await session.connect();
return session; return session;
@@ -194,14 +194,20 @@ describe("OMP agent client and session", () => {
expires: Date.now() + 60_000, 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(); db.close();
try { try {
const omp = new OmpHarness(); const env = {
await omp.start({}, undefined, {
OMP_PROFILE: "", OMP_PROFILE: "",
PI_CODING_AGENT_DIR: agentDir, PI_CODING_AGENT_DIR: agentDir,
XDG_DATA_HOME: "", XDG_DATA_HOME: "",
}); };
const omp = new OmpHarness({ env });
await omp.start({}, undefined, env);
const runtime = omp.runtime(); const runtime = omp.runtime();
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } }; runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
const requestsBefore = runtime.getStateRequestCount; const requestsBefore = runtime.getStateRequestCount;
@@ -216,6 +222,20 @@ describe("OMP agent client and session", () => {
input: { accessToken: "claude-fixture" }, input: { accessToken: "claude-fixture" },
}); });
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2); 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(); await omp.close();
} finally { } finally {
rmSync(home, { recursive: true, force: true }); rmSync(home, { recursive: true, force: true });
@@ -969,9 +969,8 @@ export class OmpAgentSession implements AgentSession {
private readonly paseoTools?: PaseoToolCatalog; private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv; private readonly usageEnv: NodeJS.ProcessEnv;
async getUsageReference() { getUsageEnvironment() {
const state = await this.runtimeSession.getState(); return this.usageEnv;
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
} }
get id(): string | null { get id(): string | null {
@@ -2274,6 +2273,27 @@ export class OmpAgentClient implements AgentClient {
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams); 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( private async configureNativePaseoTools(
runtimeSession: OmpRuntimeSession, runtimeSession: OmpRuntimeSession,
catalog: PaseoToolCatalog | undefined, catalog: PaseoToolCatalog | undefined,
@@ -71,11 +71,13 @@ export class OmpHarness {
providerIdleScheduler?: OmpProviderIdleScheduler; providerIdleScheduler?: OmpProviderIdleScheduler;
noTurnScheduler?: OmpNoTurnScheduler; noTurnScheduler?: OmpNoTurnScheduler;
usagePollScheduler?: OmpUsagePollScheduler; usagePollScheduler?: OmpUsagePollScheduler;
env?: Record<string, string>;
} = {}, } = {},
) { ) {
this.client = new OmpAgentClient({ this.client = new OmpAgentClient({
logger: pino({ level: "silent" }), logger: pino({ level: "silent" }),
runtime: this.omp, runtime: this.omp,
runtimeSettings: options.env ? { env: options.env } : undefined,
providerIdleScheduler: options.providerIdleScheduler, providerIdleScheduler: options.providerIdleScheduler,
noTurnScheduler: options.noTurnScheduler, noTurnScheduler: options.noTurnScheduler,
usagePollScheduler: options.usagePollScheduler, usagePollScheduler: options.usagePollScheduler,
@@ -484,7 +486,24 @@ export class OmpHarness {
} }
async getUsageReference() { 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[] { runningToolCallIds(): string[] {
@@ -420,19 +420,33 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, },
{ serverManager: runtime, createClient: runtime.createClient }, { 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)); const session = await client.createSession(buildConfig(cwd));
await session.setModel?.("openai/gpt-5"); await session.setModel?.("openai/gpt-5");
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex", source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" }, input: { accessToken: "oauth-token", accountId: "acct-1" },
}); });
await session.setModel?.("opencode-go/qwen"); await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "opencode-go", source: "opencode-go",
input: { apiKey: "go-key" }, 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"); await session.setModel?.("other/model");
expect(await session.getUsageReference?.()).toBeNull(); expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId; const sessionId = (await session.getRuntimeInfo()).sessionId;
@@ -448,7 +462,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
}, },
} as OpenCodeEvent); } as OpenCodeEvent);
await vi.waitFor(async () => await vi.waitFor(async () =>
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "codex", source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" }, input: { accessToken: "oauth-token", accountId: "acct-1" },
}), }),
@@ -468,6 +482,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
rmSync(cwd, { recursive: true, force: true }); 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 () => { test("usage reference ignores another auth entry with a different type", async () => {
const cwd = tmpCwd(); const cwd = tmpCwd();
const runtime = new TestOpenCodeHarness(); const runtime = new TestOpenCodeHarness();
@@ -487,7 +541,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
const session = await client.createSession(buildConfig(cwd)); const session = await client.createSession(buildConfig(cwd));
try { try {
await session.setModel?.("opencode-go/qwen"); await session.setModel?.("opencode-go/qwen");
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
source: "opencode-go", source: "opencode-go",
input: { apiKey: "go-key" }, input: { apiKey: "go-key" },
}); });
@@ -1445,6 +1445,26 @@ export class OpenCodeAgentClient implements AgentClient {
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir; 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( async createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
@@ -3620,11 +3640,8 @@ class OpenCodeAgentSession implements AgentSession {
}; };
} }
async getUsageReference(): Promise<UsageReference | null> { getUsageEnvironment(): NodeJS.ProcessEnv {
const model = this.config.model; return this.usageEnv;
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
const auth = await readOpenCodeUsageAuth(this.usageEnv);
return resolveOpenCodeUsageReference(model, auth);
} }
async setModel(modelId: string | null): Promise<void> { 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 = sessionRuntime;
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" }; 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 }, env: { PI_CODING_AGENT_DIR: agentDir },
}); });
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "codex", source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" }, input: { accessToken: "codex-token", accountId: "account-1" },
}); });
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" }; runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await session.getUsageReference?.()).toEqual({ expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
source: "claude", source: "claude",
input: { accessToken: "claude-token" }, 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" }; 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", source: "codex",
input: { accessToken: "codex-token" }, 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"), path.join(agentDir, "auth.json"),
JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }), 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" }; 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(); await session.close();
} finally { } finally {
rmSync(agentDir, { recursive: true, force: true }); 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 () => { test("forwards launch-context env to the Pi process launch", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); 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 { export class PiRpcAgentSession implements AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
async getUsageReference() { getUsageEnvironment() {
await this.refreshState(); return this.usageEnv;
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;
}
} }
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -2477,6 +2479,25 @@ export class PiRpcAgentClient implements AgentClient {
this.usagePollScheduler = options.usagePollScheduler; 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( async createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
@@ -1,10 +1,13 @@
import { fileURLToPath } from "node:url"; 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 { expect, test } from "vitest";
import { ACPAgentSession } from "../agent/providers/acp-agent.js"; import { AgentStorage } from "../agent/agent-storage.js";
import type { UsageReference } from "../agent/agent-sdk-types.js"; import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js";
import { createTestLogger } from "../../test-utils/test-logger.js";
import { DaemonClient } from "../test-utils/daemon-client.js"; import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js"; import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.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); }, 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( const directory = fileURLToPath(
new URL("./test-fixtures/session-usage-reference/", import.meta.url), new URL("./test-fixtures/session-usage-reference/", import.meta.url),
); );
const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const; 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( const agentClients = Object.fromEntries(
providers.map((provider) => { providers.map((provider) => {
const client = createTestAgentClient(provider); const client = createTestAgentClient(provider);
const createSession = client.createSession.bind(client); let input: Record<string, string> = {};
client.createSession = async (...args) => { if (provider === "claude") input = { configDir: directory };
const session = await createSession(...args); if (provider === "codex") input = { codexHome: directory };
session.getUsageReference = async () => references.get(provider) ?? null; client.resolveUsageReference = async () => ({
return session; source: provider,
}; input,
});
return [provider, client]; return [provider, client];
}), }),
); );
@@ -148,3 +97,156 @@ test("agent.resolve_usage_report resolves source IDs from default built-in and A
await daemon.close(); await daemon.close();
} }
}, 60_000); }, 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({ this.usageSession = new UsageSession({
emit: (msg) => this.emit(msg), emit: (msg) => this.emit(msg),
listAgents: () => this.agentManager.listAgents(), listAgents: () => this.agentManager.listAgents(),
getAgent: (agentId) => this.agentManager.getAgent(agentId), resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
runtime: pluginRuntime, runtime: pluginRuntime,
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
@@ -1,6 +1,5 @@
import pino from "pino"; import pino from "pino";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import type { AgentSession } from "../../agent/agent-sdk-types.js";
import type { SessionOutboundMessage } from "../../messages.js"; import type { SessionOutboundMessage } from "../../messages.js";
import { UsageSession } from "./usage-session.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 references: unknown[] = [];
const reference = { source: "fixture", input: { account: "one" } }; const reference = { source: "fixture", input: { account: "one" } };
const agent = { const agent = {
session: { id: "one",
getUsageReference: async () => reference, session: {},
} as AgentSession,
}; };
const entry = { const entry = {
id: "fixture:one", id: "fixture:one",
@@ -23,8 +21,8 @@ test("collects live references for usage reports and resolves an agent report",
}; };
const usage = new UsageSession({ const usage = new UsageSession({
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
listAgents: () => [agent, { session: null }], listAgents: () => [agent, { id: "closed", session: null }],
getAgent: (id) => (id === "one" ? agent : null), resolveAgentReference: async (id) => ({ found: id === "one", reference }),
runtime: { runtime: {
async listUsageReports(options) { async listUsageReports(options) {
references.push(options.references); 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({ const usage = new UsageSession({
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
listAgents: () => [], listAgents: () => [],
getAgent: () => null, resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: { runtime: {
async listUsageReports() { async listUsageReports() {
return []; return [];
@@ -85,7 +83,7 @@ test("unknown agent returns agent_not_found", async () => {
const usage = new UsageSession({ const usage = new UsageSession({
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
listAgents: () => [], listAgents: () => [],
getAgent: () => null, resolveAgentReference: async () => ({ found: false, reference: null }),
runtime: { runtime: {
listUsageReports: async () => [], listUsageReports: async () => [],
resolveUsageReference: async () => null, resolveUsageReference: async () => null,
@@ -1,16 +1,20 @@
import type pino from "pino"; import type pino from "pino";
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages"; 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"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
interface UsageAgent { interface UsageAgent {
session: AgentSession | null; id: string;
session: object | null;
} }
export interface UsageSessionOptions { export interface UsageSessionOptions {
emit(message: SessionOutboundMessage): void; emit(message: SessionOutboundMessage): void;
listAgents(): UsageAgent[]; listAgents(): UsageAgent[];
getAgent(agentId: string): UsageAgent | null; resolveAgentReference(agentId: string): Promise<{
found: boolean;
reference: UsageReference | null;
}>;
runtime?: { runtime?: {
listUsageReports(options: { listUsageReports(options: {
forceRefresh?: boolean; forceRefresh?: boolean;
@@ -37,8 +41,12 @@ export class UsageSession {
await Promise.all( await Promise.all(
this.options this.options
.listAgents() .listAgents()
.map( .filter((agent) => agent.session !== null)
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null, .map(async (agent) =>
this.options
.resolveAgentReference(agent.id)
.then((resolved) => resolved.reference)
.catch(() => null),
), ),
) )
).filter((reference): reference is UsageReference => reference !== null) ).filter((reference): reference is UsageReference => reference !== null)
@@ -62,12 +70,12 @@ export class UsageSession {
): Promise<void> { ): Promise<void> {
try { try {
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
const agent = this.options.getAgent(msg.agentId); const resolved = await this.options.resolveAgentReference(msg.agentId);
if (!agent) { if (!resolved.found) {
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found"); this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
return; return;
} }
const reference = (await agent.session?.getUsageReference?.()) ?? null; const reference = resolved.reference;
const reportId = reference const reportId = reference
? await this.options.runtime.resolveUsageReference(reference) ? await this.options.runtime.resolveUsageReference(reference)
: null; : null;