Add diagnostics for generic ACP providers (#955)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user