Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/effect-acp/src/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<RpcMessage.FromServerEncoded>();
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();
Expand Down
29 changes: 28 additions & 1 deletion packages/effect-acp/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Loading