Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] df081a06ed test(opencode): retry temp cleanup while Windows holds the ripgrep download 2026-09-29 08:34:57 +02:00
paseo-bot[bot]and3ae3ae c6a4ae81c9 fix(codex): start sessions on the project's configured model and effort
A session created without a model or reasoning effort read Codex defaults
without its cwd, so a trusted project's .codex/config.toml was ignored and
the global values were sent as explicit overrides. Read the effective config
for the session cwd first and keep getUserSavedConfig as the fallback for
older Codex builds.

Co-authored-by: 3ae3ae <ogyshjsm@naver.com>
2026-09-29 08:09:46 +02:00
31 changed files with 301 additions and 682 deletions
@@ -14,7 +14,6 @@ import {
expectConflictPreservesDraft,
discardTitle,
returnAndReopenSettings,
returnToPluginsPage,
expectSettingsUnavailable,
} from "../support/helpers/plugin-settings";
@@ -44,11 +43,6 @@ test("another client's save preserves a conflicting draft", async ({ 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.use({ viewport: { width: 390, height: 844 } });
@@ -1,11 +1,7 @@
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,
seedRunningMockAgentWorkspace,
} from "../support/helpers/mock-agent";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
const REPORT_ID = "fixture:fixture-account";
@@ -46,30 +42,6 @@ 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,
}) => {
@@ -3,8 +3,6 @@ import { expect, test as base, type Page } from "../fixtures";
import { gotoAppShell, openSettings } from "./app";
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
import { getServerId } from "./server-id";
import { expectAppRoute } from "./route-assertions";
import { buildSettingsHostSectionRoute } from "@/utils/host-routes";
import { connectNewWorkspaceDaemonClient } from "./new-workspace";
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
@@ -107,13 +105,6 @@ export async function returnAndReopenSettings(page: Page) {
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
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) {
await expect(
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
-1
View File
@@ -2071,7 +2071,6 @@ export const en = {
offline: "Connect to this host to open plugin settings.",
update: "Update this host to use plugin settings.",
unavailable: "This plugin settings screen is unavailable.",
backToPlugins: "Back to plugins",
},
trustedTitle: "Plugins are trusted code",
trustedDescription:
@@ -18,7 +18,6 @@ export const pluginSettings = {
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
backToPlugins: "العودة إلى الإضافات",
},
title: "الإضافات",
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
@@ -73,7 +72,6 @@ export const pluginSettings = {
offline: "Conéctate a este host para abrir los ajustes del plugin.",
update: "Actualiza este host para usar los ajustes del plugin.",
unavailable: "Esta pantalla de ajustes del plugin no está disponible.",
backToPlugins: "Volver a plugins",
},
title: "Plugins",
trustedTitle: "Los plugins son código de confianza",
@@ -128,7 +126,6 @@ export const pluginSettings = {
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.",
unavailable: "Cet écran de paramètres du plugin est indisponible.",
backToPlugins: "Retour aux plugins",
},
title: "Plugins",
trustedTitle: "Les plugins sont du code de confiance",
@@ -183,7 +180,6 @@ export const pluginSettings = {
offline: "このホストに接続してプラグイン設定を開いてください。",
update: "プラグイン設定を使用するには、このホストを更新してください。",
unavailable: "このプラグイン設定画面は利用できません。",
backToPlugins: "プラグインに戻る",
},
title: "プラグイン",
trustedTitle: "プラグインは信頼済みコードです",
@@ -238,7 +234,6 @@ export const pluginSettings = {
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
backToPlugins: "플러그인으로 돌아가기",
},
title: "플러그인",
trustedTitle: "플러그인은 신뢰하는 코드입니다",
@@ -292,7 +287,6 @@ export const pluginSettings = {
offline: "Conecte-se a este host para abrir 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.",
backToPlugins: "Voltar para plugins",
},
title: "Plugins",
trustedTitle: "Plugins são código confiável",
@@ -347,7 +341,6 @@ export const pluginSettings = {
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
update: "Обновите этот хост, чтобы использовать настройки плагина.",
unavailable: "Этот экран настроек плагина недоступен.",
backToPlugins: "Вернуться к плагинам",
},
title: "Плагины",
trustedTitle: "Плагины — доверенный код",
@@ -402,7 +395,6 @@ export const pluginSettings = {
offline: "连接到此主机以打开插件设置。",
update: "更新此主机以使用插件设置。",
unavailable: "此插件设置页面不可用。",
backToPlugins: "返回插件",
},
title: "插件",
trustedTitle: "插件是受信任的代码",
+3 -27
View File
@@ -3,7 +3,6 @@ import { useTranslation } from "react-i18next";
import { Platform, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import type { PluginHostProps } from "@getpaseo/plugin/client";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -128,30 +127,7 @@ function SettingsContent({
}
const ThemedSettingsContent = withUnistyles(SettingsContent);
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
export function PluginSettingsContent({
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>
);
export function PluginSettingsContent(props: SettingsIdentity) {
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
}
const styles = StyleSheet.create((theme) => ({
message: { color: theme.colors.foregroundMuted },
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
}));
const styles = StyleSheet.create((theme) => ({ message: { color: theme.colors.foregroundMuted } }));
@@ -1333,8 +1333,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
serverId={view.serverId}
pluginId={view.pluginId}
screenId={view.screenId}
onBackToPlugins={handleBackFromDetail}
showBackToPlugins={!isCompactLayout}
/>
);
if (view.kind === "host") {
+6 -10
View File
@@ -31,13 +31,8 @@ function usageReportsQueryKey(serverId: string) {
return ["usage", "reports", serverId] as const;
}
function agentUsageQueryKey(
serverId: string,
agentId: string,
model: string | null,
isRunning: boolean,
) {
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
return ["usage", "agent", serverId, agentId, model] as const;
}
function requireClient(serverId: string) {
@@ -165,7 +160,8 @@ 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 (a new query key). The report itself is read from the shared cache.
* 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.
*/
export function useAgentUsage(
serverId: string,
@@ -180,10 +176,10 @@ export function useAgentUsage(
}),
);
const identity = useFetchQuery({
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
queryKey: agentUsageQueryKey(serverId, agentId, model),
queryFn: async () =>
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
enabled: isConnected && isSupported,
enabled: isConnected && isSupported && !isRunning,
dataShape: "value",
staleTimeMs: 0,
});
@@ -50,7 +50,6 @@ import {
type AgentTimelineItem,
type AgentUsage,
type AgentRuntimeInfo,
type UsageReference,
type ImportedTimelineEntry,
type ImportableProviderSession,
type ListImportableSessionsOptions,
@@ -1168,24 +1167,6 @@ 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,6 +673,8 @@ 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>;
@@ -748,13 +750,6 @@ 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,12 +406,7 @@ describe("PluginAgentClientRegistry", () => {
cwd: "/workspace",
});
try {
expect(
await client.resolveUsageReference?.({
config: { provider: harness.registration.id, cwd: "/workspace" },
session,
}),
).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
} finally {
await session.close();
@@ -897,15 +897,6 @@ 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,
@@ -1105,7 +1096,7 @@ class PluginAgentSession implements AgentSession {
return agentCapabilities(this.bridge.negotiatedCapabilities);
}
getPluginUsageReference(): Promise<UsageReference | null> {
getUsageReference(): Promise<UsageReference | null> {
return this.bridge.getUsageReference();
}
@@ -458,6 +458,7 @@ 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),
@@ -489,28 +490,13 @@ 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) =>
wrapSession(
wrapSessionProvider(
provider,
await inner.createSession(
{
...config,
@@ -520,7 +506,8 @@ function wrapClientProvider(
),
),
resumeSession: async (handle, overrides, launchContext, options) =>
wrapSession(
wrapSessionProvider(
provider,
await inner.resumeSession(
{
...handle,
@@ -582,7 +569,7 @@ function wrapClientProvider(
}
return {
...imported,
session: wrapSession(imported.session),
session: wrapSessionProvider(provider, imported.session),
config: {
...imported.config,
provider,
@@ -224,17 +224,8 @@ function createSessionWithConfig(
test("ACP usage reference uses the provider ID", async () => {
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
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: {} });
const session = createSessionWithConfig({ provider });
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
}
});
@@ -882,9 +882,6 @@ 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;
@@ -1636,6 +1633,10 @@ 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 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({
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({
source: "claude",
input: { configDir: "/accounts/second" },
});
await session.close();
});
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
@@ -548,15 +548,14 @@ describe("ClaudeAgentClient binary resolution", () => {
async (name) => {
const client = new ClaudeAgentClient({
logger,
runtimeSettings: { env: { [name]: "override" } },
resolveBinary: async () => "/test/claude/bin",
});
expect(
await client.resolveUsageReference?.({
config: { provider: "claude", cwd: process.cwd() },
session: null,
}),
).toBeNull();
const session = await client.createSession(
{ provider: "claude", cwd: process.cwd() },
{ env: { [name]: "override" } },
);
expect(await session.getUsageReference?.()).toBeNull();
await session.close();
},
);
@@ -570,16 +569,15 @@ describe("ClaudeAgentClient binary resolution", () => {
},
},
});
const client = registry["work-claude"].createClient(logger);
expect(
await client.resolveUsageReference?.({
config: { provider: "work-claude", cwd: process.cwd() },
session: null,
}),
).toEqual({
const session = await registry["work-claude"].createClient(logger).createSession({
provider: "work-claude",
cwd: process.cwd(),
});
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { configDir: "/accounts/work" },
});
await session.close();
});
test("resolves the installed Claude Code version", async () => {
@@ -1519,15 +1519,6 @@ 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);
}
@@ -2051,6 +2042,15 @@ 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,18 +172,41 @@ function createSession(
}
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
const config = createConfig();
const client = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second" },
});
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
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({
source: "codex",
input: { codexHome: "/accounts/second" },
});
const custom = new CodexAppServerAgentClient(createTestLogger(), {
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
});
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
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();
});
function createProviderWithFakeAppServer(
@@ -1094,6 +1117,42 @@ describe("Codex app-server provider", () => {
});
});
test("starts a session without a model on the project's Codex model and effort", async () => {
const cwd = "/workspace/project";
const appServer = createFakeCodexAppServer({
"config/read": (params) => ({
config:
(params as { cwd?: unknown }).cwd === cwd
? { model: "project-model", model_reasoning_effort: "high" }
: { model: "global-model", model_reasoning_effort: "low" },
}),
getUserSavedConfig: () => ({
config: { model: "global-model", modelReasoningEffort: "low" },
}),
});
const session = new CodexAppServerAgentSession(
createConfig({ cwd, model: undefined }),
null,
createTestLogger(),
async () => appServer.child,
);
try {
await session.startTurn("use project defaults");
await expect(appServer.waitForRequest("thread/start")).resolves.toMatchObject({
model: "project-model",
});
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "project-model",
effort: "high",
});
appServer.assertNoErrors();
} finally {
await session.close();
}
});
test("preserves cwd-resolved Codex writable roots under an explicit workflow mode", async () => {
const appServer = createFakeCodexAppServer({
"config/read": () => ({
@@ -3100,10 +3100,32 @@ const CodexNotificationSchema = z.union([
),
]);
// config/read resolves the effective layered config, including a trusted project's
// .codex/config.toml when given its cwd; getUserSavedConfig predates it.
async function readCodexConfiguredDefaults(
client: CodexAppServerClient,
logger: Logger,
cwd: string | null,
): Promise<CodexConfiguredDefaults> {
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", { cwd }));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
if (configReadDefaults.model && configReadDefaults.thinkingOptionId) {
return configReadDefaults;
}
let savedConfigDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("getUserSavedConfig", {}));
@@ -3119,26 +3141,7 @@ async function readCodexConfiguredDefaults(
logger.debug({ error }, "Failed to read Codex saved config defaults");
}
if (savedConfigDefaults.model && savedConfigDefaults.thinkingOptionId) {
return savedConfigDefaults;
}
let configReadDefaults: CodexConfiguredDefaults = {};
try {
const response = toObjectRecord(await client.request("config/read", {}));
const config = toObjectRecord(response?.config);
const modelValue = typeof config?.model === "string" ? config.model : undefined;
const thinkingOptionValue =
typeof config?.model_reasoning_effort === "string" ? config.model_reasoning_effort : null;
configReadDefaults = {
model: normalizeCodexModelId(modelValue),
thinkingOptionId: normalizeCodexThinkingOptionId(thinkingOptionValue),
};
} catch (error) {
logger.debug({ error }, "Failed to read Codex config defaults");
}
return mergeCodexConfiguredDefaults(savedConfigDefaults, configReadDefaults);
return mergeCodexConfiguredDefaults(configReadDefaults, savedConfigDefaults);
}
interface CodexSkillPromptBlock {
@@ -3456,6 +3459,7 @@ 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",
@@ -3485,6 +3489,17 @@ 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;
}
@@ -5107,7 +5122,11 @@ export class CodexAppServerAgentSession implements AgentSession {
let model = this.config.model;
let thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
if (!model || !thinkingOptionId) {
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
configuredDefaults = await readCodexConfiguredDefaults(
this.client,
this.logger,
this.config.cwd ?? null,
);
}
if (!model) {
model = configuredDefaults.model;
@@ -7028,15 +7047,6 @@ 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,
@@ -7158,6 +7168,7 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
"interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -7190,6 +7201,7 @@ export class CodexAppServerAgentClient implements AgentClient {
autoReviewEnabled,
launchContext?.agentId,
options?.purpose ?? "interactive",
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
);
await session.connect();
return session;
@@ -7318,7 +7330,7 @@ export class CodexAppServerAgentClient implements AgentClient {
const parsedResponse = CodexModelListResponseSchema.safeParse(rawResponse);
const models = parsedResponse.success ? (parsedResponse.data.data ?? []) : [];
const configuredDefaults = await runProviderRefreshActivity(context, "config/read", () =>
readCodexConfiguredDefaults(client!, this.logger),
readCodexConfiguredDefaults(client!, this.logger, null),
);
const configuredDefaultModelId = configuredDefaults.model;
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
@@ -194,20 +194,14 @@ 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 env = {
const omp = new OmpHarness();
await omp.start({}, undefined, {
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;
@@ -222,20 +216,6 @@ 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,8 +969,9 @@ export class OmpAgentSession implements AgentSession {
private readonly paseoTools?: PaseoToolCatalog;
private readonly usageEnv: NodeJS.ProcessEnv;
getUsageEnvironment() {
return this.usageEnv;
async getUsageReference() {
const state = await this.runtimeSession.getState();
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
}
get id(): string | null {
@@ -2273,27 +2274,6 @@ 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,13 +71,11 @@ 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,
@@ -486,24 +484,7 @@ export class OmpHarness {
}
async 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);
return this.requireSession().getUsageReference();
}
runningToolCallIds(): string[] {
@@ -420,33 +420,19 @@ 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 client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
});
await session.setModel?.("opencode-go/qwen");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).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 client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const sessionId = (await session.getRuntimeInfo()).sessionId;
@@ -462,7 +448,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
},
} as OpenCodeEvent);
await vi.waitFor(async () =>
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "oauth-token", accountId: "acct-1" },
}),
@@ -482,46 +468,6 @@ 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();
@@ -541,7 +487,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
const session = await client.createSession(buildConfig(cwd));
try {
await session.setModel?.("opencode-go/qwen");
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "opencode-go",
input: { apiKey: "go-key" },
});
@@ -1445,26 +1445,6 @@ 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,
@@ -3640,8 +3620,11 @@ class OpenCodeAgentSession implements AgentSession {
};
}
getUsageEnvironment(): NodeJS.ProcessEnv {
return this.usageEnv;
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);
}
async setModel(modelId: string | null): Promise<void> {
@@ -355,7 +355,8 @@ test.each([
await resumed?.close();
await original?.close();
await client.shutdown();
await rm(root, { recursive: true, force: true });
// OpenCode can still hold its ripgrep download open on Windows right after shutdown.
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
}
},
240_000,
@@ -229,16 +229,15 @@ 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 client = createClient(pi);
const session = await client.createSession(createConfig(), {
const session = await createClient(pi).createSession(createConfig(), {
env: { PI_CODING_AGENT_DIR: agentDir },
});
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token", accountId: "account-1" },
});
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "claude",
input: { accessToken: "claude-token" },
});
@@ -250,7 +249,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 client.resolveUsageReference({ config: createConfig(), session })).toEqual({
expect(await session.getUsageReference?.()).toEqual({
source: "codex",
input: { accessToken: "codex-token" },
});
@@ -259,65 +258,15 @@ 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 client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
expect(await session.getUsageReference?.()).toBeNull();
runtime.state.model = { provider: "other", id: "other", name: "Other" };
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
expect(await session.getUsageReference?.()).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,39 +1141,37 @@ 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;
getUsageEnvironment() {
return this.usageEnv;
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;
}
}
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
@@ -2479,25 +2477,6 @@ 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,13 +1,10 @@
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 { AgentStorage } from "../agent/agent-storage.js";
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
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 { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -56,21 +53,75 @@ test("resolves a provider plugin session reference through its usage source", as
}
}, 60_000);
test("agent.resolve_usage_report resolves source IDs from provider clients", async () => {
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", 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);
let input: Record<string, string> = {};
if (provider === "claude") input = { configDir: directory };
if (provider === "codex") input = { codexHome: directory };
client.resolveUsageReference = async () => ({
source: provider,
input,
});
const createSession = client.createSession.bind(client);
client.createSession = async (...args) => {
const session = await createSession(...args);
session.getUsageReference = async () => references.get(provider) ?? null;
return session;
};
return [provider, client];
}),
);
@@ -97,156 +148,3 @@ test("agent.resolve_usage_report resolves source IDs from provider clients", asy
await daemon.close();
}
}, 60_000);
test("stored Claude and Codex agents resolve without opening a provider session", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-closed-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const sessionsOpened = new Map<string, number>();
const agentClients = Object.fromEntries(
["claude", "codex"].map((provider) => {
const providerClient = createTestAgentClient(provider, {
beforeCreateSession: async () => {
sessionsOpened.set(provider, (sessionsOpened.get(provider) ?? 0) + 1);
},
});
providerClient.resolveUsageReference = async ({ session, runtimeInfo, persistence }) => {
expect(session).toBeNull();
expect(runtimeInfo?.sessionId).toBeTruthy();
expect(runtimeInfo?.model).toBe(`${provider}/saved-model`);
expect(persistence?.sessionId).toBe(runtimeInfo?.sessionId);
return { source: "fixture-session-usage", input: { account: "from-session" } };
};
return [provider, providerClient];
}),
);
const options = {
paseoHomeRoot: root,
staticDir,
cleanup: false,
pluginsEnabled: false,
builtinPlugins: fixtureBuiltins(),
agentClients,
};
let daemon = await createTestPaseoDaemon(options);
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
const ids: string[] = [];
for (const provider of ["claude", "codex"]) {
const agent = await client.createAgent({ provider, cwd: root });
ids.push(agent.id);
await client.sendMessage(agent.id, "Say OK.");
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle");
expect(sessionsOpened.get(provider)).toBe(1);
}
await client.close();
await daemon.close();
const storage = new AgentStorage(
path.join(root, ".paseo", "agents"),
pino({ level: "silent" }),
);
for (const [index, provider] of ["claude", "codex"].entries()) {
const record = await storage.get(ids[index]!);
expect(record).not.toBeNull();
await storage.upsert({
...record!,
runtimeInfo: { ...record!.runtimeInfo!, model: `${provider}/saved-model` },
});
}
daemon = await createTestPaseoDaemon(options);
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, provider] of ["claude", "codex"].entries()) {
const result = await client.resolveAgentUsageReport({ agentId: ids[index]! });
expect(result.reportId).toBe("fixture-session-usage:from-session");
expect(
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports,
).toHaveLength(1);
expect(sessionsOpened.get(provider)).toBe(1);
}
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
test("stored default-model OpenCode and Pi agents resolve their saved runtime model", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-saved-model-"));
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
const piDir = path.join(root, "pi-agent");
await mkdir(piDir);
await writeFile(
path.join(piDir, "auth.json"),
JSON.stringify({
"openai-codex": {
type: "oauth",
access: "codex-fixture-token",
accountId: "fixture-account",
refresh: "fixture-refresh",
},
}),
);
const storage = new AgentStorage(path.join(root, ".paseo", "agents"), pino({ level: "silent" }));
const ids: string[] = [];
const now = new Date().toISOString();
for (const provider of ["opencode", "pi"]) {
const id = randomUUID();
ids.push(id);
await storage.upsert({
id,
provider,
cwd: root,
createdAt: now,
updatedAt: now,
labels: {},
lastStatus: "closed",
config: { model: null },
runtimeInfo: {
provider,
sessionId: `${provider}-saved-session`,
model: provider === "opencode" ? "openai/gpt-5" : "openai-codex/gpt-5",
},
persistence: { provider, sessionId: `${provider}-saved-session` },
});
}
const daemon = await createTestPaseoDaemon({
paseoHomeRoot: root,
staticDir,
cleanup: false,
agentClients: {
opencode: new OpenCodeAgentClient(pino({ level: "silent" }), {
env: {
OPENCODE_AUTH_CONTENT: JSON.stringify({
openai: { type: "oauth", access: "codex-fixture-token", accountId: "fixture-account" },
}),
},
}),
pi: new PiRpcAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: { env: { PI_CODING_AGENT_DIR: piDir } },
}),
},
builtinPlugins: new BuiltinPluginLoader(undefined, ["codex-usage-source"]),
});
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
for (const [index, id] of ids.entries()) {
expect(
(await client.resolveAgentUsageReport({ agentId: id })).reportId,
["opencode", "pi"][index],
).not.toBeNull();
}
} finally {
await client.close();
await daemon.close();
await rm(root, { recursive: true, force: true });
await rm(staticDir, { recursive: true, force: true });
}
}, 60_000);
+1 -1
View File
@@ -1000,7 +1000,7 @@ export class Session {
this.usageSession = new UsageSession({
emit: (msg) => this.emit(msg),
listAgents: () => this.agentManager.listAgents(),
resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
getAgent: (agentId) => this.agentManager.getAgent(agentId),
runtime: pluginRuntime,
logger: this.sessionLogger,
});
@@ -1,5 +1,6 @@
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";
@@ -8,8 +9,9 @@ test("collects live references for usage reports and resolves an agent report",
const references: unknown[] = [];
const reference = { source: "fixture", input: { account: "one" } };
const agent = {
id: "one",
session: {},
session: {
getUsageReference: async () => reference,
} as AgentSession,
};
const entry = {
id: "fixture:one",
@@ -21,8 +23,8 @@ test("collects live references for usage reports and resolves an agent report",
};
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [agent, { id: "closed", session: null }],
resolveAgentReference: async (id) => ({ found: id === "one", reference }),
listAgents: () => [agent, { session: null }],
getAgent: (id) => (id === "one" ? agent : null),
runtime: {
async listUsageReports(options) {
references.push(options.references);
@@ -57,7 +59,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
resolveAgentReference: async () => ({ found: false, reference: null }),
getAgent: () => null,
runtime: {
async listUsageReports() {
return [];
@@ -83,7 +85,7 @@ test("unknown agent returns agent_not_found", async () => {
const usage = new UsageSession({
emit: (message) => emitted.push(message),
listAgents: () => [],
resolveAgentReference: async () => ({ found: false, reference: null }),
getAgent: () => null,
runtime: {
listUsageReports: async () => [],
resolveUsageReference: async () => null,
@@ -1,20 +1,16 @@
import type pino from "pino";
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
import type { UsageReference } from "../../agent/agent-sdk-types.js";
import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
interface UsageAgent {
id: string;
session: object | null;
session: AgentSession | null;
}
export interface UsageSessionOptions {
emit(message: SessionOutboundMessage): void;
listAgents(): UsageAgent[];
resolveAgentReference(agentId: string): Promise<{
found: boolean;
reference: UsageReference | null;
}>;
getAgent(agentId: string): UsageAgent | null;
runtime?: {
listUsageReports(options: {
forceRefresh?: boolean;
@@ -41,12 +37,8 @@ export class UsageSession {
await Promise.all(
this.options
.listAgents()
.filter((agent) => agent.session !== null)
.map(async (agent) =>
this.options
.resolveAgentReference(agent.id)
.then((resolved) => resolved.reference)
.catch(() => null),
.map(
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null,
),
)
).filter((reference): reference is UsageReference => reference !== null)
@@ -70,12 +62,12 @@ export class UsageSession {
): Promise<void> {
try {
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
const resolved = await this.options.resolveAgentReference(msg.agentId);
if (!resolved.found) {
const agent = this.options.getAgent(msg.agentId);
if (!agent) {
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
return;
}
const reference = resolved.reference;
const reference = (await agent.session?.getUsageReference?.()) ?? null;
const reportId = reference
? await this.options.runtime.resolveUsageReference(reference)
: null;