| author | |
| committer | |
| log | de6a230db0bb71d3fa91d8de0ae7067b68253f82 |
| tree | 2fc51bba2441d90745168bb81d10baca9fe9bba3 |
| parent | 8c3b5bef17a637bf53f297189350359790ef7afb |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 778 | }), |
| 779 | 779 | ); |
| 780 | 780 | }); |
| 781 | ||
| 782 | const 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 | }; | |
| 792 | const decodeWireResponse = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); | |
| 793 | const decodeWireError = Schema.decodeEffect( | |
| 794 | Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Finite }) })), | |
| 795 | ); | |
| 796 | ||
| 797 | it.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 | ||
| 830 | it.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 | ||
| 863 | it.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 | ||
| 885 | it.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 | ||
| 910 | it.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 | 22 | } from "./_internal/shared.ts"; |
| 23 | 23 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; |
| 24 | 24 | |
| 25 | const decodeElicitationRequest = Schema.decodeUnknownEffect(AcpSchema.ElicitationRequest); | |
| 26 | ||
| 25 | 27 | export interface AcpClientOptions { |
| 26 | 28 | readonly logIncoming?: boolean; |
| 27 | 29 | readonly logOutgoing?: boolean; |
| ... | ... | @@ -151,7 +153,7 @@ export class AcpClient extends Context.Service< |
| 151 | 153 | ) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>, |
| 152 | 154 | ) => Effect.Effect<void>; |
| 153 | 155 | /** |
| 154 | * Registers a handler for `session/elicitation`. | |
| 156 | * Registers a handler for `session/elicitation` and `elicitation/create`. | |
| 155 | 157 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation |
| 156 | 158 | */ |
| 157 | 159 | readonly handleElicitation: ( |
| ... | ... | @@ -232,7 +234,7 @@ export class AcpClient extends Context.Service< |
| 232 | 234 | ) => Effect.Effect<void, AcpError.AcpError>, |
| 233 | 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 | 238 | * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete |
| 237 | 239 | */ |
| 238 | 240 | readonly handleElicitationComplete: ( |
| ... | ... | @@ -431,6 +433,22 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( |
| 431 | 433 | ), |
| 432 | 434 | [CLIENT_METHODS.session_elicitation]: (payload) => |
| 433 | 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 | 452 | [CLIENT_METHODS.fs_read_text_file]: (payload) => |
| 435 | 453 | runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), |
| 436 | 454 | [CLIENT_METHODS.fs_write_text_file]: (payload) => |
packages/effect-acp/src/protocol.ts+8-4| ... | ... | @@ -34,7 +34,7 @@ export type AcpIncomingNotification = |
| 34 | 34 | } |
| 35 | 35 | | { |
| 36 | 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 | 38 | readonly params: AcpSchema.ElicitationCompleteNotification; |
| 39 | 39 | } |
| 40 | 40 | | { |
| ... | ... | @@ -316,20 +316,24 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi |
| 316 | 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 | 324 | return decodeElicitationComplete(message.payload).pipe( |
| 321 | 325 | Effect.map( |
| 322 | 326 | (params) => |
| 323 | 327 | ({ |
| 324 | 328 | _tag: "ElicitationComplete", |
| 325 | method: CLIENT_METHODS.session_elicitation_complete, | |
| 329 | method, | |
| 326 | 330 | params, |
| 327 | 331 | }) satisfies AcpIncomingNotification, |
| 328 | 332 | ), |
| 329 | 333 | Effect.mapError((cause) => |
| 330 | 334 | AcpError.AcpProtocolParseError.fromSchemaError( |
| 331 | 335 | "decode-notification-payload", |
| 332 | CLIENT_METHODS.session_elicitation_complete, | |
| 336 | method, | |
| 333 | 337 | cause, |
| 334 | 338 | ), |
| 335 | 339 | ), |
packages/effect-acp/src/rpc.ts+16| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 2 | 2 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 3 | import * as Schema from "effect/Schema"; | |
| 3 | 4 | |
| 4 | 5 | import * as AcpSchema from "./_generated/schema.gen.ts"; |
| 5 | 6 | import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; |
| ... | ... | @@ -100,6 +101,20 @@ const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { |
| 100 | 101 | error: AcpSchema.Error, |
| 101 | 102 | }); |
| 102 | 103 | |
| 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. | |
| 106 | const 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 | ||
| 103 | 118 | const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { |
| 104 | 119 | payload: AcpSchema.CreateTerminalRequest, |
| 105 | 120 | success: AcpSchema.CreateTerminalResponse, |
| ... | ... | @@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make( |
| 150 | 165 | WriteTextFileRpc, |
| 151 | 166 | RequestPermissionRpc, |
| 152 | 167 | ElicitationRpc, |
| 168 | CreateElicitationRpc, | |
| 153 | 169 | CreateTerminalRpc, |
| 154 | 170 | TerminalOutputRpc, |
| 155 | 171 | ReleaseTerminalRpc, |