authorgravatar for 91240327+shivamhwp@users.noreply.github.comshivam <91240327+shivamhwp@users.noreply.github.com> 2026-09-19 05:42:41+05:30
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-18 17:12:41-07:00
logde6a230db0bb71d3fa91d8de0ae7067b68253f82
tree2fc51bba2441d90745168bb81d10baca9fe9bba3
parent8c3b5bef17a637bf53f297189350359790ef7afb
signature Signed by PGP key B5690EEEBB952194

fix(acp): accept SDK elicitation requests (#11294)


4 files changed, 191 insertions(+), 6 deletions(-)

packages/effect-acp/src/client.test.ts+147
...@@ -778,3 +778,150 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {...@@ -778,3 +778,150 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => {
778 }),778 }),
779 );779 );
780});780});
781
782const elicitationForm = {
783 sessionId: "session-1",
784 mode: "form" as const,
785 message: "Continue?",
786 requestedSchema: {
787 type: "object" as const,
788 properties: { choice: { type: "string" as const } },
789 required: ["choice"],
790 },
791};
792const decodeWireResponse = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown));
793const decodeWireError = Schema.decodeEffect(
794 Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Finite }) })),
795);
796
797it.effect.each(["accept", "decline", "cancel"] as const)(
798 "returns a flat %s action to the SDK and preserves the legacy response",
799 (action) =>
800 Effect.gen(function* () {
801 for (const method of ["elicitation/create", "session/elicitation"]) {
802 const { stdio, input, output } = yield* makeInMemoryStdio();
803 const acp = yield* AcpClient.make(stdio);
804 const answer = action === "accept" ? { action, content: { choice: "yes" } } : { action };
805 const response = { action: answer, _meta: { trace: "fixture" } };
806 yield* acp.handleElicitation((request) => {
807 assert.deepEqual(request, elicitationForm);
808 return Effect.succeed(response);
809 });
810 yield* Queue.offer(
811 input,
812 yield* encodeJsonl(jsonRpcRequest(method, AcpSchema.ElicitationRequest), {
813 jsonrpc: "2.0",
814 id: 71,
815 headers: [],
816 method,
817 params: elicitationForm,
818 }),
819 );
820 const received = yield* decodeWireResponse(yield* Queue.take(output));
821 assert.deepEqual(received, {
822 jsonrpc: "2.0",
823 id: 71,
824 result: method === "elicitation/create" ? { ...answer, _meta: response._meta } : response,
825 });
826 }
827 }).pipe(Effect.scoped),
828);
829
830it.effect("dispatches SDK URL elicitation to the same registered handler", () =>
831 Effect.gen(function* () {
832 const { stdio, input, output } = yield* makeInMemoryStdio();
833 const acp = yield* AcpClient.make(stdio);
834 const params = {
835 sessionId: "session-1",
836 mode: "url" as const,
837 message: "Sign in",
838 elicitationId: "elicitation-1",
839 url: "https://example.test/sign-in",
840 };
841 yield* acp.handleElicitation((request) => {
842 assert.deepEqual(request, params);
843 return Effect.succeed({ action: { action: "accept" as const } });
844 });
845 yield* Queue.offer(
846 input,
847 yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), {
848 jsonrpc: "2.0",
849 id: 72,
850 headers: [],
851 method: "elicitation/create",
852 params,
853 }),
854 );
855 assert.deepEqual(yield* decodeWireResponse(yield* Queue.take(output)), {
856 jsonrpc: "2.0",
857 id: 72,
858 result: { action: "accept" },
859 });
860 }).pipe(Effect.scoped),
861);
862
863it.effect.each(["elicitation/complete", "session/elicitation/complete"])(
864 "delivers %s notifications to the completion handler",
865 (method) =>
866 Effect.gen(function* () {
867 const { stdio, input } = yield* makeInMemoryStdio();
868 const acp = yield* AcpClient.make(stdio);
869 const received = yield* Deferred.make<AcpSchema.ElicitationCompleteNotification>();
870 yield* acp.handleElicitationComplete((notification) =>
871 Deferred.succeed(received, notification).pipe(Effect.asVoid),
872 );
873 yield* Queue.offer(
874 input,
875 yield* encodeJsonl(jsonRpcNotification(method, AcpSchema.ElicitationCompleteNotification), {
876 jsonrpc: "2.0",
877 method,
878 params: { elicitationId: "elicitation-1" },
879 }),
880 );
881 assert.deepEqual(yield* Deferred.await(received), { elicitationId: "elicitation-1" });
882 }).pipe(Effect.scoped),
883);
884
885it.effect("rejects malformed SDK elicitation without calling the question handler", () =>
886 Effect.gen(function* () {
887 const { stdio, input, output } = yield* makeInMemoryStdio();
888 const acp = yield* AcpClient.make(stdio);
889 let calls = 0;
890 yield* acp.handleElicitation(() => {
891 calls++;
892 return Effect.succeed({ action: { action: "cancel" as const } });
893 });
894 yield* Queue.offer(
895 input,
896 yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), {
897 jsonrpc: "2.0",
898 id: 73,
899 headers: [],
900 method: "elicitation/create",
901 params: { ...elicitationForm, message: 123 },
902 }),
903 );
904 const response = yield* decodeWireError(yield* Queue.take(output));
905 assert.equal(response.error.code, -32602);
906 assert.equal(calls, 0);
907 }).pipe(Effect.scoped),
908);
909
910it.effect("returns method-not-found when SDK elicitation has no question handler", () =>
911 Effect.gen(function* () {
912 const { stdio, input, output } = yield* makeInMemoryStdio();
913 yield* AcpClient.make(stdio);
914 yield* Queue.offer(
915 input,
916 yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), {
917 jsonrpc: "2.0",
918 id: 74,
919 headers: [],
920 method: "elicitation/create",
921 params: elicitationForm,
922 }),
923 );
924 const response = yield* decodeWireError(yield* Queue.take(output));
925 assert.equal(response.error.code, -32601);
926 }).pipe(Effect.scoped),
927);
packages/effect-acp/src/client.ts+20-2
...@@ -22,6 +22,8 @@ import {...@@ -22,6 +22,8 @@ import {
22} from "./_internal/shared.ts";22} from "./_internal/shared.ts";
23import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";23import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts";
2424
25const decodeElicitationRequest = Schema.decodeUnknownEffect(AcpSchema.ElicitationRequest);
26
25export interface AcpClientOptions {27export interface AcpClientOptions {
26 readonly logIncoming?: boolean;28 readonly logIncoming?: boolean;
27 readonly logOutgoing?: boolean;29 readonly logOutgoing?: boolean;
...@@ -151,7 +153,7 @@ export class AcpClient extends Context.Service<...@@ -151,7 +153,7 @@ export class AcpClient extends Context.Service<
151 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>,153 ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>,
152 ) => Effect.Effect<void>;154 ) => Effect.Effect<void>;
153 /**155 /**
154 * Registers a handler for `session/elicitation`.156 * Registers a handler for `session/elicitation` and `elicitation/create`.
155 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation157 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation
156 */158 */
157 readonly handleElicitation: (159 readonly handleElicitation: (
...@@ -232,7 +234,7 @@ export class AcpClient extends Context.Service<...@@ -232,7 +234,7 @@ export class AcpClient extends Context.Service<
232 ) => Effect.Effect<void, AcpError.AcpError>,234 ) => Effect.Effect<void, AcpError.AcpError>,
233 ) => Effect.Effect<void>;235 ) => Effect.Effect<void>;
234 /**236 /**
235 * Registers a handler for `session/elicitation/complete`.237 * Registers a handler for `session/elicitation/complete` and `elicitation/complete`.
236 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete238 * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete
237 */239 */
238 readonly handleElicitationComplete: (240 readonly handleElicitationComplete: (
...@@ -431,6 +433,22 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (...@@ -431,6 +433,22 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* (
431 ),433 ),
432 [CLIENT_METHODS.session_elicitation]: (payload) =>434 [CLIENT_METHODS.session_elicitation]: (payload) =>
433 runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation),435 runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation),
436 "elicitation/create": (payload) =>
437 decodeElicitationRequest(payload).pipe(
438 Effect.mapError((cause) =>
439 AcpError.AcpRequestError.invalidExtensionPayload(
440 "elicitation/create",
441 cause,
442 ).toProtocolError(),
443 ),
444 Effect.flatMap((request) =>
445 runHandler(coreHandlers.elicitation, request, "elicitation/create"),
446 ),
447 Effect.map(({ action, _meta }) => ({
448 ...action,
449 ...(_meta !== undefined ? { _meta } : {}),
450 })),
451 ),
434 [CLIENT_METHODS.fs_read_text_file]: (payload) =>452 [CLIENT_METHODS.fs_read_text_file]: (payload) =>
435 runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file),453 runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file),
436 [CLIENT_METHODS.fs_write_text_file]: (payload) =>454 [CLIENT_METHODS.fs_write_text_file]: (payload) =>
packages/effect-acp/src/protocol.ts+8-4
...@@ -34,7 +34,7 @@ export type AcpIncomingNotification =...@@ -34,7 +34,7 @@ export type AcpIncomingNotification =
34 }34 }
35 | {35 | {
36 readonly _tag: "ElicitationComplete";36 readonly _tag: "ElicitationComplete";
37 readonly method: typeof CLIENT_METHODS.session_elicitation_complete;37 readonly method: typeof CLIENT_METHODS.session_elicitation_complete | "elicitation/complete";
38 readonly params: AcpSchema.ElicitationCompleteNotification;38 readonly params: AcpSchema.ElicitationCompleteNotification;
39 }39 }
40 | {40 | {
...@@ -316,20 +316,24 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi...@@ -316,20 +316,24 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi
316 Effect.flatMap(dispatchNotification),316 Effect.flatMap(dispatchNotification),
317 );317 );
318 }318 }
319 if (message.tag === CLIENT_METHODS.session_elicitation_complete) {319 if (
320 message.tag === CLIENT_METHODS.session_elicitation_complete ||
321 message.tag === "elicitation/complete"
322 ) {
323 const method = message.tag;
320 return decodeElicitationComplete(message.payload).pipe(324 return decodeElicitationComplete(message.payload).pipe(
321 Effect.map(325 Effect.map(
322 (params) =>326 (params) =>
323 ({327 ({
324 _tag: "ElicitationComplete",328 _tag: "ElicitationComplete",
325 method: CLIENT_METHODS.session_elicitation_complete,329 method,
326 params,330 params,
327 }) satisfies AcpIncomingNotification,331 }) satisfies AcpIncomingNotification,
328 ),332 ),
329 Effect.mapError((cause) =>333 Effect.mapError((cause) =>
330 AcpError.AcpProtocolParseError.fromSchemaError(334 AcpError.AcpProtocolParseError.fromSchemaError(
331 "decode-notification-payload",335 "decode-notification-payload",
332 CLIENT_METHODS.session_elicitation_complete,336 method,
333 cause,337 cause,
334 ),338 ),
335 ),339 ),
packages/effect-acp/src/rpc.ts+16
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Rpc from "effect/unstable/rpc/Rpc";1import * as Rpc from "effect/unstable/rpc/Rpc";
2import * as RpcGroup from "effect/unstable/rpc/RpcGroup";2import * as RpcGroup from "effect/unstable/rpc/RpcGroup";
3import * as Schema from "effect/Schema";
34
4import * as AcpSchema from "./_generated/schema.gen.ts";5import * as AcpSchema from "./_generated/schema.gen.ts";
5import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";6import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts";
...@@ -100,6 +101,20 @@ const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, {...@@ -100,6 +101,20 @@ const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, {
100 error: AcpSchema.Error,101 error: AcpSchema.Error,
101});102});
102103
104// The pinned v0.11.3 schema predates the SDK's method name and flat response.
105// Keep its RPC for existing peers and translate the SDK alias at the boundary.
106const CreateElicitationRpc = Rpc.make("elicitation/create", {
107 payload: Schema.Unknown,
108 success: Schema.Struct({
109 action: Schema.Literals(["accept", "decline", "cancel"]),
110 content: Schema.optionalKey(
111 Schema.NullOr(Schema.Record(Schema.String, AcpSchema.ElicitationContentValue)),
112 ),
113 _meta: AcpSchema.ElicitationResponse.fields._meta,
114 }),
115 error: AcpSchema.Error,
116});
117
103const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, {118const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, {
104 payload: AcpSchema.CreateTerminalRequest,119 payload: AcpSchema.CreateTerminalRequest,
105 success: AcpSchema.CreateTerminalResponse,120 success: AcpSchema.CreateTerminalResponse,
...@@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make(...@@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make(
150 WriteTextFileRpc,165 WriteTextFileRpc,
151 RequestPermissionRpc,166 RequestPermissionRpc,
152 ElicitationRpc,167 ElicitationRpc,
168 CreateElicitationRpc,
153 CreateTerminalRpc,169 CreateTerminalRpc,
154 TerminalOutputRpc,170 TerminalOutputRpc,
155 ReleaseTerminalRpc,171 ReleaseTerminalRpc,