Add diagnostics for generic ACP providers (#955)

This commit is contained in:
Mohamed Boudra
2026-05-12 21:24:03 +08:00
committed by GitHub
parent b9cea49f10
commit 47d236a299
6 changed files with 431 additions and 14 deletions
+8
View File
@@ -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.
@@ -17,6 +17,8 @@ const mockState = vi.hoisted(() => {
genericAcp: [] as Array<{
command: string[];
env?: Record<string, string>;
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<string, string> }) {
constructor(options: {
command: string[];
env?: Record<string, string>;
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",
},
]);
});
@@ -517,6 +517,8 @@ function addDerivedProviders(
logger,
command,
env: override.env,
providerId,
label: override.label ?? providerId,
}),
});
continue;
@@ -713,7 +713,10 @@ export class ACPAgentClient implements AgentClient {
}
}
protected async spawnProcess(launchEnv?: Record<string, string>): Promise<SpawnedACPProcess> {
protected async spawnProcess(
launchEnv?: Record<string, string>,
options?: { initializeTimeoutMs?: number },
): Promise<SpawnedACPProcess> {
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<typeof setTimeout> | null = null;
const initializeTimeoutPromise = options?.initializeTimeoutMs
? new Promise<never>((_, 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<void> {
child.kill("SIGTERM");
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
await waitForChildExit(child, timeoutMs);
}
@@ -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<SpawnedACPProcess> {
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<void> {}
}
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<SpawnedACPProcess> {
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)");
});
});
@@ -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<string, string>;
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<boolean> {
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<ACPDiagnosticProbeResult> {
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<ACPDiagnosticProbeResult, "models" | "modes"> {
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<string, string> | undefined,
): Promise<string> {
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<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timeout: ReturnType<typeof setTimeout> | null = null;
try {
return await Promise.race([
promise,
new Promise<never>((_, reject) => {
timeout = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
}