Keep provider diagnostics useful when discovery is slow (#1724)

This commit is contained in:
Mohamed Boudra
2026-06-25 19:30:19 +08:00
committed by GitHub
parent c00121273b
commit 2cf041c4d8
10 changed files with 782 additions and 107 deletions
+7 -7
View File
@@ -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,
});
}
@@ -3673,7 +3673,7 @@ export class DaemonClient {
cwd: options?.cwd,
},
responseType: "list_provider_modes_response",
timeout: 45000,
timeout: 90000,
});
}
@@ -3688,7 +3688,7 @@ export class DaemonClient {
draftConfig,
},
responseType: "list_provider_features_response",
timeout: 45000,
timeout: 90000,
});
}
@@ -3701,7 +3701,7 @@ export class DaemonClient {
type: "list_available_providers_request",
},
responseType: "list_available_providers_response",
timeout: 30000,
timeout: 60000,
});
}
@@ -3809,7 +3809,7 @@ export class DaemonClient {
providers: options?.providers,
},
responseType: "refresh_providers_snapshot_response",
timeout: 60000,
timeout: 120000,
});
}
@@ -3824,7 +3824,7 @@ export class DaemonClient {
provider,
},
responseType: "provider_diagnostic_response",
timeout: 30000,
timeout: 180000,
});
}
@@ -1,7 +1,7 @@
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,
@@ -9,6 +9,7 @@ import {
quoteWindowsArgument,
quoteWindowsCommand,
} from "./executable-resolution.js";
import { windowsExecutableResolution } from "./windows.js";
import { isPlatform } from "../test-utils/platform.js";
const originalEnv = {
@@ -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();
@@ -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", () => {
@@ -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;
}
@@ -646,6 +646,7 @@ export interface AgentSession {
export interface FetchCatalogOptions {
cwd: string;
force: boolean;
timeoutMs?: number;
}
export interface ProviderCatalog {
@@ -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.
@@ -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() });
@@ -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" },
];
@@ -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();
}
@@ -27,10 +27,14 @@ import {
type ProviderDefinition,
} from "./provider-registry.js";
import { applyMutableProviderConfigToOverrides } from "../daemon-config-store.js";
import { formatProviderDiagnostic } from "./providers/diagnostic-utils.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
} from "./providers/diagnostic-utils.js";
import type { MutableDaemonConfig } from "../daemon-config-store.js";
const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
const DEFAULT_REFRESH_TIMEOUT_MS = 60_000;
const DEFAULT_DIAGNOSTIC_TIMEOUT_MS = 120_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
@@ -51,6 +55,13 @@ function resolveRefreshTimeoutMs(option: number | undefined): number {
return DEFAULT_REFRESH_TIMEOUT_MS;
}
function resolveDiagnosticTimeoutMs(option: number | undefined, refreshTimeoutMs: number): number {
if (typeof option === "number" && Number.isFinite(option) && option > 0) {
return option;
}
return Math.max(refreshTimeoutMs, DEFAULT_DIAGNOSTIC_TIMEOUT_MS);
}
type ProviderSnapshotChangeListener = (entries: ProviderSnapshotEntry[], cwd: string) => void;
export interface ProviderSnapshotManagerOptions {
@@ -62,6 +73,7 @@ export interface ProviderSnapshotManagerOptions {
isDev?: boolean;
extraClients?: Partial<Record<AgentProvider, AgentClient>>;
refreshTimeoutMs?: number;
diagnosticTimeoutMs?: number;
}
interface ProviderSnapshotRefreshOptions {
@@ -128,6 +140,7 @@ export class ProviderSnapshotManager {
private readonly events = new EventEmitter();
private destroyed = false;
private readonly refreshTimeoutMs: number;
private readonly diagnosticTimeoutMs: number;
private readonly logger: Logger;
private readonly workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
private readonly managedProcesses?: ManagedProcessRegistry;
@@ -149,6 +162,10 @@ export class ProviderSnapshotManager {
this.providerOverrides = options.providerOverrides;
this.baseProviderOverrides = options.providerOverrides;
this.refreshTimeoutMs = resolveRefreshTimeoutMs(options.refreshTimeoutMs);
this.diagnosticTimeoutMs = resolveDiagnosticTimeoutMs(
options.diagnosticTimeoutMs,
this.refreshTimeoutMs,
);
this.providerRegistry = this.buildRegistry();
this.providerClients = { ...this.extraClients } as Record<AgentProvider, AgentClient>;
}
@@ -313,22 +330,25 @@ export class ProviderSnapshotManager {
}
async getProviderDiagnostic(provider: AgentProvider): Promise<ProviderDiagnosticResult> {
const definition = this.requireProvider(provider);
const client = this.ensureClient(provider, definition);
const definition = this.providerRegistry[provider];
if (!definition) {
return {
provider,
diagnostic: formatProviderDiagnostic(provider, [
{ label: "Error", value: `Provider ${provider} is not configured` },
]),
};
}
// Force-refresh the snapshot so Models/Status come from the single catalog authority.
await this.refreshSnapshotForCwd({ cwd: homedir(), providers: [provider] });
const entry = await this.getProvider({ cwd: homedir(), provider, wait: true });
const baseDiagnosticPromise = this.getBaseProviderDiagnostic(provider, definition);
const snapshotEntryPromise = this.refreshDiagnosticSnapshotEntry(provider, definition);
const [baseDiagnostic, entry] = await Promise.all([
baseDiagnosticPromise,
snapshotEntryPromise,
]);
const modelCount = entry.status === "ready" ? String(entry.models?.length ?? 0) : "—";
const status = formatProviderStatus(entry);
const baseDiagnostic = client.getDiagnostic
? (await client.getDiagnostic()).diagnostic
: formatProviderDiagnostic(definition.label ?? provider, [
{ label: "Diagnostic", value: "No diagnostic available" },
]);
const diagnostic = `${baseDiagnostic}\n Models: ${modelCount}\n Status: ${status}`;
return { provider, diagnostic };
}
@@ -446,6 +466,52 @@ export class ProviderSnapshotManager {
return definition;
}
private async refreshDiagnosticSnapshotEntry(
provider: AgentProvider,
definition: ProviderDefinition,
): Promise<ProviderSnapshotEntry> {
try {
const cwd = resolveSnapshotCwd();
await this.refreshSnapshotForCwd({ cwd, providers: [provider] });
return await this.getProvider({ cwd, provider, wait: false });
} catch (error) {
return {
provider,
status: "error",
enabled: definition.enabled,
label: definition.label,
description: definition.description,
defaultModeId: definition.defaultModeId,
error: toErrorMessage(error),
};
}
}
private async getBaseProviderDiagnostic(
provider: AgentProvider,
definition: ProviderDefinition,
): Promise<string> {
try {
const client = this.ensureClient(provider, definition);
if (client.getDiagnostic) {
return (
await withTimeout(
client.getDiagnostic(),
this.diagnosticTimeoutMs,
`Timed out collecting ${definition.label ?? provider} diagnostic after ${
this.diagnosticTimeoutMs
}ms`,
)
).diagnostic;
}
return formatProviderDiagnostic(definition.label ?? provider, [
{ label: "Diagnostic", value: "No diagnostic available" },
]);
} catch (error) {
return formatProviderDiagnosticError(definition.label ?? provider, error);
}
}
private createLoadingEntries(): Map<AgentProvider, ProviderSnapshotEntry> {
const entries = new Map<AgentProvider, ProviderSnapshotEntry>();
for (const provider of this.getProviderIds()) {
@@ -655,7 +721,7 @@ export class ProviderSnapshotManager {
}
const catalog = await withTimeout(
definition.fetchCatalog({ cwd, force }, client),
definition.fetchCatalog({ cwd, force, timeoutMs: this.refreshTimeoutMs }, client),
this.refreshTimeoutMs,
`Timed out refreshing ${definition.label} after ${this.refreshTimeoutMs}ms`,
);
@@ -1,6 +1,7 @@
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { Readable, Writable } from "node:stream";
@@ -102,6 +103,12 @@ import {
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { platformShell, spawnProcess } from "../../../utils/spawn.js";
import {
type DiagnosticEntry,
toDiagnosticErrorMessage,
truncateForDiagnostic,
} from "./diagnostic-utils.js";
import { withTimeout } from "../../../utils/promise-timeout.js";
function assertChildWithPipes(
child: ChildProcess,
@@ -187,6 +194,21 @@ function resolveTerminalCommand(
return { command: shell.command, args: [...shell.flag, command] };
}
function formatDurationMs(startedAt: number): string {
return `${Math.max(0, Date.now() - startedAt)}ms`;
}
function pushACPStderrRow(rows: DiagnosticEntry[], stderrChunks: string[]): void {
const stderr = stderrChunks.join("").trim();
if (!stderr) {
return;
}
rows.push({
label: "ACP stderr",
value: truncateForDiagnostic(stderr),
});
}
export const DEFAULT_ACP_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -215,6 +237,8 @@ const ACP_CLIENT_CAPABILITIES: ACPClientCapabilities = {
// sign-in URL in the browser) when probing an ACP agent for models/modes.
// NO_BROWSER is honored by Gemini CLI; other ACP agents ignore it.
const PROBE_ENV: Record<string, string> = { NO_BROWSER: "true" };
const ACP_CATALOG_TIMEOUT_MS = 60_000;
const ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS = 20_000;
function summarizeMalformedACPStdoutError(error: unknown): { type: string; message: string } {
return {
@@ -371,6 +395,19 @@ export interface SpawnedACPProcess {
child: ChildProcessWithoutNullStreams;
connection: ClientSideConnection;
initialize: InitializeResponse;
stderrChunks?: string[];
}
type UninitializedACPProcess = Omit<SpawnedACPProcess, "initialize"> & {
initialize?: InitializeResponse;
};
interface ACPProcessTransport {
child: ChildProcessWithoutNullStreams;
connection: ClientSideConnection;
stderrChunks: string[];
spawnReady: Promise<void>;
spawnError: Promise<never>;
}
export interface ACPToolSnapshot {
@@ -763,31 +800,49 @@ export class ACPAgentClient implements AgentClient {
async fetchCatalog(options: FetchCatalogOptions): Promise<ProviderCatalog> {
const { cwd } = options;
const probe = await this.spawnProcess(PROBE_ENV);
const timeoutMs = options.timeoutMs ?? ACP_CATALOG_TIMEOUT_MS;
let probe: UninitializedACPProcess | null = null;
try {
const response = await this.runACPRequest(() =>
probe.connection.newSession({
cwd,
mcpServers: [],
}),
const catalogProbe = (async () => {
const initializedProbe = await this.spawnProcess(PROBE_ENV, {
initializeTimeoutMs: timeoutMs,
onSpawned: (spawned) => {
probe = spawned;
},
});
probe = initializedProbe;
const response = await this.runACPRequest(() =>
initializedProbe.connection.newSession({
cwd,
mcpServers: [],
}),
);
const transformed = this.transformSessionResponse(response);
const models = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return {
models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes,
};
})();
return await withTimeout(
catalogProbe,
timeoutMs,
`ACP catalog probe timed out after ${timeoutMs}ms`,
);
const transformed = this.transformSessionResponse(response);
const models = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
return {
models: this.modelTransformer ? this.modelTransformer(models) : models,
modes: modeInfo.modes,
};
} finally {
await this.closeProbe(probe);
if (probe) {
await this.closeProbe(probe);
}
}
}
@@ -874,8 +929,33 @@ export class ACPAgentClient implements AgentClient {
protected async spawnProcess(
launchEnv?: Record<string, string>,
options?: { initializeTimeoutMs?: number },
options?: {
initializeTimeoutMs?: number;
onSpawned?: (probe: UninitializedACPProcess) => void;
},
): Promise<SpawnedACPProcess> {
const transport = await this.spawnTransport(launchEnv);
const probe: UninitializedACPProcess = {
child: transport.child,
connection: transport.connection,
stderrChunks: transport.stderrChunks,
};
options?.onSpawned?.(probe);
try {
const initialize = await this.initializeTransport(transport, options?.initializeTimeoutMs);
const initializedProbe: SpawnedACPProcess = {
...probe,
initialize,
};
probe.initialize = initialize;
return initializedProbe;
} catch (error) {
await terminateChildProcess(transport.child, 2_000, this.terminateProcess);
throw error;
}
}
protected async spawnTransport(launchEnv?: Record<string, string>): Promise<ACPProcessTransport> {
const { command, args } = await this.resolveLaunchCommand();
const child = spawnProcess(command, args, {
cwd: process.cwd(),
@@ -898,6 +978,11 @@ export class ACPAgentClient implements AgentClient {
reject(new Error(stderr ? `${String(error)}\n${stderr}` : String(error)));
});
});
const spawnReadyPromise = new Promise<void>((resolve) => {
child.once("spawn", () => {
resolve();
});
});
const stream = createLoggedNdJsonStream(
Writable.toWeb(child.stdin),
@@ -906,38 +991,45 @@ export class ACPAgentClient implements AgentClient {
);
const connection = new ClientSideConnection(() => this.buildProbeClient(), stream);
return {
child,
connection,
stderrChunks,
spawnReady: spawnReadyPromise,
spawnError: spawnErrorPromise,
};
}
protected async initializeTransport(
transport: ACPProcessTransport,
initializeTimeoutMs?: number,
): Promise<InitializeResponse> {
let timeout: ReturnType<typeof setTimeout> | null = null;
const initializeTimeoutPromise = options?.initializeTimeoutMs
const initializeTimeoutPromise = initializeTimeoutMs
? new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`ACP initialize timed out after ${options.initializeTimeoutMs}ms`));
}, options.initializeTimeoutMs);
reject(new Error(`ACP initialize timed out after ${initializeTimeoutMs}ms`));
}, initializeTimeoutMs);
})
: null;
let initialize: InitializeResponse;
try {
initialize = await this.runACPRequest(() =>
return await this.runACPRequest(() =>
Promise.race([
connection.initialize({
transport.connection.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: ACP_CLIENT_CAPABILITIES,
clientInfo: { name: "Paseo", version: "dev" },
}),
spawnErrorPromise,
transport.spawnError,
...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []),
]),
);
} catch (error) {
await terminateChildProcess(child, 2_000, this.terminateProcess);
throw error;
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
return { child, connection, initialize };
}
protected buildProbeClient(): ACPClient {
@@ -961,9 +1053,9 @@ export class ACPAgentClient implements AgentClient {
};
}
protected async closeProbe(probe: SpawnedACPProcess): Promise<void> {
protected async closeProbe(probe: UninitializedACPProcess): Promise<void> {
try {
if (probe.initialize.agentCapabilities?.sessionCapabilities?.close) {
if (probe.initialize?.agentCapabilities?.sessionCapabilities?.close) {
// No active session to close here; ignore capability.
}
} finally {
@@ -979,6 +1071,114 @@ export class ACPAgentClient implements AgentClient {
}
}
protected async buildACPProbeDiagnosticRows(
options: {
cwd?: string;
phaseTimeoutMs?: number;
} = {},
): Promise<DiagnosticEntry[]> {
const rows: DiagnosticEntry[] = [];
const phaseTimeoutMs = options.phaseTimeoutMs ?? ACP_DIAGNOSTIC_PHASE_TIMEOUT_MS;
const cwd = options.cwd ?? homedir();
let transport: ACPProcessTransport | null = null;
try {
const spawnStartedAt = Date.now();
try {
transport = await this.spawnTransport(PROBE_ENV);
await withTimeout(
Promise.race([transport.spawnReady, transport.spawnError]),
phaseTimeoutMs,
`ACP spawn timed out after ${phaseTimeoutMs}ms`,
);
rows.push({
label: "ACP spawn",
value: `ok (${formatDurationMs(spawnStartedAt)})`,
});
} catch (error) {
rows.push({
label: "ACP spawn",
value: `error: ${toDiagnosticErrorMessage(error)}`,
});
return rows;
}
const activeTransport = transport;
const initializeStartedAt = Date.now();
try {
await this.initializeTransport(activeTransport, phaseTimeoutMs);
rows.push({
label: "ACP initialize",
value: `ok (${formatDurationMs(initializeStartedAt)})`,
});
} catch (error) {
rows.push({
label: "ACP initialize",
value: `error: ${toDiagnosticErrorMessage(error)}`,
});
pushACPStderrRow(rows, activeTransport.stderrChunks);
return rows;
}
const sessionStartedAt = Date.now();
try {
const response = await withTimeout(
this.runACPRequest(() =>
activeTransport.connection.newSession({
cwd,
mcpServers: [],
}),
),
phaseTimeoutMs,
`ACP session/new timed out after ${phaseTimeoutMs}ms`,
);
const transformed = this.transformSessionResponse(response);
const models = deriveModelDefinitionsFromACP(
this.provider,
transformed.models,
transformed.configOptions,
);
const modeInfo = deriveModesFromACP(
this.defaultModes,
transformed.modes,
transformed.configOptions,
);
rows.push({
label: "ACP session/new",
value: `ok (${formatDurationMs(sessionStartedAt)}; models=${models.length}; modes=${
modeInfo.modes.length
})`,
});
} catch (error) {
rows.push({
label: "ACP session/new",
value: `error: ${toDiagnosticErrorMessage(error)}`,
});
pushACPStderrRow(rows, activeTransport.stderrChunks);
return rows;
}
pushACPStderrRow(rows, activeTransport.stderrChunks);
return rows;
} finally {
if (transport) {
const cleanupStartedAt = Date.now();
try {
await terminateChildProcess(transport.child, 2_000, this.terminateProcess);
rows.push({
label: "ACP cleanup",
value: `ok (${formatDurationMs(cleanupStartedAt)})`,
});
} catch (error) {
rows.push({
label: "ACP cleanup",
value: `error: ${toDiagnosticErrorMessage(error)}`,
});
}
}
}
}
protected async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
const prefix = await resolveProviderLaunch({
commandConfig: this.runtimeSettings?.command,
@@ -44,7 +44,7 @@ export function formatDiagnosticStatus(
const DIAGNOSTIC_OUTPUT_CAP = 4096;
function truncateForDiagnostic(value: string): string {
export function truncateForDiagnostic(value: string): string {
const trimmed = value.trim();
if (trimmed.length <= DIAGNOSTIC_OUTPUT_CAP) {
return trimmed;
@@ -1,8 +1,14 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js";
const TEST_ACP_TIMEOUT_MS = 1_000;
describe("GenericACPAgentClient diagnostics", () => {
test("probes npx-backed agent packages instead of npx itself", () => {
expect(buildVersionProbeCommand(["npx", "-y", "@google/gemini-cli@0.41.1", "--acp"])).toEqual({
@@ -16,25 +22,187 @@ describe("GenericACPAgentClient diagnostics", () => {
});
});
test("reports command, binary, and version command without spawning ACP", async () => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, "acp"],
providerId: "cursor",
label: "Cursor",
test("reports command, binary, version command, and ACP phase rows", async () => {
await withFakeACPAgent("success", async (scriptPath, mode) => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, scriptPath, mode],
providerId: "cursor",
label: "Cursor",
diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Cursor (ACP)");
expect(diagnostic).toContain("Provider ID: cursor");
expect(diagnostic).toContain(`Configured command: ${process.execPath} ${scriptPath} success`);
expect(diagnostic).toContain(`Launcher binary: ${process.execPath}`);
expect(diagnostic).toContain(`Version command: ${process.execPath} --version`);
expect(diagnostic).toContain("ACP spawn: ok");
expect(diagnostic).toContain("ACP initialize: ok");
expect(diagnostic).toContain("ACP session/new: ok");
expect(diagnostic).toContain("models=1");
expect(diagnostic).toContain("modes=1");
expect(diagnostic).toContain("ACP cleanup: ok");
expect(diagnostic).not.toContain("Status:");
});
});
const { diagnostic } = await client.getDiagnostic();
test("reports a hung ACP session/new phase without failing the diagnostic", async () => {
await withFakeACPAgent("hang-session", async (scriptPath, mode) => {
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, scriptPath, mode],
providerId: "grok",
label: "Grok",
diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS,
});
expect(diagnostic).toContain("Cursor (ACP)");
expect(diagnostic).toContain("Provider ID: cursor");
expect(diagnostic).toContain(`Configured command: ${process.execPath} acp`);
expect(diagnostic).toContain(`Launcher binary: ${process.execPath}`);
expect(diagnostic).toContain(`Version command: ${process.execPath} --version`);
expect(diagnostic).not.toContain("ACP initialize");
expect(diagnostic).not.toContain("ACP session/new");
expect(diagnostic).not.toContain("Models:");
expect(diagnostic).not.toContain("Modes:");
expect(diagnostic).not.toContain("Status:");
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Grok (ACP)");
expect(diagnostic).toContain("Provider ID: grok");
expect(diagnostic).toContain(`Version command: ${process.execPath} --version`);
expect(diagnostic).toContain("ACP spawn: ok");
expect(diagnostic).toContain("ACP initialize: ok");
expect(diagnostic).toContain(
`ACP session/new: error: ACP session/new timed out after ${TEST_ACP_TIMEOUT_MS}ms`,
);
expect(diagnostic).toContain("ACP cleanup: ok");
});
});
test("terminates an ACP catalog probe when session/new times out", async () => {
await withFakeACPAgent("hang-session", async (scriptPath, mode, testDir) => {
const pidPath = path.join(testDir, "agent.pid");
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [process.execPath, scriptPath, mode, pidPath],
providerId: "grok",
label: "Grok",
});
await expect(
client.fetchCatalog({ cwd: tmpdir(), force: true, timeoutMs: TEST_ACP_TIMEOUT_MS }),
).rejects.toThrow(`ACP catalog probe timed out after ${TEST_ACP_TIMEOUT_MS}ms`);
const pid = Number(await readFile(pidPath, "utf8"));
await expectProcessExit(pid);
});
});
test("reports a missing launcher without dropping the rest of the diagnostic", async () => {
await withTempDir("paseo-missing-acp-agent-", async (testDir) => {
const missingCommand = path.join(testDir, "missing-acp-agent");
const client = new GenericACPAgentClient({
logger: createTestLogger(),
command: [missingCommand, "--acp"],
providerId: "grok",
label: "Grok",
diagnosticPhaseTimeoutMs: TEST_ACP_TIMEOUT_MS,
});
const { diagnostic } = await client.getDiagnostic();
expect(diagnostic).toContain("Grok (ACP)");
expect(diagnostic).toContain("Provider ID: grok");
expect(diagnostic).toContain(`Configured command: ${missingCommand} --acp`);
expect(diagnostic).toContain(`Launcher binary: ${missingCommand}`);
expect(diagnostic).toContain("Resolved path: not found");
expect(diagnostic).toContain("Version: unknown");
expect(diagnostic).toContain(`Version command: ${missingCommand} --version`);
expect(diagnostic).toContain("ACP spawn: error:");
expect(diagnostic).toContain("not found");
});
});
});
async function withFakeACPAgent(
mode: "success" | "hang-session",
run: (scriptPath: string, mode: string, testDir: string) => Promise<void>,
): Promise<void> {
await withTempDir("paseo-acp-diagnostic-", async (testDir) => {
const scriptPath = path.join(testDir, "fake-acp-agent.cjs");
await writeFile(scriptPath, fakeACPAgentScript, "utf8");
await run(scriptPath, mode, testDir);
});
}
async function withTempDir(prefix: string, run: (testDir: string) => Promise<void>): Promise<void> {
const testDir = await mkdtemp(path.join(tmpdir(), prefix));
try {
await run(testDir);
} finally {
await rm(testDir, { recursive: true, force: true });
}
}
async function expectProcessExit(pid: number): Promise<void> {
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
if (!isProcessRunning(pid)) {
return;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
throw new Error(`Expected process ${pid} to exit`);
}
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ESRCH") {
return false;
}
throw error;
}
}
const fakeACPAgentScript = `
const fs = require("node:fs");
const readline = require("node:readline");
const mode = process.argv[2];
const pidPath = process.argv[3];
if (pidPath) {
fs.writeFileSync(pidPath, String(process.pid));
}
const rl = readline.createInterface({ input: process.stdin });
function send(id, result) {
process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n");
}
rl.on("line", (line) => {
const message = JSON.parse(line);
if (message.method === "initialize") {
send(message.id, {
protocolVersion: message.params?.protocolVersion ?? 1,
agentCapabilities: {},
});
return;
}
if (message.method === "session/new") {
if (mode === "hang-session") {
return;
}
send(message.id, {
sessionId: "session-1",
modes: {
availableModes: [{ id: "default", name: "Default", description: null }],
currentModeId: "default",
},
models: {
availableModels: [{ modelId: "fake-model", name: "Fake Model", description: null }],
currentModelId: "fake-model",
},
configOptions: [],
});
}
});
`;
@@ -5,9 +5,10 @@ import type { AgentCapabilityFlags } from "../agent-sdk-types.js";
import { checkProviderLaunchAvailable, resolveProviderLaunch } from "../provider-launch-config.js";
import { ACPAgentClient, DEFAULT_ACP_CAPABILITIES } from "./acp-agent.js";
import {
formatProviderDiagnostic,
formatProviderDiagnosticError,
buildBinaryDiagnosticRows,
formatProviderDiagnostic,
type DiagnosticEntry,
toDiagnosticErrorMessage,
} from "./diagnostic-utils.js";
export const GenericACPProviderParamsSchema = z
@@ -27,12 +28,14 @@ interface GenericACPAgentClientOptions {
providerParams?: unknown;
waitForInitialCommands?: boolean;
initialCommandsWaitTimeoutMs?: number;
diagnosticPhaseTimeoutMs?: number;
}
export class GenericACPAgentClient extends ACPAgentClient {
private readonly command: [string, ...string[]];
private readonly providerId?: string;
private readonly label?: string;
private readonly diagnosticPhaseTimeoutMs?: number;
constructor(options: GenericACPAgentClientOptions) {
super({
@@ -50,6 +53,7 @@ export class GenericACPAgentClient extends ACPAgentClient {
this.command = options.command;
this.providerId = options.providerId;
this.label = options.label;
this.diagnosticPhaseTimeoutMs = options.diagnosticPhaseTimeoutMs;
}
protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> {
@@ -67,35 +71,43 @@ export class GenericACPAgentClient extends ACPAgentClient {
async getDiagnostic(): Promise<{ diagnostic: string }> {
const providerName = formatProviderName(this.label, this.providerId);
const entries: DiagnosticEntry[] = [
{ label: "Provider ID", value: this.providerId ?? "unknown" },
{ label: "Configured command", value: this.command.join(" ") },
];
const versionProbe = buildVersionProbeCommand(this.command);
try {
const launch = await this.resolveConfiguredLaunch();
const availability = await checkProviderLaunchAvailable(launch);
const versionProbe = buildVersionProbeCommand(this.command);
return {
diagnostic: formatProviderDiagnostic(providerName, [
{ label: "Provider ID", value: this.providerId ?? "unknown" },
{ label: "Configured command", value: this.command.join(" ") },
...(await buildBinaryDiagnosticRows(launch, availability, {
binaryLabel: "Launcher binary",
versionCommand: {
command: versionProbe.command,
args: versionProbe.args,
env: this.runtimeSettings?.env,
},
})),
{
label: "Version command",
value: formatCommand(versionProbe.command, versionProbe.args),
entries.push(
...(await buildBinaryDiagnosticRows(launch, availability, {
binaryLabel: "Launcher binary",
versionCommand: {
command: versionProbe.command,
args: versionProbe.args,
env: this.runtimeSettings?.env,
},
]),
};
})),
);
} catch (error) {
return {
diagnostic: formatProviderDiagnosticError(providerName, error),
};
entries.push({
label: "Launcher binary",
value: `error: ${toDiagnosticErrorMessage(error)}`,
});
}
entries.push(
{
label: "Version command",
value: formatCommand(versionProbe.command, versionProbe.args),
},
...(await this.getACPProbeRowsForDiagnostic()),
);
return {
diagnostic: formatProviderDiagnostic(providerName, entries),
};
}
private async resolveConfiguredLaunch() {
@@ -104,6 +116,21 @@ export class GenericACPAgentClient extends ACPAgentClient {
defaultBinary: this.command[0],
});
}
private async getACPProbeRowsForDiagnostic() {
try {
return await this.buildACPProbeDiagnosticRows({
phaseTimeoutMs: this.diagnosticPhaseTimeoutMs,
});
} catch (error) {
return [
{
label: "ACP probe",
value: `error: ${toDiagnosticErrorMessage(error)}`,
},
];
}
}
}
function buildGenericACPCapabilities(options: GenericACPAgentClientOptions): AgentCapabilityFlags {