diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 3a0b262e6e22..5d1bd1d3ac94 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -778,3 +778,150 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); }); + +const elicitationForm = { + sessionId: "session-1", + mode: "form" as const, + message: "Continue?", + requestedSchema: { + type: "object" as const, + properties: { choice: { type: "string" as const } }, + required: ["choice"], + }, +}; +const decodeWireResponse = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeWireError = Schema.decodeEffect( + Schema.fromJsonString(Schema.Struct({ error: Schema.Struct({ code: Schema.Finite }) })), +); + +it.effect.each(["accept", "decline", "cancel"] as const)( + "returns a flat %s action to the SDK and preserves the legacy response", + (action) => + Effect.gen(function* () { + for (const method of ["elicitation/create", "session/elicitation"]) { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + const answer = action === "accept" ? { action, content: { choice: "yes" } } : { action }; + const response = { action: answer, _meta: { trace: "fixture" } }; + yield* acp.handleElicitation((request) => { + assert.deepEqual(request, elicitationForm); + return Effect.succeed(response); + }); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest(method, AcpSchema.ElicitationRequest), { + jsonrpc: "2.0", + id: 71, + headers: [], + method, + params: elicitationForm, + }), + ); + const received = yield* decodeWireResponse(yield* Queue.take(output)); + assert.deepEqual(received, { + jsonrpc: "2.0", + id: 71, + result: method === "elicitation/create" ? { ...answer, _meta: response._meta } : response, + }); + } + }).pipe(Effect.scoped), +); + +it.effect("dispatches SDK URL elicitation to the same registered handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + const params = { + sessionId: "session-1", + mode: "url" as const, + message: "Sign in", + elicitationId: "elicitation-1", + url: "https://example.test/sign-in", + }; + yield* acp.handleElicitation((request) => { + assert.deepEqual(request, params); + return Effect.succeed({ action: { action: "accept" as const } }); + }); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), { + jsonrpc: "2.0", + id: 72, + headers: [], + method: "elicitation/create", + params, + }), + ); + assert.deepEqual(yield* decodeWireResponse(yield* Queue.take(output)), { + jsonrpc: "2.0", + id: 72, + result: { action: "accept" }, + }); + }).pipe(Effect.scoped), +); + +it.effect.each(["elicitation/complete", "session/elicitation/complete"])( + "delivers %s notifications to the completion handler", + (method) => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + const received = yield* Deferred.make(); + yield* acp.handleElicitationComplete((notification) => + Deferred.succeed(received, notification).pipe(Effect.asVoid), + ); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcNotification(method, AcpSchema.ElicitationCompleteNotification), { + jsonrpc: "2.0", + method, + params: { elicitationId: "elicitation-1" }, + }), + ); + assert.deepEqual(yield* Deferred.await(received), { elicitationId: "elicitation-1" }); + }).pipe(Effect.scoped), +); + +it.effect("rejects malformed SDK elicitation without calling the question handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + let calls = 0; + yield* acp.handleElicitation(() => { + calls++; + return Effect.succeed({ action: { action: "cancel" as const } }); + }); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), { + jsonrpc: "2.0", + id: 73, + headers: [], + method: "elicitation/create", + params: { ...elicitationForm, message: 123 }, + }), + ); + const response = yield* decodeWireError(yield* Queue.take(output)); + assert.equal(response.error.code, -32602); + assert.equal(calls, 0); + }).pipe(Effect.scoped), +); + +it.effect("returns method-not-found when SDK elicitation has no question handler", () => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + yield* AcpClient.make(stdio); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", AcpSchema.ElicitationRequest), { + jsonrpc: "2.0", + id: 74, + headers: [], + method: "elicitation/create", + params: elicitationForm, + }), + ); + const response = yield* decodeWireError(yield* Queue.take(output)); + assert.equal(response.error.code, -32601); + }).pipe(Effect.scoped), +); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index b3bcf3cd2433..27203c20c627 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -22,6 +22,8 @@ import { } from "./_internal/shared.ts"; import { makeChildStdio, makeTerminationError } from "./_internal/stdio.ts"; +const decodeElicitationRequest = Schema.decodeUnknownEffect(AcpSchema.ElicitationRequest); + export interface AcpClientOptions { readonly logIncoming?: boolean; readonly logOutgoing?: boolean; @@ -151,7 +153,7 @@ export class AcpClient extends Context.Service< ) => Effect.Effect, ) => Effect.Effect; /** - * Registers a handler for `session/elicitation`. + * Registers a handler for `session/elicitation` and `elicitation/create`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation */ readonly handleElicitation: ( @@ -232,7 +234,7 @@ export class AcpClient extends Context.Service< ) => Effect.Effect, ) => Effect.Effect; /** - * Registers a handler for `session/elicitation/complete`. + * Registers a handler for `session/elicitation/complete` and `elicitation/complete`. * @see https://agentclientprotocol.com/protocol/schema#session/elicitation/complete */ readonly handleElicitationComplete: ( @@ -431,6 +433,22 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( ), [CLIENT_METHODS.session_elicitation]: (payload) => runHandler(coreHandlers.elicitation, payload, CLIENT_METHODS.session_elicitation), + "elicitation/create": (payload) => + decodeElicitationRequest(payload).pipe( + Effect.mapError((cause) => + AcpError.AcpRequestError.invalidExtensionPayload( + "elicitation/create", + cause, + ).toProtocolError(), + ), + Effect.flatMap((request) => + runHandler(coreHandlers.elicitation, request, "elicitation/create"), + ), + Effect.map(({ action, _meta }) => ({ + ...action, + ...(_meta !== undefined ? { _meta } : {}), + })), + ), [CLIENT_METHODS.fs_read_text_file]: (payload) => runHandler(coreHandlers.readTextFile, payload, CLIENT_METHODS.fs_read_text_file), [CLIENT_METHODS.fs_write_text_file]: (payload) => diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index cd6043f2db00..f74a8ea29e37 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -34,7 +34,7 @@ export type AcpIncomingNotification = } | { readonly _tag: "ElicitationComplete"; - readonly method: typeof CLIENT_METHODS.session_elicitation_complete; + readonly method: typeof CLIENT_METHODS.session_elicitation_complete | "elicitation/complete"; readonly params: AcpSchema.ElicitationCompleteNotification; } | { @@ -316,20 +316,24 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Effect.flatMap(dispatchNotification), ); } - if (message.tag === CLIENT_METHODS.session_elicitation_complete) { + if ( + message.tag === CLIENT_METHODS.session_elicitation_complete || + message.tag === "elicitation/complete" + ) { + const method = message.tag; return decodeElicitationComplete(message.payload).pipe( Effect.map( (params) => ({ _tag: "ElicitationComplete", - method: CLIENT_METHODS.session_elicitation_complete, + method, params, }) satisfies AcpIncomingNotification, ), Effect.mapError((cause) => AcpError.AcpProtocolParseError.fromSchemaError( "decode-notification-payload", - CLIENT_METHODS.session_elicitation_complete, + method, cause, ), ), diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 5026645374eb..118e5e377659 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -1,5 +1,6 @@ import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import * as Schema from "effect/Schema"; import * as AcpSchema from "./_generated/schema.gen.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; @@ -100,6 +101,20 @@ const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { error: AcpSchema.Error, }); +// The pinned v0.11.3 schema predates the SDK's method name and flat response. +// Keep its RPC for existing peers and translate the SDK alias at the boundary. +const CreateElicitationRpc = Rpc.make("elicitation/create", { + payload: Schema.Unknown, + success: Schema.Struct({ + action: Schema.Literals(["accept", "decline", "cancel"]), + content: Schema.optionalKey( + Schema.NullOr(Schema.Record(Schema.String, AcpSchema.ElicitationContentValue)), + ), + _meta: AcpSchema.ElicitationResponse.fields._meta, + }), + error: AcpSchema.Error, +}); + const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { payload: AcpSchema.CreateTerminalRequest, success: AcpSchema.CreateTerminalResponse, @@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make( WriteTextFileRpc, RequestPermissionRpc, ElicitationRpc, + CreateElicitationRpc, CreateTerminalRpc, TerminalOutputRpc, ReleaseTerminalRpc,