diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index cef04ffcd498..bbd1577cfa2e 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as Ref from "effect/Ref"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; +import type * as RpcMessage from "effect/unstable/rpc/RpcMessage"; import { it, assert } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -425,6 +426,44 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("repairs a standards-compliant JSON-RPC error into a typed failure", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + }); + + const firstMessage = yield* Deferred.make(); + yield* transport.clientProtocol + .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + // A plain `{code, message}` error, as a standards-compliant (non-Effect) ACP + // agent sends it: no `_tag: "Cause"` marker, so effect/rpc's codec boxes it as + // a `Die` unless `repairJsonRpcErrorExit` rewrites it back into a `Fail`. + yield* Queue.offer( + input, + encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + id: 5, + error: { code: -32601, message: "Method not found" }, + })}\n`, + ), + ); + + const message = yield* Deferred.await(firstMessage); + assert.equal(message._tag, "Exit"); + const exit = (message as { readonly exit: { readonly _tag: string; readonly cause: any } }) + .exit; + assert.equal(exit._tag, "Failure"); + assert.deepEqual(exit.cause, [ + { _tag: "Fail", error: { code: -32601, message: "Method not found" } }, + ]); + }), + ); + it.effect("preserves numeric ids for inbound extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index cd6043f2db00..7ba9deb23f78 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -377,7 +377,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.flatMap((pending) => { const pendingRequest = pending.get(String(message.requestId)); if (!pendingRequest) { - return Queue.offer(clientQueue, message).pipe(Effect.asVoid); + return Queue.offer(clientQueue, repairJsonRpcErrorExit(message)).pipe(Effect.asVoid); } if (message.exit._tag === "Success") { return completeExtPendingSuccess(message.requestId, message.exit.value); @@ -626,3 +626,30 @@ function isProtocolError( typeof value.message === "string" ); } + +/** + * effect/rpc's ndjson codec only recognizes its own `_tag: "Cause"` marker on a + * JSON-RPC `error` as a typed failure; any other JSON-RPC error (i.e. one from a + * standards-compliant, non-Effect ACP agent) gets boxed as an opaque `Die` + * (see effect/unstable/rpc/RpcSerialization.js). Native Agent RPC responses + * (no `pendingRequest` tracked in `extPending`) skip this module's own + * request/response handling entirely, so that Die reaches `@effect/rpc`'s + * generic `Schema.Defect()` decoder and crashes instead of surfacing as the + * RPC's typed `error` schema. Rewriting the Die into a `Fail` here, while the + * defect is still the untouched raw object, lets it decode against the RPC's + * error schema like any other typed failure. + */ +function repairJsonRpcErrorExit( + message: RpcMessage.ResponseExitEncoded, +): RpcMessage.ResponseExitEncoded { + if (message.exit._tag !== "Failure") return message; + let changed = false; + const cause = message.exit.cause.map((entry) => { + if (entry._tag === "Die" && isProtocolError(entry.defect)) { + changed = true; + return { _tag: "Fail" as const, error: entry.defect }; + } + return entry; + }); + return changed ? { ...message, exit: { ...message.exit, cause } } : message; +}