| 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,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,6 +22,8 @@ import { |
| 22 | } from "./_internal/shared.ts"; | 22 | } from "./_internal/shared.ts"; |
| 23 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; | 23 | import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; |
| 24 | 24 | ||
| 25 | const decodeElicitationRequest = Schema.decodeUnknownEffect(AcpSchema.ElicitationRequest); | ||
| 26 | |||
| 25 | export interface AcpClientOptions { | 27 | export 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/elicitation | 157 | * @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/complete | 238 | * @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 @@ |
| 1 | import * as Rpc from "effect/unstable/rpc/Rpc"; | 1 | import * as Rpc from "effect/unstable/rpc/Rpc"; |
| 2 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; | 2 | import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; |
| 3 | import * as Schema from "effect/Schema"; | ||
| 3 | 4 | ||
| 4 | import * as AcpSchema from "./_generated/schema.gen.ts"; | 5 | import * as AcpSchema from "./_generated/schema.gen.ts"; |
| 5 | import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; | 6 | import { 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 | }); |
| 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 | const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { | 118 | const 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, |