diff --git a/examples/feature-examples/compatibility.md b/examples/feature-examples/compatibility.md index b3bcc58..be36822 100644 --- a/examples/feature-examples/compatibility.md +++ b/examples/feature-examples/compatibility.md @@ -6,18 +6,20 @@ SDK Version: 0.4.3 | Use Case | ACP | Claude | |----------|-----|--------| -| agent-via-blueprint | pass | pass | +| agent-via-blueprint | fail | pass | | elicitation-acp | xfail | N/A | | elicitation-claude | N/A | pass | -| single-prompt | pass | pass | +| mcp-server | pass | pass | +| single-prompt | fail | pass | ## ACP Agent × Feature | Use Case | opencode | codex-acp | qwen | gemini-cli | |----------|------------|------------|------------|------------| -| agent-via-blueprint | pass | pass | skip | skip | +| agent-via-blueprint | pass | fail | skip | skip | | elicitation-acp | xfail | xfail | xfail | xfail | -| single-prompt | pass | pass | pass | pass | +| mcp-server | pass | pass | pass | skip | +| single-prompt | fail | pass | pass | skip | --- @@ -25,18 +27,23 @@ SDK Version: 0.4.3 | Agent | Use Case | Status | Duration | Notes | |-------|----------|--------|----------|-------| -| opencode | agent-via-blueprint | pass | 1.8s | | -| opencode | elicitation-acp | xfail | 9.9s | [xfail: ACP protocol has not added full elicitation support yet] Agent did not trigger session_elicitation | -| opencode | single-prompt | pass | 2.1s | | -| codex-acp | agent-via-blueprint | pass | 2.4s | | -| codex-acp | elicitation-acp | xfail | 10.0s | [xfail: codex-acp does not advertise or send session/elicitation (uses permission requests instead)] Agent did not trigger session_elicitation | -| codex-acp | single-prompt | pass | 1.3s | | +| opencode | agent-via-blueprint | pass | 5.4s | | +| opencode | elicitation-acp | xfail | 19.5s | [xfail: ACP protocol has not added full elicitation support yet] Agent did not trigger session_elicitation | +| opencode | mcp-server | pass | 11.6s | | +| opencode | single-prompt | fail | 0.0s | Long poll timed out after 180000ms. Last result: undefined | +| codex-acp | agent-via-blueprint | fail | 0.0s | Long poll timed out after 180000ms. Last result: undefined | +| codex-acp | elicitation-acp | xfail | 0.0s | [xfail: codex-acp does not advertise or send session/elicitation (uses permission requests instead)] Long poll timed out after 180000ms. Last result: undefined | +| codex-acp | mcp-server | pass | 5.3s | | +| codex-acp | single-prompt | pass | 2.0s | | | qwen | agent-via-blueprint | skip | 0.0s | No blueprint override defined for qwen — add an entry to BLUEPRINT_OVERRIDES to test this agent via blueprint | -| qwen | elicitation-acp | xfail | 11.7s | [xfail: qwen does not advertise or send session/elicitation] Agent did not trigger session_elicitation | -| qwen | single-prompt | pass | 2.2s | | +| qwen | elicitation-acp | xfail | 12.2s | [xfail: qwen does not advertise or send session/elicitation] Agent did not trigger session_elicitation | +| qwen | mcp-server | pass | 6.7s | | +| qwen | single-prompt | pass | 2.6s | | | gemini-cli | agent-via-blueprint | skip | 0.0s | No blueprint override defined for gemini-cli — add an entry to BLUEPRINT_OVERRIDES to test this agent via blueprint | -| gemini-cli | elicitation-acp | xfail | 12.9s | [xfail: gemini-cli does not advertise or send session/elicitation] Agent did not trigger session_elicitation | -| gemini-cli | single-prompt | pass | 4.0s | | -| claude-code | agent-via-blueprint | pass | 3.8s | | -| claude-code | elicitation-claude | pass | 16.0s | | -| claude-code | single-prompt | pass | 1.6s | | +| gemini-cli | elicitation-acp | xfail | 0.5s | [xfail: gemini-cli does not advertise or send session/elicitation] [-32000] You have exhausted your daily quota on this model. {"event_type":"turn.failed"} | +| gemini-cli | mcp-server | skip | 0.0s | Cannot verify on this account: Gemini API quota exhausted (verified working with sufficient quota) | +| gemini-cli | single-prompt | skip | 0.0s | Cannot verify on this account: Gemini API quota exhausted (verified working with sufficient quota) | +| claude-code | agent-via-blueprint | pass | 1.4s | | +| claude-code | elicitation-claude | pass | 21.2s | | +| claude-code | mcp-server | pass | 7.4s | | +| claude-code | single-prompt | pass | 1.4s | | diff --git a/examples/feature-examples/src/agents.ts b/examples/feature-examples/src/agents.ts index 3a2bd8f..3be8da5 100644 --- a/examples/feature-examples/src/agents.ts +++ b/examples/feature-examples/src/agents.ts @@ -67,7 +67,7 @@ export const AGENTS: AgentConfig[] = [ brokerMount: { protocol: "acp", agentBinary: "gemini", - launchArgs: ["--experimental-acp", "--yolo"], + launchArgs: ["--acp", "--yolo", "--skip-trust"], }, secrets: { GEMINI_API_KEY: "GEMINI_API_KEY" }, }, diff --git a/examples/feature-examples/src/main.ts b/examples/feature-examples/src/main.ts index 44a85b0..6908ae2 100644 --- a/examples/feature-examples/src/main.ts +++ b/examples/feature-examples/src/main.ts @@ -84,6 +84,20 @@ async function runOne( const expectedFailReason = useCase.expectedFailures?.[agent.name]; const getDurationMs = () => (agentStartMs === null ? 0 : Date.now() - agentStartMs); + // Pre-setup skip: avoids provisioning a devbox we know we cannot use + // (e.g. an account with an exhausted API quota for this agent). + const preSkipReason = useCase.skipForAgents?.[agent.name]; + if (preSkipReason) { + return { + agent: agent.name, + useCase: useCase.name, + protocol: agent.protocol, + status: "skip", + reason: preSkipReason, + durationMs: 0, + }; + } + try { const { ctx: setupCtx } = await setup(agent, useCase); ctx = setupCtx; diff --git a/examples/feature-examples/src/scaffold.ts b/examples/feature-examples/src/scaffold.ts index 18ccc3d..f8bb03d 100644 --- a/examples/feature-examples/src/scaffold.ts +++ b/examples/feature-examples/src/scaffold.ts @@ -1,7 +1,14 @@ import { RunloopSDK, type Secret } from "@runloop/api-client"; import { ACPAxonConnection, PROTOCOL_VERSION } from "@runloop/remote-agents-sdk/acp"; import { ClaudeAxonConnection } from "@runloop/remote-agents-sdk/claude"; -import type { AgentConfig, AgentConfigOverride, BrokerMount, UseCase, RunContext } from "./types.js"; +import type { + AgentConfig, + AgentConfigOverride, + BrokerMount, + ExtraMount, + UseCase, + RunContext, +} from "./types.js"; import { SkipError } from "./types.js"; import { withTimeout } from "./validator.js"; @@ -10,7 +17,13 @@ interface SetupResult { sdk: RunloopSDK; } -const DEFAULT_WORKING_DIRECTORY = "/home/user"; +/** + * Default home directory for the devbox user. Use cases that need to drop + * config under `~` (e.g. gemini-cli's `~/.gemini/settings.json`) should + * import this rather than hardcode the path. + */ +export const DEFAULT_USER_HOME = "/home/user"; +const DEFAULT_WORKING_DIRECTORY = DEFAULT_USER_HOME; const SETUP_STEP_TIMEOUT_MS = 30_000; const SETUP_ERROR_CLEANUP_TIMEOUT_MS = 10_000; const DEVBOX_PROVISION_TIMEOUT_MS = 180_000; // 3 minutes for cold start with agent mounts @@ -71,8 +84,10 @@ export async function setup(agent: AgentConfig, useCase: UseCase): Promise { const brokerMount = buildBrokerMount(axonId, agent.brokerMount); - - if (agent.install.kind === "agent-mount") { - const agentMount = { - type: "agent_mount" as const, - agent_id: null, - agent_name: agent.install.agentName, - }; - return [agentMount, brokerMount]; - } - - // Blueprint install: agent is already in the image. - return [brokerMount]; + const base = + agent.install.kind === "agent-mount" + ? [ + { + type: "agent_mount" as const, + agent_id: null, + agent_name: agent.install.agentName, + }, + brokerMount, + ] + : [brokerMount]; + + return [...base, ...extraMounts]; } /** diff --git a/examples/feature-examples/src/types.ts b/examples/feature-examples/src/types.ts index de9a445..44ced70 100644 --- a/examples/feature-examples/src/types.ts +++ b/examples/feature-examples/src/types.ts @@ -1,6 +1,33 @@ +import type { Runloop } from "@runloop/api-client"; import type { ACPAxonConnection } from "@runloop/remote-agents-sdk/acp"; import type { ClaudeAxonConnection } from "@runloop/remote-agents-sdk/claude"; -import type { Client, Agent } from "@agentclientprotocol/sdk"; +import type { Client, Agent, McpServer } from "@agentclientprotocol/sdk"; + +/** + * Inline `file_mount` shape from the Runloop API. The file is in place when + * the devbox boots, before the broker spawns the agent process — use it for + * agent config that must exist on startup (e.g. gemini-cli's + * `~/.gemini/settings.json`). + * Note: Use file_mounts ONLY for tiny configuration files, use object_mounts for larger files. + * + * Derived from `@runloop/api-client`'s `Mount` union so the shape can't drift + * from the upstream API. + */ +export type FileMount = Extract; + +/** + * `object_mount` shape from the Runloop API for pre-uploaded storage objects. + * Derived from `@runloop/api-client`'s `Mount` union so the shape can't drift + * from the upstream API. + */ +export type ObjectMount = Extract; + +/** + * Extra devbox mounts a use case can request per-agent. Restricted to + * supplemental-config mount kinds; `agent_mount` and `broker_mount` are + * managed by scaffold.ts and must not be duplicated here. + */ +export type ExtraMount = FileMount | ObjectMount; /** * How the agent gets installed on the devbox. @@ -122,13 +149,44 @@ export interface UseCase { */ clientCapabilities?: Record; + /** + * MCP servers attached to the ACP `newSession()` call. Ignored for Claude + * paths (Claude reads MCP config from `--mcp-config` launch args instead). + */ + acpMcpServers?: McpServer[]; + + /** + * Extra devbox mounts to add at provision time, keyed by agent name. Use + * this for agent-specific config that must be on disk *before* the broker + * spawns the agent process — e.g. gemini-cli's `~/.gemini/settings.json`, + * since gemini-cli reads MCP config from settings.json at startup and does + * not honour ACP `newSession.mcpServers`. + * + * Mounts here are appended to the standard `agent_mount` / `broker_mount` + * pair built by scaffold.ts. Inline `file_mount` is the simplest option; + * `object_mount` is available for pre-uploaded storage objects. + */ + extraMountsByAgent?: Record; + /** * Per-agent expected failures (with reason), keyed by agent name. * Results will show as "xfail" instead of "fail" and won't cause exit code 1. + * Use this for protocol/feature-level limitations the agent genuinely + * doesn't implement (e.g. ACP elicitation not advertised). * E.g., `{ opencode: "Elicitation not yet supported" }`. */ expectedFailures?: Record; + /** + * Per-agent skip reasons, keyed by agent name. Skipped *before* setup so no + * devbox is provisioned. Use this for environmental limitations that prevent + * verifying the use case on the current machine/account (e.g. an exhausted + * API quota on a shared key) — distinct from `expectedFailures`, which + * documents a protocol/agent that genuinely lacks the feature. + * E.g., `{ "gemini-cli": "Cannot verify on this account: API quota exhausted" }`. + */ + skipForAgents?: Record; + /** * The test body. Receives a fully initialized RunContext. * Throw to indicate failure. Return cleanly to indicate pass. diff --git a/examples/feature-examples/src/use-cases/elicitation-claude.ts b/examples/feature-examples/src/use-cases/elicitation-claude.ts index a3f6868..cf483fc 100644 --- a/examples/feature-examples/src/use-cases/elicitation-claude.ts +++ b/examples/feature-examples/src/use-cases/elicitation-claude.ts @@ -7,7 +7,7 @@ export default { name: "elicitation-claude", description: "Handle agent-initiated user input via Claude conversational flow", protocols: ["claude"], - timeoutMs: 20_000, + timeoutMs: 30_000, async run(ctx) { if (!ctx.claude) { diff --git a/examples/feature-examples/src/use-cases/index.ts b/examples/feature-examples/src/use-cases/index.ts index a69b7cf..6c1507f 100644 --- a/examples/feature-examples/src/use-cases/index.ts +++ b/examples/feature-examples/src/use-cases/index.ts @@ -2,11 +2,13 @@ import type { UseCase } from "../types.js"; import agentViaBlueprint from "./agent-via-blueprint.js"; import elicitationAcp from "./elicitation-acp.js"; import elicitationClaude from "./elicitation-claude.js"; +import mcpServer from "./mcp-server.js"; import singlePrompt from "./single-prompt.js"; export const USE_CASES: UseCase[] = [ agentViaBlueprint, elicitationAcp, elicitationClaude, + mcpServer, singlePrompt, ]; diff --git a/examples/feature-examples/src/use-cases/mcp-server.ts b/examples/feature-examples/src/use-cases/mcp-server.ts new file mode 100644 index 0000000..aff01a8 --- /dev/null +++ b/examples/feature-examples/src/use-cases/mcp-server.ts @@ -0,0 +1,199 @@ +import type { McpServer } from "@agentclientprotocol/sdk"; +import { isToolCall, isToolCallProgress } from "@runloop/remote-agents-sdk/acp"; +import { + isClaudeResultEvent, + isClaudeSystemInitEvent, +} from "@runloop/remote-agents-sdk/claude"; +import { DEFAULT_USER_HOME } from "../scaffold.js"; +import type { UseCase } from "../types.js"; +import { waitFor } from "../validator.js"; + +/** + * MCP server: attach a public HTTP MCP server (DeepWiki) to a session and + * verify the agent can see/use it. + * + * Why DeepWiki: + * - Public, no auth, HTTP transport — works identically for ACP `mcpServers` + * and Claude `--mcp-config`. + * - Avoids on-devbox install latency (no `npx` cold start). + * + * ACP success criterion: the agent emits a `tool_call` (or `tool_call_update`) + * whose identifier fields (`title`, `rawInput`, `_meta`) reference the + * deepwiki MCP server. Different ACP agents serialize MCP tool identity + * differently (e.g. opencode uses `title: "deepwiki_ask_question"`, + * codex-acp uses `rawInput.server: "deepwiki"`, qwen uses + * `_meta.toolName: "mcp__deepwiki__ask_question"`), so we walk known + * identifier fields rather than regex over the JSON blob. + * + * Claude success criterion: the per-turn `system/init` payload from the broker + * lists `deepwiki` in `mcp_servers` — confirming the CLI loaded the MCP config + * without needing to wait on the model to invoke the tool. + * + * gemini-cli note: gemini-cli does not honour ACP `newSession.mcpServers`. + * Instead it discovers MCP servers from `~/.gemini/settings.json` at startup, + * so we mount that file via `extraMountsByAgent` so it is on disk before the + * broker eagerly spawns gemini at devbox boot (gemini reads its config exactly + * once on startup, so writing after `devbox.create()` returns is too late). + * The `--skip-trust` launch flag (so MCP tools are exposed in untrusted + * workspaces) lives in the base gemini-cli config in `agents.ts`. + */ + +const MCP_NAME = "deepwiki"; +const MCP_URL = "https://mcp.deepwiki.com/mcp"; + +// We point DeepWiki at our own repo so the test target can't disappear out +// from under us — we own this repo so it's certain to exist. +const DEEPWIKI_REPO = "runloopai/remote-agents-sdk"; + +const PROMPT = + `Use the ${MCP_NAME} MCP server to call its ask_question tool with ` + + `repo "${DEEPWIKI_REPO}" and question "What is this repo about?". ` + + `Reply with one short sentence summarising the answer.`; + +const ACP_MCP_SERVERS: McpServer[] = [ + { type: "http", name: MCP_NAME, url: MCP_URL, headers: [] }, +]; + +const CLAUDE_MCP_LAUNCH_ARGS = [ + "--dangerously-skip-permissions", + "--mcp-config", + JSON.stringify({ + mcpServers: { [MCP_NAME]: { type: "http", url: MCP_URL } }, + }), +]; + +const GEMINI_SETTINGS_TARGET = `${DEFAULT_USER_HOME}/.gemini/settings.json`; +const GEMINI_SETTINGS_CONTENT = JSON.stringify( + { + mcpServers: { + [MCP_NAME]: { httpUrl: MCP_URL, trust: true }, + }, + }, + null, + 2, +); + +const ACP_TOOL_CALL_WAIT_MS = 20_000; + +/** + * Returns true if any identifier field of the session update references the + * given MCP server name. We only inspect fields that identify the *tool* + * (title, rawInput, _meta) and never message content — otherwise stray + * mentions of the server name in the model's prose would create false + * positives. + */ +function toolCallReferencesMcp(update: unknown, name: string): boolean { + if (!update || typeof update !== "object") return false; + const lower = name.toLowerCase(); + const containsName = (v: unknown): boolean => { + if (typeof v === "string") return v.toLowerCase().includes(lower); + if (Array.isArray(v)) return v.some(containsName); + if (v && typeof v === "object") return Object.values(v).some(containsName); + return false; + }; + const u = update as { title?: unknown; rawInput?: unknown; _meta?: unknown }; + return ( + containsName(u.title) || containsName(u.rawInput) || containsName(u._meta) + ); +} + +const GEMINI_QUOTA_SKIP = + "Cannot verify on this account: Gemini API quota exhausted (verified working with sufficient quota)"; + +export default { + name: "mcp-server", + description: "Attach an MCP server to a session and exercise an MCP tool", + protocols: ["acp", "claude"], + timeoutMs: 30_000, + + acpMcpServers: ACP_MCP_SERVERS, + + skipForAgents: { + "gemini-cli": GEMINI_QUOTA_SKIP, + }, + + provisionOverridesByAgent: { + "claude-code": { brokerMount: { launchArgs: CLAUDE_MCP_LAUNCH_ARGS } }, + }, + + extraMountsByAgent: { + // gemini-cli ignores ACP `newSession.mcpServers`; it loads MCP servers + // from `~/.gemini/settings.json` at startup. Mount the config inline so + // it's on disk when the broker eagerly spawns gemini at devbox boot. + "gemini-cli": [ + { + type: "file_mount", + target: GEMINI_SETTINGS_TARGET, + content: GEMINI_SETTINGS_CONTENT, + }, + ], + }, + + async run(ctx) { + if (ctx.acp) { + ctx.log("Running ACP path..."); + + let sawMcpToolCall = false; + const unsub = ctx.acp.onSessionUpdate((_sid, update) => { + if (!isToolCall(update) && !isToolCallProgress(update)) return; + if (toolCallReferencesMcp(update, MCP_NAME)) { + sawMcpToolCall = true; + } + }); + + ctx.log(`Sending prompt: "${PROMPT}"`); + await ctx.acp.prompt({ + sessionId: ctx.sessionId!, + prompt: [{ type: "text", text: PROMPT }], + }); + + const sawTool = await waitFor(() => sawMcpToolCall, ACP_TOOL_CALL_WAIT_MS); + unsub(); + + if (!sawTool) { + throw new Error( + `Agent did not invoke an MCP tool from "${MCP_NAME}" within ${ACP_TOOL_CALL_WAIT_MS}ms`, + ); + } + ctx.log(`Pass: ACP agent invoked an MCP tool from "${MCP_NAME}"`); + } else if (ctx.claude) { + ctx.log("Running Claude path..."); + + let mcpAttached = false; + let resultError: string | undefined; + let onResult!: () => void; + const resultReceived = new Promise((resolve) => { + onResult = resolve; + }); + + const unsub = ctx.claude.onTimelineEvent((event) => { + if (isClaudeSystemInitEvent(event)) { + if (event.data.mcp_servers.some((s) => s.name === MCP_NAME)) { + mcpAttached = true; + } + } + if (isClaudeResultEvent(event)) { + if (event.data.is_error) { + resultError = `Result was an error: ${event.data.subtype}`; + } + onResult(); + } + }); + + ctx.log(`Sending prompt: "${PROMPT}"`); + await ctx.claude.send(PROMPT); + await resultReceived; + unsub(); + + if (resultError) throw new Error(resultError); + if (!mcpAttached) { + throw new Error( + `MCP server "${MCP_NAME}" was not listed in Claude system/init mcp_servers`, + ); + } + ctx.log(`Pass: Claude system/init listed "${MCP_NAME}" in mcp_servers`); + } else { + ctx.skip("No connection available"); + } + }, +} satisfies UseCase; diff --git a/examples/feature-examples/src/use-cases/single-prompt.ts b/examples/feature-examples/src/use-cases/single-prompt.ts index 0b05547..d43d20e 100644 --- a/examples/feature-examples/src/use-cases/single-prompt.ts +++ b/examples/feature-examples/src/use-cases/single-prompt.ts @@ -8,6 +8,9 @@ const PROMPT = "Say hello world"; // notifications for this turn, so give chunks a grace window to arrive. const ACP_CHUNK_WAIT_MS = 5_000; +const GEMINI_QUOTA_SKIP = + "Untestable on this account: Gemini API quota exhausted (verified working with sufficient quota)"; + /** Single-prompt: send one prompt, receive a text response. */ export default { name: "single-prompt", @@ -15,6 +18,10 @@ export default { protocols: ["acp", "claude"], timeoutMs: 30_000, + skipForAgents: { + "gemini-cli": GEMINI_QUOTA_SKIP, + }, + async run(ctx) { if (ctx.acp) { ctx.log("Running ACP path..."); diff --git a/llms.txt b/llms.txt index 6057a77..cf723ab 100644 --- a/llms.txt +++ b/llms.txt @@ -19,6 +19,7 @@ Refer to ../scaffold.ts for setup and advice on best practices. - [agent-via-blueprint](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/agent-via-blueprint.ts) — Use pre-built blueprint with agents baked in (acp + claude) - [elicitation-acp](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/elicitation-acp.ts) — Handle agent-initiated user input via ACP session_elicitation (acp) - [elicitation-claude](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/elicitation-claude.ts) — Handle agent-initiated user input via Claude conversational flow (claude) +- [mcp-server](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/mcp-server.ts) — Attach an MCP server to a session and exercise an MCP tool (acp + claude) - [single-prompt](https://github.com/runloopai/remote-agents-sdk/blob/main/examples/feature-examples/src/use-cases/single-prompt.ts) — Send one prompt, receive text response (acp + claude) ## Compatibility diff --git a/sdk/src/acp/connection.test.ts b/sdk/src/acp/connection.test.ts index 4bc8f37..4371ee9 100644 --- a/sdk/src/acp/connection.test.ts +++ b/sdk/src/acp/connection.test.ts @@ -8,6 +8,7 @@ import { makeFullAxonEvent, makeSystemEvent, } from "../__test-utils__/mock-axon.js"; +import { ACPRequestError } from "../shared/errors/acp-request-error.js"; import { ConnectionStateError } from "../shared/errors/connection-state-error.js"; import { InitializationError } from "../shared/errors/initialization-error.js"; import { ACPAxonConnection, classifyACPAxonEvent, isACPProtocolEventType } from "./connection.js"; @@ -561,6 +562,39 @@ describe("ACPAxonConnection", () => { conn.disconnect(); }); + it("initialize() wraps raw JSON-RPC error rejections into InitializationError with code-prefixed message", async () => { + const ctrl = createControllableStream(); + const { axon } = createMockAxon(ctrl); + const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never, { + replay: false, + }); + await conn.connect(); + + conn.protocol.initialize = vi.fn().mockRejectedValue({ + code: -32601, + message: '"Method not found": initialize', + data: { method: "initialize" }, + }); + + try { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientInfo: { name: "test", version: "1.0" }, + }); + expect.fail("Expected InitializationError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(InitializationError); + // Without ACPRequestError wrapping, the message would be "[object Object]". + expect((err as InitializationError).message).toBe( + '[-32601] "Method not found": initialize {"method":"initialize"}', + ); + expect((err as InitializationError).cause).toBeInstanceOf(ACPRequestError); + expect(((err as InitializationError).cause as ACPRequestError).code).toBe(-32601); + } + + conn.disconnect(); + }); + it("newSession() delegates to protocol.newSession()", async () => { const ctrl = createControllableStream(); const { axon } = createMockAxon(ctrl); @@ -600,6 +634,63 @@ describe("ACPAxonConnection", () => { conn.disconnect(); }); + it("prompt() converts raw JSON-RPC error rejections into ACPRequestError", async () => { + // The upstream @agentclientprotocol/sdk rejects with the raw `error` + // object (`{ code, message, data }`) rather than an Error — this would + // otherwise stringify to "[object Object]" at the call site. + const ctrl = createControllableStream(); + const { axon } = createMockAxon(ctrl); + const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never, { + replay: false, + }); + await conn.connect(); + + conn.protocol.prompt = vi.fn().mockRejectedValue({ + code: -32000, + message: "You have exhausted your daily quota on this model.", + data: { event_type: "turn.failed" }, + }); + + try { + await conn.prompt({ + sessionId: "s-1", + prompt: [{ type: "text", text: "Hello" }], + } as never); + expect.fail("Expected ACPRequestError to be thrown"); + } catch (err) { + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ACPRequestError); + expect((err as ACPRequestError).code).toBe(-32000); + expect((err as ACPRequestError).data).toEqual({ event_type: "turn.failed" }); + expect((err as ACPRequestError).message).toBe( + '[-32000] You have exhausted your daily quota on this model. {"event_type":"turn.failed"}', + ); + } + + conn.disconnect(); + }); + + it("prompt() rethrows real Error instances unchanged", async () => { + const ctrl = createControllableStream(); + const { axon } = createMockAxon(ctrl); + const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never, { + replay: false, + }); + await conn.connect(); + + const original = new Error("transport closed"); + conn.protocol.prompt = vi.fn().mockRejectedValue(original); + + await expect( + conn.prompt({ + sessionId: "s-1", + prompt: [{ type: "text", text: "Hello" }], + } as never), + ).rejects.toBe(original); + + conn.disconnect(); + }); + it("cancel() delegates to protocol.cancel()", async () => { const ctrl = createControllableStream(); const { axon } = createMockAxon(ctrl); diff --git a/sdk/src/acp/connection.ts b/sdk/src/acp/connection.ts index 1f7c5bc..24a174a 100644 --- a/sdk/src/acp/connection.ts +++ b/sdk/src/acp/connection.ts @@ -31,6 +31,7 @@ import type { } from "@runloop/api-client/resources/axons"; import type { Axon, Devbox } from "@runloop/api-client/sdk"; import { resolveReplayTarget } from "../shared/connect-guards.js"; +import { rethrowAsACPError, toACPError } from "../shared/errors/acp-request-error.js"; import { ConnectionStateError } from "../shared/errors/connection-state-error.js"; import { InitializationError } from "../shared/errors/initialization-error.js"; import { runDisconnectHook } from "../shared/lifecycle.js"; @@ -237,8 +238,8 @@ export class ACPAxonConnection { try { return await this.protocol.initialize(params); } catch (err) { - const message = err instanceof Error ? err.message : String(err); - throw new InitializationError(message, { cause: err }); + const wrapped = toACPError(err); + throw new InitializationError(wrapped.message, { cause: wrapped }); } } @@ -247,10 +248,11 @@ export class ACPAxonConnection { * * @param params - Session configuration including working directory and MCP servers. * @returns The newly created session ID and metadata. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ newSession(params: NewSessionRequest): Promise { this.ensureConnected(); - return this.protocol.newSession(params); + return this.protocol.newSession(params).catch(rethrowAsACPError); } /** @@ -258,10 +260,11 @@ export class ACPAxonConnection { * * @param params - Identifies the session to load. * @returns The restored session metadata. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ loadSession(params: LoadSessionRequest): Promise { this.ensureConnected(); - return this.protocol.loadSession(params); + return this.protocol.loadSession(params).catch(rethrowAsACPError); } /** @@ -269,10 +272,11 @@ export class ACPAxonConnection { * * @param params - Optional filter criteria for the session list. * @returns An array of session summaries. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ listSessions(params: ListSessionsRequest): Promise { this.ensureConnected(); - return this.protocol.listSessions(params); + return this.protocol.listSessions(params).catch(rethrowAsACPError); } /** @@ -285,10 +289,12 @@ export class ACPAxonConnection { * * @param params - Session ID and prompt content. * @returns The agent's prompt acknowledgement. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response + * (e.g. quota exhausted, auth required). */ prompt(params: PromptRequest): Promise { this.ensureConnected(); - return this.protocol.prompt(params); + return this.protocol.prompt(params).catch(rethrowAsACPError); } /** @@ -298,7 +304,7 @@ export class ACPAxonConnection { */ cancel(params: CancelNotification): Promise { this.ensureConnected(); - return this.protocol.cancel(params); + return this.protocol.cancel(params).catch(rethrowAsACPError); } /** @@ -306,10 +312,11 @@ export class ACPAxonConnection { * * @param params - Authentication method and credentials. * @returns The authentication result. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ authenticate(params: AuthenticateRequest): Promise { this.ensureConnected(); - return this.protocol.authenticate(params); + return this.protocol.authenticate(params).catch(rethrowAsACPError); } /** @@ -317,10 +324,11 @@ export class ACPAxonConnection { * * @param params - Session ID and the target mode. * @returns Confirmation of the mode change. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ setSessionMode(params: SetSessionModeRequest): Promise { this.ensureConnected(); - return this.protocol.setSessionMode(params); + return this.protocol.setSessionMode(params).catch(rethrowAsACPError); } /** @@ -328,12 +336,13 @@ export class ACPAxonConnection { * * @param params - Session ID, option key, and new value. * @returns Confirmation of the configuration change. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { this.ensureConnected(); - return this.protocol.setSessionConfigOption(params); + return this.protocol.setSessionConfigOption(params).catch(rethrowAsACPError); } /** @@ -342,10 +351,11 @@ export class ACPAxonConnection { * @param method - The custom method name. * @param params - Arbitrary key-value payload for the request. * @returns The agent's response payload. + * @throws {ACPRequestError} If the agent returns a JSON-RPC error response. */ extMethod(method: string, params: Record): Promise> { this.ensureConnected(); - return this.protocol.extMethod(method, params); + return this.protocol.extMethod(method, params).catch(rethrowAsACPError); } /** @@ -357,7 +367,7 @@ export class ACPAxonConnection { */ extNotification(method: string, params: Record): Promise { this.ensureConnected(); - return this.protocol.extNotification(method, params); + return this.protocol.extNotification(method, params).catch(rethrowAsACPError); } // --------------------------------------------------------------------------- diff --git a/sdk/src/acp/index.ts b/sdk/src/acp/index.ts index 270764a..c796ab4 100644 --- a/sdk/src/acp/index.ts +++ b/sdk/src/acp/index.ts @@ -62,6 +62,7 @@ export { ClientSideConnection, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk"; +export { ACPRequestError, isACPRequestError } from "../shared/errors/acp-request-error.js"; export { type AgentOriginEvent, isFromAgent, diff --git a/sdk/src/shared/errors/acp-request-error.test.ts b/sdk/src/shared/errors/acp-request-error.test.ts new file mode 100644 index 0000000..1f3369f --- /dev/null +++ b/sdk/src/shared/errors/acp-request-error.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { + ACPRequestError, + isACPRequestError, + isJsonRpcErrorShape, + rethrowAsACPError, + toACPError, +} from "./acp-request-error.js"; + +describe("ACPRequestError", () => { + it("is an Error subclass with the expected fields", () => { + const err = new ACPRequestError(-32603, "Internal error", { details: "boom" }); + + expect(err).toBeInstanceOf(Error); + expect(err).toBeInstanceOf(ACPRequestError); + expect(err.name).toBe("ACPRequestError"); + expect(err.code).toBe(-32603); + expect(err.data).toEqual({ details: "boom" }); + }); + + it("formats the message with the JSON-RPC code and stringified data", () => { + const err = new ACPRequestError(-32000, "Authentication required", { reason: "expired" }); + expect(err.message).toBe('[-32000] Authentication required {"reason":"expired"}'); + }); + + it("omits the data suffix when data is undefined", () => { + const err = new ACPRequestError(-32601, "Method not found", undefined); + expect(err.message).toBe("[-32601] Method not found"); + }); + + it("preserves the cause via ErrorOptions", () => { + const cause = new Error("original"); + const err = new ACPRequestError(-32603, "Internal error", undefined, { cause }); + expect(err.cause).toBe(cause); + }); + + it("fromJsonRpc() builds an instance from the raw payload shape", () => { + const err = ACPRequestError.fromJsonRpc({ + code: -32000, + message: "You have exhausted your daily quota on this model.", + data: { event_type: "turn.failed" }, + }); + + expect(err.code).toBe(-32000); + expect(err.data).toEqual({ event_type: "turn.failed" }); + expect(err.message).toBe( + '[-32000] You have exhausted your daily quota on this model. {"event_type":"turn.failed"}', + ); + }); +}); + +describe("isACPRequestError", () => { + it("returns true for ACPRequestError instances", () => { + expect(isACPRequestError(new ACPRequestError(-32603, "Internal error", undefined))).toBe(true); + }); + + it("returns false for plain Errors and JSON-RPC-shaped objects", () => { + expect(isACPRequestError(new Error("plain"))).toBe(false); + expect(isACPRequestError({ code: -32603, message: "Internal error" })).toBe(false); + expect(isACPRequestError(undefined)).toBe(false); + expect(isACPRequestError("string")).toBe(false); + }); +}); + +describe("isJsonRpcErrorShape", () => { + it("matches { code: number, message: string, data? }", () => { + expect(isJsonRpcErrorShape({ code: -1, message: "m" })).toBe(true); + expect(isJsonRpcErrorShape({ code: -1, message: "m", data: { extra: 1 } })).toBe(true); + }); + + it("rejects payloads missing required fields or with wrong types", () => { + expect(isJsonRpcErrorShape({ code: "string", message: "m" })).toBe(false); + expect(isJsonRpcErrorShape({ code: 1 })).toBe(false); + expect(isJsonRpcErrorShape({ message: "m" })).toBe(false); + expect(isJsonRpcErrorShape(null)).toBe(false); + expect(isJsonRpcErrorShape(undefined)).toBe(false); + expect(isJsonRpcErrorShape("string")).toBe(false); + }); +}); + +describe("toACPError", () => { + it("returns Error instances unchanged", () => { + const err = new Error("plain"); + expect(toACPError(err)).toBe(err); + }); + + it("returns ACPRequestError subclasses unchanged (still Error)", () => { + const err = new ACPRequestError(-32603, "Internal error", undefined); + expect(toACPError(err)).toBe(err); + }); + + it("converts JSON-RPC error shapes into ACPRequestError", () => { + const out = toACPError({ code: -32000, message: "Auth required", data: { hint: "key" } }); + + expect(out).toBeInstanceOf(ACPRequestError); + expect((out as ACPRequestError).code).toBe(-32000); + expect((out as ACPRequestError).data).toEqual({ hint: "key" }); + expect(out.message).toBe('[-32000] Auth required {"hint":"key"}'); + }); + + it("wraps arbitrary non-Error values in a generic Error with a useful message", () => { + expect(toACPError("oops").message).toBe('"oops"'); + expect(toACPError({ random: 1 }).message).toBe('{"random":1}'); + expect(toACPError(undefined).message).toBe("undefined"); + }); +}); + +describe("rethrowAsACPError", () => { + it("can be used as a .catch() handler that throws a normalized Error", async () => { + const rejected = Promise.reject({ + code: -32000, + message: "Authentication required", + }); + + await expect(rejected.catch(rethrowAsACPError)).rejects.toMatchObject({ + name: "ACPRequestError", + code: -32000, + message: "[-32000] Authentication required", + }); + }); +}); diff --git a/sdk/src/shared/errors/acp-request-error.ts b/sdk/src/shared/errors/acp-request-error.ts new file mode 100644 index 0000000..33b09a3 --- /dev/null +++ b/sdk/src/shared/errors/acp-request-error.ts @@ -0,0 +1,104 @@ +import { isNonNullObject } from "../structural-guards.js"; + +/** + * Error thrown when an ACP request fails with a JSON-RPC error response. + * + * The upstream `@agentclientprotocol/sdk` rejects pending requests with the + * raw `error` object (`{ code, message, data }`) rather than an `Error` + * instance, so they would otherwise stringify to `"[object Object]"`. + * The ACP connection wrapper converts those rejections into this typed + * error so callers can `instanceof`-check, read `.code` / `.data`, and + * get a useful `.message` (which includes the JSON-RPC code). + * + * See protocol docs: [JSON-RPC Error Object](https://www.jsonrpc.org/specification#error_object) + * + * @category Errors + */ +export class ACPRequestError extends Error { + /** JSON-RPC error code (e.g. `-32603` internal error, `-32000` auth required). */ + readonly code: number; + /** JSON-RPC `data` field (implementation-defined). */ + readonly data: unknown; + + constructor(code: number, message: string, data: unknown, options?: ErrorOptions) { + const dataSuffix = data !== undefined ? ` ${safeStringify(data)}` : ""; + super(`[${code}] ${message}${dataSuffix}`, options); + this.name = "ACPRequestError"; + this.code = code; + this.data = data; + } + + /** + * Build an `ACPRequestError` from a raw JSON-RPC error object. + * + * @param value - A JSON-RPC error payload (`{ code, message, data? }`). + * @param options - Optional `ErrorOptions` for cause chaining. + */ + static fromJsonRpc( + value: { code: number; message: string; data?: unknown }, + options?: ErrorOptions, + ): ACPRequestError { + return new ACPRequestError(value.code, value.message, value.data, options); + } +} + +/** + * Returns `true` if `err` is an {@link ACPRequestError}. + * + * @category Errors + */ +export function isACPRequestError(err: unknown): err is ACPRequestError { + return err instanceof ACPRequestError; +} + +/** + * Structural guard for raw JSON-RPC error payloads. + * + * Matches `{ code: number, message: string, data?: unknown }`. + * + * @internal + */ +export function isJsonRpcErrorShape( + value: unknown, +): value is { code: number; message: string; data?: unknown } { + if (!isNonNullObject(value)) return false; + return typeof value.code === "number" && typeof value.message === "string"; +} + +/** + * Convert an arbitrary thrown value into a real `Error` instance. + * + * - Already-`Error` values pass through unchanged. + * - Raw JSON-RPC error objects become `ACPRequestError`. + * - Anything else is wrapped in a generic `Error` whose message is the + * value's stringified form (`JSON.stringify` if possible, else `String`). + * + * @internal + */ +export function toACPError(err: unknown): Error { + if (err instanceof Error) return err; + if (isJsonRpcErrorShape(err)) return ACPRequestError.fromJsonRpc(err); + return new Error(safeStringify(err)); +} + +/** + * Helper for `.catch()` chains that rethrows after normalizing via + * {@link toACPError}. + * + * @internal + */ +export function rethrowAsACPError(err: unknown): never { + throw toACPError(err); +} + +function safeStringify(value: unknown): string { + try { + // `JSON.stringify` returns the literal `undefined` for `undefined`, + // symbols, and functions — fall back to `String(value)` in those cases + // so the error message is never empty. + const json = JSON.stringify(value); + return json !== undefined ? json : String(value); + } catch { + return String(value); + } +} diff --git a/sdk/src/shared/index.ts b/sdk/src/shared/index.ts index c1aaef4..e246c70 100644 --- a/sdk/src/shared/index.ts +++ b/sdk/src/shared/index.ts @@ -11,6 +11,7 @@ */ export { resolveReplayTarget } from "./connect-guards.js"; +export { ACPRequestError, isACPRequestError } from "./errors/acp-request-error.js"; export { ConnectionStateError, type ConnectionStateErrorCode,