diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 1d5b78849f08a..8a1cc36390bbd 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -1042,6 +1042,100 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { }), ); + it.effect.each([ + { shape: "v1", deleteCapability: {} }, + { shape: "v2", deleteCapability: {} }, + { shape: "v2", deleteCapability: undefined }, + { shape: "v2", deleteCapability: null }, + ])( + "gates session/delete for $shape with delete=$deleteCapability", + ({ shape, deleteCapability }) => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const scope = yield* Scope.make(); + const acp = yield* AcpClient.make(stdio).pipe(Effect.provideService(Scope.Scope, scope)); + const deleteSession = acp.agent.deleteSession({ sessionId: "kiro-session-1" }); + + const initializeFiber = yield* acp.agent + .initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + clientInfo: { name: "effect-acp-test", version: "0.0.0" }, + }) + .pipe(Effect.forkScoped); + const initializeRequest = yield* Queue.take(output).pipe( + Effect.flatMap(Schema.decodeEffect(Schema.fromJsonString(InitializeRequestV1))), + ); + yield* Queue.offer( + input, + shape === "v1" + ? yield* encodeJsonl(InitializeResponseV1, { + jsonrpc: "2.0", + id: initializeRequest.id, + result: { + protocolVersion: 1, + agentInfo: { name: "kiro-acp", version: "0.0.0" }, + agentCapabilities: { + sessionCapabilities: { list: {}, resume: {}, close: {}, delete: {} }, + }, + }, + }) + : yield* encodeJsonl(InitializeResponse, { + jsonrpc: "2.0", + id: initializeRequest.id, + result: { + protocolVersion: 2, + info: { name: "v2-agent", version: "0.0.0" }, + capabilities: { + session: deleteCapability === undefined ? {} : { delete: deleteCapability }, + }, + }, + }), + ); + yield* Fiber.join(initializeFiber); + + const deleteFailed = yield* Deferred.make(); + const deleteFiber = yield* deleteSession.pipe( + Effect.tapError((error) => Deferred.succeed(deleteFailed, error)), + Effect.forkScoped, + ); + const outcome = yield* Effect.raceFirst( + Queue.take(output).pipe(Effect.map((line) => ({ line }))), + Deferred.await(deleteFailed).pipe(Effect.map((error) => ({ error }))), + ); + if ("error" in outcome) { + if (deleteCapability != null) { + return yield* outcome.error; + } + assert.equal(outcome.error._tag, "AcpRequestError"); + assert.equal( + outcome.error._tag === "AcpRequestError" ? outcome.error.code : undefined, + -32601, + ); + yield* Scope.close(scope, Exit.void); + return; + } + assert.exists(deleteCapability, "session/delete reached transport without its capability"); + const deleteRequest = yield* Schema.decodeEffect( + Schema.fromJsonString(jsonRpcRequest("session/delete", AcpSchemaV1.DeleteSessionRequest)), + )(outcome.line); + assert.equal(deleteRequest.params.sessionId, "kiro-session-1"); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), { + jsonrpc: "2.0", + id: deleteRequest.id, + result: {}, + }), + ); + assert.deepEqual(yield* Fiber.join(deleteFiber), {}); + yield* Scope.close(scope, Exit.void); + }), + ); + it.effect("preserves exact ids for parallel requests with identical payloads", () => Effect.gen(function* () { const { stdio, input, output } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index c8ac3d9b841ea..ca1dba902ee8d 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -782,6 +782,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( | ((method: string, params: unknown) => Effect.Effect) | undefined; let negotiatedProtocolGeneration: 1 | 2 | undefined; + let sessionDeleteSupported = false; const runNotificationHandlers = ( registration: BufferedNotificationHandler, @@ -1081,12 +1082,14 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( // ACP v1 protocolVersion values were not generation identifiers in // every shipped agent. Antigravity, for example, reports version 2 // with the v1 agentInfo/agentCapabilities response shape. - if (isV2InitializeResponse(response)) { - negotiatedProtocolGeneration = 2; - return normalizeInitializeResponse(response); - } - negotiatedProtocolGeneration = 1; - return response as AcpSchema.InitializeResponse; + const isV2 = isV2InitializeResponse(response); + negotiatedProtocolGeneration = isV2 ? 2 : 1; + const normalized = isV2 + ? normalizeInitializeResponse(response) + : (response as AcpSchema.InitializeResponse); + sessionDeleteSupported = + normalized.agentCapabilities?.sessionCapabilities?.delete != null; + return normalized; }), ), authenticate: (payload) => @@ -1182,9 +1185,11 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( Effect.map((response) => response as AcpSchema.CloseSessionResponse), ), deleteSession: (payload) => - negotiatedProtocolGeneration === 1 - ? Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.session_delete)) - : callRpc(AGENT_METHODS.session_delete, rpc[AGENT_METHODS.session_delete](payload)), + Effect.suspend(() => + sessionDeleteSupported + ? callRpc(AGENT_METHODS.session_delete, rpc[AGENT_METHODS.session_delete](payload)) + : Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.session_delete)), + ), listProviders: (payload) => negotiatedProtocolGeneration === 1 ? Effect.fail(AcpError.AcpRequestError.methodNotFound(AGENT_METHODS.providers_list))