1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as RpcClientError from "effect/rpc/RpcClientError";
5
6import * as AcpSchema from "./_generated/schema.gen.ts";
7import { callRpc, runHandler } from "./_internal/shared.ts";
8import * as AcpError from "./errors.ts";
9
10const decodeNestedNumberPayload = Schema.decodeUnknownEffect(
11 Schema.Struct({ profile: Schema.Struct({ token: Schema.Number }) }),
12);
13const encodeUnknownJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
14
15describe("effect-acp errors", () => {
16 it.effect("retains RPC method and cause without deriving the message from the cause", () => {
17 const rootCause = new Error("connection details that must not become the public message");
18 const failure = new RpcClientError.RpcClientError({
19 reason: new RpcClientError.RpcClientDefect({
20 message: rootCause.message,
21 cause: rootCause,
22 }),
23 });
24
25 return Effect.gen(function* () {
26 const error = yield* callRpc("session/new", Effect.fail(failure)).pipe(Effect.flip);
27
28 expect(error).toMatchObject({
29 _tag: "AcpTransportError",
30 operation: "call-rpc",
31 method: "session/new",
32 cause: failure,
33 });
34 expect(error.message).toBe("ACP transport operation call-rpc failed for method session/new.");
35 expect(error.message).not.toContain(rootCause.message);
36 });
37 });
38
39 it.effect("preserves typed ACP failures carried by the RPC protocol", () => {
40 const original = new AcpError.AcpTransportError({
41 detail: "Sign in to the agent.",
42 cause: undefined,
43 });
44 const failure = new RpcClientError.RpcClientError({
45 reason: new RpcClientError.RpcClientDefect({
46 message: "ACP protocol terminated.",
47 cause: original,
48 }),
49 });
50
51 return Effect.gen(function* () {
52 const error = yield* callRpc("initialize", Effect.fail(failure)).pipe(Effect.flip);
53 expect(error).toBe(original);
54 });
55 });
56
57 it.effect("preserves protocol request errors as request errors", () => {
58 const failure = AcpSchema.Error.make({
59 code: -32602,
60 message: "Invalid params",
61 data: { field: "sessionId" },
62 });
63
64 return Effect.gen(function* () {
65 const error = yield* callRpc("session/load", Effect.fail(failure)).pipe(Effect.flip);
66
67 expect(error).toMatchObject({
68 _tag: "AcpRequestError",
69 code: -32602,
70 errorMessage: "Invalid params",
71 data: { field: "sessionId" },
72 method: "session/load",
73 operation: "receive-response",
74 });
75 });
76 });
77
78 it("does not expose legacy diagnostic detail as the transport message", () => {
79 const cause = new Error("connection refused at a private endpoint");
80 const error = new AcpError.AcpTransportError({
81 detail: cause.message,
82 cause,
83 });
84
85 expect(error.message).toBe("ACP transport operation failed.");
86 expect(error.cause).toBe(cause);
87 });
88
89 it("preserves structured extension handler failures behind stable request errors", () => {
90 const cause = new AcpError.AcpTransportError({
91 operation: "read-input-stream",
92 cause: new Error("private transport diagnostics"),
93 });
94 const error = AcpError.AcpRequestError.fromExtensionHandlerError(cause, "x/test");
95
96 expect(error).toMatchObject({
97 code: -32603,
98 method: "x/test",
99 operation: "handle-extension-request",
100 cause,
101 });
102 expect(error.message).toBe("ACP extension request handler failed for method 'x/test'");
103 expect(error.message).not.toContain(cause.message);
104 });
105
106 it.effect("uses the structured mapper for core handler failures", () => {
107 const cause = new AcpError.AcpTransportError({
108 operation: "read-input-stream",
109 cause: new Error("private transport diagnostics"),
110 });
111
112 return Effect.gen(function* () {
113 const error = yield* runHandler(() => Effect.fail(cause), {}, "fs/read_text_file").pipe(
114 Effect.flip,
115 );
116
117 expect(error).toMatchObject({
118 code: -32603,
119 message: "ACP request handler failed for method 'fs/read_text_file'",
120 });
121 expect(error.message).not.toContain(cause.message);
122 });
123 });
124
125 it.effect("keeps invalid extension payload values only in the exact schema cause", () =>
126 Effect.gen(function* () {
127 const secret = "acp-schema-payload-secret";
128 const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe(
129 Effect.flip,
130 );
131 const error = AcpError.AcpRequestError.invalidExtensionPayload("x/private", cause);
132 const { cause: directCause, ...directDiagnostics } = error;
133
134 expect(directCause).toBe(cause);
135 expect(error).toMatchObject({
136 method: "x/private",
137 operation: "decode-extension-request-payload",
138 maximumPathDepth: 2,
139 });
140 expect(error.issueCount).toBeGreaterThan(0);
141 expect(error.issueKinds).toContain("Pointer");
142 expect(error.message).toBe("Invalid payload for ACP extension method 'x/private'.");
143 expect(error.message).not.toContain(secret);
144 expect(encodeUnknownJson(directDiagnostics)).not.toContain(secret);
145 expect(encodeUnknownJson(error.toProtocolError())).not.toContain(secret);
146
147 const protocolError = AcpError.AcpProtocolParseError.fromSchemaError(
148 "decode-notification-payload",
149 "x/private",
150 cause,
151 );
152 const { cause: protocolCause, ...protocolDiagnostics } = protocolError;
153 expect(protocolCause).toBe(cause);
154 expect(protocolError).toMatchObject({
155 method: "x/private",
156 operation: "decode-notification-payload",
157 maximumPathDepth: 2,
158 });
159 expect(protocolError.message).not.toContain(secret);
160 expect(encodeUnknownJson(protocolDiagnostics)).not.toContain(secret);
161 expect("detail" in protocolError).toBe(false);
162 }),
163 );
164});