mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-15 22:02:34 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a134873d47 | ||
|
|
4274b2be0d | ||
|
|
b9ac1d4982 |
@@ -14,6 +14,7 @@ import {
|
||||
expectConflictPreservesDraft,
|
||||
discardTitle,
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
@@ -43,6 +44,11 @@ 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,7 +1,11 @@
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { expectComposerVisible, submitMessage } from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
openAgentRoute,
|
||||
seedMockAgentWorkspace,
|
||||
seedRunningMockAgentWorkspace,
|
||||
} from "../support/helpers/mock-agent";
|
||||
import { installUsageReportsFixture } from "../support/helpers/usage-reports";
|
||||
|
||||
const REPORT_ID = "fixture:fixture-account";
|
||||
@@ -42,6 +46,30 @@ async function openMockAgent(page: Page) {
|
||||
}
|
||||
|
||||
test.describe("usage composer pill", () => {
|
||||
test("shows the report when an existing running chat opens", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
const usage = await installUsageReportsFixture(page, {
|
||||
agentReportIds: [REPORT_ID],
|
||||
lists: [[agentEntry(42)]],
|
||||
});
|
||||
const session = await seedRunningMockAgentWorkspace({
|
||||
repoPrefix: "usage-running-",
|
||||
title: "Running usage pill",
|
||||
initialPrompt: "Show usage during the turn.",
|
||||
model: "one-minute-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await expect(page.getByTestId("usage-composer-pill")).toContainText("42%", {
|
||||
timeout: 20_000,
|
||||
});
|
||||
expect(usage.agentRequests()[0]).toEqual({ agentId: session.agentId });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("opens the cached report without a request and shows when it was fetched", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ 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>>;
|
||||
@@ -105,6 +107,13 @@ 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 }),
|
||||
|
||||
@@ -2071,6 +2071,7 @@ 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,6 +18,7 @@ export const pluginSettings = {
|
||||
offline: "اتصل بهذا المضيف لفتح إعدادات الإضافة.",
|
||||
update: "حدّث هذا المضيف لاستخدام إعدادات الإضافة.",
|
||||
unavailable: "شاشة إعدادات الإضافة هذه غير متاحة.",
|
||||
backToPlugins: "العودة إلى الإضافات",
|
||||
},
|
||||
title: "الإضافات",
|
||||
trustedTitle: "الإضافات تعليمات برمجية موثوقة",
|
||||
@@ -72,6 +73,7 @@ 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",
|
||||
@@ -126,6 +128,7 @@ 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",
|
||||
@@ -180,6 +183,7 @@ export const pluginSettings = {
|
||||
offline: "このホストに接続してプラグイン設定を開いてください。",
|
||||
update: "プラグイン設定を使用するには、このホストを更新してください。",
|
||||
unavailable: "このプラグイン設定画面は利用できません。",
|
||||
backToPlugins: "プラグインに戻る",
|
||||
},
|
||||
title: "プラグイン",
|
||||
trustedTitle: "プラグインは信頼済みコードです",
|
||||
@@ -234,6 +238,7 @@ export const pluginSettings = {
|
||||
offline: "플러그인 설정을 열려면 이 호스트에 연결하세요.",
|
||||
update: "플러그인 설정을 사용하려면 이 호스트를 업데이트하세요.",
|
||||
unavailable: "이 플러그인 설정 화면을 사용할 수 없습니다.",
|
||||
backToPlugins: "플러그인으로 돌아가기",
|
||||
},
|
||||
title: "플러그인",
|
||||
trustedTitle: "플러그인은 신뢰하는 코드입니다",
|
||||
@@ -287,6 +292,7 @@ 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",
|
||||
@@ -341,6 +347,7 @@ export const pluginSettings = {
|
||||
offline: "Подключитесь к этому хосту, чтобы открыть настройки плагина.",
|
||||
update: "Обновите этот хост, чтобы использовать настройки плагина.",
|
||||
unavailable: "Этот экран настроек плагина недоступен.",
|
||||
backToPlugins: "Вернуться к плагинам",
|
||||
},
|
||||
title: "Плагины",
|
||||
trustedTitle: "Плагины — доверенный код",
|
||||
@@ -395,6 +402,7 @@ export const pluginSettings = {
|
||||
offline: "连接到此主机以打开插件设置。",
|
||||
update: "更新此主机以使用插件设置。",
|
||||
unavailable: "此插件设置页面不可用。",
|
||||
backToPlugins: "返回插件",
|
||||
},
|
||||
title: "插件",
|
||||
trustedTitle: "插件是受信任的代码",
|
||||
|
||||
@@ -3,6 +3,7 @@ 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";
|
||||
@@ -127,7 +128,30 @@ function SettingsContent({
|
||||
}
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
const themeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) });
|
||||
export function PluginSettingsContent(props: SettingsIdentity) {
|
||||
return <ThemedSettingsContent {...props} uniProps={themeMapping} />;
|
||||
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>
|
||||
);
|
||||
}
|
||||
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}
|
||||
pluginId={view.pluginId}
|
||||
screenId={view.screenId}
|
||||
onBackToPlugins={handleBackFromDetail}
|
||||
showBackToPlugins={!isCompactLayout}
|
||||
/>
|
||||
);
|
||||
if (view.kind === "host") {
|
||||
|
||||
@@ -31,8 +31,13 @@ function usageReportsQueryKey(serverId: string) {
|
||||
return ["usage", "reports", serverId] as const;
|
||||
}
|
||||
|
||||
function agentUsageQueryKey(serverId: string, agentId: string, model: string | null) {
|
||||
return ["usage", "agent", serverId, agentId, model] as const;
|
||||
function agentUsageQueryKey(
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
model: string | null,
|
||||
isRunning: boolean,
|
||||
) {
|
||||
return ["usage", "agent", serverId, agentId, model, isRunning] as const;
|
||||
}
|
||||
|
||||
function requireClient(serverId: string) {
|
||||
@@ -160,8 +165,7 @@ export function useUsageByHost(): {
|
||||
/**
|
||||
* The usage report for the account an agent is spending. The daemon resolves the
|
||||
* report ID, which is re-resolved when the model changes (a new key) and when a
|
||||
* turn completes (the query is paused while the agent runs, and its data is always
|
||||
* stale, so resuming refetches). The report itself is read from the shared cache.
|
||||
* turn completes (a new query key). The report itself is read from the shared cache.
|
||||
*/
|
||||
export function useAgentUsage(
|
||||
serverId: string,
|
||||
@@ -176,10 +180,10 @@ export function useAgentUsage(
|
||||
}),
|
||||
);
|
||||
const identity = useFetchQuery({
|
||||
queryKey: agentUsageQueryKey(serverId, agentId, model),
|
||||
queryKey: agentUsageQueryKey(serverId, agentId, model, isRunning),
|
||||
queryFn: async () =>
|
||||
(await requireClient(serverId).resolveAgentUsageReport({ agentId })).reportId,
|
||||
enabled: isConnected && isSupported && !isRunning,
|
||||
enabled: isConnected && isSupported,
|
||||
dataShape: "value",
|
||||
staleTimeMs: 0,
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
type AgentTimelineItem,
|
||||
type AgentUsage,
|
||||
type AgentRuntimeInfo,
|
||||
type UsageReference,
|
||||
type ImportedTimelineEntry,
|
||||
type ImportableProviderSession,
|
||||
type ListImportableSessionsOptions,
|
||||
@@ -1167,6 +1168,24 @@ export class AgentManager {
|
||||
return agent ? { ...agent } : null;
|
||||
}
|
||||
|
||||
async resolveUsageReference(
|
||||
id: string,
|
||||
): Promise<{ found: boolean; reference: UsageReference | null }> {
|
||||
const agent = this.getAgent(id);
|
||||
const stored = agent?.session ? null : await this.registry?.get(id);
|
||||
const source = stored ?? agent;
|
||||
if (!source) return { found: false, reference: null };
|
||||
const config = stored ? buildStoredAgentConfig(stored) : agent!.config;
|
||||
const reference =
|
||||
(await this.clients.get(source.provider)?.resolveUsageReference?.({
|
||||
config,
|
||||
runtimeInfo: source.runtimeInfo ?? null,
|
||||
persistence: source.persistence ?? null,
|
||||
session: agent?.session ?? null,
|
||||
})) ?? null;
|
||||
return { found: true, reference };
|
||||
}
|
||||
|
||||
async waitForAgentClose(agentId: string): Promise<void> {
|
||||
// Loading during reload must wait for the replacement, not resume another writer.
|
||||
await this.lifecycleMutationTails.get(agentId);
|
||||
|
||||
@@ -673,8 +673,6 @@ export interface AgentSession {
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
/** Resolved at fetch time because model and credentials may change during a session. */
|
||||
getUsageReference?(): Promise<UsageReference | null>;
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -750,6 +748,13 @@ export interface ResolveAgentDefaultModeInput {
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
/** Account used by the agent. Resolving this must not start or load a session. */
|
||||
resolveUsageReference?(input: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
session: AgentSession | null;
|
||||
}): Promise<UsageReference | null>;
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
|
||||
@@ -406,7 +406,12 @@ describe("PluginAgentClientRegistry", () => {
|
||||
cwd: "/workspace",
|
||||
});
|
||||
try {
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: harness.registration.id, cwd: "/workspace" },
|
||||
session,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(harness.inputs.some((input) => input.type === "session.usage_reference")).toBe(false);
|
||||
} finally {
|
||||
await session.close();
|
||||
|
||||
@@ -897,6 +897,15 @@ class PluginAgentClient implements AgentClient {
|
||||
return agentCapabilities(this.runtime.negotiatedCapabilities);
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
session: AgentSession | null;
|
||||
}) {
|
||||
return session instanceof PluginAgentSession ? session.getPluginUsageReference() : null;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -1096,7 +1105,7 @@ class PluginAgentSession implements AgentSession {
|
||||
return agentCapabilities(this.bridge.negotiatedCapabilities);
|
||||
}
|
||||
|
||||
getUsageReference(): Promise<UsageReference | null> {
|
||||
getPluginUsageReference(): Promise<UsageReference | null> {
|
||||
return this.bridge.getUsageReference();
|
||||
}
|
||||
|
||||
|
||||
@@ -458,7 +458,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
}
|
||||
},
|
||||
getRuntimeInfo: async () => mapRuntimeInfo(provider, await inner.getRuntimeInfo()),
|
||||
getUsageReference: inner.getUsageReference?.bind(inner),
|
||||
getAvailableModes: () => inner.getAvailableModes(),
|
||||
getCurrentMode: () => inner.getCurrentMode(),
|
||||
setMode: (modeId) => inner.setMode(modeId),
|
||||
@@ -490,13 +489,28 @@ function wrapClientProvider(
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
// An alias's live session can carry launch env that is fresher than stored config.
|
||||
const sessions = new WeakMap<AgentSession, AgentSession>();
|
||||
const wrapSession = (session: AgentSession) => {
|
||||
const wrapped = wrapSessionProvider(provider, session);
|
||||
sessions.set(wrapped, session);
|
||||
return wrapped;
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
capabilities: inner.capabilities,
|
||||
resolveUsageReference: inner.resolveUsageReference
|
||||
? ({ config, runtimeInfo, persistence, session }) =>
|
||||
inner.resolveUsageReference!({
|
||||
config: { ...config, provider: inner.provider },
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
session: session ? (sessions.get(session) ?? null) : null,
|
||||
})
|
||||
: undefined,
|
||||
createSession: async (config, launchContext) =>
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
wrapSession(
|
||||
await inner.createSession(
|
||||
{
|
||||
...config,
|
||||
@@ -506,8 +520,7 @@ function wrapClientProvider(
|
||||
),
|
||||
),
|
||||
resumeSession: async (handle, overrides, launchContext, options) =>
|
||||
wrapSessionProvider(
|
||||
provider,
|
||||
wrapSession(
|
||||
await inner.resumeSession(
|
||||
{
|
||||
...handle,
|
||||
@@ -569,7 +582,7 @@ function wrapClientProvider(
|
||||
}
|
||||
return {
|
||||
...imported,
|
||||
session: wrapSessionProvider(provider, imported.session),
|
||||
session: wrapSession(imported.session),
|
||||
config: {
|
||||
...imported.config,
|
||||
provider,
|
||||
|
||||
@@ -224,8 +224,17 @@ function createSessionWithConfig(
|
||||
|
||||
test("ACP usage reference uses the provider ID", async () => {
|
||||
for (const provider of ["copilot", "cursor", "kimi", "custom-source"]) {
|
||||
const session = createSessionWithConfig({ provider });
|
||||
expect(await session.getUsageReference()).toEqual({ source: provider, input: {} });
|
||||
const client = new ACPAgentClient({
|
||||
provider,
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["unused"],
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
config: { provider, cwd: "/tmp/paseo-acp-test" },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({ source: provider, input: {} });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -882,6 +882,9 @@ function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): b
|
||||
export class ACPAgentClient implements AgentClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
async resolveUsageReference() {
|
||||
return { source: this.provider, input: {} };
|
||||
}
|
||||
readonly resolveCreateConfig = resolveACPCreateConfig;
|
||||
readonly isCreateConfigUnattended = isACPCreateConfigUnattended;
|
||||
|
||||
@@ -1633,10 +1636,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
readonly provider: string;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
async getUsageReference() {
|
||||
return { source: this.provider, input: {} };
|
||||
}
|
||||
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly defaultCommand: [string, ...string[]];
|
||||
|
||||
@@ -531,16 +531,16 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
const logger = createTestLogger();
|
||||
|
||||
test("Claude usage reference follows CLAUDE_CONFIG_DIR and excludes API overrides", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd: process.cwd() },
|
||||
{ env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
|
||||
);
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
const config = { provider: "claude", cwd: process.cwd() };
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: "/accounts/second" } },
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
expect(await client.resolveUsageReference?.({ config, session: null })).toEqual({
|
||||
source: "claude",
|
||||
input: { configDir: "/accounts/second" },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test.each(["ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"])(
|
||||
@@ -548,14 +548,15 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
async (name) => {
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
runtimeSettings: { env: { [name]: "override" } },
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession(
|
||||
{ provider: "claude", cwd: process.cwd() },
|
||||
{ env: { [name]: "override" } },
|
||||
);
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
await session.close();
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: "claude", cwd: process.cwd() },
|
||||
session: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -569,15 +570,16 @@ describe("ClaudeAgentClient binary resolution", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const session = await registry["work-claude"].createClient(logger).createSession({
|
||||
provider: "work-claude",
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
const client = registry["work-claude"].createClient(logger);
|
||||
expect(
|
||||
await client.resolveUsageReference?.({
|
||||
config: { provider: "work-claude", cwd: process.cwd() },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({
|
||||
source: "claude",
|
||||
input: { configDir: "/accounts/work" },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("resolves the installed Claude Code version", async () => {
|
||||
|
||||
@@ -1519,6 +1519,15 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
this.rewindSdk = options.rewindSdk ?? realClaudeRewindSdk;
|
||||
}
|
||||
|
||||
async resolveUsageReference() {
|
||||
const env = createProviderEnv({ baseEnv: process.env, runtimeSettings: this.runtimeSettings });
|
||||
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
|
||||
return {
|
||||
source: "claude",
|
||||
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
|
||||
};
|
||||
}
|
||||
|
||||
resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition {
|
||||
return resolveConfiguredClaudeModel(model);
|
||||
}
|
||||
@@ -2042,15 +2051,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
readonly provider = "claude" as const;
|
||||
readonly capabilities = CLAUDE_CAPABILITIES;
|
||||
|
||||
async getUsageReference() {
|
||||
const env = this.buildSdkEnv();
|
||||
if (env.ANTHROPIC_BASE_URL || env.ANTHROPIC_API_KEY || env.ANTHROPIC_AUTH_TOKEN) return null;
|
||||
return {
|
||||
source: "claude",
|
||||
input: { configDir: env.CLAUDE_CONFIG_DIR || path.join(env.HOME || homedir(), ".claude") },
|
||||
};
|
||||
}
|
||||
|
||||
private readonly config: ClaudeAgentConfig;
|
||||
private readonly launchEnv?: Record<string, string>;
|
||||
private readonly agentId?: string;
|
||||
|
||||
@@ -172,41 +172,18 @@ function createSession(
|
||||
}
|
||||
|
||||
test("Codex usage reference follows CODEX_HOME and excludes custom base URLs", async () => {
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"interactive",
|
||||
{ CODEX_HOME: "/accounts/second" },
|
||||
);
|
||||
expect(await session.getUsageReference()).toEqual({
|
||||
const config = createConfig();
|
||||
const client = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
env: { CODEX_HOME: "/accounts/second" },
|
||||
});
|
||||
expect(await client.resolveUsageReference({ config, session: null })).toEqual({
|
||||
source: "codex",
|
||||
input: { codexHome: "/accounts/second" },
|
||||
});
|
||||
const custom = new CodexAppServerAgentSession(
|
||||
createConfig(),
|
||||
null,
|
||||
createTestLogger(),
|
||||
() => {
|
||||
throw new Error("unused");
|
||||
},
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"interactive",
|
||||
{ CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
|
||||
);
|
||||
expect(await custom.getUsageReference()).toBeNull();
|
||||
const custom = new CodexAppServerAgentClient(createTestLogger(), {
|
||||
env: { CODEX_HOME: "/accounts/second", OPENAI_BASE_URL: "https://example.test" },
|
||||
});
|
||||
expect(await custom.resolveUsageReference({ config, session: null })).toBeNull();
|
||||
});
|
||||
|
||||
function createProviderWithFakeAppServer(
|
||||
@@ -1117,42 +1094,6 @@ 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,32 +3100,10 @@ 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", {}));
|
||||
@@ -3141,7 +3119,26 @@ async function readCodexConfiguredDefaults(
|
||||
logger.debug({ error }, "Failed to read Codex saved config defaults");
|
||||
}
|
||||
|
||||
return mergeCodexConfiguredDefaults(configReadDefaults, savedConfigDefaults);
|
||||
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);
|
||||
}
|
||||
|
||||
interface CodexSkillPromptBlock {
|
||||
@@ -3459,7 +3456,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly autoReviewEnabled: boolean = false,
|
||||
private readonly agentId?: string,
|
||||
private readonly initialResumePurpose: "interactive" | "history" = "interactive",
|
||||
private readonly usageEnv: NodeJS.ProcessEnv = process.env,
|
||||
) {
|
||||
this.logger = logger.child({
|
||||
module: "agent",
|
||||
@@ -3489,17 +3485,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async getUsageReference() {
|
||||
if (this.usageEnv.OPENAI_BASE_URL) return null;
|
||||
return {
|
||||
source: "codex",
|
||||
input: {
|
||||
codexHome:
|
||||
this.usageEnv.CODEX_HOME || path.join(this.usageEnv.HOME || os.homedir(), ".codex"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.currentThreadId;
|
||||
}
|
||||
@@ -5122,11 +5107,7 @@ 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,
|
||||
this.config.cwd ?? null,
|
||||
);
|
||||
configuredDefaults = await readCodexConfiguredDefaults(this.client, this.logger);
|
||||
}
|
||||
if (!model) {
|
||||
model = configuredDefaults.model;
|
||||
@@ -7047,6 +7028,15 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
private readonly deps: CodexAppServerAgentDeps = {},
|
||||
) {}
|
||||
|
||||
async resolveUsageReference() {
|
||||
const env = buildCodexAppServerEnv(this.runtimeSettings);
|
||||
if (env.OPENAI_BASE_URL) return null;
|
||||
return {
|
||||
source: "codex",
|
||||
input: { codexHome: env.CODEX_HOME || path.join(env.HOME || os.homedir(), ".codex") },
|
||||
};
|
||||
}
|
||||
|
||||
private sessionDeps(launchEnv: Record<string, string> | undefined): CodexAppServerAgentDeps {
|
||||
return {
|
||||
...this.deps,
|
||||
@@ -7168,7 +7158,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
autoReviewEnabled,
|
||||
launchContext?.agentId,
|
||||
"interactive",
|
||||
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -7201,7 +7190,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
autoReviewEnabled,
|
||||
launchContext?.agentId,
|
||||
options?.purpose ?? "interactive",
|
||||
buildCodexAppServerEnv(this.runtimeSettings, launchContext?.env),
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
@@ -7330,7 +7318,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, null),
|
||||
readCodexConfiguredDefaults(client!, this.logger),
|
||||
);
|
||||
const configuredDefaultModelId = configuredDefaults.model;
|
||||
const configuredDefaultThinkingOptionId = configuredDefaults.thinkingOptionId;
|
||||
|
||||
@@ -194,14 +194,20 @@ describe("OMP agent client and session", () => {
|
||||
expires: Date.now() + 60_000,
|
||||
}),
|
||||
);
|
||||
db.prepare("INSERT INTO cache (key, value, expires_at) VALUES (?, ?, ?)").run(
|
||||
"session:sticky:openai-codex:omp-session-1",
|
||||
JSON.stringify({ type: "oauth", credentialId: 1 }),
|
||||
Math.floor(Date.now() / 1000) + 100,
|
||||
);
|
||||
db.close();
|
||||
try {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({}, undefined, {
|
||||
const env = {
|
||||
OMP_PROFILE: "",
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
XDG_DATA_HOME: "",
|
||||
});
|
||||
};
|
||||
const omp = new OmpHarness({ env });
|
||||
await omp.start({}, undefined, env);
|
||||
const runtime = omp.runtime();
|
||||
runtime.state = { ...runtime.state, model: { provider: "openai-codex", id: "codex-model" } };
|
||||
const requestsBefore = runtime.getStateRequestCount;
|
||||
@@ -216,6 +222,20 @@ describe("OMP agent client and session", () => {
|
||||
input: { accessToken: "claude-fixture" },
|
||||
});
|
||||
expect(runtime.getStateRequestCount).toBe(requestsBefore + 2);
|
||||
const beforeClosed = runtime.getStateRequestCount;
|
||||
expect(
|
||||
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1"),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-fixture", accountId: "codex-account" },
|
||||
});
|
||||
expect(
|
||||
await omp.getStoredUsageReference("openai-codex/codex-model", "omp-session-1", null),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-fixture", accountId: "codex-account" },
|
||||
});
|
||||
expect(runtime.getStateRequestCount).toBe(beforeClosed);
|
||||
await omp.close();
|
||||
} finally {
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
|
||||
@@ -969,9 +969,8 @@ export class OmpAgentSession implements AgentSession {
|
||||
private readonly paseoTools?: PaseoToolCatalog;
|
||||
private readonly usageEnv: NodeJS.ProcessEnv;
|
||||
|
||||
async getUsageReference() {
|
||||
const state = await this.runtimeSession.getState();
|
||||
return resolveOmpUsageReference(state.sessionId, state.model?.provider ?? "", this.usageEnv);
|
||||
getUsageEnvironment() {
|
||||
return this.usageEnv;
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
@@ -2274,6 +2273,27 @@ export class OmpAgentClient implements AgentClient {
|
||||
options.runtime ?? createRuntime(options.logger, runtimeSettings, this.providerParams);
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
config,
|
||||
runtimeInfo,
|
||||
persistence,
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
persistence: AgentPersistenceHandle | null;
|
||||
session: AgentSession | null;
|
||||
}) {
|
||||
const liveInfo = session ? await session.getRuntimeInfo() : null;
|
||||
const model = liveInfo?.model ?? config.model ?? runtimeInfo?.model;
|
||||
const sessionId = liveInfo?.sessionId ?? persistence?.sessionId;
|
||||
const env =
|
||||
session instanceof OmpAgentSession
|
||||
? session.getUsageEnvironment()
|
||||
: { ...process.env, ...this.runtimeSettings?.env };
|
||||
return resolveOmpUsageReference(sessionId ?? "", model?.split("/")[0] ?? "", env);
|
||||
}
|
||||
|
||||
private async configureNativePaseoTools(
|
||||
runtimeSession: OmpRuntimeSession,
|
||||
catalog: PaseoToolCatalog | undefined,
|
||||
|
||||
@@ -71,11 +71,13 @@ export class OmpHarness {
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
noTurnScheduler?: OmpNoTurnScheduler;
|
||||
usagePollScheduler?: OmpUsagePollScheduler;
|
||||
env?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
this.client = new OmpAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: this.omp,
|
||||
runtimeSettings: options.env ? { env: options.env } : undefined,
|
||||
providerIdleScheduler: options.providerIdleScheduler,
|
||||
noTurnScheduler: options.noTurnScheduler,
|
||||
usagePollScheduler: options.usagePollScheduler,
|
||||
@@ -484,7 +486,24 @@ export class OmpHarness {
|
||||
}
|
||||
|
||||
async getUsageReference() {
|
||||
return this.requireSession().getUsageReference();
|
||||
return this.client.resolveUsageReference({
|
||||
config: { provider: "omp", cwd: CWD },
|
||||
session: this.requireSession(),
|
||||
});
|
||||
}
|
||||
|
||||
async getStoredUsageReference(
|
||||
model: string,
|
||||
sessionId: string,
|
||||
configuredModel: string | null = model,
|
||||
) {
|
||||
const input = {
|
||||
config: { provider: "omp", cwd: CWD, model: configuredModel },
|
||||
runtimeInfo: { provider: "omp", sessionId, model },
|
||||
persistence: { provider: "omp", sessionId },
|
||||
session: null,
|
||||
};
|
||||
return this.client.resolveUsageReference(input);
|
||||
}
|
||||
|
||||
runningToolCallIds(): string[] {
|
||||
|
||||
@@ -420,19 +420,33 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
},
|
||||
{ serverManager: runtime, createClient: runtime.createClient },
|
||||
);
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
await session.setModel?.("openai/gpt-5");
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
await session.setModel?.("opencode-go/qwen");
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "opencode-go",
|
||||
input: { apiKey: "go-key" },
|
||||
});
|
||||
await session.setModel?.("openai/gpt-5");
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
await session.setModel?.("other/model");
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toBeNull();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const sessionId = (await session.getRuntimeInfo()).sessionId;
|
||||
@@ -448,7 +462,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
},
|
||||
} as OpenCodeEvent);
|
||||
await vi.waitFor(async () =>
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
}),
|
||||
@@ -468,6 +482,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("usage reference survives a model switch and default-model restart", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const client = new OpenCodeAgentClient(logger, {
|
||||
env: {
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify({
|
||||
openai: { type: "oauth", access: "oauth-token", accountId: "acct-1" },
|
||||
"opencode-go": { type: "api", key: "go-key" },
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const runtimeInfo = {
|
||||
provider: "opencode",
|
||||
sessionId: "saved-session",
|
||||
model: "openai/gpt-5",
|
||||
};
|
||||
const closed = {
|
||||
config: { ...buildConfig(cwd), model: "openai/gpt-5" },
|
||||
runtimeInfo,
|
||||
persistence: null,
|
||||
session: null,
|
||||
};
|
||||
expect(await client.resolveUsageReference(closed)).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
...closed,
|
||||
config: { ...buildConfig(cwd), model: null },
|
||||
}),
|
||||
).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "oauth-token", accountId: "acct-1" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("usage reference ignores another auth entry with a different type", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
@@ -487,7 +541,7 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
const session = await client.createSession(buildConfig(cwd));
|
||||
try {
|
||||
await session.setModel?.("opencode-go/qwen");
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: buildConfig(cwd), session })).toEqual({
|
||||
source: "opencode-go",
|
||||
input: { apiKey: "go-key" },
|
||||
});
|
||||
|
||||
@@ -1445,6 +1445,26 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
config,
|
||||
runtimeInfo,
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
session: AgentSession | null;
|
||||
}): Promise<UsageReference | null> {
|
||||
const model = session
|
||||
? (await session.getRuntimeInfo()).model
|
||||
: (config.model ?? runtimeInfo?.model);
|
||||
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
|
||||
const env =
|
||||
session instanceof OpenCodeAgentSession
|
||||
? session.getUsageEnvironment()
|
||||
: { ...process.env, ...this.runtimeSettings?.env };
|
||||
return resolveOpenCodeUsageReference(model, await readOpenCodeUsageAuth(env));
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -3620,11 +3640,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
async getUsageReference(): Promise<UsageReference | null> {
|
||||
const model = this.config.model;
|
||||
if (!model?.startsWith("openai/") && !model?.startsWith("opencode-go/")) return null;
|
||||
const auth = await readOpenCodeUsageAuth(this.usageEnv);
|
||||
return resolveOpenCodeUsageReference(model, auth);
|
||||
getUsageEnvironment(): NodeJS.ProcessEnv {
|
||||
return this.usageEnv;
|
||||
}
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
|
||||
@@ -355,8 +355,7 @@ test.each([
|
||||
await resumed?.close();
|
||||
await original?.close();
|
||||
await client.shutdown();
|
||||
// OpenCode can still hold its ripgrep download open on Windows right after shutdown.
|
||||
await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 });
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
240_000,
|
||||
|
||||
@@ -229,15 +229,16 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
|
||||
runtime = sessionRuntime;
|
||||
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
|
||||
});
|
||||
const session = await createClient(pi).createSession(createConfig(), {
|
||||
const client = createClient(pi);
|
||||
const session = await client.createSession(createConfig(), {
|
||||
env: { PI_CODING_AGENT_DIR: agentDir },
|
||||
});
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-token", accountId: "account-1" },
|
||||
});
|
||||
runtime.state.model = { provider: "anthropic", id: "claude", name: "Claude" };
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
|
||||
source: "claude",
|
||||
input: { accessToken: "claude-token" },
|
||||
});
|
||||
@@ -249,7 +250,7 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
|
||||
}),
|
||||
);
|
||||
runtime.state.model = { provider: "openai-codex", id: "gpt", name: "GPT" };
|
||||
expect(await session.getUsageReference?.()).toEqual({
|
||||
expect(await client.resolveUsageReference({ config: createConfig(), session })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-token" },
|
||||
});
|
||||
@@ -258,15 +259,65 @@ test("Pi usage reference follows the current OAuth model and agent directory", a
|
||||
path.join(agentDir, "auth.json"),
|
||||
JSON.stringify({ anthropic: { type: "api_key", key: "api-key" } }),
|
||||
);
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
|
||||
runtime.state.model = { provider: "other", id: "other", name: "Other" };
|
||||
expect(await session.getUsageReference?.()).toBeNull();
|
||||
expect(await client.resolveUsageReference({ config: createConfig(), session })).toBeNull();
|
||||
await session.close();
|
||||
} finally {
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Pi resolves a stored model without opening a session", async () => {
|
||||
const agentDir = mkdtempSync(path.join(tmpdir(), "paseo-pi-stored-usage-"));
|
||||
try {
|
||||
writeFileSync(
|
||||
path.join(agentDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
anthropic: { type: "oauth", access: "claude-token", refresh: "refresh" },
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "codex-token",
|
||||
accountId: "account-1",
|
||||
refresh: "refresh",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const client = new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtime: new FakePi(),
|
||||
runtimeSettings: { env: { PI_CODING_AGENT_DIR: agentDir } },
|
||||
});
|
||||
expect(
|
||||
await client.resolveUsageReference({
|
||||
config: createConfig({ model: "anthropic/claude-sonnet-4" }),
|
||||
session: null,
|
||||
}),
|
||||
).toEqual({ source: "claude", input: { accessToken: "claude-token" } });
|
||||
const runtimeInfo = {
|
||||
provider: "pi",
|
||||
sessionId: "saved-session",
|
||||
model: "openai-codex/gpt-5",
|
||||
};
|
||||
const closed = {
|
||||
config: createConfig({ model: "openai-codex/gpt-5" }),
|
||||
runtimeInfo,
|
||||
persistence: null,
|
||||
session: null,
|
||||
};
|
||||
expect(await client.resolveUsageReference(closed)).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-token", accountId: "account-1" },
|
||||
});
|
||||
expect(await client.resolveUsageReference({ ...closed, config: createConfig() })).toEqual({
|
||||
source: "codex",
|
||||
input: { accessToken: "codex-token", accountId: "account-1" },
|
||||
});
|
||||
} finally {
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("forwards launch-context env to the Pi process launch", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
@@ -1141,37 +1141,39 @@ function createRuntime(
|
||||
});
|
||||
}
|
||||
|
||||
async function resolvePiUsageReference(provider: string | undefined, env?: Record<string, string>) {
|
||||
let source: string | null = null;
|
||||
if (provider === "openai-codex") source = "codex";
|
||||
if (provider === "anthropic") source = "claude";
|
||||
if (!source || !provider) return null;
|
||||
try {
|
||||
const auth: unknown = JSON.parse(
|
||||
await readFile(join(resolvePiAgentDir(env), "auth.json"), "utf8"),
|
||||
);
|
||||
if (provider === "openai-codex") {
|
||||
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
|
||||
if (!credential) return null;
|
||||
return {
|
||||
source,
|
||||
input: {
|
||||
accessToken: credential.access,
|
||||
...(credential.accountId ? { accountId: credential.accountId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
|
||||
return credential ? { source, input: { accessToken: credential.access } } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export class PiRpcAgentSession implements AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
|
||||
async getUsageReference() {
|
||||
await this.refreshState();
|
||||
const provider = this.state.model?.provider;
|
||||
let source: string | null = null;
|
||||
if (provider === "openai-codex") source = "codex";
|
||||
if (provider === "anthropic") source = "claude";
|
||||
if (!source || !provider) return null;
|
||||
try {
|
||||
const auth: unknown = JSON.parse(
|
||||
await readFile(join(resolvePiAgentDir(this.usageEnv), "auth.json"), "utf8"),
|
||||
);
|
||||
if (provider === "openai-codex") {
|
||||
const credential = piCodexUsageAuthSchema.parse(auth)["openai-codex"];
|
||||
if (!credential) return null;
|
||||
return {
|
||||
source,
|
||||
input: {
|
||||
accessToken: credential.access,
|
||||
...(credential.accountId ? { accountId: credential.accountId } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
const credential = piClaudeUsageAuthSchema.parse(auth).anthropic;
|
||||
return credential ? { source, input: { accessToken: credential.access } } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
getUsageEnvironment() {
|
||||
return this.usageEnv;
|
||||
}
|
||||
|
||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||
@@ -2477,6 +2479,25 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
this.usagePollScheduler = options.usagePollScheduler;
|
||||
}
|
||||
|
||||
async resolveUsageReference({
|
||||
config,
|
||||
runtimeInfo,
|
||||
session,
|
||||
}: {
|
||||
config: AgentSessionConfig;
|
||||
runtimeInfo: AgentRuntimeInfo | null;
|
||||
session: AgentSession | null;
|
||||
}) {
|
||||
const model = session
|
||||
? (await session.getRuntimeInfo()).model
|
||||
: (config.model ?? runtimeInfo?.model);
|
||||
const env =
|
||||
session instanceof PiRpcAgentSession
|
||||
? session.getUsageEnvironment()
|
||||
: this.runtimeSettings?.env;
|
||||
return resolvePiUsageReference(model?.split("/")[0], env);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import { ACPAgentSession } from "../agent/providers/acp-agent.js";
|
||||
import type { UsageReference } from "../agent/agent-sdk-types.js";
|
||||
import { ClaudeAgentClient } from "../agent/providers/claude/agent.js";
|
||||
import { CodexAppServerAgentSession } from "../agent/providers/codex-app-server-agent.js";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "../agent/agent-storage.js";
|
||||
import { OpenCodeAgentClient } from "../agent/providers/opencode-agent.js";
|
||||
import { PiRpcAgentClient } from "../agent/providers/pi/agent.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestAgentClient } from "../test-utils/fake-agent-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -53,75 +56,21 @@ test("resolves a provider plugin session reference through its usage source", as
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("agent.resolve_usage_report resolves source IDs from default built-in and ACP sessions", async () => {
|
||||
test("agent.resolve_usage_report resolves source IDs from provider clients", async () => {
|
||||
const directory = fileURLToPath(
|
||||
new URL("./test-fixtures/session-usage-reference/", import.meta.url),
|
||||
);
|
||||
const providers = ["claude", "codex", "copilot", "cursor", "kimi", "generic-acp"] as const;
|
||||
const logger = createTestLogger();
|
||||
const references = new Map<string, UsageReference | null>();
|
||||
const claude = await new ClaudeAgentClient({
|
||||
logger,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession(
|
||||
{ provider: "claude", cwd: directory },
|
||||
{
|
||||
env: {
|
||||
HOME: directory,
|
||||
CLAUDE_CONFIG_DIR: "",
|
||||
ANTHROPIC_BASE_URL: "",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
},
|
||||
},
|
||||
);
|
||||
references.set("claude", (await claude.getUsageReference?.()) ?? null);
|
||||
await claude.close();
|
||||
const codex = new CodexAppServerAgentSession(
|
||||
{ provider: "codex", cwd: directory },
|
||||
null,
|
||||
logger,
|
||||
() => {
|
||||
throw new Error("Codex runtime should not start");
|
||||
},
|
||||
{},
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
undefined,
|
||||
"interactive",
|
||||
{ HOME: directory, OPENAI_BASE_URL: "" },
|
||||
);
|
||||
references.set("codex", await codex.getUsageReference());
|
||||
for (const provider of providers.slice(2)) {
|
||||
const session = new ACPAgentSession(
|
||||
{ provider, cwd: directory },
|
||||
{
|
||||
provider,
|
||||
logger,
|
||||
defaultCommand: ["unused"],
|
||||
defaultModes: [],
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
references.set(provider, await session.getUsageReference());
|
||||
}
|
||||
const agentClients = Object.fromEntries(
|
||||
providers.map((provider) => {
|
||||
const client = createTestAgentClient(provider);
|
||||
const createSession = client.createSession.bind(client);
|
||||
client.createSession = async (...args) => {
|
||||
const session = await createSession(...args);
|
||||
session.getUsageReference = async () => references.get(provider) ?? null;
|
||||
return session;
|
||||
};
|
||||
let input: Record<string, string> = {};
|
||||
if (provider === "claude") input = { configDir: directory };
|
||||
if (provider === "codex") input = { codexHome: directory };
|
||||
client.resolveUsageReference = async () => ({
|
||||
source: provider,
|
||||
input,
|
||||
});
|
||||
return [provider, client];
|
||||
}),
|
||||
);
|
||||
@@ -148,3 +97,156 @@ test("agent.resolve_usage_report resolves source IDs from default built-in and A
|
||||
await daemon.close();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("stored Claude and Codex agents resolve without opening a provider session", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-closed-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
|
||||
const sessionsOpened = new Map<string, number>();
|
||||
const agentClients = Object.fromEntries(
|
||||
["claude", "codex"].map((provider) => {
|
||||
const providerClient = createTestAgentClient(provider, {
|
||||
beforeCreateSession: async () => {
|
||||
sessionsOpened.set(provider, (sessionsOpened.get(provider) ?? 0) + 1);
|
||||
},
|
||||
});
|
||||
providerClient.resolveUsageReference = async ({ session, runtimeInfo, persistence }) => {
|
||||
expect(session).toBeNull();
|
||||
expect(runtimeInfo?.sessionId).toBeTruthy();
|
||||
expect(runtimeInfo?.model).toBe(`${provider}/saved-model`);
|
||||
expect(persistence?.sessionId).toBe(runtimeInfo?.sessionId);
|
||||
return { source: "fixture-session-usage", input: { account: "from-session" } };
|
||||
};
|
||||
return [provider, providerClient];
|
||||
}),
|
||||
);
|
||||
const options = {
|
||||
paseoHomeRoot: root,
|
||||
staticDir,
|
||||
cleanup: false,
|
||||
pluginsEnabled: false,
|
||||
builtinPlugins: fixtureBuiltins(),
|
||||
agentClients,
|
||||
};
|
||||
let daemon = await createTestPaseoDaemon(options);
|
||||
let client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
const ids: string[] = [];
|
||||
for (const provider of ["claude", "codex"]) {
|
||||
const agent = await client.createAgent({ provider, cwd: root });
|
||||
ids.push(agent.id);
|
||||
await client.sendMessage(agent.id, "Say OK.");
|
||||
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle");
|
||||
expect(sessionsOpened.get(provider)).toBe(1);
|
||||
}
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
|
||||
const storage = new AgentStorage(
|
||||
path.join(root, ".paseo", "agents"),
|
||||
pino({ level: "silent" }),
|
||||
);
|
||||
for (const [index, provider] of ["claude", "codex"].entries()) {
|
||||
const record = await storage.get(ids[index]!);
|
||||
expect(record).not.toBeNull();
|
||||
await storage.upsert({
|
||||
...record!,
|
||||
runtimeInfo: { ...record!.runtimeInfo!, model: `${provider}/saved-model` },
|
||||
});
|
||||
}
|
||||
|
||||
daemon = await createTestPaseoDaemon(options);
|
||||
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
for (const [index, provider] of ["claude", "codex"].entries()) {
|
||||
const result = await client.resolveAgentUsageReport({ agentId: ids[index]! });
|
||||
expect(result.reportId).toBe("fixture-session-usage:from-session");
|
||||
expect(
|
||||
(await client.listUsageReports({ reportIds: [result.reportId!] })).reports,
|
||||
).toHaveLength(1);
|
||||
expect(sessionsOpened.get(provider)).toBe(1);
|
||||
}
|
||||
} finally {
|
||||
await client.close().catch(() => undefined);
|
||||
await daemon.close().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
test("stored default-model OpenCode and Pi agents resolve their saved runtime model", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-saved-model-"));
|
||||
const staticDir = await mkdtemp(path.join(os.tmpdir(), "paseo-usage-static-"));
|
||||
const piDir = path.join(root, "pi-agent");
|
||||
await mkdir(piDir);
|
||||
await writeFile(
|
||||
path.join(piDir, "auth.json"),
|
||||
JSON.stringify({
|
||||
"openai-codex": {
|
||||
type: "oauth",
|
||||
access: "codex-fixture-token",
|
||||
accountId: "fixture-account",
|
||||
refresh: "fixture-refresh",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const storage = new AgentStorage(path.join(root, ".paseo", "agents"), pino({ level: "silent" }));
|
||||
const ids: string[] = [];
|
||||
const now = new Date().toISOString();
|
||||
for (const provider of ["opencode", "pi"]) {
|
||||
const id = randomUUID();
|
||||
ids.push(id);
|
||||
await storage.upsert({
|
||||
id,
|
||||
provider,
|
||||
cwd: root,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
config: { model: null },
|
||||
runtimeInfo: {
|
||||
provider,
|
||||
sessionId: `${provider}-saved-session`,
|
||||
model: provider === "opencode" ? "openai/gpt-5" : "openai-codex/gpt-5",
|
||||
},
|
||||
persistence: { provider, sessionId: `${provider}-saved-session` },
|
||||
});
|
||||
}
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
paseoHomeRoot: root,
|
||||
staticDir,
|
||||
cleanup: false,
|
||||
agentClients: {
|
||||
opencode: new OpenCodeAgentClient(pino({ level: "silent" }), {
|
||||
env: {
|
||||
OPENCODE_AUTH_CONTENT: JSON.stringify({
|
||||
openai: { type: "oauth", access: "codex-fixture-token", accountId: "fixture-account" },
|
||||
}),
|
||||
},
|
||||
}),
|
||||
pi: new PiRpcAgentClient({
|
||||
logger: pino({ level: "silent" }),
|
||||
runtimeSettings: { env: { PI_CODING_AGENT_DIR: piDir } },
|
||||
}),
|
||||
},
|
||||
builtinPlugins: new BuiltinPluginLoader(undefined, ["codex-usage-source"]),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
for (const [index, id] of ids.entries()) {
|
||||
expect(
|
||||
(await client.resolveAgentUsageReport({ agentId: id })).reportId,
|
||||
["opencode", "pi"][index],
|
||||
).not.toBeNull();
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(staticDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -1000,7 +1000,7 @@ export class Session {
|
||||
this.usageSession = new UsageSession({
|
||||
emit: (msg) => this.emit(msg),
|
||||
listAgents: () => this.agentManager.listAgents(),
|
||||
getAgent: (agentId) => this.agentManager.getAgent(agentId),
|
||||
resolveAgentReference: (agentId) => this.agentManager.resolveUsageReference(agentId),
|
||||
runtime: pluginRuntime,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import pino from "pino";
|
||||
import { expect, test } from "vitest";
|
||||
import type { AgentSession } from "../../agent/agent-sdk-types.js";
|
||||
import type { SessionOutboundMessage } from "../../messages.js";
|
||||
import { UsageSession } from "./usage-session.js";
|
||||
|
||||
@@ -9,9 +8,8 @@ test("collects live references for usage reports and resolves an agent report",
|
||||
const references: unknown[] = [];
|
||||
const reference = { source: "fixture", input: { account: "one" } };
|
||||
const agent = {
|
||||
session: {
|
||||
getUsageReference: async () => reference,
|
||||
} as AgentSession,
|
||||
id: "one",
|
||||
session: {},
|
||||
};
|
||||
const entry = {
|
||||
id: "fixture:one",
|
||||
@@ -23,8 +21,8 @@ test("collects live references for usage reports and resolves an agent report",
|
||||
};
|
||||
const usage = new UsageSession({
|
||||
emit: (message) => emitted.push(message),
|
||||
listAgents: () => [agent, { session: null }],
|
||||
getAgent: (id) => (id === "one" ? agent : null),
|
||||
listAgents: () => [agent, { id: "closed", session: null }],
|
||||
resolveAgentReference: async (id) => ({ found: id === "one", reference }),
|
||||
runtime: {
|
||||
async listUsageReports(options) {
|
||||
references.push(options.references);
|
||||
@@ -59,7 +57,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () =
|
||||
const usage = new UsageSession({
|
||||
emit: (message) => emitted.push(message),
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
resolveAgentReference: async () => ({ found: false, reference: null }),
|
||||
runtime: {
|
||||
async listUsageReports() {
|
||||
return [];
|
||||
@@ -85,7 +83,7 @@ test("unknown agent returns agent_not_found", async () => {
|
||||
const usage = new UsageSession({
|
||||
emit: (message) => emitted.push(message),
|
||||
listAgents: () => [],
|
||||
getAgent: () => null,
|
||||
resolveAgentReference: async () => ({ found: false, reference: null }),
|
||||
runtime: {
|
||||
listUsageReports: async () => [],
|
||||
resolveUsageReference: async () => null,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type pino from "pino";
|
||||
import type { ProviderUsage, UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import type { AgentSession, UsageReference } from "../../agent/agent-sdk-types.js";
|
||||
import type { UsageReference } from "../../agent/agent-sdk-types.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
interface UsageAgent {
|
||||
session: AgentSession | null;
|
||||
id: string;
|
||||
session: object | null;
|
||||
}
|
||||
|
||||
export interface UsageSessionOptions {
|
||||
emit(message: SessionOutboundMessage): void;
|
||||
listAgents(): UsageAgent[];
|
||||
getAgent(agentId: string): UsageAgent | null;
|
||||
resolveAgentReference(agentId: string): Promise<{
|
||||
found: boolean;
|
||||
reference: UsageReference | null;
|
||||
}>;
|
||||
runtime?: {
|
||||
listUsageReports(options: {
|
||||
forceRefresh?: boolean;
|
||||
@@ -37,8 +41,12 @@ export class UsageSession {
|
||||
await Promise.all(
|
||||
this.options
|
||||
.listAgents()
|
||||
.map(
|
||||
async (agent) => agent.session?.getUsageReference?.().catch(() => null) ?? null,
|
||||
.filter((agent) => agent.session !== null)
|
||||
.map(async (agent) =>
|
||||
this.options
|
||||
.resolveAgentReference(agent.id)
|
||||
.then((resolved) => resolved.reference)
|
||||
.catch(() => null),
|
||||
),
|
||||
)
|
||||
).filter((reference): reference is UsageReference => reference !== null)
|
||||
@@ -62,12 +70,12 @@ export class UsageSession {
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!this.options.runtime) throw new Error("Plugin runtime is unavailable");
|
||||
const agent = this.options.getAgent(msg.agentId);
|
||||
if (!agent) {
|
||||
const resolved = await this.options.resolveAgentReference(msg.agentId);
|
||||
if (!resolved.found) {
|
||||
this.emitError(msg, new Error(`Agent not found: ${msg.agentId}`), "agent_not_found");
|
||||
return;
|
||||
}
|
||||
const reference = (await agent.session?.getUsageReference?.()) ?? null;
|
||||
const reference = resolved.reference;
|
||||
const reportId = reference
|
||||
? await this.options.runtime.resolveUsageReference(reference)
|
||||
: null;
|
||||
|
||||
Reference in New Issue
Block a user