mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat(acp): Handle ACP request cancellation signals (#801)
This commit is contained in:
19
package-lock.json
generated
19
package-lock.json
generated
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.49.0",
|
"version": "0.49.0",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.28.1",
|
"@agentclientprotocol/sdk": "0.29.0",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "0.105.0",
|
"@anthropic-ai/sdk": "0.105.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
|
"@tsconfig/node22": "^22.0.5",
|
||||||
"@types/node": "26.0.0",
|
"@types/node": "26.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||||
"@typescript-eslint/parser": "8.61.1",
|
"@typescript-eslint/parser": "8.61.1",
|
||||||
@@ -29,12 +30,15 @@
|
|||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"vitest": "4.1.9"
|
"vitest": "4.1.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@agentclientprotocol/sdk": {
|
"node_modules/@agentclientprotocol/sdk": {
|
||||||
"version": "0.28.1",
|
"version": "0.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.29.0.tgz",
|
||||||
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
|
"integrity": "sha512-Sb8xJ/cIhqlvxeJiB0+1scBk0LAkVAwJ9WAFG9XCe3LEI5JWAyOxbqaCa8O1HmNnrk4+qHN11eaKH6zL2iHIYA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
@@ -872,6 +876,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@tsconfig/node22": {
|
||||||
|
"version": "22.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tsconfig/node22/-/node22-22.0.5.tgz",
|
||||||
|
"integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.2",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
|
|||||||
@@ -59,14 +59,18 @@
|
|||||||
},
|
},
|
||||||
"author": "Zed Industries",
|
"author": "Zed Industries",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.28.1",
|
"@agentclientprotocol/sdk": "0.29.0",
|
||||||
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
"@anthropic-ai/claude-agent-sdk": "0.3.185",
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@anthropic-ai/sdk": "0.105.0",
|
"@anthropic-ai/sdk": "0.105.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
|
"@tsconfig/node22": "22.0.5",
|
||||||
"@types/node": "26.0.0",
|
"@types/node": "26.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "8.61.1",
|
"@typescript-eslint/eslint-plugin": "8.61.1",
|
||||||
"@typescript-eslint/parser": "8.61.1",
|
"@typescript-eslint/parser": "8.61.1",
|
||||||
|
|||||||
118
src/acp-agent.ts
118
src/acp-agent.ts
@@ -635,10 +635,21 @@ export function describeAlwaysAllow(
|
|||||||
*/
|
*/
|
||||||
export interface AcpClient {
|
export interface AcpClient {
|
||||||
sessionUpdate(params: SessionNotification): Promise<void>;
|
sessionUpdate(params: SessionNotification): Promise<void>;
|
||||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse>;
|
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||||
|
* permission request so the client can dismiss its prompt (and settle our
|
||||||
|
* await) instead of leaving the dialog open after the turn was cancelled. */
|
||||||
|
requestPermission(
|
||||||
|
params: RequestPermissionRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RequestPermissionResponse>;
|
||||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
|
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
|
||||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
|
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
|
||||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse>;
|
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||||
|
* elicitation so the client can dismiss its prompt and settle our await. */
|
||||||
|
unstable_createElicitation(
|
||||||
|
params: CreateElicitationRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CreateElicitationResponse>;
|
||||||
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||||
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
||||||
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
||||||
@@ -657,8 +668,13 @@ class ClientConnection implements AcpClient {
|
|||||||
return this.ctx.notify(methods.client.session.update, params);
|
return this.ctx.notify(methods.client.session.update, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
requestPermission(
|
||||||
return this.ctx.request(methods.client.session.requestPermission, params);
|
params: RequestPermissionRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<RequestPermissionResponse> {
|
||||||
|
return this.ctx.request(methods.client.session.requestPermission, params, {
|
||||||
|
cancellationSignal: signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||||
@@ -669,8 +685,13 @@ class ClientConnection implements AcpClient {
|
|||||||
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
return this.ctx.request(methods.client.fs.writeTextFile, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
unstable_createElicitation(params: CreateElicitationRequest): Promise<CreateElicitationResponse> {
|
unstable_createElicitation(
|
||||||
return this.ctx.request(methods.client.elicitation.create, params);
|
params: CreateElicitationRequest,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CreateElicitationResponse> {
|
||||||
|
return this.ctx.request(methods.client.elicitation.create, params, {
|
||||||
|
cancellationSignal: signal,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
unstable_completeElicitation(params: CompleteElicitationNotification): Promise<void> {
|
||||||
@@ -2442,6 +2463,27 @@ export class ClaudeAcpAgent {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Forward a permission request to the client, wiring the tool call's
|
||||||
|
* `signal` through as a `cancellationSignal`. When the turn is cancelled
|
||||||
|
* while the client's prompt is still open the signal aborts, the SDK sends
|
||||||
|
* `$/cancel_request`, and the client settles the request (a `cancelled`
|
||||||
|
* outcome or a `requestCancelled` rejection). Either way we surface the same
|
||||||
|
* "Tool use aborted" the callers already expect, so a cancelled dialog no
|
||||||
|
* longer leaves the `await` hanging. */
|
||||||
|
private async requestPermissionFromClient(
|
||||||
|
params: RequestPermissionRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<RequestPermissionResponse> {
|
||||||
|
try {
|
||||||
|
return await this.client.requestPermission(params, signal);
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error("Tool use aborted", { cause: error });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
canUseTool(sessionId: string): CanUseTool {
|
canUseTool(sessionId: string): CanUseTool {
|
||||||
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
||||||
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
||||||
@@ -2489,7 +2531,8 @@ export class ClaudeAcpAgent {
|
|||||||
session.modes.availableModes.some((m) => m.id === o.optionId),
|
session.modes.availableModes.some((m) => m.id === o.optionId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const response = await this.client.requestPermission({
|
const response = await this.requestPermissionFromClient(
|
||||||
|
{
|
||||||
options,
|
options,
|
||||||
sessionId,
|
sessionId,
|
||||||
toolCall: {
|
toolCall: {
|
||||||
@@ -2501,7 +2544,9 @@ export class ClaudeAcpAgent {
|
|||||||
session?.cwd,
|
session?.cwd,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
|
||||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||||
throw new Error("Tool use aborted");
|
throw new Error("Tool use aborted");
|
||||||
@@ -2550,7 +2595,8 @@ export class ClaudeAcpAgent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await this.client.requestPermission({
|
const response = await this.requestPermissionFromClient(
|
||||||
|
{
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
kind: "allow_always",
|
kind: "allow_always",
|
||||||
@@ -2570,7 +2616,9 @@ export class ClaudeAcpAgent {
|
|||||||
session?.cwd,
|
session?.cwd,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
signal,
|
||||||
|
);
|
||||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||||
throw new Error("Tool use aborted");
|
throw new Error("Tool use aborted");
|
||||||
}
|
}
|
||||||
@@ -2624,12 +2672,17 @@ export class ClaudeAcpAgent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.client.unstable_createElicitation(createRequest);
|
const response = await this.client.unstable_createElicitation(createRequest, signal);
|
||||||
if (signal.aborted) {
|
if (signal.aborted) {
|
||||||
return { action: "cancel" };
|
return { action: "cancel" };
|
||||||
}
|
}
|
||||||
return createElicitationResponseToElicitResult(response);
|
return createElicitationResponseToElicitResult(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// A cancellation we requested (signal aborted) settles as a cancel, not
|
||||||
|
// a hard decline — the elicitation was abandoned, not refused.
|
||||||
|
if (signal.aborted) {
|
||||||
|
return { action: "cancel" };
|
||||||
|
}
|
||||||
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
this.logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||||
return { action: "decline" };
|
return { action: "decline" };
|
||||||
}
|
}
|
||||||
@@ -2655,8 +2708,13 @@ export class ClaudeAcpAgent {
|
|||||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID);
|
||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
response = await this.client.unstable_createElicitation(createRequest);
|
response = await this.client.unstable_createElicitation(createRequest, signal);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// A cancellation we requested (signal aborted) settles as an aborted tool
|
||||||
|
// use, matching the post-response check below.
|
||||||
|
if (signal.aborted) {
|
||||||
|
throw new Error("Tool use aborted", { cause: error });
|
||||||
|
}
|
||||||
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||||
}
|
}
|
||||||
@@ -4408,6 +4466,38 @@ export function streamEventToAcpNotifications(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run a `session/prompt` while honoring `$/cancel_request` for it. ACP clients
|
||||||
|
* normally stop a turn with the `session/cancel` notification, but `signal`
|
||||||
|
* (the prompt request's abort signal) also fires when the client sends the
|
||||||
|
* generic `$/cancel_request` for this prompt — the protocol's complementary
|
||||||
|
* cancellation fallback. Route that to the same `agent.cancel` path so a client
|
||||||
|
* using only the generic mechanism still stops the turn (and the prompt
|
||||||
|
* resolves "cancelled" instead of running to completion).
|
||||||
|
*
|
||||||
|
* The listener is scoped to this call: once the prompt settles it is removed,
|
||||||
|
* so a later teardown-time abort of the (per-request) signal can't cancel a
|
||||||
|
* subsequent turn. `signal` also aborts on connection close, in which case
|
||||||
|
* cancelling the in-flight turn is the desired behavior anyway. */
|
||||||
|
export async function runPromptWithCancellation(
|
||||||
|
agent: Pick<ClaudeAcpAgent, "prompt" | "cancel" | "logger">,
|
||||||
|
params: PromptRequest,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<PromptResponse> {
|
||||||
|
const onAbort = () => {
|
||||||
|
// Fire-and-forget: nothing awaits this listener, so swallow (and log) any
|
||||||
|
// rejection rather than surfacing it as an unhandled rejection.
|
||||||
|
agent.cancel({ sessionId: params.sessionId }).catch((error) => {
|
||||||
|
agent.logger.error(`Failed to cancel prompt via $/cancel_request: ${error}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
try {
|
||||||
|
return await agent.prompt(params);
|
||||||
|
} finally {
|
||||||
|
signal.removeEventListener("abort", onAbort);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function runAcp() {
|
export function runAcp() {
|
||||||
const input = nodeToWebWritable(process.stdout);
|
const input = nodeToWebWritable(process.stdout);
|
||||||
const output = nodeToWebReadable(process.stdin);
|
const output = nodeToWebReadable(process.stdin);
|
||||||
@@ -4436,7 +4526,9 @@ export function runAcp() {
|
|||||||
agent.setSessionConfigOption(ctx.params),
|
agent.setSessionConfigOption(ctx.params),
|
||||||
)
|
)
|
||||||
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
.onRequest(methods.agent.authenticate, (ctx) => agent.authenticate(ctx.params))
|
||||||
.onRequest(methods.agent.session.prompt, (ctx) => agent.prompt(ctx.params))
|
.onRequest(methods.agent.session.prompt, (ctx) =>
|
||||||
|
runPromptWithCancellation(agent, ctx.params, ctx.signal),
|
||||||
|
)
|
||||||
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
.onNotification(methods.agent.session.cancel, (ctx) => agent.cancel(ctx.params))
|
||||||
.connect(stream);
|
.connect(stream);
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
messageIdForGrouping,
|
messageIdForGrouping,
|
||||||
buildConfigOptions,
|
buildConfigOptions,
|
||||||
discoverCustomAgents,
|
discoverCustomAgents,
|
||||||
|
runPromptWithCancellation,
|
||||||
type AcpClient,
|
type AcpClient,
|
||||||
type SDKMessageFilter,
|
type SDKMessageFilter,
|
||||||
} from "../acp-agent.js";
|
} from "../acp-agent.js";
|
||||||
@@ -1783,6 +1784,148 @@ describe("permission requests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("permission request cancellation", () => {
|
||||||
|
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||||
|
function* empty() {}
|
||||||
|
const gen = Object.assign(empty(), { interrupt: vi.fn(), close: vi.fn() });
|
||||||
|
agent.sessions[sessionId] = {
|
||||||
|
query: gen as any,
|
||||||
|
input: new Pushable(),
|
||||||
|
cancelled: false,
|
||||||
|
cwd: "/test",
|
||||||
|
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||||
|
modes: { currentModeId: "default", availableModes: [] },
|
||||||
|
models: { currentModelId: "default", availableModels: [] },
|
||||||
|
modelInfos: [],
|
||||||
|
settingsManager: { dispose: vi.fn() } as any,
|
||||||
|
accumulatedUsage: {
|
||||||
|
inputTokens: 0,
|
||||||
|
outputTokens: 0,
|
||||||
|
cachedReadTokens: 0,
|
||||||
|
cachedWriteTokens: 0,
|
||||||
|
},
|
||||||
|
configOptions: [],
|
||||||
|
agents: [],
|
||||||
|
currentAgent: "default",
|
||||||
|
abortController: new AbortController(),
|
||||||
|
emitRawSDKMessages: false,
|
||||||
|
contextWindowSize: 200000,
|
||||||
|
taskState: new Map(),
|
||||||
|
toolUseCache: {},
|
||||||
|
messageIdToUuid: new Map(),
|
||||||
|
} as any;
|
||||||
|
return agent.sessions[sessionId]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
it("forwards the tool-call signal so a pending permission request is cancelled on abort", async () => {
|
||||||
|
let receivedSignal: AbortSignal | undefined;
|
||||||
|
const mockClient = {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
// A `$/cancel_request`-aware client settles the request once the agent
|
||||||
|
// aborts it; model that by rejecting when the forwarded signal fires.
|
||||||
|
requestPermission: (_params: RequestPermissionRequest, signal?: AbortSignal) => {
|
||||||
|
receivedSignal = signal;
|
||||||
|
return new Promise<RequestPermissionResponse>((_resolve, reject) => {
|
||||||
|
signal?.addEventListener("abort", () => reject(new Error("Request cancelled")), {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
} as unknown as AcpClient;
|
||||||
|
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||||
|
injectSession(agent, "session-1");
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const pending = agent.canUseTool("session-1")("Bash", { command: "ls" }, {
|
||||||
|
signal: controller.signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "tool-1",
|
||||||
|
} as any);
|
||||||
|
// Let canUseTool reach the awaited requestPermission before cancelling.
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
// The tool-call signal is threaded through as the cancellation signal.
|
||||||
|
expect(receivedSignal).toBe(controller.signal);
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(pending).rejects.toThrow("Tool use aborted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a cancelled permission outcome as an aborted tool use", async () => {
|
||||||
|
const mockClient = {
|
||||||
|
sessionUpdate: async () => {},
|
||||||
|
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||||
|
} as unknown as AcpClient;
|
||||||
|
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||||
|
injectSession(agent, "session-1");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
agent.canUseTool("session-1")("Bash", { command: "ls" }, {
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
suggestions: [],
|
||||||
|
toolUseID: "tool-1",
|
||||||
|
} as any),
|
||||||
|
).rejects.toThrow("Tool use aborted");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("runPromptWithCancellation", () => {
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("cancels the in-flight prompt when the request signal aborts ($/cancel_request)", async () => {
|
||||||
|
const promptResult = deferred<PromptResponse>();
|
||||||
|
const cancel = vi.fn(async () => {});
|
||||||
|
const agent = {
|
||||||
|
prompt: vi.fn(() => promptResult.promise),
|
||||||
|
cancel,
|
||||||
|
logger: { log: () => {}, error: () => {} },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const params = { sessionId: "session-1", prompt: [] } as any;
|
||||||
|
const pending = runPromptWithCancellation(agent, params, controller.signal);
|
||||||
|
|
||||||
|
// No cancel yet — the turn is running.
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Client sends $/cancel_request -> the SDK aborts this request's signal.
|
||||||
|
controller.abort();
|
||||||
|
expect(cancel).toHaveBeenCalledWith({ sessionId: "session-1" });
|
||||||
|
|
||||||
|
// The prompt settles "cancelled" through the normal cancel path.
|
||||||
|
promptResult.resolve({ stopReason: "cancelled" });
|
||||||
|
await expect(pending).resolves.toEqual({ stopReason: "cancelled" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not cancel after the prompt settles normally", async () => {
|
||||||
|
const promptResult = deferred<PromptResponse>();
|
||||||
|
const cancel = vi.fn(async () => {});
|
||||||
|
const agent = {
|
||||||
|
prompt: vi.fn(() => promptResult.promise),
|
||||||
|
cancel,
|
||||||
|
logger: { log: () => {}, error: () => {} },
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const params = { sessionId: "session-1", prompt: [] } as any;
|
||||||
|
const pending = runPromptWithCancellation(agent, params, controller.signal);
|
||||||
|
|
||||||
|
promptResult.resolve({ stopReason: "end_turn" });
|
||||||
|
await expect(pending).resolves.toEqual({ stopReason: "end_turn" });
|
||||||
|
|
||||||
|
// A late abort (e.g. per-request signal cleanup) must not cancel a later turn.
|
||||||
|
controller.abort();
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("stop reason propagation", () => {
|
describe("stop reason propagation", () => {
|
||||||
function createMockAgent() {
|
function createMockAgent() {
|
||||||
const mockClient = {
|
const mockClient = {
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "@tsconfig/node22/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2021",
|
|
||||||
"module": "NodeNext",
|
|
||||||
"moduleResolution": "NodeNext",
|
|
||||||
"lib": ["ES2021"],
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user