diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index b28accd07..6939089b6 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -156,6 +156,90 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("refreshTimeoutMs option overrides the default and yields a timeout error", async () => { + // never-resolving isAvailable forces the timeout path + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 1, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.provider).toBe("codex"); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is honored when no option is given", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + expect(entry.error).toMatch(/after 1ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + + test("PASEO_PROVIDER_REFRESH_TIMEOUT_MS env var is ignored when option is provided", async () => { + vi.stubEnv("PASEO_PROVIDER_REFRESH_TIMEOUT_MS", "1"); + const isAvailable = vi.fn(() => new Promise(() => {})); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + refreshTimeoutMs: 5, + providerOverrides: { + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + }, + extraClients: { codex: createExtraClient("codex", { isAvailable }) }, + }); + try { + const entry = await manager.getProvider({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(entry.status).toBe("error"); + // explicit option (5) wins over env var (1) + expect(entry.error).toMatch(/after 5ms/); + } finally { + manager.destroy(); + vi.unstubAllEnvs(); + } + }); + test("listProviders returns an entry per registered provider", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 08a51c2c5..0acbc3fe8 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -29,6 +29,25 @@ import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js import type { MutableDaemonConfig } from "../daemon-config-store.js"; const DEFAULT_REFRESH_TIMEOUT_MS = 30_000; +const REFRESH_TIMEOUT_ENV_VAR = "PASEO_PROVIDER_REFRESH_TIMEOUT_MS"; + +// Provider refresh probes can be slow on cold starts (e.g. Copilot's first +// `copilot --acp` invocation, OpenCode workspace probes with many MCP servers). +// Allow operators to bump the ceiling via env var without rebuilding. +function resolveRefreshTimeoutMs(option: number | undefined): number { + if (typeof option === "number" && Number.isFinite(option) && option > 0) { + return option; + } + const fromEnv = process.env[REFRESH_TIMEOUT_ENV_VAR]; + if (fromEnv) { + // Number() handles scientific notation (e.g. "6e4") which parseInt would silently truncate. + const parsed = Number(fromEnv); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return DEFAULT_REFRESH_TIMEOUT_MS; +} type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void; @@ -124,7 +143,7 @@ export class ProviderSnapshotManager { this.runtimeSettings = options.runtimeSettings; this.providerOverrides = options.providerOverrides; this.baseProviderOverrides = options.providerOverrides; - this.refreshTimeoutMs = options.refreshTimeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS; + this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs); this.providerRegistry = this.buildRegistry(); this.providerClients = { ...this.extraClients } as Record; }