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 }, + }); } }