From f499c39aed1a8fff892ac6bad37567be66e32238 Mon Sep 17 00:00:00 2001 From: Sleeyax Date: Mon, 12 Oct 2026 13:25:03 +0200 Subject: [PATCH] fix(claude-tty): report an adapter error in its own words instead of "Internal error" Co-Authored-By: Claude Opus 5.5 --- apps/claude-tty-acp/src/main.ts | 3 +- .../claude-tty-acp/src/request-errors.test.ts | 50 +++++++++++++++++++ apps/claude-tty-acp/src/request-errors.ts | 28 +++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 apps/claude-tty-acp/src/request-errors.test.ts create mode 100644 apps/claude-tty-acp/src/request-errors.ts diff --git a/apps/claude-tty-acp/src/main.ts b/apps/claude-tty-acp/src/main.ts index 4e519dcb..29c11d82 100644 --- a/apps/claude-tty-acp/src/main.ts +++ b/apps/claude-tty-acp/src/main.ts @@ -4,6 +4,7 @@ import { ClaudeTtyAgent } from "./agent.ts"; import { useAnswersDirectory } from "./card-answers.ts"; import { readIdleTimeout } from "./idle-timeout.ts"; import { enableLogFile, writeLog } from "./log.ts"; +import { withRequestErrors } from "./request-errors.ts"; import { cleanupAbandonedRuntimeDirectories } from "./runtime-directories.ts"; import { useSettingsFile } from "./settings-document.ts"; @@ -34,7 +35,7 @@ export async function runAcpServer(settingsFile: string | null, answersDirectory void shutdown("workspace_removed").finally(() => process.exit(0)); }, }); - return agent; + return withRequestErrors(agent); }, ndJsonStream(output, input)); const handleSignal = (signal: NodeJS.Signals): void => { diff --git a/apps/claude-tty-acp/src/request-errors.test.ts b/apps/claude-tty-acp/src/request-errors.test.ts new file mode 100644 index 00000000..8105a99e --- /dev/null +++ b/apps/claude-tty-acp/src/request-errors.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + RequestError, + type Client, +} from "@agentclientprotocol/sdk"; +import { ClaudeTtyAgent } from "./agent.ts"; +import { withRequestErrors } from "./request-errors.ts"; + +/** Connects a client to the agent over the SDK's own JSON-RPC, so the test sees what Paseo sees. */ +function connect(): { client: ClientSideConnection; agent: () => ClaudeTtyAgent | null } { + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + let agent: ClaudeTtyAgent | null = null; + new AgentSideConnection((connection) => { + agent = new ClaudeTtyAgent(connection); + return withRequestErrors(agent); + }, ndJsonStream(toClient.writable, toAgent.readable)); + const client = new ClientSideConnection( + () => ({ requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), sessionUpdate: async () => undefined }) as Client, + ndJsonStream(toAgent.writable, toClient.readable), + ); + return { client, agent: () => agent }; +} + +test("answers a failed request with the adapter's own sentence", async () => { + const { client, agent } = connect(); + await client.initialize({ protocolVersion: PROTOCOL_VERSION }); + + await assert.rejects(client.prompt({ sessionId: "missing", prompt: [{ type: "text", text: "hi" }] }), { + code: -32603, + message: "Session missing not found", + }); + await agent()?.close(); +}); + +test("passes a RequestError through as it was thrown", async () => { + const thrown = RequestError.invalidParams(undefined, "no such mode"); + const wrapped = withRequestErrors({ + initialize: async () => { + throw thrown; + }, + } as unknown as ClaudeTtyAgent); + + await assert.rejects(wrapped.initialize({ protocolVersion: PROTOCOL_VERSION }), (error) => error === thrown); +}); diff --git a/apps/claude-tty-acp/src/request-errors.ts b/apps/claude-tty-acp/src/request-errors.ts new file mode 100644 index 00000000..ccbbc15a --- /dev/null +++ b/apps/claude-tty-acp/src/request-errors.ts @@ -0,0 +1,28 @@ +import { type Agent, RequestError } from "@agentclientprotocol/sdk"; + +/** + * Hands the SDK an agent whose failures carry their own text as the JSON-RPC message. + * The SDK answers any error that is not a `RequestError` with the message "Internal error" and + * the real text in `data.details`, which a client is free to drop, and Paseo has. The code stays + * -32603: Paseo branches on -32602 alone, so another code would change nothing but the meaning. + */ +export function withRequestErrors(agent: T): T { + return new Proxy(agent, { + get(target, property, receiver) { + const value: unknown = Reflect.get(target, property, receiver); + if (typeof value !== "function") return value; + return async (...args: unknown[]) => { + try { + return await value.apply(target, args); + } catch (error) { + throw toRequestError(error); + } + }; + }, + }); +} + +export function toRequestError(error: unknown): RequestError { + if (error instanceof RequestError) return error; + return new RequestError(-32603, error instanceof Error ? error.message : String(error)); +}