import * as Path from "effect/Path"; import * as AcpError from "./errors.ts"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; 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 { it, assert } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as AcpSchema from "./_generated/schema.gen.ts"; import * as AcpProtocol from "./protocol.ts"; import { encodeJsonl, jsonRpcNotification, jsonRpcRequest, jsonRpcResponse, } from "./_internal/shared.ts"; import { makeInMemoryStdio, makeTerminationError, makeChildStdio } from "./_internal/stdio.ts"; const SessionCancelNotification = jsonRpcNotification( "session/cancel", AcpSchema.CancelNotification, ); const SessionUpdateNotification = jsonRpcNotification( "session/update", AcpSchema.SessionNotification, ); const ElicitationCompleteNotification = jsonRpcNotification( "session/elicitation/complete", AcpSchema.ElicitationCompleteNotification, ); const RequestPermissionRequest = jsonRpcRequest( "session/request_permission", AcpSchema.RequestPermissionRequest, ); const RequestPermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse); const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String })); const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean })); const decodeSessionCancelNotification = Schema.decodeEffect( Schema.fromJsonString(SessionCancelNotification), ); const decodeExtRequest = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); const decodeExtResponse = Schema.decodeEffect(Schema.fromJsonString(ExtResponse)); const decodeRequestPermissionResponse = Schema.decodeEffect( Schema.fromJsonString(RequestPermissionResponse), ); const encodeUnknownJsonString = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encoder = new TextEncoder(); const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"), ); const mockPeerArgs = (path: string) => [path]; const makeHandle = (env?: Record) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), { cwd: path.join(import.meta.dirname, ".."), ...(env ? { env: { ...process.env, ...env } } : {}), }); return yield* spawner.spawn(command); }); it.layer(NodeServices.layer)("effect-acp protocol", (it) => { it.effect( "emits exact JSON-RPC notifications and decodes inbound session/update and elicitation completion", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); const notifications = yield* Deferred.make>(); yield* transport.incoming.pipe( Stream.take(2), Stream.runCollect, Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), Effect.forkScoped, ); yield* transport.notify("session/cancel", { sessionId: "session-1" }); const outbound = yield* Queue.take(output); assert.deepEqual(yield* decodeSessionCancelNotification(outbound), { jsonrpc: "2.0", method: "session/cancel", params: { sessionId: "session-1", }, }); yield* Queue.offer( input, yield* encodeJsonl(SessionUpdateNotification, { jsonrpc: "2.0", method: "session/update", params: { sessionId: "session-1", update: { sessionUpdate: "plan", entries: [ { content: "Inspect repository", priority: "high", status: "in_progress", }, ], }, }, }), ); yield* Queue.offer( input, yield* encodeJsonl(ElicitationCompleteNotification, { jsonrpc: "2.0", method: "session/elicitation/complete", params: { elicitationId: "elicitation-1", }, }), ); const [update, completion] = yield* Deferred.await(notifications); assert.equal(update?._tag, "SessionUpdate"); assert.equal(completion?._tag, "ElicitationComplete"); }), ); it.effect("keeps only recent raw notifications after their callbacks run", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); const handled = yield* Deferred.make(); let handledCount = 0; const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), onNotification: () => Effect.sync(() => ++handledCount).pipe( Effect.flatMap((count) => count === 64 ? Deferred.succeed(handled, undefined).pipe(Effect.asVoid) : Effect.void, ), ), }); const messages = Array.from({ length: 64 }, (_, index) => encodeUnknownJsonString({ jsonrpc: "2.0", method: "x/performance", params: { index }, }), ); yield* Queue.offer(input, encoder.encode(`${messages.join("\n")}\n`)); yield* Deferred.await(handled); const retained = yield* transport.incoming.pipe(Stream.take(32), Stream.runCollect); assert.equal(handledCount, 64); assert.equal(retained.length, 32); assert.deepEqual(retained[0]?.params, { index: 32 }); assert.deepEqual(retained[31]?.params, { index: 63 }); }), ); it.effect("keeps invalid core notification values only in the schema cause", () => Effect.gen(function* () { const secret = "acp-core-notification-secret-sentinel"; const { stdio, input } = yield* makeInMemoryStdio(); const termination = yield* Deferred.make(); let transformCalls = 0; yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), transformSessionUpdate: (notification) => { transformCalls += 1; return notification; }, onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), }); yield* Queue.offer( input, encoder.encode( `${encodeUnknownJsonString({ jsonrpc: "2.0", method: "session/update", params: { sessionId: { secret }, update: { sessionUpdate: "plan", entries: [], }, }, })}\n`, ), ); const error = yield* Deferred.await(termination); assert.equal(transformCalls, 0); assert.instanceOf(error, AcpError.AcpProtocolParseError); const parseError = error as AcpError.AcpProtocolParseError; const { cause, ...directDiagnostics } = parseError; assert.equal(parseError.operation, "decode-notification-payload"); assert.equal(parseError.method, "session/update"); assert.isAbove(parseError.issueCount ?? 0, 0); assert.include(parseError.issueKinds ?? [], "Pointer"); assert.isAbove(parseError.maximumPathDepth ?? 0, 0); assert.isTrue(Schema.isSchemaError(cause)); assert.notInclude(parseError.message, secret); assert.notInclude(encodeUnknownJsonString(directDiagnostics), secret); }), ); it.effect("logs outgoing notifications when logOutgoing is enabled", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); const events: Array = []; const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), logOutgoing: true, logger: (event) => Effect.sync(() => { events.push(event); }), }); yield* transport.notify("session/cancel", { sessionId: "session-1" }); // A notification must not carry `id` or `headers`. Grok CLI drops frames that do. assert.deepEqual(events, [ { direction: "outgoing", stage: "decoded", payload: { _tag: "Notification", tag: "session/cancel", payload: { sessionId: "session-1", }, }, }, { direction: "outgoing", stage: "raw", payload: '{"jsonrpc":"2.0","method":"session/cancel","params":{"sessionId":"session-1"}}\n', }, ]); }), ); it.effect("logs decode failures without copying the cause or wire payload", () => Effect.gen(function* () { const secret = "acp-wire-secret-sentinel"; const { stdio, input } = yield* makeInMemoryStdio(); const events: Array = []; const termination = yield* Deferred.make(); yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), logIncoming: true, logger: (event) => Effect.sync(() => { events.push(event); }), onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), }); yield* Queue.offer(input, encoder.encode(`{"secret":"${secret}"\n`)); yield* Deferred.await(termination); const event = events.find(({ stage }) => stage === "decode_failed"); assert.deepEqual(event, { direction: "incoming", stage: "decode_failed", payload: { operation: "decode-wire-message", }, }); assert.notInclude(encodeUnknownJsonString(event), secret); }), ); it.effect("fails notification encoding through the declared ACP error channel", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); // Notifications encode through Schema, so the cause is the schema failure rather // than the raw TypeError JSON.stringify throws. The ACP error shape is what callers see. const bigintError = yield* transport.notify("x/test", 1n).pipe(Effect.flip); assert.instanceOf(bigintError, AcpError.AcpProtocolParseError); assert.equal(bigintError.operation, "encode-message"); assert.equal(bigintError.method, "x/test"); assert.isDefined(bigintError.cause); assert.equal( bigintError.message, "ACP protocol operation 'encode-message' failed for method 'x/test'.", ); const circular: Record = {}; circular.self = circular; const circularError = yield* transport.notify("x/test", circular).pipe(Effect.flip); assert.instanceOf(circularError, AcpError.AcpProtocolParseError); assert.equal(circularError.operation, "encode-message"); assert.equal(circularError.method, "x/test"); assert.isDefined(circularError.cause); const requestError = yield* transport.request("x/request", 1n).pipe( Effect.match({ onFailure: (error) => error, onSuccess: () => assert.fail("Expected request encoding to fail"), }), ); assert.instanceOf(requestError, AcpError.AcpProtocolParseError); assert.deepInclude(requestError, { operation: "encode-message", method: "x/request", requestId: 1, }); }), ); it.effect("supports generic extension requests over the patched transport", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); const response = yield* transport .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); assert.deepEqual(yield* decodeExtRequest(outbound), { jsonrpc: "2.0", id: 1, method: "x/test", params: { hello: "world", }, headers: [], }); yield* Queue.offer( input, yield* encodeJsonl(ExtResponse, { jsonrpc: "2.0", id: 1, result: { ok: true, }, }), ); const resolved = yield* Fiber.join(response); assert.deepEqual(resolved, { ok: true }); }), ); it.effect("correlates extension response errors with the originating request", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); const response = yield* transport .request("x/private", { hello: "world" }) .pipe(Effect.forkScoped); yield* Queue.take(output); yield* Queue.offer( input, encoder.encode( `${encodeUnknownJsonString({ jsonrpc: "2.0", id: 1, error: { _tag: "Cause", code: -32602, message: "Invalid params", data: [ { _tag: "Fail", error: { code: -32602, message: "Invalid params", data: { field: "hello" }, }, }, ], }, })}\n`, ), ); const error = yield* Fiber.join(response).pipe( Effect.match({ onFailure: (error) => error, onSuccess: () => assert.fail("Expected extension request to fail"), }), ); assert.instanceOf(error, AcpError.AcpRequestError); assert.deepInclude(error, { code: -32602, errorMessage: "Invalid params", method: "x/private", requestId: 1, operation: "receive-response", }); }), ); it.effect("preserves numeric ids for inbound extension requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), onExtRequest: () => Effect.succeed({ ok: true }), }); yield* Queue.offer( input, yield* encodeJsonl(ExtRequest, { jsonrpc: "2.0", id: 7, method: "x/test", params: { hello: "world", }, headers: [], }), ); const outbound = yield* Queue.take(output); assert.deepEqual(yield* decodeExtResponse(outbound), { jsonrpc: "2.0", id: 7, result: { ok: true, }, }); }), ); it.effect("preserves zero-valued ids for inbound core client requests", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(["session/request_permission"]), }); const inboundRequest = yield* Deferred.make(); yield* transport.serverProtocol .run((_clientId, message) => Deferred.succeed(inboundRequest, message).pipe(Effect.asVoid)) .pipe(Effect.forkScoped); yield* Queue.offer( input, yield* encodeJsonl(RequestPermissionRequest, { jsonrpc: "2.0", id: 0, method: "session/request_permission", params: { sessionId: "session-1", toolCall: { toolCallId: "tool-1", title: "Allow mock action", }, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], }, headers: [], }), ); const message = yield* Deferred.await(inboundRequest); assert.deepEqual(message, { _tag: "Request", id: 0, tag: "session/request_permission", payload: { sessionId: "session-1", toolCall: { toolCallId: "tool-1", title: "Allow mock action", }, options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], }, headers: [], }); yield* transport.serverProtocol.send(0, { _tag: "Exit", requestId: 0, exit: { _tag: "Success", value: { outcome: { outcome: "selected", optionId: "allow", }, }, }, }); const outbound = yield* Queue.take(output); assert.deepEqual(yield* decodeRequestPermissionResponse(outbound), { jsonrpc: "2.0", id: 0, result: { outcome: { outcome: "selected", optionId: "allow", }, }, }); }), ); it.effect("cleans up interrupted extension requests before a late response arrives", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); const lateResponse = yield* Deferred.make(); yield* transport.clientProtocol .run(0, (message) => Deferred.succeed(lateResponse, message).pipe(Effect.asVoid)) .pipe(Effect.forkScoped); const response = yield* transport .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); const outbound = yield* Queue.take(output); assert.deepEqual(yield* decodeExtRequest(outbound), { jsonrpc: "2.0", id: 1, method: "x/test", params: { hello: "world", }, headers: [], }); yield* Fiber.interrupt(response); yield* Queue.offer( input, yield* encodeJsonl(ExtResponse, { jsonrpc: "2.0", id: 1, result: { ok: true, }, }), ); const message = yield* Deferred.await(lateResponse); assert.deepEqual(message, { _tag: "Exit", requestId: 1, exit: { _tag: "Success", value: { ok: true, }, }, }); }), ); it.effect("propagates the real child exit code when the input stream ends", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_EXIT_IMMEDIATELY_CODE: "7" }); const firstMessage = yield* Deferred.make(); const termination = yield* Deferred.make(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeChildStdio(handle), terminationError: makeTerminationError(handle), serverRequestMethods: new Set(), onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), }); yield* transport.clientProtocol .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) .pipe(Effect.forkScoped); const message = yield* Deferred.await(firstMessage); const exitError = yield* Deferred.await(termination); assert.instanceOf(exitError, AcpError.AcpProcessExitedError); assert.equal((exitError as AcpError.AcpProcessExitedError).code, 7); assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); assert.equal(defect.message, "ACP protocol terminated."); assert.instanceOf(defect.cause, AcpError.AcpProcessExitedError); assert.equal((defect.cause as AcpError.AcpProcessExitedError).code, 7); }), ); it.effect("classifies an input stream ending without inventing a cause", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); const termination = yield* Deferred.make(); yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid), }); yield* Queue.end(input); const error = yield* Deferred.await(termination); assert.instanceOf(error, AcpError.AcpInputStreamEndedError); assert.equal(error.message, "ACP input stream ended."); assert.equal("cause" in error, false); }), ); it.effect("does not emit a second process-exit error after a decode failure", () => Effect.gen(function* () { const handle = yield* makeHandle({ ACP_MOCK_MALFORMED_OUTPUT: "1", ACP_MOCK_MALFORMED_OUTPUT_EXIT_CODE: "23", }); const terminationCalls = yield* Ref.make(0); const firstMessage = yield* Deferred.make(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio: makeChildStdio(handle), terminationError: makeTerminationError(handle), serverRequestMethods: new Set(), onTermination: () => Ref.update(terminationCalls, (count) => count + 1), }); yield* transport.clientProtocol .run(0, (message) => Deferred.succeed(firstMessage, message).pipe(Effect.asVoid)) .pipe(Effect.forkScoped); const message = yield* Deferred.await(firstMessage); assert.equal(yield* Ref.get(terminationCalls), 1); assert.equal((message as { readonly _tag?: string })._tag, "ClientProtocolError"); const defect = (message as { readonly error: { readonly reason: unknown } }).error.reason as { readonly _tag: string; readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); assert.equal(defect.message, "ACP protocol terminated."); assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); }), ); it.effect("keeps client send failure messages independent from the cause", () => Effect.gen(function* () { const { stdio } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), }); const failure = yield* transport.clientProtocol .send(0, { _tag: "Request", id: "request-1", tag: "x/test", payload: 1n, headers: [], }) .pipe(Effect.flip); const defect = failure.reason as { readonly _tag: string; readonly message: string; readonly cause: unknown; }; assert.equal(defect._tag, "RpcClientDefect"); assert.equal(defect.message, "Failed to send ACP protocol message."); assert.instanceOf(defect.cause, AcpError.AcpProtocolParseError); }), ); it.effect("fails pending extension requests with the propagated exit code", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, terminationError: Effect.succeed(new AcpError.AcpProcessExitedError({ code: 0 })), serverRequestMethods: new Set(), }); const response = yield* transport .request("x/test", { hello: "world" }) .pipe(Effect.forkScoped); yield* Queue.take(output); yield* Queue.end(input); const error = yield* Fiber.join(response).pipe( Effect.match({ onFailure: (error) => error, onSuccess: () => assert.fail("Expected request to fail after process exit"), }), ); assert.instanceOf(error, AcpError.AcpProcessExitedError); assert.equal(error.code, 0); }), ); for (const operation of ["request", "notification"] as const) { it.effect(`rejects a ${operation} if the connection ends while its logger is running`, () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); const writeStarted = yield* Deferred.make(); const releaseWrite = yield* Deferred.make(); const terminated = yield* Deferred.make(); const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ stdio, serverRequestMethods: new Set(), logOutgoing: true, logger: (event) => event.stage === "raw" ? Deferred.succeed(writeStarted, undefined).pipe( Effect.andThen(Deferred.await(releaseWrite)), ) : Effect.void, onTermination: (error) => Deferred.succeed(terminated, error).pipe(Effect.asVoid), }); const send = yield* ( operation === "request" ? transport.request("x/test", { hello: "world" }) : transport.notify("session/cancel", { sessionId: "session-1" }) ).pipe(Effect.forkScoped); yield* Deferred.await(writeStarted); yield* Queue.end(input); const error = yield* Deferred.await(terminated); yield* Deferred.succeed(releaseWrite, undefined); const failure = yield* Fiber.join(send).pipe( Effect.match({ onFailure: (failure) => failure, onSuccess: () => assert.fail("Expected the send to fail after termination"), }), ); assert.strictEqual(failure, error); assert.equal(yield* Queue.size(output), 0); }), ); } });