Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions packages/client/src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3656,8 +3656,8 @@ export class DaemonClient {
cwd: options?.cwd,
},
responseType: "list_provider_models_response",
// Provider SDK cold starts (especially model discovery) can exceed 30s.
timeout: 45000,
// Provider SDK cold starts (especially model discovery) can exceed 60s.
timeout: 90000,
});
}

Expand All @@ -3673,7 +3673,7 @@ export class DaemonClient {
cwd: options?.cwd,
},
responseType: "list_provider_modes_response",
timeout: 45000,
timeout: 90000,
});
}

Expand All @@ -3688,7 +3688,7 @@ export class DaemonClient {
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
timeout: 90000,
});
}

Expand All @@ -3701,7 +3701,7 @@ export class DaemonClient {
type: "list_available_providers_request",
},
responseType: "list_available_providers_response",
timeout: 30000,
timeout: 60000,
});
}

Expand Down Expand Up @@ -3809,7 +3809,7 @@ export class DaemonClient {
providers: options?.providers,
},
responseType: "refresh_providers_snapshot_response",
timeout: 60000,
timeout: 120000,
});
}

Expand All @@ -3824,7 +3824,7 @@ export class DaemonClient {
provider,
},
responseType: "provider_diagnostic_response",
timeout: 30000,
timeout: 180000,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";

import {
executableExists,
findExecutable,
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable-resolution.js";
import { windowsExecutableResolution } from "./windows.js";
import { isPlatform } from "../test-utils/platform.js";

const originalEnv = {
Expand Down Expand Up @@ -109,6 +110,13 @@ describe("findExecutable", () => {
expectWindowsPathsEqual(await findExecutable(command), cmd);
});

test("does not probe fabricated absolute path extensions that do not exist", async () => {
const dir = makeTempDir();
const command = path.join(dir, "missing-command");

await expect(findExecutable(command)).resolves.toBeNull();
});

test("finds a winget portable executable outside PATH", async () => {
const originalLocalAppData = process.env.LOCALAPPDATA;
const localAppData = makeTempDir();
Expand Down Expand Up @@ -149,6 +157,20 @@ describe("findExecutable", () => {

await expect(findExecutable("paseo-definitely-missing-command")).resolves.toBeNull();
});

test("Windows resolution skips literal path candidates that do not exist", async () => {
const probeExecutable = vi.fn(async () => true);

await expect(
windowsExecutableResolution.find("C:\\tools\\missing-command", {
enumeratePathCandidates: async () => [],
probeExecutable,
exists: () => false,
probeTimeoutMs: 100,
}),
).resolves.toBeNull();
expect(probeExecutable).not.toHaveBeenCalled();
});
});

describe("executableExists", () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/server/src/executable-resolution/windows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ async function findFirstProbeable(
continue;
}
seen.add(candidate);
if (!options.exists(candidate)) {
continue;
}
if (await options.probeExecutable(candidate, options.probeTimeoutMs)) {
return candidate;
}
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/server/agent/agent-sdk-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ export interface AgentSession {
export interface FetchCatalogOptions {
cwd: string;
force: boolean;
timeoutMs?: number;
}

export interface ProviderCatalog {
Expand Down
198 changes: 193 additions & 5 deletions packages/server/src/server/agent/provider-snapshot-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const TEST_CAPABILITIES = {
supportsReasoningStream: false,
supportsToolInvocations: false,
} as const;
const TEST_REFRESH_TIMEOUT_MS = 120_000;

// Builds an AgentClient that can be injected via the public extraClients option.
// extraClients is the only injection surface the manager exposes for tests.
Expand All @@ -48,6 +49,20 @@ function createExtraClient(
} satisfies AgentClient;
}

async function withEnv(key: string, value: string, run: () => Promise<void>): Promise<void> {
const previous = process.env[key];
process.env[key] = value;
try {
await run();
} finally {
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
}

describe("ProviderSnapshotManager public surface", () => {
test("listRegisteredProviderIds includes the built-in providers", () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
Expand Down Expand Up @@ -453,7 +468,7 @@ describe("ProviderSnapshotManager public surface", () => {
}
});

test("getProviderDiagnostic force-refreshes the snapshot via a single fetchCatalog call", async () => {
test("getProviderDiagnostic force-refreshes the snapshot and appends models/status", async () => {
const catalogModels: AgentModelDefinition[] = [
{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" },
];
Expand Down Expand Up @@ -512,12 +527,185 @@ describe("ProviderSnapshotManager public surface", () => {
}
});

test("getProviderDiagnostic throws for an unknown provider", async () => {
test("getProviderDiagnostic turns provider diagnostic failures into diagnostic text", async () => {
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }],
modes: [] as AgentMode[],
}),
getDiagnostic: async () => {
throw new Error("diagnostic probe exploded");
},
}),
},
});
try {
const result = await manager.getProviderDiagnostic("codex");
expect(result.diagnostic).toContain("Error: diagnostic probe exploded");
expect(result.diagnostic).toContain("Models: 1");
expect(result.diagnostic).toContain("Status: Ready");
} finally {
manager.destroy();
}
});

test("getProviderDiagnostic starts provider diagnostics before waiting for snapshot refresh", async () => {
vi.useFakeTimers();
let diagnosticStarted = false;
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => new Promise(() => {}),
getDiagnostic: async () => {
diagnosticStarted = true;
return { diagnostic: "codex diagnostics available" };
},
}),
},
});
try {
const diagnosticRequest = manager.getProviderDiagnostic("codex");
expect(diagnosticStarted).toBe(true);

const diagnosticOrBlocked = Promise.race([
diagnosticRequest.then(() => ({ type: "diagnostic" as const })),
new Promise<{ type: "blocked" }>((finish) => {
setTimeout(() => finish({ type: "blocked" }), 1);
}),
]);
await vi.advanceTimersByTimeAsync(1);
await expect(diagnosticOrBlocked).resolves.toEqual({ type: "blocked" });

await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS - 1);
const result = await diagnosticRequest;
expect(result.diagnostic).toContain("codex diagnostics available");
expect(result.diagnostic).toContain(
`Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`,
);
} finally {
manager.destroy();
vi.useRealTimers();
}
});

