From 59e6232784a7b7f5147e2b2adc6831d9cc589dd9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 26 Aug 2026 09:53:41 +0200 Subject: [PATCH] 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. --- .../agent/providers/claude/agent.test.ts | 80 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 14 +++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 4023079541..5e860732bc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -24,6 +24,12 @@ interface TestClaudeSession { close(): Promise; } +function isPermissionResolvedEvent( + event: AgentStreamEvent, +): event is Extract { + 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({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index fb9726e127..8e88972fd7 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 }, + }); } }