Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions packages/effect-acp/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AcpSchema.ElicitationCompleteNotification>();
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),
);
22 changes: 20 additions & 2 deletions packages/effect-acp/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -151,7 +153,7 @@ export class AcpClient extends Context.Service<
) => Effect.Effect<AcpSchema.RequestPermissionResponse, AcpError.AcpError>,
) => Effect.Effect<void>;
/**
* 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: (
Expand Down Expand Up @@ -232,7 +234,7 @@ export class AcpClient extends Context.Service<
) => Effect.Effect<void, AcpError.AcpError>,
) => Effect.Effect<void>;
/**
* 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: (
Expand Down Expand Up @@ -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) =>
Expand Down
12 changes: 8 additions & 4 deletions packages/effect-acp/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
| {
Expand Down Expand Up @@ -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,
),
),
Expand Down
16 changes: 16 additions & 0 deletions packages/effect-acp/src/rpc.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -150,6 +165,7 @@ export const ClientRpcs = RpcGroup.make(
WriteTextFileRpc,
RequestPermissionRpc,
ElicitationRpc,
CreateElicitationRpc,
CreateTerminalRpc,
TerminalOutputRpc,
ReleaseTerminalRpc,
Expand Down
Loading