1import * as Path from "effect/Path";
2import * as Cause from "effect/Cause";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Exit from "effect/Exit";
6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
9import * as Queue from "effect/Queue";
10import * as Ref from "effect/Ref";
11import * as Schema from "effect/Schema";
12import * as Scope from "effect/Scope";
13import * as Stream from "effect/Stream";
14import * as TestClock from "effect/testing/TestClock";
15import { ChildProcess, ChildProcessSpawner } from "effect/process";
16
17import * as NodeServices from "@effect/platform-node/NodeServices";
18import { it, assert } from "@effect/vitest";
19
20import * as AcpClient from "./client.ts";
21import * as AcpSchema from "./_generated/schema.gen.ts";
22import * as AcpSchemaV1 from "./_generated/schema-v1.gen.ts";
23import * as AcpError from "./errors.ts";
24import type * as AcpCompat from "./compat.ts";
25import type * as AcpProtocol from "./protocol.ts";
26import {
27 encodeJsonl,
28 jsonRpcNotification,
29 jsonRpcRequest,
30 jsonRpcResponse,
31} from "./_internal/shared.ts";
32import { makeInMemoryStdio } from "./_internal/stdio.ts";
33
34import antigravityInitialize from "../test/fixtures/antigravity-initialize.json" with { type: "json" };
35
36const InitializeRequest = jsonRpcRequest("initialize", AcpSchema.InitializeRequest);
37const InitializeResponse = jsonRpcResponse(AcpSchema.InitializeResponse);
38const InitializeRequestV1 = jsonRpcRequest("initialize", AcpSchemaV1.InitializeRequest);
39const InitializeResponseV1 = jsonRpcResponse(AcpSchemaV1.InitializeResponse);
40const NewSessionRequestV1 = jsonRpcRequest("session/new", AcpSchemaV1.NewSessionRequest);
41const PromptRequestV1 = jsonRpcRequest("session/prompt", AcpSchemaV1.PromptRequest);
42const PromptResponseV1 = jsonRpcResponse(AcpSchemaV1.PromptResponse);
43const ExtRequest = jsonRpcRequest("x/test", Schema.Struct({ hello: Schema.String }));
44const ExtResponse = jsonRpcResponse(Schema.Struct({ ok: Schema.Boolean }));
45const PromptRequest = jsonRpcRequest("session/prompt", AcpSchema.PromptRequest);
46const PromptResponse = jsonRpcResponse(AcpSchema.PromptResponse);
47const SessionUpdateNotification = jsonRpcNotification("session/update", Schema.Unknown);
48const PermissionRequest = jsonRpcRequest(
49 "session/request_permission",
50 AcpSchema.RequestPermissionRequest,
51);
52const PermissionResponse = jsonRpcResponse(AcpSchema.RequestPermissionResponse);
53const ElicitationRequest = jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest);
54const ElicitationResponse = jsonRpcResponse(AcpSchema.CreateElicitationResponse);
55/** A JSON-RPC error response; only its id and the presence of an error matter here. */
56const ErrorResponse = Schema.Struct({ id: Schema.String, error: Schema.Unknown });
57/** A response whose cause is a handler's defect, as RpcServer encodes it. */
58const DieResponse = Schema.Struct({
59 id: Schema.String,
60 error: Schema.Struct({
61 _tag: Schema.Literal("Cause"),
62 data: Schema.Tuple([Schema.Struct({ _tag: Schema.Literal("Die") })]),
63 }),
64});
65const decodePromptRequestLine = Schema.decodeEffect(Schema.fromJsonString(PromptRequest));
66const XAiPromptCompleteNotification = jsonRpcNotification(
67 "_x.ai/session/prompt_complete",
68 Schema.Struct({
69 sessionId: Schema.String,
70 promptId: Schema.String,
71 stopReason: Schema.String,
72 agentResult: Schema.NullOr(Schema.Unknown),
73 }),
74);
75const XAiQueueChangedNotification = jsonRpcNotification(
76 "_x.ai/queue/changed",
77 Schema.Struct({
78 sessionId: Schema.String,
79 entries: Schema.Array(Schema.Unknown),
80 }),
81);
82const XAiSessionsChangedNotification = jsonRpcNotification(
83 "_x.ai/sessions/changed",
84 Schema.Struct({
85 upserted: Schema.Array(Schema.Unknown),
86 removed: Schema.Array(Schema.Unknown),
87 }),
88);
89const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) =>
90 path.join(import.meta.dirname, "../test/fixtures/acp-mock-peer.ts"),
91);
92const mockPeerArgs = (path: string) => [path];
93const mockStartupNotice = "Mock ACP startup notice";
94const stripMockStartupNotice = (stdout: ChildProcessSpawner.ChildProcessHandle["stdout"]) =>
95 stdout.pipe(
96 Stream.decodeText,
97 Stream.splitLines,
98 Stream.filter((line) => line !== mockStartupNotice),
99 Stream.map((line) => `${line}\n`),
100 Stream.encodeText,
101 );
102
103function concatBytes(chunks: ReadonlyArray<Uint8Array>): Uint8Array {
104 const batch = new Uint8Array(chunks.reduce((total, chunk) => total + chunk.length, 0));
105 let offset = 0;
106 for (const chunk of chunks) {
107 batch.set(chunk, offset);
108 offset += chunk.length;
109 }
110 return batch;
111}
112
113it.layer(NodeServices.layer)("effect-acp client", (it) => {
114 const makeHandle = (env?: Record<string, string>) =>
115 Effect.gen(function* () {
116 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
117 const path = yield* Path.Path;
118 const command = ChildProcess.make(process.execPath, mockPeerArgs(yield* mockPeerPath), {
119 cwd: path.join(import.meta.dirname, ".."),
120 ...(env ? { env: { ...process.env, ...env } } : {}),
121 });
122 return yield* spawner.spawn(command);
123 });
124
125 it.effect("transforms fragmented stdout before parsing and preserves UTF-8", () =>
126 Effect.gen(function* () {
127 const handle = yield* makeHandle({ ACP_MOCK_STDOUT_PREFIX: `${mockStartupNotice}\n` });
128 yield* Effect.gen(function* () {
129 const acp = yield* AcpClient.AcpClient;
130 const initialized = yield* acp.agent.initialize({ protocolVersion: 1 });
131 assert.equal(initialized.protocolVersion, 2);
132
133 const echoed = yield* acp.raw.request("x/echo", { message: "café" });
134 assert.deepEqual(echoed, {
135 echoedMethod: "x/echo",
136 echoedParams: { message: "café" },
137 });
138 }).pipe(
139 Effect.provide(
140 AcpClient.layerChildProcess(handle, {
141 transformStdout: (stdout) =>
142 stdout.pipe(
143 Stream.flatMap((chunk) =>
144 Stream.fromIterable(Array.from(chunk, (byte) => Uint8Array.of(byte))),
145 ),
146 stripMockStartupNotice,
147 ),
148 }),
149 ),
150 );
151 }),
152 );
153
154 it.effect("reports malformed JSON that remains after a stdout transform", () =>
155 Effect.gen(function* () {
156 const handle = yield* makeHandle({
157 ACP_MOCK_STDOUT_PREFIX: `${mockStartupNotice}\n`,
158 ACP_MOCK_MALFORMED_OUTPUT: "1",
159 });
160 const termination = yield* Deferred.make<AcpError.AcpError>();
161 const error = yield* Deferred.await(termination).pipe(
162 Effect.provide(
163 AcpClient.layerChildProcess(handle, {
164 transformStdout: stripMockStartupNotice,
165 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
166 }),
167 ),
168 );
169
170 if (error._tag !== "AcpProtocolParseError") {
171 return assert.fail(`Expected a parse error, got ${error._tag}`);
172 }
173 assert.equal(error.operation, "decode-wire-message");
174 }),
175 );
176
177 it.effect("preserves stdout transform errors without logging rejected input", () =>
178 Effect.gen(function* () {
179 const privateNotice = "Mock sign-in URL: https://example.test/login?token=mock-private-token";
180 const handle = yield* makeHandle({ ACP_MOCK_STDOUT_PREFIX: `${privateNotice}\n` });
181 const termination = yield* Deferred.make<AcpError.AcpError>();
182 const logs = yield* Ref.make<Array<unknown>>([]);
183 const expectedError = new AcpError.AcpTransportError({
184 operation: "read-input-stream",
185 detail: "Sign in to the mock agent.",
186 cause: undefined,
187 });
188 const error = yield* Effect.gen(function* () {
189 const acp = yield* AcpClient.AcpClient;
190 const failure = yield* acp.agent.initialize({ protocolVersion: 1 }).pipe(Effect.flip);
191 assert.strictEqual(yield* Deferred.await(termination), expectedError);
192 return failure;
193 }).pipe(
194 Effect.provide(
195 AcpClient.layerChildProcess(handle, {
196 logIncoming: true,
197 logger: (event) => Ref.update(logs, (current) => [...current, event]),
198 transformStdout: (stdout) =>
199 stdout.pipe(
200 Stream.decodeText,
201 Stream.splitLines,
202 Stream.mapEffect((line) =>
203 line === privateNotice ? Effect.fail(expectedError) : Effect.succeed(`${line}\n`),
204 ),
205 Stream.encodeText,
206 ),
207 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
208 }),
209 ),
210 );
211
212 assert.strictEqual(error, expectedError);
213 assert.isEmpty(yield* Ref.get(logs));
214 }),
215 );
216
217 it.effect("retains only transformed session updates in raw notifications and handlers", () =>
218 Effect.gen(function* () {
219 const { stdio, input } = yield* makeInMemoryStdio();
220 const delivered = yield* Deferred.make<import("./compat.ts").SessionNotification>();
221 const imageData = "A".repeat(1_048_576);
222 const normalized = {
223 sessionId: "session-1",
224 update: {
225 sessionUpdate: "agent_message_chunk",
226 content: { type: "text", text: "Image data omitted." },
227 },
228 } satisfies import("./compat.ts").SessionNotification;
229 let transformCalls = 0;
230 const acp = yield* AcpClient.make(stdio, {
231 transformSessionUpdate: (notification) => {
232 if (
233 notification.update.sessionUpdate !== "agent_message_chunk" ||
234 notification.update.content.type !== "image"
235 ) {
236 return notification;
237 }
238 assert.equal(notification.update.content.data, imageData);
239 transformCalls += 1;
240 return normalized;
241 },
242 });
243 yield* acp.handleSessionUpdate((notification) =>
244 Deferred.succeed(delivered, notification).pipe(Effect.asVoid),
245 );
246
247 yield* Queue.offer(
248 input,
249 yield* encodeJsonl(SessionUpdateNotification, {
250 jsonrpc: "2.0",
251 method: "session/update",
252 params: {
253 sessionId: "session-1",
254 update: {
255 sessionUpdate: "agent_message_chunk",
256 content: { type: "image", data: imageData, mimeType: "image/png" },
257 },
258 },
259 }),
260 );
261
262 assert.strictEqual(yield* Deferred.await(delivered), normalized);
263 const [retained] = yield* acp.raw.notifications.pipe(Stream.take(1), Stream.runCollect);
264 assert.deepEqual(retained, {
265 _tag: "SessionUpdate",
266 method: "session/update",
267 params: normalized,
268 });
269 assert.equal(transformCalls, 1);
270 }),
271 );
272
273 it.effect("reports idle child termination and rejects later requests and notifications", () =>
274 Effect.gen(function* () {
275 const handle = yield* makeHandle();
276 const termination = yield* Deferred.make<AcpError.AcpError>();
277 yield* Effect.gen(function* () {
278 const acp = yield* AcpClient.AcpClient;
279 yield* acp.agent.initialize({ protocolVersion: 1 });
280 yield* handle.kill();
281
282 const error = yield* Deferred.await(termination);
283 if (error._tag !== "AcpProcessExitedError") {
284 return assert.fail(`Expected a process exit, got ${error._tag}`);
285 }
286 assert.equal(error.pid, handle.pid);
287 assert.equal(error.code, yield* handle.exitCode);
288 const rawRequestError = yield* acp.raw.request("x/echo", {}).pipe(
289 Effect.match({
290 onFailure: (failure) => failure,
291 onSuccess: () => assert.fail("Expected the request to fail after process exit"),
292 }),
293 );
294 assert.strictEqual(rawRequestError, error);
295 assert.strictEqual(yield* acp.raw.notify("x/notify", {}).pipe(Effect.flip), error);
296 assert.strictEqual(
297 yield* acp.agent.cancel({ sessionId: "mock-session-1" }).pipe(Effect.flip),
298 error,
299 );
300
301 const requestError = yield* acp.agent
302 .createSession({ cwd: process.cwd(), mcpServers: [] })
303 .pipe(Effect.flip);
304 assert.strictEqual(requestError, error);
305 }).pipe(
306 Effect.provide(
307 AcpClient.layerChildProcess(handle, {
308 onTermination: (error) => Deferred.succeed(termination, error).pipe(Effect.asVoid),
309 }),
310 ),
311 );
312 }),
313 );
314
315 it.effect("initializes, prompts, receives updates, and handles permission requests", () =>
316 Effect.gen(function* () {
317 const updates = yield* Ref.make<Array<unknown>>([]);
318 const elicitationCompletions = yield* Ref.make<Array<unknown>>([]);
319 const typedRequests = yield* Ref.make<Array<unknown>>([]);
320 const typedNotifications = yield* Ref.make<Array<unknown>>([]);
321 const requestContexts = yield* Ref.make<Array<AcpProtocol.AcpRequestContext>>([]);
322 const handle = yield* makeHandle();
323 const scope = yield* Scope.make();
324 const layerAcp = AcpClient.layerChildProcess(handle);
325 const context = yield* Layer.buildWithScope(layerAcp, scope);
326
327 const ext = yield* Effect.gen(function* () {
328 const acp = yield* AcpClient.AcpClient;
329
330 yield* acp.handleRequestPermission((_request, requestContext) =>
331 Ref.update(requestContexts, (current) => [...current, requestContext]).pipe(
332 Effect.as({
333 outcome: {
334 outcome: "selected",
335 optionId: "allow",
336 },
337 }),
338 ),
339 );
340 yield* acp.handleElicitation((_request, requestContext) =>
341 Ref.update(requestContexts, (current) => [...current, requestContext]).pipe(
342 Effect.as({
343 action: "accept",
344 content: {
345 approved: true,
346 },
347 }),
348 ),
349 );
350 yield* acp.handleSessionUpdate((notification) =>
351 Ref.update(updates, (current) => [...current, notification]),
352 );
353 yield* acp.handleElicitationComplete((notification) =>
354 Ref.update(elicitationCompletions, (current) => [...current, notification]),
355 );
356 yield* acp.handleExtRequest(
357 "x/typed_request",
358 Schema.Struct({ message: Schema.String }),
359 (payload, requestContext) =>
360 Ref.update(typedRequests, (current) => [...current, payload]).pipe(
361 Effect.andThen(
362 Ref.update(requestContexts, (current) => [...current, requestContext]),
363 ),
364 Effect.as({
365 ok: true,
366 echoedMessage: payload.message,
367 }),
368 ),
369 );
370 yield* acp.handleExtNotification(
371 "x/typed_notification",
372 Schema.Struct({ count: Schema.Number }),
373 (payload) => Ref.update(typedNotifications, (current) => [...current, payload]),
374 );
375
376 const init = yield* acp.agent.initialize({
377 protocolVersion: 1,
378 clientCapabilities: {
379 fs: { readTextFile: false, writeTextFile: false },
380 terminal: false,
381 },
382 clientInfo: {
383 name: "effect-acp-test",
384 version: "0.0.0",
385 },
386 });
387 assert.equal(init.protocolVersion, 2);
388
389 yield* acp.agent.authenticate({ methodId: "cursor_login" });
390
391 const session = yield* acp.agent.createSession({
392 cwd: process.cwd(),
393 mcpServers: [],
394 });
395 assert.equal(session.sessionId, "mock-session-1");
396 // ACP v2 removed session/set_mode; modes are config options there.
397 const setMode = yield* acp.agent
398 .setSessionMode({ sessionId: session.sessionId, modeId: "code" })
399 .pipe(Effect.flip);
400 assert.equal(setMode._tag === "AcpRequestError" ? setMode.code : undefined, -32601);
401
402 const prompt = yield* acp.agent.prompt({
403 sessionId: session.sessionId,
404 prompt: [{ type: "text", text: "hello" }],
405 });
406 assert.equal(prompt.stopReason, "end_turn");
407
408 const streamed = yield* Stream.runCollect(Stream.take(acp.raw.notifications, 2));
409 assert.equal(streamed.length, 2);
410 assert.equal(streamed[0]?._tag, "SessionUpdate");
411 assert.equal(streamed[1]?._tag, "ElicitationComplete");
412 assert.equal((yield* Ref.get(updates)).length, 2);
413 assert.equal((yield* Ref.get(elicitationCompletions)).length, 1);
414 assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]);
415 assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]);
416 const observedRequestContexts = yield* Ref.get(requestContexts);
417 assert.deepEqual(
418 observedRequestContexts.map((requestContext) => requestContext.method),
419 ["session/request_permission", "elicitation/create", "x/typed_request"],
420 );
421 assert.equal(
422 new Set(observedRequestContexts.map((requestContext) => requestContext.requestId)).size,
423 observedRequestContexts.length,
424 );
425
426 return yield* acp.raw.request("x/echo", {
427 hello: "world",
428 });
429 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
430
431 assert.deepEqual(ext, {
432 echoedMethod: "x/echo",
433 echoedParams: {
434 hello: "world",
435 },
436 });
437 }),
438 );
439
440 it.effect(
441 "returns structured invalid params without exposing values from typed extension request payloads",
442 () =>
443 Effect.gen(function* () {
444 const handle = yield* makeHandle({ ACP_MOCK_BAD_TYPED_REQUEST: "1" });
445 const scope = yield* Scope.make();
446 const layerAcp = AcpClient.layerChildProcess(handle);
447 const context = yield* Layer.buildWithScope(layerAcp, scope);
448
449 const result = yield* Effect.gen(function* () {
450 const acp = yield* AcpClient.AcpClient;
451
452 yield* acp.handleRequestPermission(() =>
453 Effect.succeed({
454 outcome: {
455 outcome: "selected",
456 optionId: "allow",
457 },
458 }),
459 );
460 yield* acp.handleElicitation(() =>
461 Effect.succeed({
462 action: "accept",
463 content: {
464 approved: true,
465 },
466 }),
467 );
468 yield* acp.handleExtRequest(
469 "x/typed_request",
470 Schema.Struct({ message: Schema.String }),
471 () => Effect.succeed({ ok: true }),
472 );
473
474 yield* acp.agent.initialize({
475 protocolVersion: 1,
476 clientCapabilities: {
477 fs: { readTextFile: false, writeTextFile: false },
478 terminal: false,
479 },
480 clientInfo: {
481 name: "effect-acp-test",
482 version: "0.0.0",
483 },
484 });
485
486 yield* acp.agent.authenticate({ methodId: "cursor_login" });
487
488 const session = yield* acp.agent.createSession({
489 cwd: process.cwd(),
490 mcpServers: [],
491 });
492
493 return yield* Effect.exit(
494 acp.agent.prompt({
495 sessionId: session.sessionId,
496 prompt: [{ type: "text", text: "hello" }],
497 }),
498 );
499 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
500
501 if (result._tag !== "Failure") {
502 assert.fail("Expected prompt to fail for invalid typed extension payload");
503 }
504 const rendered = Cause.pretty(result.cause);
505 assert.include(rendered, "Invalid payload for ACP extension method 'x/typed_request'.");
506 assert.notInclude(rendered, "Expected string, got 123");
507 }),
508 );
509
510 it.effect("preserves ACP v2 structured diff semantics", () =>
511 Effect.gen(function* () {
512 const updates = yield* Ref.make<Array<AcpCompat.SessionNotification>>([]);
513 const handle = yield* makeHandle({ ACP_MOCK_V2_DIFF: "1" });
514 const scope = yield* Scope.make();
515 const context = yield* Layer.buildWithScope(AcpClient.layerChildProcess(handle), scope);
516
517 yield* Effect.gen(function* () {
518 const acp = yield* AcpClient.AcpClient;
519 yield* acp.handleRequestPermission(() =>
520 Effect.succeed({ outcome: { outcome: "selected", optionId: "allow" } }),
521 );
522 yield* acp.handleElicitation(() =>
523 Effect.succeed({ action: "accept", content: { approved: true } }),
524 );
525 yield* acp.handleExtRequest(
526 "x/typed_request",
527 Schema.Struct({ message: Schema.String }),
528 () => Effect.succeed({ ok: true }),
529 );
530 yield* acp.handleSessionUpdate((notification) =>
531 Ref.update(updates, (current) => [...current, notification]),
532 );
533
534 yield* acp.agent.initialize({ protocolVersion: 2 });
535 yield* acp.agent.authenticate({ methodId: "cursor_login" });
536 const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] });
537 yield* acp.agent.prompt({
538 sessionId: session.sessionId,
539 prompt: [{ type: "text", text: "show the diff" }],
540 });
541 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
542
543 const diffUpdate = (yield* Ref.get(updates)).find(
544 (notification) => notification.update.sessionUpdate === "tool_call_update",
545 );
546 assert.deepEqual(diffUpdate?.update, {
547 sessionUpdate: "tool_call_update",
548 toolCallId: "tool-diff",
549 content: [
550 {
551 type: "diff",
552 changes: [
553 {
554 operation: "move",
555 oldPath: "/workspace/old.ts",
556 path: "/workspace/new.ts",
557 fileType: "text",
558 mimeType: "text/typescript",
559 },
560 ],
561 patch: {
562 format: "git_patch",
563 text: "diff --git a/old.ts b/new.ts\nsimilarity index 100%\nrename from old.ts\nrename to new.ts\n",
564 },
565 },
566 ],
567 });
568 }),
569 );
570
571 it.effect("does not turn unsupported ACP v2 auth methods into agent login", () =>
572 Effect.gen(function* () {
573 const handle = yield* makeHandle({ ACP_MOCK_CUSTOM_AUTH: "1" });
574 const response = yield* Effect.gen(function* () {
575 const acp = yield* AcpClient.AcpClient;
576 return yield* acp.agent.initialize({ protocolVersion: 2 });
577 }).pipe(Effect.provide(AcpClient.layerChildProcess(handle)));
578 assert.deepEqual(response.authMethods, []);
579 }),
580 );
581
582 it.effect("preserves registry env-var authentication extensions", () =>
583 Effect.gen(function* () {
584 const handle = yield* makeHandle({ ACP_MOCK_ENV_VAR_AUTH: "1" });
585 const scope = yield* Scope.make();
586 const context = yield* Layer.buildWithScope(AcpClient.layerChildProcess(handle), scope);
587
588 const response = yield* Effect.gen(function* () {
589 const acp = yield* AcpClient.AcpClient;
590 return yield* acp.agent.initialize({ protocolVersion: 2 });
591 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
592
593 assert.deepEqual(response.authMethods, [
594 {
595 id: "api_key",
596 name: "API key",
597 type: "env_var",
598 vars: [{ name: "MOCK_API_KEY", label: "Mock API key" }],
599 link: "https://example.test/keys",
600 },
601 ]);
602 }),
603 );
604
605 it.effect("replays buffered notifications to handlers registered after they arrive", () =>
606 Effect.gen(function* () {
607 const updates = yield* Ref.make<Array<unknown>>([]);
608 const elicitationCompletions = yield* Ref.make<Array<unknown>>([]);
609 const typedRequests = yield* Ref.make<Array<unknown>>([]);
610 const typedNotifications = yield* Ref.make<Array<unknown>>([]);
611 const handle = yield* makeHandle();
612 const scope = yield* Scope.make();
613 const layerAcp = AcpClient.layerChildProcess(handle);
614 const context = yield* Layer.buildWithScope(layerAcp, scope);
615
616 yield* Effect.gen(function* () {
617 const acp = yield* AcpClient.AcpClient;
618
619 yield* acp.handleRequestPermission(() =>
620 Effect.succeed({
621 outcome: {
622 outcome: "selected",
623 optionId: "allow",
624 },
625 }),
626 );
627 yield* acp.handleElicitation(() =>
628 Effect.succeed({
629 action: "accept",
630 content: {
631 approved: true,
632 },
633 }),
634 );
635 yield* acp.handleExtRequest(
636 "x/typed_request",
637 Schema.Struct({ message: Schema.String }),
638 (payload) =>
639 Ref.update(typedRequests, (current) => [...current, payload]).pipe(
640 Effect.as({
641 ok: true,
642 echoedMessage: payload.message,
643 }),
644 ),
645 );
646 yield* acp.handleExtNotification(
647 "x/typed_notification",
648 Schema.Struct({ count: Schema.Number }),
649 (payload) => Ref.update(typedNotifications, (current) => [...current, payload]),
650 );
651
652 yield* acp.agent.initialize({
653 protocolVersion: 1,
654 clientCapabilities: {
655 fs: { readTextFile: false, writeTextFile: false },
656 terminal: false,
657 },
658 clientInfo: {
659 name: "effect-acp-test",
660 version: "0.0.0",
661 },
662 });
663 yield* acp.agent.authenticate({ methodId: "cursor_login" });
664
665 const session = yield* acp.agent.createSession({
666 cwd: process.cwd(),
667 mcpServers: [],
668 });
669 yield* acp.agent.prompt({
670 sessionId: session.sessionId,
671 prompt: [{ type: "text", text: "hello" }],
672 });
673
674 yield* acp.handleSessionUpdate((notification) =>
675 Ref.update(updates, (current) => [...current, notification]),
676 );
677 yield* acp.handleElicitationComplete((notification) =>
678 Ref.update(elicitationCompletions, (current) => [...current, notification]),
679 );
680
681 assert.equal((yield* Ref.get(updates)).length, 2);
682 assert.equal((yield* Ref.get(elicitationCompletions)).length, 1);
683 assert.deepEqual(yield* Ref.get(typedRequests), [{ message: "hello from typed request" }]);
684 assert.deepEqual(yield* Ref.get(typedNotifications), [{ count: 2 }]);
685 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
686 }),
687 );
688
689 it.effect("continues dispatching session updates after one handler fails", () =>
690 Effect.gen(function* () {
691 const successfulHandlers = yield* Ref.make(0);
692 const handle = yield* makeHandle();
693 const scope = yield* Scope.make();
694 const layerAcp = AcpClient.layerChildProcess(handle);
695 const context = yield* Layer.buildWithScope(layerAcp, scope);
696
697 yield* Effect.gen(function* () {
698 const acp = yield* AcpClient.AcpClient;
699
700 yield* acp.handleRequestPermission(() =>
701 Effect.succeed({
702 outcome: {
703 outcome: "selected",
704 optionId: "allow",
705 },
706 }),
707 );
708 yield* acp.handleElicitation(() =>
709 Effect.succeed({
710 action: "accept",
711 content: {
712 approved: true,
713 },
714 }),
715 );
716 yield* acp.handleExtRequest(
717 "x/typed_request",
718 Schema.Struct({ message: Schema.String }),
719 () => Effect.succeed({ ok: true }),
720 );
721 yield* acp.handleExtNotification(
722 "x/typed_notification",
723 Schema.Struct({ count: Schema.Number }),
724 () => Effect.void,
725 );
726 yield* acp.handleSessionUpdate(() =>
727 Effect.fail(AcpError.AcpRequestError.internalError("session update handler failed")),
728 );
729 yield* acp.handleSessionUpdate(() => Ref.update(successfulHandlers, (count) => count + 1));
730
731 yield* acp.agent.initialize({
732 protocolVersion: 1,
733 clientCapabilities: {
734 fs: { readTextFile: false, writeTextFile: false },
735 terminal: false,
736 },
737 clientInfo: {
738 name: "effect-acp-test",
739 version: "0.0.0",
740 },
741 });
742 yield* acp.agent.authenticate({ methodId: "cursor_login" });
743
744 const session = yield* acp.agent.createSession({
745 cwd: process.cwd(),
746 mcpServers: [],
747 });
748 yield* acp.agent.prompt({
749 sessionId: session.sessionId,
750 prompt: [{ type: "text", text: "hello" }],
751 });
752
753 assert.equal(yield* Ref.get(successfulHandlers), 2);
754 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
755 }),
756 );
757
758 it.effect("uses distinct ids for RPC calls and extension requests", () =>
759 Effect.gen(function* () {
760 const { stdio, input, output } = yield* makeInMemoryStdio();
761 const scope = yield* Scope.make();
762 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
763
764 const initializeFiber = yield* acp.agent
765 .initialize({
766 protocolVersion: 1,
767 clientCapabilities: {
768 fs: { readTextFile: false, writeTextFile: false },
769 terminal: false,
770 },
771 clientInfo: {
772 name: "effect-acp-test",
773 version: "0.0.0",
774 },
775 })
776 .pipe(Effect.forkScoped);
777 const extFiber = yield* acp.raw.request("x/test", { hello: "world" }).pipe(Effect.forkScoped);
778
779 const firstOutbound = yield* Queue.take(output);
780 const secondOutbound = yield* Queue.take(output);
781
782 const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest));
783 const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest));
784 const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe(
785 Effect.match({
786 onFailure: () => false,
787 onSuccess: () => true,
788 }),
789 );
790
791 const initializeRequest = firstIsInitialize
792 ? yield* decodedInitialize(firstOutbound)
793 : yield* decodedInitialize(secondOutbound);
794 const extRequest = firstIsInitialize
795 ? yield* decodedExt(secondOutbound)
796 : yield* decodedExt(firstOutbound);
797
798 assert.notEqual(initializeRequest.id, extRequest.id);
799
800 yield* Queue.offer(
801 input,
802 yield* encodeJsonl(InitializeResponse, {
803 jsonrpc: "2.0",
804 id: initializeRequest.id,
805 result: {
806 protocolVersion: 2,
807 capabilities: {},
808 info: {
809 name: "mock-agent",
810 version: "0.0.0",
811 },
812 },
813 }),
814 );
815 yield* Queue.offer(
816 input,
817 yield* encodeJsonl(ExtResponse, {
818 jsonrpc: "2.0",
819 id: extRequest.id,
820 result: { ok: true },
821 }),
822 );
823
824 yield* Fiber.join(initializeFiber);
825 assert.deepEqual(yield* Fiber.join(extFiber), { ok: true });
826 yield* Scope.close(scope, Exit.void);
827 }),
828 );
829
830 it.effect.each([1, 2])("keeps legacy wire behavior with version %s", (protocolVersion) =>
831 Effect.gen(function* () {
832 const { stdio, input, output } = yield* makeInMemoryStdio();
833 const scope = yield* Scope.make();
834 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
835 const updates = yield* Ref.make<Array<unknown>>([]);
836 yield* acp.handleSessionUpdate((notification) =>
837 Ref.update(updates, (current) => [...current, notification]),
838 );
839
840 const initializeFiber = yield* acp.agent
841 .initialize({
842 protocolVersion: 2,
843 clientInfo: { name: "effect-acp-test", version: "0.0.0" },
844 clientCapabilities: { _meta: { "terminal-auth": true } },
845 })
846 .pipe(Effect.forkScoped);
847 const initializeRequest = yield* Queue.take(output).pipe(
848 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(InitializeRequestV1))),
849 );
850 assert.equal(initializeRequest.params.protocolVersion, 2);
851 assert.equal(initializeRequest.params.clientCapabilities?._meta?.["terminal-auth"], true);
852 yield* Queue.offer(
853 input,
854 yield* encodeJsonl(InitializeResponseV1, {
855 jsonrpc: "2.0",
856 id: initializeRequest.id,
857 result: {
858 ...antigravityInitialize,
859 protocolVersion,
860 },
861 }),
862 );
863 const initialized = yield* Fiber.join(initializeFiber);
864 assert.equal(initialized.protocolVersion, protocolVersion);
865 assert.equal(initialized.agentInfo?.name, "antigravity-acp");
866
867 const exchange = <A, E>(effect: Effect.Effect<A, E>, method: string) =>
868 Effect.gen(function* () {
869 const fiber = yield* effect.pipe(Effect.forkScoped);
870 const request = yield* Queue.take(output).pipe(
871 Effect.flatMap(
872 Schema.decodeEffect(Schema.fromJsonString(jsonRpcRequest(method, Schema.Unknown))),
873 ),
874 );
875 yield* Queue.offer(
876 input,
877 yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), {
878 jsonrpc: "2.0",
879 id: request.id,
880 result: {},
881 }),
882 );
883 yield* Fiber.join(fiber);
884 return request.params;
885 });
886 assert.deepEqual(
887 yield* exchange(acp.agent.authenticate({ methodId: "oauth-personal" }), "authenticate"),
888 { methodId: "oauth-personal" },
889 );
890 const mcpServers = [{ name: "fixture", command: "fixture-only", args: [], env: [] }];
891
892 const sessionFiber = yield* acp.agent
893 .createSession({ cwd: process.cwd(), mcpServers })
894 .pipe(Effect.forkScoped);
895 const sessionRequest = yield* Queue.take(output).pipe(
896 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(NewSessionRequestV1))),
897 );
898 assert.deepEqual(sessionRequest.params.mcpServers, mcpServers);
899 yield* Queue.offer(
900 input,
901 yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), {
902 jsonrpc: "2.0",
903 id: sessionRequest.id,
904 result: {
905 sessionId: "pi-session-1",
906 models: {
907 currentModelId: "native-model",
908 availableModels: [{ modelId: "native-model", name: "Native model" }],
909 },
910 },
911 }),
912 );
913 const session = yield* Fiber.join(sessionFiber);
914 assert.equal(session.sessionId, "pi-session-1");
915 assert.equal(session.models?.currentModelId, "native-model");
916 const resume = { sessionId: session.sessionId, cwd: process.cwd(), mcpServers };
917 assert.deepEqual(yield* exchange(acp.agent.resumeSession(resume), "session/resume"), resume);
918 assert.deepEqual(yield* exchange(acp.agent.loadSession(resume), "session/load"), resume);
919 const selectModel = yield* acp.agent
920 .setSessionModel({ sessionId: session.sessionId, modelId: "native-model" })
921 .pipe(Effect.forkScoped);
922 const selection = yield* Queue.take(output).pipe(
923 Effect.flatMap(
924 Schema.decodeEffect(
925 Schema.fromJsonString(
926 jsonRpcRequest(
927 "session/set_model",
928 Schema.Struct({ sessionId: Schema.String, modelId: Schema.String }),
929 ),
930 ),
931 ),
932 ),
933 );
934 assert.equal(selection.params.modelId, "native-model");
935 yield* Queue.offer(
936 input,
937 yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), {
938 jsonrpc: "2.0",
939 id: selection.id,
940 result: {},
941 }),
942 );
943 yield* Fiber.join(selectModel);
944 const selectMode = yield* acp.agent
945 .setSessionMode({ sessionId: session.sessionId, modeId: "yolo" })
946 .pipe(Effect.forkScoped);
947 const modeSelection = yield* Queue.take(output).pipe(
948 Effect.flatMap(
949 Schema.decodeEffect(
950 Schema.fromJsonString(
951 jsonRpcRequest(
952 "session/set_mode",
953 Schema.Struct({ sessionId: Schema.String, modeId: Schema.String }),
954 ),
955 ),
956 ),
957 ),
958 );
959 assert.equal(modeSelection.params.modeId, "yolo");
960 yield* Queue.offer(
961 input,
962 yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), {
963 jsonrpc: "2.0",
964 id: modeSelection.id,
965 result: {},
966 }),
967 );
968 yield* Fiber.join(selectMode);
969
970 yield* acp.handleElicitation(() =>
971 Effect.succeed({ action: "accept", content: { branch: "main" } }),
972 );
973 yield* Queue.offer(
974 input,
975 yield* encodeJsonl(jsonRpcRequest("session/elicitation", Schema.Unknown), {
976 jsonrpc: "2.0",
977 id: "legacy-question",
978 headers: [],
979 method: "session/elicitation",
980 params: {
981 sessionId: session.sessionId,
982 mode: "form",
983 message: "Choose a branch",
984 requestedSchema: { type: "object", properties: {} },
985 },
986 }),
987 );
988 const answer = yield* Queue.take(output).pipe(
989 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(jsonRpcResponse(Schema.Unknown)))),
990 );
991 assert.deepEqual(answer.result, {
992 action: { action: "accept", content: { branch: "main" } },
993 });
994 // Recorded Devin 2026-09-10 called the older underscore method.
995 yield* Queue.offer(
996 input,
997 yield* encodeJsonl(jsonRpcRequest("_session/elicitation", Schema.Unknown), {
998 jsonrpc: "2.0",
999 id: "devin-question",
1000 headers: [],
1001 method: "_session/elicitation",
1002 params: {
1003 sessionId: session.sessionId,
1004 mode: "form",
1005 message: "Proceed?",
1006 requestedSchema: { type: "object", properties: {} },
1007 },
1008 }),
1009 );
1010 const devinAnswer = yield* Queue.take(output).pipe(
1011 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(jsonRpcResponse(Schema.Unknown)))),
1012 );
1013 assert.deepEqual(devinAnswer.result, {
1014 action: { action: "accept", content: { branch: "main" } },
1015 });
1016 const completed = yield* Deferred.make<void>();
1017 yield* acp.handleElicitationComplete(() =>
1018 Deferred.succeed(completed, undefined).pipe(Effect.asVoid),
1019 );
1020 yield* Queue.offer(
1021 input,
1022 yield* encodeJsonl(jsonRpcNotification("session/elicitation/complete", Schema.Unknown), {
1023 jsonrpc: "2.0",
1024 method: "session/elicitation/complete",
1025 params: { elicitationId: "legacy-login" },
1026 }),
1027 );
1028 yield* Deferred.await(completed);
1029
1030 const promptFiber = yield* acp.agent
1031 .prompt({
1032 sessionId: "pi-session-1",
1033 prompt: [{ type: "text", text: "hello" }],
1034 })
1035 .pipe(Effect.forkScoped);
1036 const promptRequest = yield* Queue.take(output).pipe(
1037 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(PromptRequestV1))),
1038 );
1039 yield* Queue.offer(
1040 input,
1041 concatBytes([
1042 yield* encodeJsonl(
1043 jsonRpcNotification("session/update", AcpSchemaV1.SessionNotification),
1044 {
1045 jsonrpc: "2.0",
1046 method: "session/update",
1047 params: {
1048 sessionId: "pi-session-1",
1049 update: {
1050 sessionUpdate: "available_commands_update",
1051 availableCommands: [
1052 { name: "review", description: "Review changes", input: { hint: "scope" } },
1053 ],
1054 },
1055 },
1056 },
1057 ),
1058 yield* encodeJsonl(PromptResponseV1, {
1059 jsonrpc: "2.0",
1060 id: promptRequest.id,
1061 result: { stopReason: "end_turn" },
1062 }),
1063 ]),
1064 );
1065 assert.deepEqual(yield* Fiber.join(promptFiber), { stopReason: "end_turn" });
1066 assert.deepEqual(yield* Ref.get(updates), [
1067 {
1068 sessionId: "pi-session-1",
1069 update: {
1070 sessionUpdate: "available_commands_update",
1071 availableCommands: [
1072 {
1073 name: "review",
1074 description: "Review changes",
1075 input: { type: "text", hint: "scope" },
1076 },
1077 ],
1078 },
1079 },
1080 ]);
1081 yield* exchange(acp.agent.logout({}), "logout");
1082 yield* Scope.close(scope, Exit.void);
1083 }),
1084 );
1085
1086 it.effect("preserves exact ids for parallel requests with identical payloads", () =>
1087 Effect.gen(function* () {
1088 const { stdio, input, output } = yield* makeInMemoryStdio();
1089 const scope = yield* Scope.make();
1090 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
1091 const contexts = yield* Ref.make<Array<AcpProtocol.AcpRequestContext>>([]);
1092 yield* acp.handleRequestPermission((_request, context) =>
1093 Ref.update(contexts, (current) => [...current, context]).pipe(
1094 Effect.as({ outcome: { outcome: "selected", optionId: "allow" } } as const),
1095 ),
1096 );
1097 const payload = {
1098 sessionId: "session-1",
1099 title: "Shared tool",
1100 subject: {
1101 type: "tool_call" as const,
1102 toolCall: { toolCallId: "shared-tool", title: "Shared tool" },
1103 },
1104 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }],
1105 };
1106 yield* Queue.offer(
1107 input,
1108 concatBytes(
1109 yield* Effect.all(
1110 ["permission-a", "permission-b"].map((id) =>
1111 encodeJsonl(PermissionRequest, {
1112 jsonrpc: "2.0",
1113 id,
1114 method: "session/request_permission",
1115 params: payload,
1116 headers: [],
1117 }),
1118 ),
1119 ),
1120 ),
1121 );
1122
1123 const decodeResponse = Schema.decodeEffect(Schema.fromJsonString(PermissionResponse));
1124 const responses = yield* Effect.all([
1125 Queue.take(output).pipe(Effect.flatMap(decodeResponse)),
1126 Queue.take(output).pipe(Effect.flatMap(decodeResponse)),
1127 ]);
1128 assert.deepEqual(responses.map((response) => response.id).toSorted(), [
1129 "permission-a",
1130 "permission-b",
1131 ]);
1132 assert.deepEqual(
1133 (yield* Ref.get(contexts))
1134 .map(({ requestId, method }) => ({ requestId, method }))
1135 .toSorted((left, right) => left.requestId.localeCompare(right.requestId)),
1136 [
1137 { requestId: "permission-a", method: "session/request_permission" },
1138 { requestId: "permission-b", method: "session/request_permission" },
1139 ],
1140 );
1141 yield* Scope.close(scope, Exit.void);
1142 }),
1143 );
1144
1145 it.effect("answers each request whose handler dies, and keeps reading", () =>
1146 Effect.gen(function* () {
1147 const errorLogs = yield* Queue.unbounded<string>();
1148 const logger = Logger.make(({ logLevel, message }) => {
1149 if (logLevel === "Error") Queue.offerUnsafe(errorLogs, String([message].flat()[0]));
1150 });
1151 const { stdio, input, output } = yield* makeInMemoryStdio();
1152 const scope = yield* Scope.make();
1153 const acp = yield* AcpClient.make(stdio).pipe(
1154 Effect.provideService(Scope.Scope, scope),
1155 Effect.provide(Logger.layer([logger])),
1156 );
1157 const bug = () => Effect.die(new Error("handler bug"));
1158 yield* acp.handleRequestPermission(bug);
1159 yield* acp.handleExtRequest("x/dies", Schema.Unknown, bug);
1160 yield* acp.handleExtNotification("x/notification-dies", Schema.Unknown, bug);
1161 yield* acp.handleSessionUpdate(bug);
1162 const updates = yield* Queue.unbounded<string>();
1163 yield* acp.handleSessionUpdate((notification) =>
1164 Queue.offer(updates, notification.sessionId).pipe(Effect.asVoid),
1165 );
1166 yield* acp.handleExtRequest("x/test", Schema.Struct({ hello: Schema.String }), () =>
1167 Effect.succeed({ ok: true }),
1168 );
1169 const send = <E>(line: Effect.Effect<Uint8Array, E>) =>
1170 Effect.flatMap(line, (bytes) => Queue.offer(input, bytes));
1171 const decodeDie = Schema.decodeEffect(Schema.fromJsonString(DieResponse));
1172 // A stopped reader leaves these waiting; fail instead of hanging.
1173 const next = <A>(queue: Queue.Dequeue<A>) =>
1174 TestClock.withLive(Queue.take(queue).pipe(Effect.timeout("2 seconds")));
1175
1176 yield* send(
1177 encodeJsonl(PermissionRequest, {
1178 jsonrpc: "2.0",
1179 id: "permission-a",
1180 method: "session/request_permission",
1181 params: {
1182 sessionId: "session-1",
1183 title: "Tool",
1184 subject: {
1185 type: "tool_call" as const,
1186 toolCall: { toolCallId: "tool-1", title: "Tool" },
1187 },
1188 options: [{ optionId: "allow", name: "Allow", kind: "allow_once" as const }],
1189 },
1190 headers: [],
1191 }),
1192 );
1193 assert.equal((yield* next(output).pipe(Effect.flatMap(decodeDie))).id, "permission-a");
1194
1195 yield* send(
1196 encodeJsonl(jsonRpcRequest("x/dies", Schema.Unknown), {
1197 jsonrpc: "2.0",
1198 id: "ext-a",
1199 method: "x/dies",
1200 params: {},
1201 headers: [],
1202 }),
1203 );
1204 const extDied = yield* next(output).pipe(
1205 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ErrorResponse))),
1206 );
1207 assert.equal(extDied.id, "ext-a");
1208
1209 yield* send(
1210 encodeJsonl(jsonRpcNotification("x/notification-dies", Schema.Unknown), {
1211 jsonrpc: "2.0",
1212 method: "x/notification-dies",
1213 params: {},
1214 }),
1215 );
1216 yield* send(
1217 encodeJsonl(SessionUpdateNotification, {
1218 jsonrpc: "2.0",
1219 method: "session/update",
1220 params: {
1221 sessionId: "session-1",
1222 update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } },
1223 },
1224 }),
1225 );
1226
1227 // The next session handler still ran.
1228 assert.equal(yield* next(updates), "session-1");
1229
1230 // The reader survived all three: a later request is still answered.
1231 yield* send(
1232 encodeJsonl(ExtRequest, {
1233 jsonrpc: "2.0",
1234 id: "ext-b",
1235 method: "x/test",
1236 params: { hello: "world" },
1237 headers: [],
1238 }),
1239 );
1240 const answered = yield* next(output).pipe(
1241 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ExtResponse))),
1242 );
1243 assert.deepEqual([answered.id, answered.result], ["ext-b", { ok: true }]);
1244
1245 // Every defect was logged at Error, once.
1246 assert.deepEqual((yield* Queue.clear(errorLogs)).toSorted(), [
1247 "ACP extension request handler failed for 'x/dies'",
1248 "ACP notification handler failed",
1249 "ACP notification handler failed",
1250 "ACP request handler failed for 'session/request_permission'",
1251 ]);
1252 yield* Scope.close(scope, Exit.void);
1253 }),
1254 );
1255
1256 it.effect("answers elicitation/create with the flat action shape", () =>
1257 Effect.gen(function* () {
1258 const { stdio, input, output } = yield* makeInMemoryStdio();
1259 const scope = yield* Scope.make();
1260 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
1261 yield* acp.handleElicitation(() =>
1262 Effect.succeed({ action: "accept" as const, content: { approved: true } }),
1263 );
1264
1265 yield* Queue.offer(
1266 input,
1267 yield* encodeJsonl(ElicitationRequest, {
1268 jsonrpc: "2.0",
1269 id: "elicitation",
1270 method: "elicitation/create",
1271 params: {
1272 sessionId: "session-1",
1273 mode: "form" as const,
1274 message: "Approve this call?",
1275 requestedSchema: {
1276 type: "object" as const,
1277 properties: { approved: { type: "boolean" as const } },
1278 },
1279 },
1280 headers: [],
1281 }),
1282 );
1283 const response = yield* Queue.take(output).pipe(
1284 Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(ElicitationResponse))),
1285 );
1286 assert.deepEqual(response.result, {
1287 action: "accept",
1288 content: { approved: true },
1289 });
1290 yield* Scope.close(scope, Exit.void);
1291 }),
1292 );
1293
1294 it.effect(
1295 "routes a standard prompt response after Grok extension notifications in the same batch",
1296 () =>
1297 Effect.gen(function* () {
1298 const { stdio, input, output } = yield* makeInMemoryStdio();
1299 const scope = yield* Scope.make();
1300 const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope));
1301
1302 const promptFiber = yield* acp.agent
1303 .prompt({
1304 sessionId: "grok-session-1",
1305 prompt: [{ type: "text", text: "run the ls command" }],
1306 })
1307 .pipe(Effect.forkScoped);
1308
1309 const outbound = yield* Queue.take(output);
1310 const decodedPrompt = yield* decodePromptRequestLine(outbound);
1311
1312 const responseBatch = concatBytes(
1313 yield* Effect.all([
1314 encodeJsonl(XAiQueueChangedNotification, {
1315 jsonrpc: "2.0",
1316 method: "_x.ai/queue/changed",
1317 params: { sessionId: "grok-session-1", entries: [] },
1318 }),
1319 encodeJsonl(XAiPromptCompleteNotification, {
1320 jsonrpc: "2.0",
1321 method: "_x.ai/session/prompt_complete",
1322 params: {
1323 sessionId: "grok-session-1",
1324 promptId: "prompt-1",
1325 stopReason: "end_turn",
1326 agentResult: null,
1327 },
1328 }),
1329 encodeJsonl(XAiSessionsChangedNotification, {
1330 jsonrpc: "2.0",
1331 method: "_x.ai/sessions/changed",
1332 params: {
1333 upserted: [
1334 {
1335 sessionId: "grok-session-1",
1336 title: null,
1337 cwd: process.cwd(),
1338 isWorktree: false,
1339 modelId: "grok-composer-2.5-fast",
1340 yolo: false,
1341 activity: "idle",
1342 resident: true,
1343 lastChangeUnixMs: 1_710_000_000_000,
1344 origin: { kind: "local" },
1345 },
1346 ],
1347 removed: [],
1348 },
1349 }),
1350 encodeJsonl(PromptResponse, {
1351 jsonrpc: "2.0",
1352 id: decodedPrompt.id,
1353 result: {},
1354 }),
1355 encodeJsonl(SessionUpdateNotification, {
1356 jsonrpc: "2.0",
1357 method: "session/update",
1358 params: {
1359 sessionId: "grok-session-1",
1360 update: {
1361 sessionUpdate: "state_update",
1362 state: "idle",
1363 stopReason: "end_turn",
1364 _meta: {
1365 sessionId: "grok-session-1",
1366 requestId: "prompt-1",
1367 promptId: "prompt-1",
1368 modelId: "grok-composer-2.5-fast",
1369 },
1370 },
1371 },
1372 }),
1373 ]),
1374 );
1375 yield* Queue.offer(input, responseBatch);
1376
1377 assert.deepEqual(yield* Fiber.join(promptFiber), {
1378 stopReason: "end_turn",
1379 _meta: {
1380 sessionId: "grok-session-1",
1381 requestId: "prompt-1",
1382 promptId: "prompt-1",
1383 modelId: "grok-composer-2.5-fast",
1384 },
1385 });
1386 yield* Scope.close(scope, Exit.void);
1387 }),
1388 );
1389
1390 it.effect("calls ACP v2 session deletion and provider-management methods", () =>
1391 Effect.gen(function* () {
1392 const handle = yield* makeHandle({ ACP_MOCK_V2_MANAGEMENT: "1" });
1393 const scope = yield* Scope.make();
1394 const context = yield* Layer.buildWithScope(AcpClient.layerChildProcess(handle), scope);
1395
1396 yield* Effect.gen(function* () {
1397 const acp = yield* AcpClient.AcpClient;
1398 const initialized = yield* acp.agent.initialize({
1399 protocolVersion: 2,
1400 clientCapabilities: {},
1401 clientInfo: { name: "effect-acp-test", version: "0.0.0" },
1402 });
1403 assert.ok(initialized.agentCapabilities?.sessionCapabilities?.delete);
1404 assert.ok(initialized.agentCapabilities?.providers);
1405
1406 assert.deepEqual(yield* acp.agent.deleteSession({ sessionId: "mock-session-1" }), {});
1407 assert.deepEqual(yield* acp.agent.listProviders({}), {
1408 providers: [
1409 {
1410 providerId: "mock-provider",
1411 supported: ["openai"],
1412 required: false,
1413 current: null,
1414 },
1415 ],
1416 });
1417 assert.deepEqual(
1418 yield* acp.agent.setProvider({
1419 providerId: "mock-provider",
1420 apiType: "openai",
1421 baseUrl: "https://api.example.test/v1",
1422 headers: { Authorization: "Bearer secret" },
1423 }),
1424 {},
1425 );
1426 assert.deepEqual(yield* acp.agent.disableProvider({ providerId: "mock-provider" }), {});
1427 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
1428 }),
1429 );
1430
1431 it.effect("routes MCP-over-ACP connect, message, notification, and disconnect callbacks", () =>
1432 Effect.gen(function* () {
1433 const handle = yield* makeHandle({ ACP_MOCK_MCP_OVER_ACP: "1" });
1434 const scope = yield* Scope.make();
1435 const context = yield* Layer.buildWithScope(AcpClient.layerChildProcess(handle), scope);
1436 const methods = yield* Ref.make<Array<string>>([]);
1437
1438 yield* Effect.gen(function* () {
1439 const acp = yield* AcpClient.AcpClient;
1440 yield* acp.handleRequestPermission(() =>
1441 Effect.succeed({ outcome: { outcome: "selected", optionId: "allow" } }),
1442 );
1443 yield* acp.handleElicitation(() => Effect.succeed({ action: "decline" }));
1444 yield* acp.handleExtRequest(
1445 "x/typed_request",
1446 Schema.Struct({ message: Schema.String }),
1447 () => Effect.succeed({ ok: true }),
1448 );
1449 yield* acp.handleExtNotification(
1450 "x/typed_notification",
1451 Schema.Struct({ count: Schema.Number }),
1452 () => Effect.void,
1453 );
1454 yield* acp.handleMcpConnect(() =>
1455 Ref.update(methods, (current) => [...current, "connect"]).pipe(
1456 Effect.as({ connectionId: "connection-1" }),
1457 ),
1458 );
1459 yield* acp.handleMcpMessage(() =>
1460 Ref.update(methods, (current) => [...current, "message"]).pipe(Effect.as({ tools: [] })),
1461 );
1462 yield* acp.handleMcpNotification(() =>
1463 Ref.update(methods, (current) => [...current, "notification"]),
1464 );
1465 yield* acp.handleMcpDisconnect(() =>
1466 Ref.update(methods, (current) => [...current, "disconnect"]).pipe(Effect.as({})),
1467 );
1468 yield* acp.agent.initialize({
1469 protocolVersion: 2,
1470 clientCapabilities: {},
1471 clientInfo: { name: "effect-acp-test", version: "0.0.0" },
1472 });
1473 const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] });
1474 yield* acp.agent.prompt({
1475 sessionId: session.sessionId,
1476 prompt: [{ type: "text", text: "exercise MCP" }],
1477 });
1478 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
1479
1480 assert.deepEqual(yield* Ref.get(methods), [
1481 "connect",
1482 "message",
1483 "notification",
1484 "disconnect",
1485 ]);
1486 }),
1487 );
1488
1489 it.effect("preserves future content and session-update variants", () =>
1490 Effect.gen(function* () {
1491 const handle = yield* makeHandle({ ACP_MOCK_UNKNOWN_VARIANTS: "1" });
1492 const scope = yield* Scope.make();
1493 const context = yield* Layer.buildWithScope(AcpClient.layerChildProcess(handle), scope);
1494 const updates = yield* Ref.make<Array<AcpCompat.SessionNotification>>([]);
1495
1496 yield* Effect.gen(function* () {
1497 const acp = yield* AcpClient.AcpClient;
1498 yield* acp.handleRequestPermission(() =>
1499 Effect.succeed({ outcome: { outcome: "selected", optionId: "allow" } }),
1500 );
1501 yield* acp.handleElicitation(() => Effect.succeed({ action: "decline" }));
1502 yield* acp.handleExtRequest(
1503 "x/typed_request",
1504 Schema.Struct({ message: Schema.String }),
1505 () => Effect.succeed({ ok: true }),
1506 );
1507 yield* acp.handleExtNotification(
1508 "x/typed_notification",
1509 Schema.Struct({ count: Schema.Number }),
1510 () => Effect.void,
1511 );
1512 yield* acp.handleSessionUpdate((update) =>
1513 Ref.update(updates, (current) => [...current, update]),
1514 );
1515 yield* acp.agent.initialize({
1516 protocolVersion: 2,
1517 clientCapabilities: {},
1518 clientInfo: { name: "effect-acp-test", version: "0.0.0" },
1519 });
1520 const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] });
1521 yield* acp.agent.prompt({
1522 sessionId: session.sessionId,
1523 prompt: [{ type: "text", text: "exercise future variants" }],
1524 });
1525 }).pipe(Effect.provide(context), Effect.ensuring(Scope.close(scope, Exit.void)));
1526
1527 const received = yield* Ref.get(updates);
1528 assert.deepEqual(received[0]?.update, {
1529 sessionUpdate: "agent_message_chunk",
1530 messageId: "future-content",
1531 content: {
1532 type: "_t3_unknown",
1533 originalType: "chart",
1534 raw: { type: "chart", points: [] },
1535 },
1536 });
1537 assert.deepEqual(received[1]?.update, {
1538 sessionUpdate: "_t3_unknown",
1539 originalSessionUpdate: "timeline_update",
1540 raw: { sessionUpdate: "timeline_update", entries: [] },
1541 });
1542 }),
1543 );
1544});
1545
1546const elicitationForm = {
1547 sessionId: "session-1",
1548 mode: "form" as const,
1549 message: "Continue?",
1550 requestedSchema: {
1551 type: "object" as const,
1552 properties: { choice: { type: "string" as const } },
1553 required: ["choice"],
1554 },
1555};
1556const decodeWireResponse = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
1557const decodeWireError = Schema.decodeEffect(
1558 Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Finite }) })),
1559);
1560
1561it.effect.each(["accept", "decline", "cancel"] as const)(
1562 "returns a flat %s action to the SDK and preserves the legacy response",
1563 (action) =>
1564 Effect.gen(function* () {
1565 for (const method of ["elicitation/create", "session/elicitation"]) {
1566 const { stdio, input, output } = yield* makeInMemoryStdio();
1567 const acp = yield* AcpClient.make(stdio);
1568 const answer = action === "accept" ? { action, content: { choice: "yes" } } : { action };
1569 const response = { ...answer, _meta: { trace: "fixture" } };
1570 yield* acp.handleElicitation((request) => {
1571 assert.deepEqual(request, elicitationForm);
1572 return Effect.succeed(response);
1573 });
1574 yield* Queue.offer(
1575 input,
1576 yield* encodeJsonl(jsonRpcRequest(method, AcpSchema.CreateElicitationRequest), {
1577 jsonrpc: "2.0",
1578 id: 71,
1579 headers: [],
1580 method,
1581 params: elicitationForm,
1582 }),
1583 );
1584 const received = yield* decodeWireResponse(yield* Queue.take(output));
1585 assert.deepEqual(received, {
1586 jsonrpc: "2.0",
1587 id: 71,
1588 result:
1589 method === "elicitation/create" ? response : { action: answer, _meta: response._meta },
1590 });
1591 }
1592 }).pipe(Effect.scoped),
1593);
1594
1595it.effect("dispatches SDK URL elicitation to the same registered handler", () =>
1596 Effect.gen(function* () {
1597 const { stdio, input, output } = yield* makeInMemoryStdio();
1598 const acp = yield* AcpClient.make(stdio);
1599 const params = {
1600 sessionId: "session-1",
1601 mode: "url" as const,
1602 message: "Sign in",
1603 elicitationId: "elicitation-1",
1604 url: "https://example.test/sign-in",
1605 };
1606 yield* acp.handleElicitation((request) => {
1607 assert.deepEqual(request, params);
1608 return Effect.succeed({ action: "accept" as const });
1609 });
1610 yield* Queue.offer(
1611 input,
1612 yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest), {
1613 jsonrpc: "2.0",
1614 id: 72,
1615 headers: [],
1616 method: "elicitation/create",
1617 params,
1618 }),
1619 );
1620 assert.deepEqual(yield* decodeWireResponse(yield* Queue.take(output)), {
1621 jsonrpc: "2.0",
1622 id: 72,
1623 result: { action: "accept" },
1624 });
1625 }).pipe(Effect.scoped),
1626);
1627
1628it.effect.each(["elicitation/complete", "session/elicitation/complete"])(
1629 "delivers %s notifications to the completion handler",
1630 (method) =>
1631 Effect.gen(function* () {
1632 const { stdio, input } = yield* makeInMemoryStdio();
1633 const acp = yield* AcpClient.make(stdio);
1634 const received = yield* Deferred.make<AcpSchema.CompleteElicitationNotification>();
1635 yield* acp.handleElicitationComplete((notification) =>
1636 Deferred.succeed(received, notification).pipe(Effect.asVoid),
1637 );
1638 yield* Queue.offer(
1639 input,
1640 yield* encodeJsonl(jsonRpcNotification(method, AcpSchema.CompleteElicitationNotification), {
1641 jsonrpc: "2.0",
1642 method,
1643 params: { elicitationId: "elicitation-1" },
1644 }),
1645 );
1646 assert.deepEqual(yield* Deferred.await(received), { elicitationId: "elicitation-1" });
1647 }).pipe(Effect.scoped),
1648);
1649
1650it.effect("rejects malformed SDK elicitation without calling the question handler", () =>
1651 Effect.gen(function* () {
1652 const { stdio, input, output } = yield* makeInMemoryStdio();
1653 const acp = yield* AcpClient.make(stdio);
1654 let calls = 0;
1655 yield* acp.handleElicitation(() => {
1656 calls++;
1657 return Effect.succeed({ action: "cancel" as const });
1658 });
1659 yield* Queue.offer(
1660 input,
1661 yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), {
1662 jsonrpc: "2.0",
1663 id: 73,
1664 headers: [],
1665 method: "elicitation/create",
1666 params: { ...elicitationForm, message: 123 },
1667 }),
1668 );
1669 const response = yield* decodeWireError(yield* Queue.take(output));
1670 assert.equal(response.error.code, -32602);
1671 assert.equal(calls, 0);
1672 }).pipe(Effect.scoped),
1673);
1674
1675it.effect("returns method-not-found when SDK elicitation has no question handler", () =>
1676 Effect.gen(function* () {
1677 const { stdio, input, output } = yield* makeInMemoryStdio();
1678 yield* AcpClient.make(stdio);
1679 yield* Queue.offer(
1680 input,
1681 yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.CreateElicitationRequest), {
1682 jsonrpc: "2.0",
1683 id: 74,
1684 headers: [],
1685 method: "elicitation/create",
1686 params: elicitationForm,
1687 }),
1688 );
1689 const response = yield* decodeWireError(yield* Queue.take(output));
1690 assert.equal(response.error.code, -32601);
1691 }).pipe(Effect.scoped),
1692);