test("getProviderDiagnostic starts snapshot refresh even when provider diagnostics hang", async () => {
vi.useFakeTimers();
let diagnosticStarted = false;
let snapshotStarted = false;
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => {
snapshotStarted = true;
return new Promise(() => {});
},
getDiagnostic: async () => {
diagnosticStarted = true;
return new Promise(() => {});
},
}),
},
});
try {
const diagnosticRequest = manager.getProviderDiagnostic("codex");
await vi.advanceTimersByTimeAsync(0);

expect(diagnosticStarted).toBe(true);
expect(snapshotStarted).toBe(true);

await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS);
const result = await diagnosticRequest;
expect(result.diagnostic).toContain(
`Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`,
);
expect(result.diagnostic).toContain(
`Status: Error: Timed out refreshing Codex after ${TEST_REFRESH_TIMEOUT_MS}ms`,
);
} finally {
manager.destroy();
vi.useRealTimers();
}
});

test("getProviderDiagnostic reports provider diagnostic timeout while preserving snapshot details", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS,
extraClients: {
codex: createExtraClient("codex", {
isAvailable: async () => true,
fetchCatalog: async () => ({
models: [{ provider: "codex", id: "gpt-5.4-mini", label: "GPT 5.4 Mini" }],
modes: [] as AgentMode[],
}),
getDiagnostic: async () => new Promise(() => {}),
}),
},
});
try {
const diagnosticRequest = manager.getProviderDiagnostic("codex");
await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS);

const result = await diagnosticRequest;
expect(result.diagnostic).toContain(
`Error: Timed out collecting Codex diagnostic after ${TEST_REFRESH_TIMEOUT_MS}ms`,
);
expect(result.diagnostic).toContain("Models: 1");
expect(result.diagnostic).toContain("Status: Ready");
} finally {
manager.destroy();
vi.useRealTimers();
}
});

test("getProviderDiagnostic reports a stuck catalog refresh inside the diagnostic", async () => {
await withEnv("PASEO_ENABLE_MOCK_SLOW", "true", async () => {
vi.useFakeTimers();
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
isDev: true,
refreshTimeoutMs: TEST_REFRESH_TIMEOUT_MS,
});
try {
const diagnosticRequest = manager.getProviderDiagnostic("mock-slow");
await vi.advanceTimersByTimeAsync(TEST_REFRESH_TIMEOUT_MS);

const result = await diagnosticRequest;
expect(result.provider).toBe("mock-slow");
expect(result.diagnostic).toContain("Mock slow provider");
expect(result.diagnostic).toContain("Models: —");
expect(result.diagnostic).toContain(
`Status: Error: Timed out refreshing Mock Slow Provider after ${TEST_REFRESH_TIMEOUT_MS}ms`,
);
} finally {
manager.destroy();
vi.useRealTimers();
}
});
});

test("getProviderDiagnostic returns an error diagnostic for an unknown provider", async () => {
const manager = new ProviderSnapshotManager({ logger: createTestLogger() });
try {
await expect(
manager.getProviderDiagnostic("unknown-provider" as AgentProvider),
).rejects.toThrow(/not configured/);
await expect(manager.getProviderDiagnostic("unknown-provider" as AgentProvider)).resolves
.toMatchInlineSnapshot(`
{
"diagnostic": "unknown-provider
Error: Provider unknown-provider is not configured",
"provider": "unknown-provider",
}
`);
} finally {
manager.destroy();
}
Expand Down
Loading
Loading