diff --git a/docs/custom-providers.md b/docs/custom-providers.md index c869459b1..b1bbb8c95 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -366,6 +366,14 @@ Required fields for ACP providers: - `label` - `command` — the command to spawn the agent process (must support ACP over stdio) +### Generic ACP diagnostics + +Paseo diagnostics for `extends: "acp"` providers report the configured command, resolved launcher binary, version output, ACP `initialize`, ACP `session/new`, model count, modes, and final status. + +For package-runner commands such as `npx -y @google/gemini-cli --acp`, the version probe keeps the package spec and runs `npx -y @google/gemini-cli --version`. This diagnoses the actual agent package instead of only proving that `npx` exists. + +ACP probes use short timeouts and browser-suppression environment variables so agents that enter an auth/browser flow fail as a diagnostic error instead of hanging the provider screen. + ### Example: Google Gemini CLI [Gemini CLI](https://github.com/google-gemini/gemini-cli) supports ACP via the `--acp` flag. diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index e07411d9f..14db97fff 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -17,6 +17,8 @@ const mockState = vi.hoisted(() => { genericAcp: [] as Array<{ command: string[]; env?: Record; + providerId?: string; + label?: string; }>, }, isCommandAvailable: vi.fn(async (_command: string) => false), @@ -241,7 +243,12 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ readonly provider = "acp"; readonly runtimeSettings?: unknown; - constructor(options: { command: string[]; env?: Record }) { + constructor(options: { + command: string[]; + env?: Record; + providerId?: string; + label?: string; + }) { this.runtimeSettings = { command: { mode: "replace", @@ -252,6 +259,8 @@ vi.mock("./providers/generic-acp-agent.js", () => ({ mockState.constructorArgs.genericAcp.push({ command: options.command, env: options.env, + providerId: options.providerId, + label: options.label, }); } @@ -387,12 +396,16 @@ test("new provider extending acp uses GenericACPAgentClient", () => { env: { ACP_TOKEN: "secret", }, + providerId: "my-agent", + label: "My Agent", }, { command: ["my-agent", "--acp"], env: { ACP_TOKEN: "secret", }, + providerId: "my-agent", + label: "My Agent", }, ]); }); diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 4da1d875a..21e71ea05 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -517,6 +517,8 @@ function addDerivedProviders( logger, command, env: override.env, + providerId, + label: override.label ?? providerId, }), }); continue; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 20100d1ae..8ca3307a6 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -713,7 +713,10 @@ export class ACPAgentClient implements AgentClient { } } - protected async spawnProcess(launchEnv?: Record): Promise { + protected async spawnProcess( + launchEnv?: Record, + options?: { initializeTimeoutMs?: number }, + ): Promise { const { command, args } = await this.resolveLaunchCommand(); const child = spawnProcess(command, args, { cwd: process.cwd(), @@ -743,14 +746,35 @@ export class ACPAgentClient implements AgentClient { { logger: this.logger, provider: this.provider }, ); const connection = new ClientSideConnection(() => this.buildProbeClient(), stream); - const initialize = await Promise.race([ - connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: ACP_CLIENT_CAPABILITIES, - clientInfo: { name: "Paseo", version: "dev" }, - }), - spawnErrorPromise, - ]); + + let timeout: ReturnType | null = null; + const initializeTimeoutPromise = options?.initializeTimeoutMs + ? new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`ACP initialize timed out after ${options.initializeTimeoutMs}ms`)); + }, options.initializeTimeoutMs); + }) + : null; + + let initialize: InitializeResponse; + try { + initialize = await Promise.race([ + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: ACP_CLIENT_CAPABILITIES, + clientInfo: { name: "Paseo", version: "dev" }, + }), + spawnErrorPromise, + ...(initializeTimeoutPromise ? [initializeTimeoutPromise] : []), + ]); + } catch (error) { + await terminateChildProcess(child, 2_000); + throw error; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } return { child, connection, initialize }; } @@ -782,8 +806,7 @@ export class ACPAgentClient implements AgentClient { // No active session to close here; ignore capability. } } finally { - probe.child.kill("SIGTERM"); - await waitForChildExit(probe.child, 2_000); + await terminateChildProcess(probe.child, 2_000); } } @@ -2706,3 +2729,14 @@ async function waitForChildExit( child.kill("SIGKILL"); } } + +async function terminateChildProcess( + child: ChildProcessWithoutNullStreams, + timeoutMs: number, +): Promise { + child.kill("SIGTERM"); + child.stdin.destroy(); + child.stdout.destroy(); + child.stderr.destroy(); + await waitForChildExit(child, timeoutMs); +} diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts new file mode 100644 index 000000000..2632c2d85 --- /dev/null +++ b/packages/server/src/server/agent/providers/generic-acp-agent.diagnostic.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test, vi } from "vitest"; + +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import { buildVersionProbeCommand, GenericACPAgentClient } from "./generic-acp-agent.js"; +import type { SpawnedACPProcess } from "./acp-agent.js"; + +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({ + command: "npx", + args: ["-y", "@google/gemini-cli@0.41.1", "--version"], + }); + + expect(buildVersionProbeCommand(["pnpm", "dlx", "@agent/foo@1.2.3", "--acp"])).toEqual({ + command: "pnpm", + args: ["dlx", "@agent/foo@1.2.3", "--version"], + }); + }); + + test("reports command, binary, ACP initialize, session, models, and modes", async () => { + class TestGenericACPAgentClient extends GenericACPAgentClient { + protected override async spawnProcess(): Promise { + return { + child: { kill: vi.fn(), exitCode: 0, signalCode: null, once: vi.fn() }, + initialize: { + protocolVersion: 1, + agentInfo: { name: "Cursor Agent", version: "2026.05.09" }, + agentCapabilities: {}, + }, + connection: { + newSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + models: { + currentModelId: "composer-2[fast=true]", + availableModels: [ + { + modelId: "composer-2[fast=true]", + name: "Composer 2", + }, + ], + }, + modes: { + currentModeId: "ask", + availableModes: [ + { id: "agent", name: "Agent" }, + { id: "ask", name: "Ask" }, + ], + }, + configOptions: [], + }), + }, + } as SpawnedACPProcess; + } + + protected override async closeProbe(): Promise {} + } + + const client = new TestGenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, "acp"], + providerId: "cursor", + label: "Cursor", + }); + + const { diagnostic } = await client.getDiagnostic(); + + 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).toContain("ACP initialize: ok (protocol 1, Cursor Agent 2026.05.09)"); + expect(diagnostic).toContain("ACP session/new: ok (session-1)"); + expect(diagnostic).toContain("Models: 1"); + expect(diagnostic).toContain("Modes: Agent, Ask"); + expect(diagnostic).toContain("Status: Available"); + }); + + test("reports ACP probe failures instead of falling back to no diagnostic", async () => { + class FailingGenericACPAgentClient extends GenericACPAgentClient { + protected override async spawnProcess(): Promise { + throw new Error("initialize timed out"); + } + } + + const client = new FailingGenericACPAgentClient({ + logger: createTestLogger(), + command: [process.execPath, "acp"], + providerId: "cursor", + label: "Cursor", + }); + + const { diagnostic } = await client.getDiagnostic(); + + expect(diagnostic).toContain("Cursor (ACP)"); + expect(diagnostic).toContain("ACP initialize: Error - initialize timed out"); + expect(diagnostic).toContain("Status: Error (ACP probe failed: initialize timed out)"); + }); +}); diff --git a/packages/server/src/server/agent/providers/generic-acp-agent.ts b/packages/server/src/server/agent/providers/generic-acp-agent.ts index 682b16f61..0c2d07a47 100644 --- a/packages/server/src/server/agent/providers/generic-acp-agent.ts +++ b/packages/server/src/server/agent/providers/generic-acp-agent.ts @@ -1,16 +1,32 @@ +import { homedir } from "node:os"; import type { Logger } from "pino"; -import { isCommandAvailable } from "../../../utils/executable.js"; -import { ACPAgentClient } from "./acp-agent.js"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; +import { execCommand } from "../../../utils/spawn.js"; +import { createProviderEnvSpec } from "../provider-launch-config.js"; +import { ACPAgentClient, type SessionStateResponse } from "./acp-agent.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; + +const ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS = 8_000; +const ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS = 8_000; interface GenericACPAgentClientOptions { logger: Logger; command: [string, ...string[]]; env?: Record; + providerId?: string; + label?: string; } export class GenericACPAgentClient extends ACPAgentClient { private readonly command: [string, ...string[]]; + private readonly providerId?: string; + private readonly label?: string; constructor(options: GenericACPAgentClientOptions) { super({ @@ -23,6 +39,8 @@ export class GenericACPAgentClient extends ACPAgentClient { }); this.command = options.command; + this.providerId = options.providerId; + this.label = options.label; } protected override async resolveLaunchCommand(): Promise<{ command: string; args: string[] }> { @@ -35,4 +53,247 @@ export class GenericACPAgentClient extends ACPAgentClient { override async isAvailable(): Promise { return isCommandAvailable(this.command[0]); } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + const providerName = formatProviderName(this.label, this.providerId); + + try { + const resolvedBinary = await findExecutable(this.command[0]); + const available = resolvedBinary !== null; + const versionProbe = buildVersionProbeCommand(this.command); + const probeResult = available + ? await this.runDiagnosticACPProbe() + : { + status: formatDiagnosticStatus(false), + initialize: "Not checked", + session: "Not checked", + models: "Not checked", + modes: "Not checked", + }; + + return { + diagnostic: formatProviderDiagnostic(providerName, [ + { label: "Provider ID", value: this.providerId ?? "unknown" }, + { label: "Configured command", value: this.command.join(" ") }, + { label: "Launcher binary", value: resolvedBinary ?? "not found" }, + { + label: "Version command", + value: formatCommand(versionProbe.command, versionProbe.args), + }, + { + label: "Version", + value: resolvedBinary + ? await resolveCommandVersion(versionProbe, this.runtimeSettings?.env) + : "unknown", + }, + { label: "ACP initialize", value: probeResult.initialize }, + { label: "ACP session/new", value: probeResult.session }, + { label: "Models", value: probeResult.models }, + { label: "Modes", value: probeResult.modes }, + { label: "Status", value: probeResult.status }, + ]), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError(providerName, error), + }; + } + } + + private async runDiagnosticACPProbe(): Promise { + let initializeValue = "Not checked"; + let sessionValue = "Not checked"; + + try { + const probe = await this.spawnProcess( + { + NO_BROWSER: "true", + NO_OPEN_BROWSER: "1", + GEMINI_CLI_NO_BROWSER: "true", + CI: "1", + }, + { + initializeTimeoutMs: ACP_DIAGNOSTIC_INITIALIZE_TIMEOUT_MS, + }, + ); + try { + initializeValue = formatInitializeResult(probe.initialize); + const response = await withTimeout( + probe.connection.newSession({ + cwd: homedir(), + mcpServers: [], + }), + ACP_DIAGNOSTIC_SESSION_TIMEOUT_MS, + "ACP session/new", + ); + sessionValue = response.sessionId ? `ok (${response.sessionId})` : "ok"; + return { + status: formatDiagnosticStatus(true), + initialize: initializeValue, + session: sessionValue, + ...summarizeSessionState(this.transformSessionResponse(response)), + }; + } finally { + await this.closeProbe(probe); + } + } catch (error) { + return { + status: formatDiagnosticStatus(true, { + source: "ACP probe", + cause: error, + }), + initialize: formatProbeError(initializeValue, error), + session: + initializeValue === "Not checked" ? "Not checked" : formatProbeError(sessionValue, error), + models: "Not checked", + modes: "Not checked", + }; + } + } +} + +interface ACPDiagnosticProbeResult { + status: string; + initialize: string; + session: string; + models: string; + modes: string; +} + +export interface CommandInvocation { + command: string; + args: string[]; +} + +function formatProviderName(label: string | undefined, providerId: string | undefined): string { + if (label) { + return `${label} (ACP)`; + } + if (providerId) { + return `${providerId} (ACP)`; + } + return "Custom ACP"; +} + +function formatCommand(command: string, args: string[]): string { + return [command, ...args].join(" "); +} + +export function buildVersionProbeCommand(command: [string, ...string[]]): CommandInvocation { + const [launcher, ...args] = command; + if (isPackageRunner(launcher)) { + return { + command: launcher, + args: [...takePackageRunnerPrefix(args), "--version"], + }; + } + + return { + command: launcher, + args: ["--version"], + }; +} + +function isPackageRunner(command: string): boolean { + return ["npx", "bunx", "pnpm", "uvx"].includes(command); +} + +function takePackageRunnerPrefix(args: string[]): string[] { + if (args.length === 0) { + return []; + } + if (args[0] === "dlx") { + return ["dlx", ...takePackageSpecPrefix(args.slice(1))]; + } + return takePackageSpecPrefix(args); +} + +function takePackageSpecPrefix(args: string[]): string[] { + const prefix: string[] = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + prefix.push(arg); + if (arg === "--package" || arg === "-p") { + if (args[index + 1]) { + prefix.push(args[index + 1]); + index += 1; + } + continue; + } + if (!arg.startsWith("-")) { + break; + } + } + return prefix; +} + +function formatInitializeResult(initialize: { + protocolVersion: number; + agentInfo?: unknown; +}): string { + const agentInfo = isAgentInfo(initialize.agentInfo) + ? `${initialize.agentInfo.name}${initialize.agentInfo.version ? ` ${initialize.agentInfo.version}` : ""}` + : "ok"; + return `ok (protocol ${initialize.protocolVersion}, ${agentInfo})`; +} + +function isAgentInfo(value: unknown): value is { name: string; version?: string } { + return ( + typeof value === "object" && + value !== null && + "name" in value && + typeof Reflect.get(value, "name") === "string" + ); +} + +function summarizeSessionState( + response: SessionStateResponse, +): Pick { + const models = response.models?.availableModels ?? []; + const modes = response.modes?.availableModes ?? []; + return { + models: `${models.length}`, + modes: + modes.length > 0 ? modes.map((mode) => mode.name || mode.id).join(", ") : "none reported", + }; +} + +function formatProbeError(currentValue: string, error: unknown): string { + if (currentValue !== "Not checked") { + return currentValue; + } + return `Error - ${toDiagnosticErrorMessage(error)}`; +} + +async function resolveCommandVersion( + invocation: CommandInvocation, + env: Record | undefined, +): Promise { + try { + const { stdout, stderr } = await execCommand(invocation.command, invocation.args, { + ...createProviderEnvSpec({ runtimeSettings: { env } }), + timeout: 5_000, + }); + return stdout.trim() || stderr.trim() || "unknown"; + } catch (error) { + return `error: ${toDiagnosticErrorMessage(error)}`; + } +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeout: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } }