Skip to content

Commit d5197f9

Browse files
authored
feat(unstable): Add support for request cancellation (#195)
* Add JSON-RPC request cancellation support * Abort connection before rejecting pending responses * Keep cancelled requests pending until response * Handle abort errors in JSON-RPC requests * Rename request cancellation signal option * Queue already-aborted request cancellations
1 parent 06c932a commit d5197f9

6 files changed

Lines changed: 769 additions & 49 deletions

File tree

scripts/generate.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ async function main() {
7575
7676
export const CLIENT_METHODS = ${JSON.stringify(metadata.clientMethods, null, 2)} as const;
7777
78+
export const PROTOCOL_METHODS = ${JSON.stringify(metadata.protocolMethods, null, 2)} as const;
79+
7880
export const PROTOCOL_VERSION = ${metadata.version};
7981
`,
8082
{ parser: "typescript" },

src/acp.test.ts

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,8 @@ describe("Connection", () => {
721721
const appAgent = createAgent({ name: "app-agent" })
722722
.onRequest(AGENT_METHODS.initialize, (c) => {
723723
events.push(`initialize:${c.params.protocolVersion}`);
724-
expect(Object.keys(c).sort()).toEqual(["client", "params"]);
724+
expect(Object.keys(c).sort()).toEqual(["client", "params", "signal"]);
725+
expect(c.signal.aborted).toBe(false);
725726

726727
return {
727728
protocolVersion: c.params.protocolVersion,
@@ -742,7 +743,7 @@ describe("Connection", () => {
742743
},
743744
},
744745
(c) => {
745-
expect(Object.keys(c).sort()).toEqual(["client", "params"]);
746+
expect(Object.keys(c).sort()).toEqual(["client", "params", "signal"]);
746747
events.push(`agent-route:${String(c.params.message)}`);
747748
},
748749
)
@@ -777,7 +778,8 @@ describe("Connection", () => {
777778
return { message: String(message).toUpperCase() };
778779
},
779780
(c) => {
780-
expect(Object.keys(c).sort()).toEqual(["agent", "params"]);
781+
expect(Object.keys(c).sort()).toEqual(["agent", "params", "signal"]);
782+
expect(c.signal.aborted).toBe(false);
781783
events.push(`client-route:${String(c.params.message)}`);
782784

783785
return { message: c.params.message };
@@ -824,6 +826,78 @@ describe("Connection", () => {
824826
]);
825827
});
826828

829+
it("aborts app request context signals for protocol cancellation", async () => {
830+
const requestSignal = Promise.withResolvers<AbortSignal>();
831+
832+
const appAgent = createAgent({ name: "cancel-signal-agent" }).onRequest(
833+
"vendor/slow",
834+
(params) => params as Record<string, never>,
835+
async (c) => {
836+
requestSignal.resolve(c.signal);
837+
await new Promise<void>((resolve) => {
838+
c.signal.addEventListener("abort", () => resolve(), { once: true });
839+
});
840+
841+
return { cancelled: c.signal.aborted };
842+
},
843+
);
844+
const appClient = createClient({ name: "cancel-signal-client" });
845+
846+
const result = await appClient.connectWith(appAgent, async (agentCx) => {
847+
const response = agentCx.request<{ cancelled: boolean }>(
848+
"vendor/slow",
849+
{},
850+
);
851+
const signal = await requestSignal.promise;
852+
853+
expect(signal.aborted).toBe(false);
854+
await agentCx.notify(methods.protocol.cancelRequest, { requestId: 0 });
855+
856+
return response;
857+
});
858+
859+
expect(result).toEqual({ cancelled: true });
860+
});
861+
862+
it("maps app request abort errors to request cancellation", async () => {
863+
const requestSignal = Promise.withResolvers<AbortSignal>();
864+
865+
const appAgent = createAgent({ name: "abort-error-agent" }).onRequest(
866+
"vendor/abort",
867+
(params) => params as Record<string, never>,
868+
async (c) => {
869+
requestSignal.resolve(c.signal);
870+
await new Promise<void>((_, reject) => {
871+
c.signal.addEventListener(
872+
"abort",
873+
() => {
874+
const error = new Error("aborted");
875+
error.name = "AbortError";
876+
reject(error);
877+
},
878+
{ once: true },
879+
);
880+
});
881+
882+
return {};
883+
},
884+
);
885+
const appClient = createClient({ name: "abort-error-client" });
886+
887+
await appClient.connectWith(appAgent, async (agentCx) => {
888+
const response = agentCx.request("vendor/abort", {});
889+
const signal = await requestSignal.promise;
890+
891+
expect(signal.aborted).toBe(false);
892+
await agentCx.notify(methods.protocol.cancelRequest, { requestId: 0 });
893+
894+
await expect(response).rejects.toMatchObject({
895+
code: -32800,
896+
message: "Request cancelled",
897+
});
898+
});
899+
});
900+
827901
it("returns peer contexts from app connection handles", async () => {
828902
const events: string[] = [];
829903

0 commit comments

Comments
 (0)