fix(server): resolve canceled permission requests (#3792)

Provider callbacks can be canceled without a user response. Publish a terminal resolution for that path so every surfaced request has a matching lifecycle event, while preserving the existing interruption cleanup.
This commit is contained in:
Mohamed Boudra
2026-08-26 09:53:41 +02:00
committed by GitHub
parent d46267aed6
commit 59e6232784
2 changed files with 93 additions and 1 deletions
@@ -24,6 +24,12 @@ interface TestClaudeSession {
close(): Promise<void>;
}
function isPermissionResolvedEvent(
event: AgentStreamEvent,
): event is Extract<AgentStreamEvent, { type: "permission_resolved" }> {
return event.type === "permission_resolved";
}
afterEach(() => {
vi.restoreAllMocks();
});
@@ -629,6 +635,80 @@ describe("ClaudeAgentSession features", () => {
return { queryFactory, queryMock, launches };
}
test("publishes a resolution when the SDK aborts a permission callback", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
try {
await session.startTurn("run the tool");
const canUseTool = queryFactory.mock.calls[0]?.[0].options.canUseTool;
if (!canUseTool) throw new Error("Expected canUseTool callback");
const abort = new AbortController();
const permission = canUseTool(
"Bash",
{ command: "printf test" },
{ signal: abort.signal, toolUseID: "tool-aborted" },
);
abort.abort();
await expect(permission).rejects.toThrow("Permission request aborted");
expect(events.find(isPermissionResolvedEvent)).toMatchObject({
type: "permission_resolved",
provider: "claude",
requestId: expect.any(String),
resolution: { behavior: "deny", message: "Permission request canceled" },
});
expect(session.getPendingPermissions()).toEqual([]);
} finally {
unsubscribe();
await session.close();
}
});
test("does not duplicate a resolution when interruption later aborts the SDK callback", async () => {
const { queryFactory } = createQueryMock();
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" });
const events: AgentStreamEvent[] = [];
const unsubscribe = session.subscribe((event) => events.push(event));
try {
await session.startTurn("run the tool");
const canUseTool = queryFactory.mock.calls[0]?.[0].options.canUseTool;
if (!canUseTool) throw new Error("Expected canUseTool callback");
const abort = new AbortController();
const permission = canUseTool(
"Bash",
{ command: "printf test" },
{ signal: abort.signal, toolUseID: "tool-interrupted" },
);
await session.interrupt();
abort.abort();
await expect(permission).rejects.toThrow("Permission request canceled");
expect(events.filter(isPermissionResolvedEvent)).toEqual([
expect.objectContaining({
provider: "claude",
resolution: { behavior: "deny", message: "Permission request canceled" },
}),
]);
expect(session.getPendingPermissions()).toEqual([]);
} finally {
unsubscribe();
await session.close();
}
});
test("passes exact configured Fable 5 IDs through to Claude Code", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -2232,7 +2232,7 @@ class ClaudeAgentSession implements AgentSession {
if (this.cancelCurrentTurn === requestCancel) {
this.cancelCurrentTurn = null;
}
this.rejectAllPendingPermissions(new Error("Permission request aborted"));
this.rejectAllPendingPermissions(new Error("Permission request canceled"));
this.finishForegroundTurn({
type: "turn_canceled",
provider: "claude",
@@ -4635,6 +4635,12 @@ class ClaudeAgentSession implements AgentSession {
const abortHandler = () => {
this.pendingPermissions.delete(requestId);
cleanup();
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId,
resolution: { behavior: "deny", message: "Permission request canceled" },
});
reject(new Error("Permission request aborted"));
};
@@ -4775,6 +4781,12 @@ class ClaudeAgentSession implements AgentSession {
pending.cleanup?.();
pending.reject(error);
this.pendingPermissions.delete(id);
this.pushEvent({
type: "permission_resolved",
provider: "claude",
requestId: id,
resolution: { behavior: "deny", message: error.message },
});
}
}