authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:25:16-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:25:16-07:00
log4c16c66368c934a3f23794fc4826a2c77fe9f323
tree7e08f5076a98fd2e0eab8ac840d9bcbdae2ec240
parent4407a5a6b716b1d37bed917345d79f627a42f59e
signature Signed by PGP key B5690EEEBB952194

[codex] Structure ACP transport errors (#3251)

Co-authored-by: codex <codex@users.noreply.github.com>

10 files changed, 593 insertions(+), 152 deletions(-)

packages/effect-acp/src/_internal/shared.ts+19-27
...@@ -1,30 +1,29 @@...@@ -1,30 +1,29 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";2import * as Schema from "effect/Schema";
3import * as SchemaIssue from "effect/SchemaIssue";
4import { RpcClientError } from "effect/unstable/rpc";3import { RpcClientError } from "effect/unstable/rpc";
54
6import * as AcpSchema from "../_generated/schema.gen.ts";5import * as AcpSchema from "../_generated/schema.gen.ts";
7import * as AcpError from "../errors.ts";6import * as AcpError from "../errors.ts";
8const isError = Schema.is(AcpSchema.Error);7const isError = Schema.is(AcpSchema.Error);
9const isAcpRequestError = Schema.is(AcpError.AcpRequestError);
10
11const formatSchemaIssue = SchemaIssue.makeFormatterDefault();
128
13export const callRpc = <A>(9export const callRpc = <A>(
10 method: string,
14 effect: Effect.Effect<A, RpcClientError.RpcClientError | AcpSchema.Error>,11 effect: Effect.Effect<A, RpcClientError.RpcClientError | AcpSchema.Error>,
15): Effect.Effect<A, AcpError.AcpError> =>12): Effect.Effect<A, AcpError.AcpError> =>
16 effect.pipe(13 effect.pipe(
17 Effect.catchTag("RpcClientError", (error) =>
18 Effect.fail(
19 new AcpError.AcpTransportError({
20 detail: error.message,
21 cause: error,
22 }),
23 ),
24 ),
25 Effect.catchIf(isError, (error) =>14 Effect.catchIf(isError, (error) =>
26 Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)),15 Effect.fail(AcpError.AcpRequestError.fromProtocolError(error)),
27 ),16 ),
17 Effect.catchTags({
18 RpcClientError: (cause) =>
19 Effect.fail(
20 new AcpError.AcpTransportError({
21 operation: "call-rpc",
22 method,
23 cause,
24 }),
25 ),
26 }),
28 );27 );
2928
30export const runHandler = Effect.fnUntraced(function* <A, B>(29export const runHandler = Effect.fnUntraced(function* <A, B>(
...@@ -37,9 +36,7 @@ export const runHandler = Effect.fnUntraced(function* <A, B>(...@@ -37,9 +36,7 @@ export const runHandler = Effect.fnUntraced(function* <A, B>(
37 }36 }
38 return yield* handler(payload).pipe(37 return yield* handler(payload).pipe(
39 Effect.mapError((error) =>38 Effect.mapError((error) =>
40 isAcpRequestError(error)39 AcpError.AcpRequestError.fromCoreHandlerError(error, method).toProtocolError(),
41 ? error.toProtocolError()
42 : AcpError.AcpRequestError.internalError(error.message).toProtocolError(),
43 ),40 ),
44 );41 );
45});42});
...@@ -51,12 +48,7 @@ export function decodeExtRequestRegistration<A, I>(...@@ -51,12 +48,7 @@ export function decodeExtRequestRegistration<A, I>(
51) {48) {
52 return (params: unknown): Effect.Effect<unknown, AcpError.AcpError> =>49 return (params: unknown): Effect.Effect<unknown, AcpError.AcpError> =>
53 Schema.decodeUnknownEffect(payload)(params).pipe(50 Schema.decodeUnknownEffect(payload)(params).pipe(
54 Effect.mapError((error) =>51 Effect.mapError((error) => AcpError.AcpRequestError.invalidExtensionPayload(method, error)),
55 AcpError.AcpRequestError.invalidParams(
56 `Invalid ${method} payload: ${formatSchemaIssue(error.issue)}`,
57 { issue: error.issue },
58 ),
59 ),
60 Effect.flatMap((decoded) => handler(decoded)),52 Effect.flatMap((decoded) => handler(decoded)),
61 );53 );
62}54}
...@@ -68,12 +60,12 @@ export function decodeExtNotificationRegistration<A, I>(...@@ -68,12 +60,12 @@ export function decodeExtNotificationRegistration<A, I>(
68) {60) {
69 return (params: unknown): Effect.Effect<void, AcpError.AcpError> =>61 return (params: unknown): Effect.Effect<void, AcpError.AcpError> =>
70 Schema.decodeUnknownEffect(payload)(params).pipe(62 Schema.decodeUnknownEffect(payload)(params).pipe(
71 Effect.mapError(63 Effect.mapError((error) =>
72 (error) =>64 AcpError.AcpProtocolParseError.fromSchemaError(
73 new AcpError.AcpProtocolParseError({65 "decode-notification-payload",
74 detail: `Invalid ${method} notification payload: ${formatSchemaIssue(error.issue)}`,66 method,
75 cause: error,67 error,
76 }),68 ),
77 ),69 ),
78 Effect.flatMap((decoded) => handler(decoded)),70 Effect.flatMap((decoded) => handler(decoded)),
79 );71 );
packages/effect-acp/src/_internal/stdio.ts+1-1
...@@ -50,7 +50,7 @@ export const makeTerminationError = (...@@ -50,7 +50,7 @@ export const makeTerminationError = (
50 Effect.match(handle.exitCode, {50 Effect.match(handle.exitCode, {
51 onFailure: (cause) =>51 onFailure: (cause) =>
52 new AcpError.AcpTransportError({52 new AcpError.AcpTransportError({
53 detail: "Failed to determine ACP process exit status",53 operation: "read-process-exit-status",
54 cause,54 cause,
55 }),55 }),
56 onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }),56 onSuccess: (code) => new AcpError.AcpProcessExitedError({ code }),
packages/effect-acp/src/agent.ts+54-40
...@@ -288,12 +288,12 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* (...@@ -288,12 +288,12 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* (
288 notification.method === AGENT_METHODS.session_cancel288 notification.method === AGENT_METHODS.session_cancel
289 ) {289 ) {
290 return decodeCancelNotification(notification.params).pipe(290 return decodeCancelNotification(notification.params).pipe(
291 Effect.mapError(291 Effect.mapError((error) =>
292 (error) =>292 AcpError.AcpProtocolParseError.fromSchemaError(
293 new AcpError.AcpProtocolParseError({293 "decode-notification-payload",
294 detail: `Invalid ${AGENT_METHODS.session_cancel} notification payload`,294 AGENT_METHODS.session_cancel,
295 cause: error,295 error,
296 }),296 ),
297 ),297 ),
298 Effect.flatMap((decoded) =>298 Effect.flatMap((decoded) =>
299 Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }),299 Effect.forEach(cancelHandlers, (handler) => handler(decoded), { discard: true }),
...@@ -376,41 +376,55 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* (...@@ -376,41 +376,55 @@ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* (
376 },376 },
377 client: {377 client: {
378 requestPermission: (payload) =>378 requestPermission: (payload) =>
379 callRpc(rpc[CLIENT_METHODS.session_request_permission](payload)),379 callRpc(
380 elicit: (payload) => callRpc(rpc[CLIENT_METHODS.session_elicitation](payload)),380 CLIENT_METHODS.session_request_permission,
381 readTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_read_text_file](payload)),381 rpc[CLIENT_METHODS.session_request_permission](payload),
382 writeTextFile: (payload) => callRpc(rpc[CLIENT_METHODS.fs_write_text_file](payload)),382 ),
383 elicit: (payload) =>
384 callRpc(
385 CLIENT_METHODS.session_elicitation,
386 rpc[CLIENT_METHODS.session_elicitation](payload),
387 ),
388 readTextFile: (payload) =>
389 callRpc(CLIENT_METHODS.fs_read_text_file, rpc[CLIENT_METHODS.fs_read_text_file](payload)),
390 writeTextFile: (payload) =>
391 callRpc(CLIENT_METHODS.fs_write_text_file, rpc[CLIENT_METHODS.fs_write_text_file](payload)),
383 createTerminal: (payload) =>392 createTerminal: (payload) =>
384 callRpc(rpc[CLIENT_METHODS.terminal_create](payload)).pipe(393 callRpc(CLIENT_METHODS.terminal_create, rpc[CLIENT_METHODS.terminal_create](payload)).pipe(
385 Effect.map((response) =>394 Effect.map(
386 AcpTerminal.makeTerminal({395 (response) =>
387 sessionId: payload.sessionId,396 ({
388 terminalId: response.terminalId,397 sessionId: payload.sessionId,
389 output: callRpc(398 terminalId: response.terminalId,
390 rpc[CLIENT_METHODS.terminal_output]({399 output: callRpc(
391 sessionId: payload.sessionId,400 CLIENT_METHODS.terminal_output,
392 terminalId: response.terminalId,401 rpc[CLIENT_METHODS.terminal_output]({
393 }),402 sessionId: payload.sessionId,
394 ),403 terminalId: response.terminalId,
395 waitForExit: callRpc(404 }),
396 rpc[CLIENT_METHODS.terminal_wait_for_exit]({405 ),
397 sessionId: payload.sessionId,406 waitForExit: callRpc(
398 terminalId: response.terminalId,407 CLIENT_METHODS.terminal_wait_for_exit,
399 }),408 rpc[CLIENT_METHODS.terminal_wait_for_exit]({
400 ),409 sessionId: payload.sessionId,
401 kill: callRpc(410 terminalId: response.terminalId,
402 rpc[CLIENT_METHODS.terminal_kill]({411 }),
403 sessionId: payload.sessionId,412 ),
404 terminalId: response.terminalId,413 kill: callRpc(
405 }),414 CLIENT_METHODS.terminal_kill,
406 ),415 rpc[CLIENT_METHODS.terminal_kill]({
407 release: callRpc(416 sessionId: payload.sessionId,
408 rpc[CLIENT_METHODS.terminal_release]({417 terminalId: response.terminalId,
409 sessionId: payload.sessionId,418 }),
410 terminalId: response.terminalId,419 ),
411 }),420 release: callRpc(
412 ),421 CLIENT_METHODS.terminal_release,
413 }),422 rpc[CLIENT_METHODS.terminal_release]({
423 sessionId: payload.sessionId,
424 terminalId: response.terminalId,
425 }),
426 ),
427 }) satisfies AcpTerminal.AcpTerminal,
414 ),428 ),
415 ),429 ),
416 sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload),430 sessionUpdate: (payload) => transport.notify(CLIENT_METHODS.session_update, payload),
packages/effect-acp/src/client.test.ts+3-3
...@@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {...@@ -147,7 +147,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
147 );147 );
148148
149 it.effect(149 it.effect(
150 "returns formatted invalid params when a typed extension request payload is wrong",150 "returns structured invalid params without exposing values from typed extension request payloads",
151 () =>151 () =>
152 Effect.gen(function* () {152 Effect.gen(function* () {
153 const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" });153 const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" });
...@@ -213,8 +213,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {...@@ -213,8 +213,8 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
213 assert.fail("Expected prompt to fail for invalid typed extension payload");213 assert.fail("Expected prompt to fail for invalid typed extension payload");
214 }214 }
215 const rendered = Cause.pretty(result.cause);215 const rendered = Cause.pretty(result.cause);
216 assert.include(rendered, "Invalid x/typed_request payload:");216 assert.include(rendered, "Invalid payload for ACP extension method 'x/typed_request'.");
217 assert.include(rendered, "Expected string, got 123");217 assert.notInclude(rendered, "Expected string, got 123");
218 }),218 }),
219 );219 );
220220
packages/effect-acp/src/client.ts+25-12
...@@ -462,19 +462,32 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (...@@ -462,19 +462,32 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
462 notify: transport.notify,462 notify: transport.notify,
463 },463 },
464 agent: {464 agent: {
465 initialize: (payload) => callRpc(rpc[AGENT_METHODS.initialize](payload)),465 initialize: (payload) =>
466 authenticate: (payload) => callRpc(rpc[AGENT_METHODS.authenticate](payload)),466 callRpc(AGENT_METHODS.initialize, rpc[AGENT_METHODS.initialize](payload)),
467 logout: (payload) => callRpc(rpc[AGENT_METHODS.logout](payload)),467 authenticate: (payload) =>
468 createSession: (payload) => callRpc(rpc[AGENT_METHODS.session_new](payload)),468 callRpc(AGENT_METHODS.authenticate, rpc[AGENT_METHODS.authenticate](payload)),
469 loadSession: (payload) => callRpc(rpc[AGENT_METHODS.session_load](payload)),469 logout: (payload) => callRpc(AGENT_METHODS.logout, rpc[AGENT_METHODS.logout](payload)),
470 listSessions: (payload) => callRpc(rpc[AGENT_METHODS.session_list](payload)),470 createSession: (payload) =>
471 forkSession: (payload) => callRpc(rpc[AGENT_METHODS.session_fork](payload)),471 callRpc(AGENT_METHODS.session_new, rpc[AGENT_METHODS.session_new](payload)),
472 resumeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_resume](payload)),472 loadSession: (payload) =>
473 closeSession: (payload) => callRpc(rpc[AGENT_METHODS.session_close](payload)),473 callRpc(AGENT_METHODS.session_load, rpc[AGENT_METHODS.session_load](payload)),
474 setSessionModel: (payload) => callRpc(rpc[AGENT_METHODS.session_set_model](payload)),474 listSessions: (payload) =>
475 callRpc(AGENT_METHODS.session_list, rpc[AGENT_METHODS.session_list](payload)),
476 forkSession: (payload) =>
477 callRpc(AGENT_METHODS.session_fork, rpc[AGENT_METHODS.session_fork](payload)),
478 resumeSession: (payload) =>
479 callRpc(AGENT_METHODS.session_resume, rpc[AGENT_METHODS.session_resume](payload)),
480 closeSession: (payload) =>
481 callRpc(AGENT_METHODS.session_close, rpc[AGENT_METHODS.session_close](payload)),
482 setSessionModel: (payload) =>
483 callRpc(AGENT_METHODS.session_set_model, rpc[AGENT_METHODS.session_set_model](payload)),
475 setSessionConfigOption: (payload) =>484 setSessionConfigOption: (payload) =>
476 callRpc(rpc[AGENT_METHODS.session_set_config_option](payload)),485 callRpc(
477 prompt: (payload) => callRpc(rpc[AGENT_METHODS.session_prompt](payload)),486 AGENT_METHODS.session_set_config_option,
487 rpc[AGENT_METHODS.session_set_config_option](payload),
488 ),
489 prompt: (payload) =>
490 callRpc(AGENT_METHODS.session_prompt, rpc[AGENT_METHODS.session_prompt](payload)),
478 cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload),491 cancel: (payload) => transport.notify(AGENT_METHODS.session_cancel, payload),
479 },492 },
480 handleRequestPermission: (handler) =>493 handleRequestPermission: (handler) =>
packages/effect-acp/src/errors.test.ts created+144
...@@ -0,0 +1,144 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as RpcClientError from "effect/unstable/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.UnknownFromJsonString);
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 protocol request errors as request errors", () => {
40 const failure = AcpSchema.Error.make({
41 code: -32602,
42 message: "Invalid params",
43 data: { field: "sessionId" },
44 });
45
46 return Effect.gen(function* () {
47 const error = yield* callRpc("session/load", Effect.fail(failure)).pipe(Effect.flip);
48
49 expect(error).toMatchObject({
50 _tag: "AcpRequestError",
51 code: -32602,
52 errorMessage: "Invalid params",
53 data: { field: "sessionId" },
54 });
55 });
56 });
57
58 it("does not expose legacy diagnostic detail as the transport message", () => {
59 const cause = new Error("connection refused at a private endpoint");
60 const error = new AcpError.AcpTransportError({
61 detail: cause.message,
62 cause,
63 });
64
65 expect(error.message).toBe("ACP transport operation failed.");
66 expect(error.cause).toBe(cause);
67 });
68
69 it("preserves structured extension handler failures behind stable request errors", () => {
70 const cause = new AcpError.AcpTransportError({
71 operation: "read-input-stream",
72 cause: new Error("private transport diagnostics"),
73 });
74 const error = AcpError.AcpRequestError.fromExtensionHandlerError(cause, "x/test");
75
76 expect(error).toMatchObject({
77 code: -32603,
78 method: "x/test",
79 operation: "handle-extension-request",
80 cause,
81 });
82 expect(error.message).toBe("ACP extension request handler failed for method 'x/test'");
83 expect(error.message).not.toContain(cause.message);
84 });
85
86 it.effect("uses the structured mapper for core handler failures", () => {
87 const cause = new AcpError.AcpTransportError({
88 operation: "read-input-stream",
89 cause: new Error("private transport diagnostics"),
90 });
91
92 return Effect.gen(function* () {
93 const error = yield* runHandler(() => Effect.fail(cause), {}, "fs/read_text_file").pipe(
94 Effect.flip,
95 );
96
97 expect(error).toMatchObject({
98 code: -32603,
99 message: "ACP request handler failed for method 'fs/read_text_file'",
100 });
101 expect(error.message).not.toContain(cause.message);
102 });
103 });
104
105 it.effect("keeps invalid extension payload values only in the exact schema cause", () =>
106 Effect.gen(function* () {
107 const secret = "acp-schema-payload-secret";
108 const cause = yield* decodeNestedNumberPayload({ profile: { token: secret } }).pipe(
109 Effect.flip,
110 );
111 const error = AcpError.AcpRequestError.invalidExtensionPayload("x/private", cause);
112 const { cause: directCause, ...directDiagnostics } = error;
113
114 expect(directCause).toBe(cause);
115 expect(error).toMatchObject({
116 method: "x/private",
117 operation: "decode-extension-request-payload",
118 maximumPathDepth: 2,
119 });
120 expect(error.issueCount).toBeGreaterThan(0);
121 expect(error.issueKinds).toContain("Pointer");
122 expect(error.message).toBe("Invalid payload for ACP extension method 'x/private'.");
123 expect(error.message).not.toContain(secret);
124 expect(encodeUnknownJson(directDiagnostics)).not.toContain(secret);
125 expect(encodeUnknownJson(error.toProtocolError())).not.toContain(secret);
126
127 const protocolError = AcpError.AcpProtocolParseError.fromSchemaError(
128 "decode-notification-payload",
129 "x/private",
130 cause,
131 );
132 const { cause: protocolCause, ...protocolDiagnostics } = protocolError;
133 expect(protocolCause).toBe(cause);
134 expect(protocolError).toMatchObject({
135 method: "x/private",
136 operation: "decode-notification-payload",
137 maximumPathDepth: 2,
138 });
139 expect(protocolError.message).not.toContain(secret);
140 expect(encodeUnknownJson(protocolDiagnostics)).not.toContain(secret);
141 expect("detail" in protocolError).toBe(false);
142 }),
143 );
144});
packages/effect-acp/src/errors.ts+172-6
...@@ -1,7 +1,77 @@...@@ -1,7 +1,77 @@
1import * as Schema from "effect/Schema";1import * as Schema from "effect/Schema";
2import type * as SchemaIssue from "effect/SchemaIssue";
23
3import * as AcpSchema from "./_generated/schema.gen.ts";4import * as AcpSchema from "./_generated/schema.gen.ts";
45
6export const AcpRequestOperation = Schema.Literals([
7 "decode-extension-request-payload",
8 "handle-request",
9 "handle-extension-request",
10]);
11export type AcpRequestOperation = typeof AcpRequestOperation.Type;
12
13export const AcpSchemaIssueKind = Schema.Literals([
14 "Filter",
15 "Encoding",
16 "Pointer",
17 "Composite",
18 "AnyOf",
19 "InvalidType",
20 "InvalidValue",
21 "MissingKey",
22 "UnexpectedKey",
23 "Forbidden",
24 "OneOf",
25]);
26export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type;
27
28export interface AcpSchemaIssueDiagnostics {
29 readonly issueCount: number;
30 readonly issueKinds: ReadonlyArray<AcpSchemaIssueKind>;
31 readonly maximumPathDepth: number;
32}
33
34const schemaIssueDiagnostics = (root: SchemaIssue.Issue): AcpSchemaIssueDiagnostics => {
35 let issueCount = 0;
36 let maximumPathDepth = 0;
37 const issueKinds = new Set<AcpSchemaIssueKind>();
38
39 const visit = (issue: SchemaIssue.Issue, pathDepth: number): void => {
40 issueCount += 1;
41 issueKinds.add(issue._tag);
42 maximumPathDepth = Math.max(maximumPathDepth, pathDepth);
43 switch (issue._tag) {
44 case "Filter":
45 case "Encoding":
46 visit(issue.issue, pathDepth);
47 break;
48 case "Pointer":
49 visit(issue.issue, pathDepth + issue.path.length);
50 break;
51 case "Composite":
52 case "AnyOf":
53 for (const child of issue.issues) visit(child, pathDepth);
54 break;
55 }
56 };
57
58 visit(root, 0);
59 return {
60 issueCount,
61 issueKinds: [...issueKinds],
62 maximumPathDepth,
63 };
64};
65
66export interface AcpRequestDiagnostics {
67 readonly method?: string;
68 readonly operation?: AcpRequestOperation;
69 readonly cause?: unknown;
70 readonly issueCount?: number;
71 readonly issueKinds?: ReadonlyArray<AcpSchemaIssueKind>;
72 readonly maximumPathDepth?: number;
73}
74
5export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", {75export class AcpSpawnError extends Schema.TaggedErrorClass<AcpSpawnError>()("AcpSpawnError", {
6 command: Schema.optional(Schema.String),76 command: Schema.optional(Schema.String),
7 cause: Schema.Defect(),77 cause: Schema.Defect(),
...@@ -27,27 +97,68 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExi...@@ -27,27 +97,68 @@ export class AcpProcessExitedError extends Schema.TaggedErrorClass<AcpProcessExi
27 }97 }
28}98}
2999
100export const AcpProtocolParseOperation = Schema.Literals([
101 "encode-message",
102 "decode-wire-message",
103 "decode-notification-payload",
104]);
105export type AcpProtocolParseOperation = typeof AcpProtocolParseOperation.Type;
106
30export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolParseError>()(107export class AcpProtocolParseError extends Schema.TaggedErrorClass<AcpProtocolParseError>()(
31 "AcpProtocolParseError",108 "AcpProtocolParseError",
32 {109 {
33 detail: Schema.String,110 operation: AcpProtocolParseOperation,
34 cause: Schema.optional(Schema.Defect()),111 method: Schema.optionalKey(Schema.String),
112 issueCount: Schema.optionalKey(Schema.Number),
113 issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)),
114 maximumPathDepth: Schema.optionalKey(Schema.Number),
115 cause: Schema.Defect(),
35 },116 },
36) {117) {
37 override get message() {118 override get message() {
38 return `Failed to parse ACP protocol message: ${this.detail}`;119 const method = this.method === undefined ? "" : ` for method '${this.method}'`;
120 return `ACP protocol operation '${this.operation}' failed${method}.`;
121 }
122
123 static fromSchemaError(
124 operation: AcpProtocolParseOperation,
125 method: string,
126 cause: Schema.SchemaError,
127 ) {
128 return new AcpProtocolParseError({
129 operation,
130 method,
131 ...schemaIssueDiagnostics(cause.issue),
132 cause,
133 });
39 }134 }
40}135}
41136
42export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError>()(137export class AcpTransportError extends Schema.TaggedErrorClass<AcpTransportError>()(
43 "AcpTransportError",138 "AcpTransportError",
44 {139 {
45 detail: Schema.String,140 operation: Schema.optional(
141 Schema.Literals(["call-rpc", "read-input-stream", "read-process-exit-status"]),
142 ),
143 method: Schema.optional(Schema.String),
144 detail: Schema.optional(Schema.String),
46 cause: Schema.Defect(),145 cause: Schema.Defect(),
47 },146 },
48) {147) {
49 override get message() {148 override get message() {
50 return this.detail;149 const method = this.method ? ` for method ${this.method}` : "";
150 return this.operation
151 ? `ACP transport operation ${this.operation} failed${method}.`
152 : "ACP transport operation failed.";
153 }
154}
155
156export class AcpInputStreamEndedError extends Schema.TaggedErrorClass<AcpInputStreamEndedError>()(
157 "AcpInputStreamEndedError",
158 {},
159) {
160 override get message() {
161 return "ACP input stream ended.";
51 }162 }
52}163}
53164
...@@ -55,6 +166,12 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(...@@ -55,6 +166,12 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(
55 code: AcpSchema.ErrorCode,166 code: AcpSchema.ErrorCode,
56 errorMessage: Schema.String,167 errorMessage: Schema.String,
57 data: Schema.optional(Schema.Unknown),168 data: Schema.optional(Schema.Unknown),
169 method: Schema.optionalKey(Schema.String),
170 operation: Schema.optionalKey(AcpRequestOperation),
171 issueCount: Schema.optionalKey(Schema.Number),
172 issueKinds: Schema.optionalKey(Schema.Array(AcpSchemaIssueKind)),
173 maximumPathDepth: Schema.optionalKey(Schema.Number),
174 cause: Schema.optionalKey(Schema.Defect()),
58}) {175}) {
59 override get message() {176 override get message() {
60 return this.errorMessage;177 return this.errorMessage;
...@@ -68,6 +185,36 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(...@@ -68,6 +185,36 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(
68 });185 });
69 }186 }
70187
188 static fromCoreHandlerError(error: AcpError, method: string) {
189 if (error._tag === "AcpRequestError") {
190 return error;
191 }
192 return AcpRequestError.internalError(
193 `ACP request handler failed for method '${method}'`,
194 undefined,
195 {
196 method,
197 operation: "handle-request",
198 cause: error,
199 },
200 );
201 }
202
203 static fromExtensionHandlerError(error: AcpError, method: string) {
204 if (error._tag === "AcpRequestError") {
205 return error;
206 }
207 return AcpRequestError.internalError(
208 `ACP extension request handler failed for method '${method}'`,
209 undefined,
210 {
211 method,
212 operation: "handle-extension-request",
213 cause: error,
214 },
215 );
216 }
217
71 static parseError(message = "Parse error", data?: unknown) {218 static parseError(message = "Parse error", data?: unknown) {
72 return new AcpRequestError({219 return new AcpRequestError({
73 code: -32700,220 code: -32700,
...@@ -99,11 +246,29 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(...@@ -99,11 +246,29 @@ export class AcpRequestError extends Schema.TaggedErrorClass<AcpRequestError>()(
99 });246 });
100 }247 }
101248
102 static internalError(message = "Internal error", data?: unknown) {249 static invalidExtensionPayload(method: string, cause: Schema.SchemaError) {
250 const diagnostics = schemaIssueDiagnostics(cause.issue);
251 return new AcpRequestError({
252 code: -32602,
253 errorMessage: `Invalid payload for ACP extension method '${method}'.`,
254 data: diagnostics,
255 method,
256 operation: "decode-extension-request-payload",
257 ...diagnostics,
258 cause,
259 });
260 }
261
262 static internalError(
263 message = "Internal error",
264 data?: unknown,
265 diagnostics: AcpRequestDiagnostics = {},
266 ) {
103 return new AcpRequestError({267 return new AcpRequestError({
104 code: -32603,268 code: -32603,
105 errorMessage: message,269 errorMessage: message,
106 ...(data !== undefined ? { data } : {}),270 ...(data !== undefined ? { data } : {}),
271 ...diagnostics,
107 });272 });
108 }273 }
109274
...@@ -138,6 +303,7 @@ export const AcpError = Schema.Union([...@@ -138,6 +303,7 @@ export const AcpError = Schema.Union([
138 AcpProcessExitedError,303 AcpProcessExitedError,
139 AcpProtocolParseError,304 AcpProtocolParseError,
140 AcpTransportError,305 AcpTransportError,
306 AcpInputStreamEndedError,
141]);307]);
142308
143export type AcpError = typeof AcpError.Type;309export type AcpError = typeof AcpError.Type;
packages/effect-acp/src/protocol.test.ts+134-2
...@@ -48,6 +48,8 @@ const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest));...@@ -48,6 +48,8 @@ const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest));
48const decodeRequestPermissionResponse = Schema.decodeEffect(48const decodeRequestPermissionResponse = Schema.decodeEffect(
49 Schema.fromJsonString(RequestPermissionResponse),49 Schema.fromJsonString(RequestPermissionResponse),
50);50);
51const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.UnknownFromJsonString);
52const encoder = new TextEncoder();
51const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>53const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
52 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),54 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
53);55);
...@@ -132,6 +134,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -132,6 +134,49 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
132 }),134 }),
133 );135 );
134136
137 it.effect("keeps invalid core notification values only in the schema cause", () =>
138 Effect.gen(function* () {
139 const secret = "acp-core-notification-secret-sentinel";
140 const { stdio, input } = yield* makeInMemoryStdio();
141 const termination = yield* Deferred.make<AcpError.AcpError>();
142 yield* AcpProtocol.makeAcpPatchedProtocol({
143 stdio,
144 serverRequestMethods: new Set(),
145 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
146 });
147
148 yield* Queue.offer(
149 input,
150 encoder.encode(
151 `${encodeUnknownJsonString({
152 jsonrpc: "2.0",
153 method: "session/update",
154 params: {
155 sessionId: { secret },
156 update: {
157 sessionUpdate: "plan",
158 entries: [],
159 },
160 },
161 })}\n`,
162 ),
163 );
164
165 const error = yield* Deferred.await(termination);
166 assert.instanceOf(error, AcpError.AcpProtocolParseError);
167 const parseError = error as AcpError.AcpProtocolParseError;
168 const { cause, ...directDiagnostics } = parseError;
169 assert.equal(parseError.operation, "decode-notification-payload");
170 assert.equal(parseError.method, "session/update");
171 assert.isAbove(parseError.issueCount ?? 0, 0);
172 assert.include(parseError.issueKinds ?? [], "Pointer");
173 assert.isAbove(parseError.maximumPathDepth ?? 0, 0);
174 assert.isTrue(Schema.isSchemaError(cause));
175 assert.notInclude(parseError.message, secret);
176 assert.notInclude(encodeUnknownJsonString(directDiagnostics), secret);
177 }),
178 );
179
135 it.effect("logs outgoing notifications when logOutgoing is enabled", () =>180 it.effect("logs outgoing notifications when logOutgoing is enabled", () =>
136 Effect.gen(function* () {181 Effect.gen(function* () {
137 const { stdio } = yield* makeInMemoryStdio();182 const { stdio } = yield* makeInMemoryStdio();
...@@ -172,6 +217,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -172,6 +217,38 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
172 }),217 }),
173 );218 );
174219
220 it.effect("logs decode failures without copying the cause or wire payload", () =>
221 Effect.gen(function* () {
222 const secret = "acp-wire-secret-sentinel";
223 const { stdio, input } = yield* makeInMemoryStdio();
224 const events: Array<AcpProtocol.AcpProtocolLogEvent> = [];
225 const termination = yield* Deferred.make<AcpError.AcpError>();
226 yield* AcpProtocol.makeAcpPatchedProtocol({
227 stdio,
228 serverRequestMethods: new Set(),
229 logIncoming: true,
230 logger: (event) =>
231 Effect.sync(() => {
232 events.push(event);
233 }),
234 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
235 });
236
237 yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`));
238 yield* Deferred.await(termination);
239
240 const event = events.find(({ stage }) => stage === "decode_failed");
241 assert.deepEqual(event, {
242 direction: "incoming",
243 stage: "decode_failed",
244 payload: {
245 operation: "decode-wire-message",
246 },
247 });
248 assert.notInclude(encodeUnknownJsonString(event), secret);
249 }),
250 );
251
175 it.effect("fails notification encoding through the declared ACP error channel", () =>252 it.effect("fails notification encoding through the declared ACP error channel", () =>
176 Effect.gen(function* () {253 Effect.gen(function* () {
177 const { stdio } = yield* makeInMemoryStdio();254 const { stdio } = yield* makeInMemoryStdio();
...@@ -182,13 +259,16 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -182,13 +259,16 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
182259
183 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);260 const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip);
184 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);261 assert.instanceOf(bigintError, AcpError.AcpProtocolParseError);
185 assert.equal(bigintError.detail, "Failed to encode ACP message");262 assert.equal(bigintError.operation, "encode-message");
263 assert.instanceOf(bigintError.cause, TypeError);
264 assert.equal(bigintError.message, "ACP protocol operation 'encode-message' failed.");
186265
187 const circular: Record<string, unknown> = {};266 const circular: Record<string, unknown> = {};
188 circular.self = circular;267 circular.self = circular;
189 const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip);268 const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip);
190 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);269 assert.instanceOf(circularError, AcpError.AcpProtocolParseError);
191 assert.equal(circularError.detail, "Failed to encode ACP message");270 assert.equal(circularError.operation, "encode-message");
271 assert.instanceOf(circularError.cause, TypeError);
192 }),272 }),
193 );273 );
194274
...@@ -381,14 +461,35 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -381,14 +461,35 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
381 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");461 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");
382 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {462 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {
383 readonly _tag: string;463 readonly _tag: string;
464 readonly message: string;
384 readonly cause: unknown;465 readonly cause: unknown;
385 };466 };
386 assert.equal(defect._tag, "RpcClientDefect");467 assert.equal(defect._tag, "RpcClientDefect");
468 assert.equal(defect.message, "ACP protocol terminated.");
387 assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError);469 assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError);
388 assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7);470 assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7);
389 }),471 }),
390 );472 );
391473
474 it.effect("classifies an input stream ending without inventing a cause", () =>
475 Effect.gen(function* () {
476 const { stdio, input } = yield* makeInMemoryStdio();
477 const termination = yield* Deferred.make<AcpError.AcpError>();
478 yield* AcpProtocol.makeAcpPatchedProtocol({
479 stdio,
480 serverRequestMethods: new Set(),
481 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
482 });
483
484 yield* Queue.end(input);
485
486 const error = yield* Deferred.await(termination);
487 assert.instanceOf(error, AcpError.AcpInputStreamEndedError);
488 assert.equal(error.message, "ACP input stream ended.");
489 assert.equal("cause" in error, false);
490 }),
491 );
492
392 it.effect("does not emit a second process-exit error after a decode failure", () =>493 it.effect("does not emit a second process-exit error after a decode failure", () =>
393 Effect.gen(function* () {494 Effect.gen(function* () {
394 const handle = yield* makeHandle({495 const handle = yield* makeHandle({
...@@ -413,9 +514,40 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {...@@ -413,9 +514,40 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => {
413 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");514 assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError");
414 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {515 const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as {
415 readonly _tag: string;516 readonly _tag: string;
517 readonly message: string;
416 readonly cause: unknown;518 readonly cause: unknown;
417 };519 };
418 assert.equal(defect._tag, "RpcClientDefect");520 assert.equal(defect._tag, "RpcClientDefect");
521 assert.equal(defect.message, "ACP protocol terminated.");
522 assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError);
523 }),
524 );
525
526 it.effect("keeps client send failure messages independent from the cause", () =>
527 Effect.gen(function* () {
528 const { stdio } = yield* makeInMemoryStdio();
529 const transport = yield* AcpProtocol.makeAcpPatchedProtocol({
530 stdio,
531 serverRequestMethods: new Set(),
532 });
533
534 const failure = yield* transport.clientProtocol
535 .send(0, {
536 _tag: "Request",
537 id: "request-1",
538 tag: "x/test",
539 payload: 1n,
540 headers: [],
541 })
542 .pipe(Effect.flip);
543 const defect = failure.reason as {
544 readonly _tag: string;
545 readonly message: string;
546 readonly cause: unknown;
547 };
548
549 assert.equal(defect._tag, "RpcClientDefect");
550 assert.equal(defect.message, "Failed to send ACP protocol message.");
419 assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError);551 assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError);
420 }),552 }),
421 );553 );
packages/effect-acp/src/protocol.ts+41-41
...@@ -17,7 +17,6 @@ import * as AcpSchema from "./_generated/schema.gen.ts";...@@ -17,7 +17,6 @@ import * as AcpSchema from "./_generated/schema.gen.ts";
17import { CLIENT_METHODS } from "./_generated/meta.gen.ts";17import { CLIENT_METHODS } from "./_generated/meta.gen.ts";
18import * as AcpError from "./errors.ts";18import * as AcpError from "./errors.ts";
19const isAcpError = Schema.is(AcpError.AcpError);19const isAcpError = Schema.is(AcpError.AcpError);
20const isAcpRequestError = Schema.is(AcpError.AcpRequestError);
2120
22export interface AcpProtocolLogEvent {21export interface AcpProtocolLogEvent {
23 readonly direction: "incoming" | "outgoing";22 readonly direction: "incoming" | "outgoing";
...@@ -114,7 +113,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -114,7 +113,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
114 try: () => parser.encode(message),113 try: () => parser.encode(message),
115 catch: (cause) =>114 catch: (cause) =>
116 new AcpError.AcpProtocolParseError({115 new AcpError.AcpProtocolParseError({
117 detail: "Failed to encode ACP message",116 operation: "encode-message",
118 cause,117 cause,
119 }),118 }),
120 });119 });
...@@ -184,7 +183,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -184,7 +183,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
184 _tag: "ClientProtocolError",183 _tag: "ClientProtocolError",
185 error: new RpcClientError.RpcClientError({184 error: new RpcClientError.RpcClientError({
186 reason: new RpcClientError.RpcClientDefect({185 reason: new RpcClientError.RpcClientDefect({
187 message: error.message,186 message: "ACP protocol terminated.",
188 cause: error,187 cause: error,
189 }),188 }),
190 }),189 }),
...@@ -243,7 +242,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -243,7 +242,11 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
243 }242 }
244 return options.onExtRequest(message.tag, message.payload).pipe(243 return options.onExtRequest(message.tag, message.payload).pipe(
245 Effect.matchEffect({244 Effect.matchEffect({
246 onFailure: (error) => respondWithError(message.id, normalizeToRequestError(error)),245 onFailure: (error) =>
246 respondWithError(
247 message.id,
248 AcpError.AcpRequestError.fromExtensionHandlerError(error, message.tag),
249 ),
247 onSuccess: (value) => respondWithSuccess(message.id, value),250 onSuccess: (value) => respondWithSuccess(message.id, value),
248 }),251 }),
249 );252 );
...@@ -261,12 +264,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -261,12 +264,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
261 params,264 params,
262 }) satisfies AcpIncomingNotification,265 }) satisfies AcpIncomingNotification,
263 ),266 ),
264 Effect.mapError(267 Effect.mapError((cause) =>
265 (cause) =>268 AcpError.AcpProtocolParseError.fromSchemaError(
266 new AcpError.AcpProtocolParseError({269 "decode-notification-payload",
267 detail: `Invalid ${CLIENT_METHODS.session_update} notification payload`,270 CLIENT_METHODS.session_update,
268 cause,271 cause,
269 }),272 ),
270 ),273 ),
271 Effect.flatMap(dispatchNotification),274 Effect.flatMap(dispatchNotification),
272 );275 );
...@@ -281,12 +284,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -281,12 +284,12 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
281 params,284 params,
282 }) satisfies AcpIncomingNotification,285 }) satisfies AcpIncomingNotification,
283 ),286 ),
284 Effect.mapError(287 Effect.mapError((cause) =>
285 (cause) =>288 AcpError.AcpProtocolParseError.fromSchemaError(
286 new AcpError.AcpProtocolParseError({289 "decode-notification-payload",
287 detail: `Invalid ${CLIENT_METHODS.session_elicitation_complete} notification payload`,290 CLIENT_METHODS.session_elicitation_complete,
288 cause,291 cause,
289 }),292 ),
290 ),293 ),
291 Effect.flatMap(dispatchNotification),294 Effect.flatMap(dispatchNotification),
292 );295 );
...@@ -379,7 +382,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -379,7 +382,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
379 >,382 >,
380 catch: (cause) =>383 catch: (cause) =>
381 new AcpError.AcpProtocolParseError({384 new AcpError.AcpProtocolParseError({
382 detail: "Failed to decode ACP wire message",385 operation: "decode-wire-message",
383 cause,386 cause,
384 }),387 }),
385 }),388 }),
...@@ -396,8 +399,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -396,8 +399,13 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
396 direction: "incoming",399 direction: "incoming",
397 stage: "decode_failed",400 stage: "decode_failed",
398 payload: {401 payload: {
399 detail: error.detail,402 operation: error.operation,
400 cause: error.cause,403 ...(error.method === undefined ? {} : { method: error.method }),
404 ...(error.issueCount === undefined ? {} : { issueCount: error.issueCount }),
405 ...(error.issueKinds === undefined ? {} : { issueKinds: error.issueKinds }),
406 ...(error.maximumPathDepth === undefined
407 ? {}
408 : { maximumPathDepth: error.maximumPathDepth }),
401 },409 },
402 }),410 }),
403 ),411 ),
...@@ -413,7 +421,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -413,7 +421,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
413 const normalized: AcpError.AcpError = isAcpError(error)421 const normalized: AcpError.AcpError = isAcpError(error)
414 ? error422 ? error
415 : new AcpError.AcpTransportError({423 : new AcpError.AcpTransportError({
416 detail: error instanceof Error ? error.message : String(error),424 operation: "read-input-stream",
417 cause: error,425 cause: error,
418 });426 });
419 return handleTermination(() => Effect.succeed(normalized));427 return handleTermination(() => Effect.succeed(normalized));
...@@ -421,13 +429,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -421,13 +429,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
421 onSuccess: () =>429 onSuccess: () =>
422 handleTermination(430 handleTermination(
423 () =>431 () =>
424 options.terminationError ??432 options.terminationError ?? Effect.succeed(new AcpError.AcpInputStreamEndedError({})),
425 Effect.succeed(
426 new AcpError.AcpTransportError({
427 detail: "ACP input stream ended",
428 cause: new Error("ACP input stream ended"),
429 }),
430 ),
431 ),433 ),
432 }),434 }),
433 Effect.forkScoped,435 Effect.forkScoped,
...@@ -441,7 +443,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -441,7 +443,18 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
441 Stream.runForEach((message) => f(message)),443 Stream.runForEach((message) => f(message)),
442 Effect.forever,444 Effect.forever,
443 ),445 ),
444 send: (_clientId, request) => offerOutgoing(request).pipe(Effect.mapError(toRpcClientError)),446 send: (_clientId, request) =>
447 offerOutgoing(request).pipe(
448 Effect.mapError(
449 (error) =>
450 new RpcClientError.RpcClientError({
451 reason: new RpcClientError.RpcClientDefect({
452 message: "Failed to send ACP protocol message.",
453 cause: error,
454 }),
455 }),
456 ),
457 ),
445 supportsAck: true,458 supportsAck: true,
446 supportsTransferables: false,459 supportsTransferables: false,
447 });460 });
...@@ -521,16 +534,3 @@ function isProtocolError(...@@ -521,16 +534,3 @@ function isProtocolError(
521 typeof value.message === "string"534 typeof value.message === "string"
522 );535 );
523}536}
524
525function normalizeToRequestError(error: AcpError.AcpError): AcpError.AcpRequestError {
526 return isAcpRequestError(error) ? error : AcpError.AcpRequestError.internalError(error.message);
527}
528
529function toRpcClientError(error: AcpError.AcpError): RpcClientError.RpcClientError {
530 return new RpcClientError.RpcClientError({
531 reason: new RpcClientError.RpcClientDefect({
532 message: error.message,
533 cause: error,
534 }),
535 });
536}
packages/effect-acp/src/terminal.ts-20
...@@ -23,23 +23,3 @@ export interface AcpTerminal {...@@ -23,23 +23,3 @@ export interface AcpTerminal {
23 */23 */
24 readonly release: Effect.Effect<AcpSchema.ReleaseTerminalResponse, AcpError.AcpError>;24 readonly release: Effect.Effect<AcpSchema.ReleaseTerminalResponse, AcpError.AcpError>;
25}25}
26
27export interface MakeTerminalOptions {
28 readonly sessionId: string;
29 readonly terminalId: string;
30 readonly output: Effect.Effect<AcpSchema.TerminalOutputResponse, AcpError.AcpError>;
31 readonly waitForExit: Effect.Effect<AcpSchema.WaitForTerminalExitResponse, AcpError.AcpError>;
32 readonly kill: Effect.Effect<AcpSchema.KillTerminalResponse, AcpError.AcpError>;
33 readonly release: Effect.Effect<AcpSchema.ReleaseTerminalResponse, AcpError.AcpError>;
34}
35
36export function makeTerminal(options: MakeTerminalOptions): AcpTerminal {
37 return {
38 sessionId: options.sessionId,
39 terminalId: options.terminalId,
40 output: options.output,
41 waitForExit: options.waitForExit,
42 kill: options.kill,
43 release: options.release,
44 };
45}