fix(opencode): prevent indexing the entire home directory (#1704)
* fix(opencode): prevent indexing the entire home directory Paseo launches opencode serve with cwd=os.homedir() and refreshes the global provider snapshot with directory=/Users/admin. OpenCode treats that as a workspace and starts location services + bigram indexing for the entire home tree, causing ~466% CPU and ~4GB RAM usage. - Use a neutral scratch directory as the opencode serve cwd. - Use a separate scratch directory for global provider catalog refresh so model/mode discovery no longer triggers home directory indexing. Fixes high CPU/RAM when Paseo starts opencode with no explicit project. * fix(opencode): use realpath-aware matcher for home detection in catalog refresh Switch the home-directory check in fetchCatalog from a string-based path.resolve() comparison to createRealpathAwarePathMatcher, so we catch macOS /private/var/... aliases, symlinks, trailing separators, and Windows casing — consistent with the rest of opencode-agent.ts. Also: - Hoist the matcher to module scope so each fetchCatalog call doesn't rebuild it (the matcher runs realpathSync twice on construction). - Log a debug line when we rewrite the cwd to the scratch path, so it's easy to diagnose missing per-directory config in catalog scope. - Update opencode-agent.test.ts to expect the scratch directory when cwd === os.homedir(), with a comment pointing to the rationale. * fix(opencode): isolate helper server home * fix(opencode): pass semantic global catalog scope * fix(opencode): release catalog acquisition on home resolution failure --------- Co-authored-by: rex-chang <rex-chang@users.noreply.github.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
rex-chang
Mohamed Boudra
parent
8228bafc5d
commit
5ef118ea33
+3
-3
@@ -56,15 +56,15 @@ Daemon bootstrap reconciles that ledger in the background, without blocking star
|
||||
|
||||
## Provider Snapshot Refresh Contract
|
||||
|
||||
The daemon keeps provider snapshots per resolved working directory. Missing or blank cwd resolves to the user's home directory. Workspace selectors and old model/mode list requests should pass the cwd that will launch the provider so providers with project-specific models or modes are probed in the right context. Settings/provider management intentionally uses the home-directory snapshot.
|
||||
The daemon keeps provider snapshots per resolved working directory, with a separate semantic global scope for settings/provider management and requests that do not carry a cwd. Provider catalog probes receive a discriminated `FetchCatalogOptions`: `{ scope: "global", force }` for global catalog refreshes, or `{ scope: "workspace", cwd, force }` for project-scoped refreshes. Providers decide what global means for their runtime; do not infer global by comparing a cwd to the user's home directory.
|
||||
|
||||
Snapshot reads may probe providers only while the requested cwd scope is cold. Once an entry is warm, its `ready`, `error`, or `unavailable` state stays cached until an explicit refresh. Do not add TTL revalidation, focus-triggered refreshes, selector-open refreshes, or config-reload refreshes. Selector-open refetches may read an already-loading or stale React Query, but they must not force provider probing on their own.
|
||||
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the home-directory snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
Settings refresh is the user-facing "forget stale provider knowledge everywhere" action. A settings refresh clears provider snapshot caches and in-flight loads across all cwd scopes, then immediately refreshes only the global snapshot with `force: true`. Workspace snapshots are re-probed lazily on the next scoped read; do not fan out a settings refresh across every known workspace.
|
||||
|
||||
Registry/config replacement may update visible metadata such as label, description, default mode, enabled state, and provider membership, but it must not spawn provider processes. If a provider needs to be re-probed after a config change, route that through the explicit settings refresh path.
|
||||
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that cwd; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only home.
|
||||
Boundary tests should assert observable behavior: cold reads may call provider availability/model/mode discovery for that scope; warm reads and registry replacement must not; explicit workspace refreshes affect only one cwd; settings refresh wipes all scopes but immediately refreshes only global.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3702,7 +3702,11 @@ export class AgentManager {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const catalog = await client.fetchCatalog({ cwd: config.cwd, force: false });
|
||||
const catalog = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: config.cwd,
|
||||
force: false,
|
||||
});
|
||||
return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id;
|
||||
} catch {
|
||||
// Provider may not support model listing — leave model undefined.
|
||||
|
||||
@@ -643,11 +643,18 @@ export interface AgentSession {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface FetchCatalogOptions {
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
export type FetchCatalogOptions =
|
||||
| {
|
||||
scope: "global";
|
||||
force: boolean;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
| {
|
||||
scope: "workspace";
|
||||
cwd: string;
|
||||
force: boolean;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export interface ProviderCatalog {
|
||||
models: AgentModelDefinition[];
|
||||
|
||||
@@ -810,6 +810,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -845,6 +846,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -883,6 +885,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -917,6 +920,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -959,6 +963,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1009,6 +1014,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1056,6 +1062,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1095,6 +1102,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1147,6 +1155,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1185,6 +1194,7 @@ describe("model merging", () => {
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1225,6 +1235,7 @@ describe("model merging", () => {
|
||||
|
||||
const client = registry.codex.createClient(logger);
|
||||
const catalog = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1259,6 +1270,7 @@ describe("model merging", () => {
|
||||
|
||||
const client = registry.claude.createClient(logger);
|
||||
const catalog = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1287,6 +1299,7 @@ describe("model merging", () => {
|
||||
});
|
||||
|
||||
const { models } = await registry.claude.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/registry-models",
|
||||
force: false,
|
||||
});
|
||||
@@ -1304,6 +1317,7 @@ describe("fetchCatalog", () => {
|
||||
|
||||
const registry = buildProviderRegistry(logger);
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
@@ -1327,6 +1341,7 @@ describe("fetchCatalog", () => {
|
||||
});
|
||||
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
@@ -1345,6 +1360,7 @@ describe("fetchCatalog", () => {
|
||||
});
|
||||
|
||||
const catalog = await registry.codex.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/catalog",
|
||||
force: false,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
@@ -11,7 +12,11 @@ import type {
|
||||
ResolveAgentCreateConfigInput,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { ProviderSnapshotManager, resolveSnapshotCwd } from "./provider-snapshot-manager.js";
|
||||
import {
|
||||
GLOBAL_PROVIDER_SNAPSHOT_KEY,
|
||||
ProviderSnapshotManager,
|
||||
resolveSnapshotCwd,
|
||||
} from "./provider-snapshot-manager.js";
|
||||
import { OpenCodeAgentClient } from "./providers/opencode-agent.js";
|
||||
|
||||
const TEST_CAPABILITIES = {
|
||||
@@ -488,6 +493,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
try {
|
||||
const result = await manager.getProviderDiagnostic("codex");
|
||||
expect(fetchCatalog).toHaveBeenCalledTimes(1);
|
||||
expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true });
|
||||
expect(result.diagnostic).toContain("Models: 1");
|
||||
expect(result.diagnostic).toContain("Status: Ready");
|
||||
} finally {
|
||||
@@ -1063,6 +1069,60 @@ describe("ProviderSnapshotManager lifecycle", () => {
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager cwd routing", () => {
|
||||
test("settings refresh passes the semantic global scope to providers", async () => {
|
||||
const fetchCatalog = vi.fn(async () => ({
|
||||
models: [] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
isAvailable: vi.fn(async () => true),
|
||||
fetchCatalog,
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||
|
||||
expect(fetchCatalog.mock.calls[0]?.[0]).toMatchObject({ scope: "global", force: true });
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("global snapshot does not satisfy an explicit home workspace read", async () => {
|
||||
const fetchCatalog = vi.fn(async () => ({
|
||||
models: [] as AgentModelDefinition[],
|
||||
modes: [] as AgentMode[],
|
||||
}));
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
isAvailable: vi.fn(async () => true),
|
||||
fetchCatalog,
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await manager.refreshSettingsSnapshot({ providers: ["codex"] });
|
||||
await manager.listProviders({ cwd: homedir(), providers: ["codex"], wait: true });
|
||||
|
||||
expect(fetchCatalog.mock.calls.map((call) => call[0])).toEqual([
|
||||
expect.objectContaining({ scope: "global", force: true }),
|
||||
expect.objectContaining({
|
||||
scope: "workspace",
|
||||
cwd: resolveSnapshotCwd(homedir()),
|
||||
force: false,
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("different cwd keys produce independent snapshots", () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
@@ -1086,7 +1146,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("getSnapshot called with no cwd resolves to the home snapshot key", () => {
|
||||
test("getSnapshot called with no cwd resolves to the global snapshot key", () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
@@ -1103,13 +1163,7 @@ describe("ProviderSnapshotManager cwd routing", () => {
|
||||
manager.getSnapshot();
|
||||
manager.applyMutableProviderConfig({});
|
||||
const cwds = listener.mock.calls.map((call) => call[1]);
|
||||
// applyMutableProviderConfig emits change for each primed cwd; the home
|
||||
// snapshot must be present.
|
||||
expect(cwds.length).toBeGreaterThanOrEqual(1);
|
||||
for (const cwd of cwds) {
|
||||
expect(typeof cwd).toBe("string");
|
||||
expect(cwd.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(cwds).toContain(GLOBAL_PROVIDER_SNAPSHOT_KEY);
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
FetchCatalogOptions,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
@@ -36,6 +37,7 @@ import type { MutableDaemonConfig } from "../daemon-config-store.js";
|
||||
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_000;
|
||||
const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS";
|
||||
export const GLOBAL_PROVIDER_SNAPSHOT_KEY = "paseo:global";
|
||||
|
||||
// Provider refresh probes can be slow on cold starts (e.g. Copilot's first
|
||||
// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers).
|
||||
@@ -81,6 +83,11 @@ interface ProviderSnapshotRefreshOptions {
|
||||
providers?: AgentProvider[];
|
||||
}
|
||||
|
||||
interface ProviderSnapshotWarmUpOptions {
|
||||
cwd?: string | null;
|
||||
providers?: AgentProvider[];
|
||||
}
|
||||
|
||||
interface ProviderSnapshotReadOptions {
|
||||
cwd?: string | null;
|
||||
providers?: AgentProvider[];
|
||||
@@ -126,14 +133,22 @@ export interface AgentManagerProviderState {
|
||||
}
|
||||
|
||||
interface ProviderLoadOptions {
|
||||
cwd: string;
|
||||
snapshotCwd: string;
|
||||
providers: AgentProvider[];
|
||||
catalogScope: ProviderCatalogScope;
|
||||
force: boolean;
|
||||
}
|
||||
interface ProviderLoad {
|
||||
promise: Promise<void>;
|
||||
}
|
||||
|
||||
type ProviderCatalogScope = { scope: "global" } | { scope: "workspace"; cwd: string };
|
||||
|
||||
interface ProviderSnapshotTarget {
|
||||
snapshotCwd: string;
|
||||
catalogScope: ProviderCatalogScope;
|
||||
}
|
||||
|
||||
export class ProviderSnapshotManager {
|
||||
private readonly snapshots = new Map<string, Map<AgentProvider, ProviderSnapshotEntry>>();
|
||||
private readonly providerLoads = new Map<string, Map<AgentProvider, ProviderLoad>>();
|
||||
@@ -171,37 +186,36 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
getSnapshot(cwd?: string): ProviderSnapshotEntry[] {
|
||||
const resolvedCwd = resolveSnapshotCwd(cwd);
|
||||
const providersToWarm = this.resolveProvidersToWarm(resolvedCwd);
|
||||
if (providersToWarm.length > 0) {
|
||||
void this.warmUp(resolvedCwd, providersToWarm);
|
||||
}
|
||||
return entriesToArray(this.getOrCreateSnapshot(resolvedCwd));
|
||||
const target = resolveProviderSnapshotTarget(cwd);
|
||||
return this.getSnapshotForTarget(target);
|
||||
}
|
||||
|
||||
async refreshSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||
const target = createWorkspaceSnapshotTarget(snapshotCwd);
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
this.resetSnapshotToLoading(snapshotCwd, providers, { preserveExisting: false });
|
||||
this.emitChange(snapshotCwd);
|
||||
await this.refreshProviders(snapshotCwd, providers ?? this.getProviderIds());
|
||||
await this.refreshProviders(target, providers ?? this.getProviderIds());
|
||||
}
|
||||
|
||||
async refreshSettingsSnapshot(
|
||||
options: Omit<ProviderSnapshotRefreshOptions, "cwd"> = {},
|
||||
): Promise<void> {
|
||||
const homeCwd = resolveSnapshotCwd();
|
||||
const target = createGlobalSnapshotTarget();
|
||||
const homeCwd = target.snapshotCwd;
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
this.clearCachedProviders(providers);
|
||||
this.resetSnapshotToLoading(homeCwd, providers, { preserveExisting: false });
|
||||
this.emitChange(homeCwd);
|
||||
await this.refreshProviders(homeCwd, providersToRefresh);
|
||||
await this.refreshProviders(target, providersToRefresh);
|
||||
}
|
||||
|
||||
async warmUpSnapshotForCwd(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
const snapshotCwd = resolveSnapshotCwd(options.cwd);
|
||||
async warmUpSnapshotForCwd(options: ProviderSnapshotWarmUpOptions): Promise<void> {
|
||||
const target = resolveProviderSnapshotTarget(options.cwd);
|
||||
const snapshotCwd = target.snapshotCwd;
|
||||
const providers = this.resolveRefreshProviders(options.providers);
|
||||
if (options.providers && providers?.length === 0) {
|
||||
return;
|
||||
@@ -211,7 +225,7 @@ export class ProviderSnapshotManager {
|
||||
if (providersToWarm.length === 0) {
|
||||
return;
|
||||
}
|
||||
await this.warmUp(snapshotCwd, providersToWarm);
|
||||
await this.warmUp(target, providersToWarm);
|
||||
}
|
||||
|
||||
async refresh(options: ProviderSnapshotRefreshOptions): Promise<void> {
|
||||
@@ -261,12 +275,12 @@ export class ProviderSnapshotManager {
|
||||
}
|
||||
|
||||
async listProviders(input: ProviderSnapshotReadOptions = {}): Promise<ProviderSnapshotEntry[]> {
|
||||
const cwd = resolveSnapshotCwd(input.cwd);
|
||||
const target = resolveProviderSnapshotTarget(input.cwd);
|
||||
if (input.wait) {
|
||||
await this.warmUpSnapshotForCwd({ cwd, providers: input.providers });
|
||||
await this.warmUpSnapshotForCwd({ cwd: input.cwd, providers: input.providers });
|
||||
}
|
||||
const providerFilter = input.providers ? new Set(input.providers) : null;
|
||||
const entries = this.getSnapshot(cwd);
|
||||
const entries = this.getSnapshotForTarget(target);
|
||||
return providerFilter ? entries.filter((entry) => providerFilter.has(entry.provider)) : entries;
|
||||
}
|
||||
|
||||
@@ -442,6 +456,14 @@ export class ProviderSnapshotManager {
|
||||
};
|
||||
}
|
||||
|
||||
private getSnapshotForTarget(target: ProviderSnapshotTarget): ProviderSnapshotEntry[] {
|
||||
const providersToWarm = this.resolveProvidersToWarm(target.snapshotCwd);
|
||||
if (providersToWarm.length > 0) {
|
||||
void this.warmUp(target, providersToWarm);
|
||||
}
|
||||
return entriesToArray(this.getOrCreateSnapshot(target.snapshotCwd));
|
||||
}
|
||||
|
||||
private async getReadyProvider(
|
||||
input: ProviderSnapshotProviderOptions,
|
||||
): Promise<ProviderSnapshotEntry> {
|
||||
@@ -471,9 +493,11 @@ export class ProviderSnapshotManager {
|
||||
definition: ProviderDefinition,
|
||||
): Promise<ProviderSnapshotEntry> {
|
||||
try {
|
||||
const cwd = resolveSnapshotCwd();
|
||||
await this.refreshSnapshotForCwd({ cwd, providers: [provider] });
|
||||
return await this.getProvider({ cwd, provider, wait: false });
|
||||
const target = createGlobalSnapshotTarget();
|
||||
this.resetSnapshotToLoading(target.snapshotCwd, [provider], { preserveExisting: false });
|
||||
this.emitChange(target.snapshotCwd);
|
||||
await this.refreshProviders(target, [provider]);
|
||||
return await this.getProvider({ provider, wait: false });
|
||||
} catch (error) {
|
||||
return {
|
||||
provider,
|
||||
@@ -561,18 +585,27 @@ export class ProviderSnapshotManager {
|
||||
return entries;
|
||||
}
|
||||
|
||||
private async warmUp(cwd: string, providers?: AgentProvider[]): Promise<void> {
|
||||
private async warmUp(target: ProviderSnapshotTarget, providers?: AgentProvider[]): Promise<void> {
|
||||
const providersToRefresh = providers ?? this.getProviderIds();
|
||||
|
||||
await this.loadProviders({
|
||||
cwd,
|
||||
snapshotCwd: target.snapshotCwd,
|
||||
catalogScope: target.catalogScope,
|
||||
providers: providersToRefresh,
|
||||
force: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshProviders(cwd: string, providers: AgentProvider[]): Promise<void> {
|
||||
await this.loadProviders({ cwd, providers, force: true });
|
||||
private async refreshProviders(
|
||||
target: ProviderSnapshotTarget,
|
||||
providers: AgentProvider[],
|
||||
): Promise<void> {
|
||||
await this.loadProviders({
|
||||
snapshotCwd: target.snapshotCwd,
|
||||
catalogScope: target.catalogScope,
|
||||
providers,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveProvidersToWarm(cwd: string, providers?: AgentProvider[]): AgentProvider[] {
|
||||
@@ -644,11 +677,11 @@ export class ProviderSnapshotManager {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const existingLoad = this.getProviderLoad(options.cwd, options.provider);
|
||||
const existingLoad = this.getProviderLoad(options.snapshotCwd, options.provider);
|
||||
if (existingLoad && !options.force) {
|
||||
return existingLoad.promise;
|
||||
}
|
||||
const existingEntry = this.snapshots.get(options.cwd)?.get(options.provider);
|
||||
const existingEntry = this.snapshots.get(options.snapshotCwd)?.get(options.provider);
|
||||
if (existingEntry && existingEntry.status !== "loading" && !options.force) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
@@ -656,11 +689,12 @@ export class ProviderSnapshotManager {
|
||||
const load: ProviderLoad = {
|
||||
promise: Promise.resolve(),
|
||||
};
|
||||
this.setProviderLoad(options.cwd, options.provider, load);
|
||||
this.setProviderLoad(options.snapshotCwd, options.provider, load);
|
||||
load.promise = Promise.resolve()
|
||||
.then(() =>
|
||||
this.refreshProvider({
|
||||
cwd: options.cwd,
|
||||
snapshotCwd: options.snapshotCwd,
|
||||
catalogScope: options.catalogScope,
|
||||
provider: options.provider,
|
||||
definition,
|
||||
load,
|
||||
@@ -668,26 +702,27 @@ export class ProviderSnapshotManager {
|
||||
}),
|
||||
)
|
||||
.finally(() => {
|
||||
const providerLoads = this.providerLoads.get(options.cwd);
|
||||
const providerLoads = this.providerLoads.get(options.snapshotCwd);
|
||||
if (providerLoads?.get(options.provider) === load) {
|
||||
providerLoads.delete(options.provider);
|
||||
}
|
||||
if (providerLoads?.size === 0) {
|
||||
this.providerLoads.delete(options.cwd);
|
||||
this.providerLoads.delete(options.snapshotCwd);
|
||||
}
|
||||
});
|
||||
return load.promise;
|
||||
}
|
||||
|
||||
private async refreshProvider(options: {
|
||||
cwd: string;
|
||||
snapshotCwd: string;
|
||||
catalogScope: ProviderCatalogScope;
|
||||
provider: AgentProvider;
|
||||
definition: ProviderDefinition;
|
||||
load: ProviderLoad;
|
||||
force: boolean;
|
||||
}): Promise<void> {
|
||||
const { cwd, provider, definition, load, force } = options;
|
||||
const snapshot = this.getOrCreateSnapshot(options.cwd);
|
||||
const { snapshotCwd, catalogScope, provider, definition, load, force } = options;
|
||||
const snapshot = this.getOrCreateSnapshot(snapshotCwd);
|
||||
const base = {
|
||||
provider,
|
||||
label: definition.label,
|
||||
@@ -695,11 +730,11 @@ export class ProviderSnapshotManager {
|
||||
defaultModeId: definition.defaultModeId,
|
||||
};
|
||||
const setEntry = (entry: ProviderSnapshotEntry) => {
|
||||
if (!this.isCurrentProviderLoad(cwd, provider, load)) {
|
||||
if (!this.isCurrentProviderLoad(snapshotCwd, provider, load)) {
|
||||
return false;
|
||||
}
|
||||
snapshot.set(provider, entry);
|
||||
this.emitChange(cwd);
|
||||
this.emitChange(snapshotCwd);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -720,8 +755,9 @@ export class ProviderSnapshotManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const catalogOptions = createFetchCatalogOptions(catalogScope, force);
|
||||
const catalog = await withTimeout(
|
||||
definition.fetchCatalog({ cwd, force, timeoutMs: this.refreshTimeoutMs }, client),
|
||||
definition.fetchCatalog({ ...catalogOptions, timeoutMs: this.refreshTimeoutMs }, client),
|
||||
this.refreshTimeoutMs,
|
||||
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
|
||||
);
|
||||
@@ -742,7 +778,10 @@ export class ProviderSnapshotManager {
|
||||
error: toErrorMessage(error),
|
||||
});
|
||||
if (emitted) {
|
||||
this.logger.warn({ err: error, provider, cwd }, "Failed to refresh provider snapshot");
|
||||
this.logger.warn(
|
||||
{ err: error, provider, cwd: snapshotCwd },
|
||||
"Failed to refresh provider snapshot",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -856,6 +895,42 @@ export function resolveSnapshotCwd(cwd?: string | null): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function resolveProviderSnapshotTarget(cwd?: string | null): ProviderSnapshotTarget {
|
||||
const trimmed = cwd?.trim();
|
||||
if (!trimmed) {
|
||||
return createGlobalSnapshotTarget();
|
||||
}
|
||||
return createWorkspaceSnapshotTarget(resolveSnapshotCwd(trimmed));
|
||||
}
|
||||
|
||||
function createGlobalSnapshotTarget(): ProviderSnapshotTarget {
|
||||
return {
|
||||
snapshotCwd: GLOBAL_PROVIDER_SNAPSHOT_KEY,
|
||||
catalogScope: { scope: "global" },
|
||||
};
|
||||
}
|
||||
|
||||
function createWorkspaceSnapshotTarget(cwd: string): ProviderSnapshotTarget {
|
||||
const snapshotCwd = resolveSnapshotCwd(cwd);
|
||||
return {
|
||||
snapshotCwd,
|
||||
catalogScope: { scope: "workspace", cwd: snapshotCwd },
|
||||
};
|
||||
}
|
||||
|
||||
function createFetchCatalogOptions(
|
||||
scope: ProviderCatalogScope,
|
||||
force: boolean,
|
||||
): FetchCatalogOptions {
|
||||
return scope.scope === "global"
|
||||
? { scope: "global", force }
|
||||
: { scope: "workspace", cwd: scope.cwd, force };
|
||||
}
|
||||
|
||||
export function isGlobalProviderSnapshotKey(cwd: string): boolean {
|
||||
return cwd === GLOBAL_PROVIDER_SNAPSHOT_KEY;
|
||||
}
|
||||
|
||||
function entriesToArray(
|
||||
entries: Map<AgentProvider, ProviderSnapshotEntry>,
|
||||
): ProviderSnapshotEntry[] {
|
||||
|
||||
@@ -1379,7 +1379,9 @@ describe("ACPAgentClient modelTransformer", () => {
|
||||
modelTransformer: transformPiModels,
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-models", force: false })).resolves.toEqual({
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
provider: "pi",
|
||||
@@ -1481,7 +1483,9 @@ describe("ACPAgentClient sessionResponseTransformer", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-modes", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [
|
||||
{
|
||||
@@ -1517,7 +1521,7 @@ describe("ACPAgentClient fetchCatalog", () => {
|
||||
defaultModes: [],
|
||||
});
|
||||
|
||||
await client.fetchCatalog({ cwd: "/tmp/acp-catalog-cwd", force: false });
|
||||
await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-catalog-cwd", force: false });
|
||||
|
||||
expect(newSession).toHaveBeenCalledWith({
|
||||
cwd: "/tmp/acp-catalog-cwd",
|
||||
@@ -1563,7 +1567,9 @@ describe("ACPAgentClient fetchCatalog", () => {
|
||||
defaultModes: [],
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/acp-modes", force: false })).resolves.toEqual({
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-modes", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [],
|
||||
});
|
||||
@@ -2443,7 +2449,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.fetchCatalog({ cwd: "/tmp/acp-models", force: false });
|
||||
await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/acp-models", force: false });
|
||||
|
||||
expect(terminator.terminated).toContain(child);
|
||||
expect(child.stdin.destroyed).toBe(true);
|
||||
|
||||
@@ -799,7 +799,7 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const { cwd } = options;
|
||||
const cwd = options.scope === "global" ? homedir() : options.cwd;
|
||||
const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS;
|
||||
let probe: UninitializedACPProcess | null = null;
|
||||
try {
|
||||
|
||||
@@ -406,7 +406,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/claude-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models.map((m) => m.id)).toEqual([
|
||||
"claude-fable-5",
|
||||
@@ -441,7 +445,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
configDir: emptyConfigDir,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/claude-models", force: false });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/claude-models",
|
||||
force: false,
|
||||
});
|
||||
const getThinkingIds = (modelId: string) => {
|
||||
return models.find((model) => model.id === modelId)?.thinkingOptions?.map(({ id }) => id);
|
||||
};
|
||||
|
||||
@@ -78,7 +78,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models).toEqual([
|
||||
...getClaudeModels(),
|
||||
@@ -127,7 +131,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -137,7 +145,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -153,7 +165,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models).toEqual(getClaudeModels());
|
||||
});
|
||||
@@ -169,7 +185,11 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const client = new ClaudeAgentClient({ logger: createTestLogger() });
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: os.tmpdir(), force: true });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: os.tmpdir(),
|
||||
force: true,
|
||||
});
|
||||
|
||||
expect(models.map((model) => model.id)).toEqual([
|
||||
...getClaudeModels().map((model) => model.id),
|
||||
|
||||
@@ -26,7 +26,7 @@ describe("Codex app-server provider (real)", () => {
|
||||
test("lists models and runs a simple prompt", async () => {
|
||||
const client = createRealProviderClient("codex", createTestLogger());
|
||||
const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-e2e-"));
|
||||
const { models } = await client.fetchCatalog({ cwd, force: false });
|
||||
const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
|
||||
const session = await client.createSession({
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ describe("CodexAppServerAgentClient spawn error handling", () => {
|
||||
|
||||
try {
|
||||
await expect(
|
||||
client.fetchCatalog({ cwd: "/tmp/codex-models", force: false }),
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/codex-models", force: false }),
|
||||
).rejects.toThrow();
|
||||
// Drain microtask queue to ensure no deferred uncaught errors
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
@@ -45,7 +45,9 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
configOptions: [],
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [
|
||||
{
|
||||
provider: "acp",
|
||||
@@ -68,7 +70,9 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
configOptions: [],
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/cursor", force: false })).resolves.toEqual({
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/cursor", force: false }),
|
||||
).resolves.toEqual({
|
||||
models: [],
|
||||
modes: [],
|
||||
});
|
||||
|
||||
@@ -33,7 +33,11 @@ describe("MockLoadTestAgentClient", () => {
|
||||
test("default model is a five minute foreground stream with token-rate intervals", async () => {
|
||||
const client = new MockLoadTestAgentClient();
|
||||
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/mock-models", force: false });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/mock-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models[0]).toMatchObject({
|
||||
id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID,
|
||||
|
||||
@@ -81,7 +81,11 @@ describe("OpenCode auto_accept feature", () => {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
const { modes } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/project",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "paseo-custom"]);
|
||||
});
|
||||
@@ -93,7 +97,11 @@ describe("OpenCode auto_accept feature", () => {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { modes } = await client.fetchCatalog({ cwd: "/tmp/project", force: false });
|
||||
const { modes } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/project",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(modes.map((mode) => mode.id)).toEqual(["build", "plan"]);
|
||||
});
|
||||
|
||||
+15
-7
@@ -44,7 +44,11 @@ test("allows a slow provider.list call to succeed instead of failing after 10 se
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const modelsPromise = client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
const modelsPromise = client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/opencode-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
|
||||
@@ -76,7 +80,7 @@ test("uses a new server for explicit catalog refresh", async () => {
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: true });
|
||||
await client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: true });
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "new", releaseCount: 1 }]);
|
||||
});
|
||||
@@ -110,7 +114,11 @@ test("includes models from api-source providers not in connected", async () => {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const { models } = await client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false });
|
||||
const { models } = await client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/tmp/opencode-models",
|
||||
force: false,
|
||||
});
|
||||
|
||||
expect(models).toMatchObject([
|
||||
{
|
||||
@@ -146,9 +154,9 @@ test("throws when no providers are accessible (neither connected nor api-source)
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false })).rejects.toThrow(
|
||||
"OpenCode has no connected providers",
|
||||
);
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: false }),
|
||||
).rejects.toThrow("OpenCode has no connected providers");
|
||||
});
|
||||
|
||||
test("does not throw when only api-source providers are present with no connected providers", async () => {
|
||||
@@ -177,7 +185,7 @@ test("does not throw when only api-source providers are present with no connecte
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.fetchCatalog({ cwd: "/tmp/opencode-models", force: false }),
|
||||
client.fetchCatalog({ scope: "workspace", cwd: "/tmp/opencode-models", force: false }),
|
||||
).resolves.toMatchObject({
|
||||
models: [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -305,12 +305,14 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
],
|
||||
};
|
||||
runtime.enqueueClient(openCodeClient);
|
||||
const paseoHome = tmpCwd();
|
||||
const opencodeHome = path.join(paseoHome, "opencode-home");
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
resolveHomeDir: () => opencodeHome,
|
||||
});
|
||||
const cwd = os.homedir();
|
||||
const catalog = await client.fetchCatalog({ cwd, force: false });
|
||||
const catalog = await client.fetchCatalog({ scope: "global", force: false });
|
||||
|
||||
expect(Array.isArray(catalog.models)).toBe(true);
|
||||
expect(catalog.models).toHaveLength(1);
|
||||
@@ -339,9 +341,46 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
contextWindowMaxTokens: 200_000,
|
||||
},
|
||||
});
|
||||
expect(openCodeClient.calls.providerList).toEqual([{ directory: cwd }]);
|
||||
expect(openCodeClient.calls.providerList).toEqual([{ directory: opencodeHome }]);
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
}, 60_000);
|
||||
|
||||
test("fetchCatalog releases the acquired server when opencode-home cannot be created", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const paseoHome = tmpCwd();
|
||||
const opencodeHome = path.join(paseoHome, "opencode-home");
|
||||
writeFileSync(opencodeHome, "not a directory");
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
resolveHomeDir: () => opencodeHome,
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow();
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
|
||||
expect(runtime.clientCreations).toEqual([]);
|
||||
rmSync(paseoHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("fetchCatalog releases the acquired server when opencode-home cannot be resolved", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
resolveHomeDir: () => {
|
||||
throw new Error("cannot resolve opencode-home");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(client.fetchCatalog({ scope: "global", force: false })).rejects.toThrow(
|
||||
"cannot resolve opencode-home",
|
||||
);
|
||||
|
||||
expect(runtime.acquisitions).toEqual([{ kind: "current", releaseCount: 1 }]);
|
||||
expect(runtime.clientCreations).toEqual([]);
|
||||
});
|
||||
|
||||
test("limits concurrent OpenCode metadata requests across clients", async () => {
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
let activeProviderListCalls = 0;
|
||||
@@ -382,7 +421,11 @@ describe("OpenCodeAgentClient adapter smoke tests", () => {
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from({ length: 12 }, (_, index) =>
|
||||
client.fetchCatalog({ cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`), force: false }),
|
||||
client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: path.join(os.tmpdir(), `opencode-cwd-${index}`),
|
||||
force: false,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type Session as OpenCodeSession,
|
||||
type TextPartInput as OpenCodeTextPartInput,
|
||||
} from "@opencode-ai/sdk/v2/client";
|
||||
import fs from "node:fs/promises";
|
||||
import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import pLimit from "p-limit";
|
||||
import type { Logger } from "pino";
|
||||
@@ -70,6 +71,7 @@ import {
|
||||
OpenCodeServerManager,
|
||||
type OpenCodeServerManagerLike,
|
||||
} from "./opencode/server-manager.js";
|
||||
import { resolveOpenCodeHomeDir } from "./opencode/paths.js";
|
||||
import {
|
||||
formatProviderDiagnostic,
|
||||
formatProviderDiagnosticError,
|
||||
@@ -1206,6 +1208,7 @@ export const __openCodeInternals = {
|
||||
resolveOpenCodeSelectedModelContextWindow,
|
||||
isSelectableOpenCodeAgent,
|
||||
mapOpenCodeAgentToMode,
|
||||
resolveOpenCodeHomeDir,
|
||||
get OpenCodeAgentSession() {
|
||||
return OpenCodeAgentSession;
|
||||
},
|
||||
@@ -1214,6 +1217,7 @@ export const __openCodeInternals = {
|
||||
interface OpenCodeAgentClientDeps {
|
||||
serverManager?: OpenCodeServerManagerLike;
|
||||
createClient?: OpenCodeClientFactory;
|
||||
resolveHomeDir?: () => string;
|
||||
managedProcesses?: ManagedProcessRegistry;
|
||||
}
|
||||
|
||||
@@ -1231,6 +1235,7 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
|
||||
private readonly serverManager: OpenCodeServerManagerLike;
|
||||
private readonly createOpenCodeClient: OpenCodeClientFactory;
|
||||
private readonly resolveHomeDir: () => string;
|
||||
private readonly logger: Logger;
|
||||
private readonly runtimeSettings?: ProviderRuntimeSettings;
|
||||
private readonly modelContextWindows = new Map<string, number>();
|
||||
@@ -1246,8 +1251,10 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
deps.serverManager ??
|
||||
OpenCodeServerManager.getInstance(this.logger, runtimeSettings, {
|
||||
managedProcesses: deps.managedProcesses,
|
||||
resolveHomeDir: deps.resolveHomeDir,
|
||||
});
|
||||
this.createOpenCodeClient = deps.createClient ?? createSdkOpenCodeClient;
|
||||
this.resolveHomeDir = deps.resolveHomeDir ?? resolveOpenCodeHomeDir;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
@@ -1348,10 +1355,22 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
? await this.serverManager.acquireNew()
|
||||
: await this.serverManager.acquireCurrent();
|
||||
const { url } = acquisition.server;
|
||||
const directory = options.cwd;
|
||||
const client = this.createOpenCodeClient({ baseUrl: url, directory });
|
||||
const isGlobalCatalog = options.scope === "global";
|
||||
|
||||
try {
|
||||
// OpenCode treats the catalog directory as a workspace. The global catalog
|
||||
// is not a project, so use the neutral OpenCode home instead of user home.
|
||||
const directory = isGlobalCatalog ? this.resolveHomeDir() : options.cwd;
|
||||
|
||||
if (isGlobalCatalog) {
|
||||
await fs.mkdir(directory, { recursive: true });
|
||||
this.logger.debug(
|
||||
{ directory },
|
||||
"opencode catalog refresh: using opencode-home for global provider catalog",
|
||||
);
|
||||
}
|
||||
|
||||
const client = this.createOpenCodeClient({ baseUrl: url, directory });
|
||||
const [models, modes] = await Promise.all([
|
||||
this.fetchModelsFromClient(client, directory),
|
||||
this.fetchModesFromClient(client, directory),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
@@ -208,15 +211,39 @@ describe("OpenCodeServerManager managed process ledger", () => {
|
||||
expect(runtime.terminatedPorts).toEqual([4602]);
|
||||
expect(await runtime.managedProcesses.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test("starts helper server from opencode-home", async () => {
|
||||
const tempDir = mkdtempSync(path.join(os.tmpdir(), "opencode-server-home-"));
|
||||
const opencodeHomeDir = path.join(tempDir, "opencode-home");
|
||||
try {
|
||||
const { manager, runtime } = createTestManager([4603], { opencodeHomeDir });
|
||||
|
||||
const acquisition = await manager.acquireCurrent();
|
||||
|
||||
expect(runtime.spawnCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
command: "opencode",
|
||||
args: ["serve", "--port", "4603"],
|
||||
options: expect.objectContaining({ cwd: opencodeHomeDir }),
|
||||
}),
|
||||
]);
|
||||
|
||||
acquisition.release();
|
||||
await manager.shutdown();
|
||||
} finally {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function createTestManager(
|
||||
ports: number[],
|
||||
options: { autoAnnounce?: boolean } = {},
|
||||
options: { autoAnnounce?: boolean; opencodeHomeDir?: string } = {},
|
||||
): {
|
||||
manager: OpenCodeServerManager;
|
||||
runtime: FakeOpenCodeServerRuntime;
|
||||
} {
|
||||
const { opencodeHomeDir } = options;
|
||||
const runtime = new FakeOpenCodeServerRuntime(ports, {
|
||||
autoAnnounce: options.autoAnnounce ?? true,
|
||||
});
|
||||
@@ -226,6 +253,7 @@ function createTestManager(
|
||||
managedProcesses: runtime.managedProcesses,
|
||||
portAllocator: runtime.allocatePort,
|
||||
resolveCommandPrefix: runtime.resolveCommandPrefix,
|
||||
...(opencodeHomeDir ? { resolveHomeDir: () => opencodeHomeDir } : {}),
|
||||
spawnServerProcess: runtime.spawnServerProcess,
|
||||
terminateProcess: runtime.terminateProcess,
|
||||
}),
|
||||
@@ -236,6 +264,11 @@ function createTestManager(
|
||||
class FakeOpenCodeServerRuntime {
|
||||
readonly managedProcesses = new FakeManagedProcesses();
|
||||
readonly terminatedPorts: number[] = [];
|
||||
readonly spawnCalls: Array<{
|
||||
command: string;
|
||||
args: string[];
|
||||
options: Parameters<OpenCodeServerProcessSpawner>[2];
|
||||
}> = [];
|
||||
private readonly ports: number[];
|
||||
private readonly autoAnnounce: boolean;
|
||||
private readonly processesByChild = new Map<ChildProcess, FakeOpenCodeProcess>();
|
||||
@@ -263,7 +296,8 @@ class FakeOpenCodeServerRuntime {
|
||||
args: [],
|
||||
});
|
||||
|
||||
readonly spawnServerProcess: OpenCodeServerProcessSpawner = (command, args) => {
|
||||
readonly spawnServerProcess: OpenCodeServerProcessSpawner = (command, args, options) => {
|
||||
this.spawnCalls.push({ command, args, options });
|
||||
const port = Number(args.at(-1));
|
||||
const process = new FakeOpenCodeProcess({ port, pid: 10_000 + port });
|
||||
this.processesByChild.set(process.child, process);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { resolvePaseoHome } from "../../../paseo-home.js";
|
||||
|
||||
const OPENCODE_HOME_DIRNAME = "opencode-home";
|
||||
|
||||
export function resolveOpenCodeHomeDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return path.join(resolvePaseoHome(env), OPENCODE_HOME_DIRNAME);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { findExecutable } from "../../../../executable-resolution/executable-resolution.js";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
resolveProviderCommandPrefix,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
import { resolveOpenCodeHomeDir } from "./paths.js";
|
||||
|
||||
const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5_000;
|
||||
const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1_000;
|
||||
@@ -55,6 +56,7 @@ export interface OpenCodeServerManagerOptions {
|
||||
terminateProcess?: ProcessTerminator;
|
||||
portAllocator?: OpenCodePortAllocator;
|
||||
resolveCommandPrefix?: OpenCodeCommandPrefixResolver;
|
||||
resolveHomeDir?: () => string;
|
||||
spawnServerProcess?: OpenCodeServerProcessSpawner;
|
||||
}
|
||||
|
||||
@@ -72,6 +74,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
private readonly terminateProcess: ProcessTerminator;
|
||||
private readonly portAllocator: OpenCodePortAllocator;
|
||||
private readonly resolveCommandPrefix: OpenCodeCommandPrefixResolver;
|
||||
private readonly resolveHomeDir: () => string;
|
||||
private readonly spawnServerProcess: OpenCodeServerProcessSpawner;
|
||||
|
||||
constructor(options: OpenCodeServerManagerOptions) {
|
||||
@@ -84,6 +87,7 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
this.resolveCommandPrefix =
|
||||
options.resolveCommandPrefix ??
|
||||
(() => resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary));
|
||||
this.resolveHomeDir = options.resolveHomeDir ?? resolveOpenCodeHomeDir;
|
||||
this.spawnServerProcess = options.spawnServerProcess ?? spawnProcess;
|
||||
}
|
||||
|
||||
@@ -249,7 +253,11 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
const launchPrefix = await this.resolveCommandPrefix();
|
||||
const serverArgs = [...launchPrefix.args, "serve", "--port", String(port)];
|
||||
const serverCwd = os.homedir();
|
||||
// Use a neutral OpenCode home as the server cwd. Launching from the user's
|
||||
// home directory causes OpenCode to treat it as the default workspace and
|
||||
// index the entire home tree.
|
||||
const serverCwd = this.resolveHomeDir();
|
||||
mkdirSync(serverCwd, { recursive: true });
|
||||
|
||||
const serverProcess = this.spawnServerProcess(launchPrefix.command, serverArgs, {
|
||||
cwd: serverCwd,
|
||||
|
||||
@@ -861,7 +861,11 @@ describe("PiRpcAgentClient", () => {
|
||||
test("discovers models from a short-lived Pi session in the requested cwd", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
const catalogPromise = client.fetchCatalog({ cwd: "/workspace/with-extension", force: false });
|
||||
const catalogPromise = client.fetchCatalog({
|
||||
scope: "workspace",
|
||||
cwd: "/workspace/with-extension",
|
||||
force: false,
|
||||
});
|
||||
pi.latestSession().models = [
|
||||
{
|
||||
provider: "openrouter",
|
||||
|
||||
@@ -1971,7 +1971,9 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
|
||||
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
|
||||
const runtimeSession = await this.runtime.startSession({ cwd: options.cwd });
|
||||
const runtimeSession = await this.runtime.startSession({
|
||||
cwd: options.scope === "global" ? homedir() : options.cwd,
|
||||
});
|
||||
try {
|
||||
const models = transformPiModels(
|
||||
(await runtimeSession.getAvailableModels(PI_CATALOG_REQUEST_TIMEOUT_MS)).map(mapPiModel),
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
type ScheduleCadence,
|
||||
type UpdateScheduleInput,
|
||||
} from "@getpaseo/protocol/schedule/types";
|
||||
import { resolveSnapshotCwd, type ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import type { ProviderSnapshotManager } from "../provider-snapshot-manager.js";
|
||||
import {
|
||||
AgentModelSchema,
|
||||
AgentProviderEnum,
|
||||
@@ -2319,7 +2319,6 @@ export function createPaseoToolCatalog(options: PaseoToolHostDependencies): Pase
|
||||
},
|
||||
async ({ provider }) => {
|
||||
const models = await providerSnapshotManager.listModels({
|
||||
cwd: resolveSnapshotCwd(),
|
||||
provider,
|
||||
wait: true,
|
||||
});
|
||||
|
||||
@@ -656,7 +656,7 @@ test(
|
||||
const client = createPiClient();
|
||||
const cwd = tmpCwd("pi-list-models-");
|
||||
try {
|
||||
const { models } = await client.fetchCatalog({ cwd, force: false });
|
||||
const { models } = await client.fetchCatalog({ scope: "workspace", cwd, force: false });
|
||||
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
for (const model of models) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { execSync } from "child_process";
|
||||
import { mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "fs";
|
||||
import { homedir, tmpdir } from "os";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -1212,7 +1212,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd);
|
||||
});
|
||||
|
||||
test("normalizes legacy model and mode list requests without cwd to home", async () => {
|
||||
test("preserves legacy model and mode list requests without cwd as global", async () => {
|
||||
const messages: unknown[] = [];
|
||||
const {
|
||||
manager: providerSnapshotManager,
|
||||
@@ -1239,9 +1239,9 @@ describe("session provider refresh cwd routing", () => {
|
||||
requestId: "modes-home",
|
||||
});
|
||||
|
||||
expect(getSnapshot).toHaveBeenCalledWith(homedir());
|
||||
expect(getSnapshot).toHaveBeenCalledWith(undefined);
|
||||
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
|
||||
cwd: homedir(),
|
||||
cwd: undefined,
|
||||
providers: ["codex"],
|
||||
});
|
||||
});
|
||||
@@ -1343,7 +1343,7 @@ describe("session provider refresh cwd routing", () => {
|
||||
});
|
||||
|
||||
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
|
||||
cwd: homedir(),
|
||||
cwd: undefined,
|
||||
providers: ["codex"],
|
||||
});
|
||||
warmupDeferred.resolve();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import pino from "pino";
|
||||
import {
|
||||
ProviderCatalogSession,
|
||||
@@ -8,7 +8,7 @@ import { createStub } from "../../test-utils/class-mocks.js";
|
||||
import { findByType } from "../../test-utils/session-stubs.js";
|
||||
import type { SessionOutboundMessage } from "../../messages.js";
|
||||
import {
|
||||
resolveSnapshotCwd,
|
||||
GLOBAL_PROVIDER_SNAPSHOT_KEY,
|
||||
type ProviderSnapshotManager,
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||
@@ -66,7 +66,10 @@ function makeSubsystem(options: MakeOptions = {}) {
|
||||
providerUsageService: createStub<ProviderUsageService>(options.usage ?? {}),
|
||||
logger: pino({ level: "silent" }),
|
||||
});
|
||||
function pushSnapshotChange(entries: ProviderSnapshotEntry[], cwd = resolveSnapshotCwd()): void {
|
||||
function pushSnapshotChange(
|
||||
entries: ProviderSnapshotEntry[],
|
||||
cwd = GLOBAL_PROVIDER_SNAPSHOT_KEY,
|
||||
): void {
|
||||
if (!changeHandler) throw new Error("start() must run before a snapshot change");
|
||||
changeHandler(entries, cwd);
|
||||
}
|
||||
@@ -142,6 +145,26 @@ describe("ProviderCatalogSession", () => {
|
||||
expect(res?.payload.error).toBe("Provider codex is disabled");
|
||||
});
|
||||
|
||||
it("preserves missing cwd as the semantic global snapshot for model list reads", async () => {
|
||||
const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]);
|
||||
const warmUpSnapshotForCwd = vi.fn(async () => {});
|
||||
const { subsystem } = makeSubsystem({
|
||||
snapshot: { getSnapshot, warmUpSnapshotForCwd },
|
||||
});
|
||||
|
||||
await subsystem.handleListProviderModelsRequest({
|
||||
type: "list_provider_models_request",
|
||||
provider: "codex",
|
||||
requestId: "m-global",
|
||||
});
|
||||
|
||||
expect(getSnapshot).toHaveBeenCalledWith(undefined);
|
||||
expect(warmUpSnapshotForCwd).toHaveBeenCalledWith({
|
||||
cwd: undefined,
|
||||
providers: ["codex"],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a usage-list failure as an rpc_error envelope", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
usage: {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type pino from "pino";
|
||||
import { getErrorMessage } from "@getpaseo/protocol/error-utils";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
import {
|
||||
resolveSnapshotCwd,
|
||||
isGlobalProviderSnapshotKey,
|
||||
type ProviderSnapshotManager,
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import type {
|
||||
@@ -77,7 +77,7 @@ export class ProviderCatalogSession {
|
||||
const visibleEntries = entries.filter((entry) =>
|
||||
this.host.isProviderVisibleToClient(entry.provider),
|
||||
);
|
||||
const snapshotCwd = cwd === resolveSnapshotCwd() ? undefined : cwd;
|
||||
const snapshotCwd = isGlobalProviderSnapshotKey(cwd) ? undefined : cwd;
|
||||
this.host.emit({
|
||||
type: "providers_snapshot_update",
|
||||
payload: {
|
||||
@@ -141,7 +141,7 @@ export class ProviderCatalogSession {
|
||||
async handleListProviderModelsRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_models_request" }>,
|
||||
): Promise<void> {
|
||||
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||||
const cwd = resolveCatalogRequestCwd(msg.cwd);
|
||||
const fetchedAt = new Date().toISOString();
|
||||
|
||||
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
||||
@@ -198,7 +198,7 @@ export class ProviderCatalogSession {
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_modes_request" }>,
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const cwd = resolveSnapshotCwd(msg.cwd ? expandTilde(msg.cwd) : undefined);
|
||||
const cwd = resolveCatalogRequestCwd(msg.cwd);
|
||||
const entry = await this.getProviderSnapshotEntryForRead(cwd, msg.provider);
|
||||
|
||||
if (!entry) {
|
||||
@@ -250,7 +250,7 @@ export class ProviderCatalogSession {
|
||||
}
|
||||
|
||||
private async getProviderSnapshotEntryForRead(
|
||||
cwd: string,
|
||||
cwd: string | undefined,
|
||||
provider: AgentProvider,
|
||||
): Promise<ProviderSnapshotEntry | undefined> {
|
||||
const manager = this.providerSnapshotManager;
|
||||
@@ -452,3 +452,8 @@ export class ProviderCatalogSession {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCatalogRequestCwd(cwd?: string | null): string | undefined {
|
||||
const trimmed = cwd?.trim();
|
||||
return trimmed ? expandTilde(trimmed) : undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user