From 2920d613515be0565f26060303afef2a1bd4c31b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:20:04 +0200 Subject: [PATCH] fix(claude): accept permission responses from event subscribers Register complete pending permission state before publishing the request, so a synchronous subscriber can answer it without a microtask deferral. --- .../agent.permission-events.real.e2e.test.ts | 50 ++++++++++++ .../claude/agent.permission-events.test.ts | 79 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 17 ++-- 3 files changed, 141 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.permission-events.real.e2e.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/agent.permission-events.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.permission-events.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.permission-events.real.e2e.test.ts new file mode 100644 index 0000000000..23be39d9bd --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.permission-events.real.e2e.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { ClaudeAgentClient } from "./agent.js"; +import { withTimeout } from "../../../../utils/promise-timeout.js"; + +// Explicit real-provider coverage, separate from the default unit suite. +test("answers a real plan approval synchronously from the permission subscriber", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "paseo-permission-event-")); + const session = await new ClaudeAgentClient({ logger: pino({ level: "silent" }) }).createSession( + { provider: "claude", cwd, model: "haiku", modeId: "plan" }, + undefined, + { persistSession: false }, + ); + let requestName: string | undefined; + let complete!: () => void; + let fail!: (error: unknown) => void; + const responded = new Promise((resolve, reject) => { + complete = resolve; + fail = reject; + }); + void responded.catch(() => undefined); + session.subscribe((event) => { + if (event.type === "permission_requested") { + requestName = event.request.name; + void session + .respondToPermission(event.request.id, { + behavior: "deny", + message: "End verification here", + }) + .then(complete, fail); + } + if (event.type === "turn_failed") fail(new Error(JSON.stringify(event))); + }); + try { + await session.startTurn( + "Make a one-line plan to create hello.txt containing hello, then call ExitPlanMode now to request approval. Do not use other tools or ask questions.", + ); + await expect( + withTimeout(responded, 90_000, "No permission within 90 seconds"), + ).resolves.toBeUndefined(); + expect(requestName).toBe("ExitPlanMode"); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await session.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 120_000); diff --git a/packages/server/src/server/agent/providers/claude/agent.permission-events.test.ts b/packages/server/src/server/agent/providers/claude/agent.permission-events.test.ts new file mode 100644 index 0000000000..b6a676f229 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.permission-events.test.ts @@ -0,0 +1,79 @@ +import { expect, test } from "vitest"; +import type { CanUseTool, Query } from "@anthropic-ai/claude-agent-sdk"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import { ClaudeAgentClient } from "./agent.js"; + +// Drive the SDK's permission callback through the injected query dependency. +// Subscribers and permission state use the real AgentSession implementation. +test.each([ + ["Bash", { command: "echo permission" }], + ["AskUserQuestion", { questions: [{ question: "Proceed?", header: "Proceed" }] }], + ["ExitPlanMode", { plan: "Implement the plan" }], +])("a subscriber can immediately deny %s", async (toolName, input) => { + let requestPermission: CanUseTool | undefined; + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const query = { + async next() { + await finished; + return { done: true, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + close: finish, + }; + const session = await new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude", + queryFactory: ({ options }) => { + requestPermission = options.canUseTool; + return query as Query; + }, + }).createSession({ provider: "claude", cwd: process.cwd(), modeId: "default" }); + const events: AgentStreamEvent[] = []; + let response: Promise | undefined; + let pendingAtPublication: string[] = []; + session.subscribe((event) => { + events.push(event); + if (event.type === "permission_requested") { + pendingAtPublication = session.getPendingPermissions().map((request) => request.id); + response = session.respondToPermission(event.request.id, { + behavior: "deny", + message: "Denied immediately", + }); + void response.catch(() => undefined); + } + }); + try { + await session.startTurn("Request permission"); + if (!requestPermission) throw new Error("SDK permission callback was not configured"); + const result = requestPermission(toolName, input, { + signal: new AbortController().signal, + toolUseID: "permission-tool-call", + }); + void result.catch(() => undefined); + expect(response).toBeDefined(); + await expect(response).resolves.toBeUndefined(); + await expect(result).resolves.toMatchObject({ + behavior: "deny", + message: "Denied immediately", + }); + const requested = events.find((event) => event.type === "permission_requested"); + if (requested?.type !== "permission_requested") throw new Error("No permission event"); + expect(pendingAtPublication).toContain(requested.request.id); + expect(session.getPendingPermissions()).toEqual([]); + expect( + events + .filter( + (event) => event.type === "permission_requested" || event.type === "permission_resolved", + ) + .map((event) => event.type), + ).toEqual(["permission_requested", "permission_resolved"]); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 681988a813..22b0fd864e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -5072,13 +5072,16 @@ class ClaudeAgentSession implements AgentSession { metadata: Object.keys(metadata).length ? metadata : undefined, }; - this.pushEvent({ - type: "permission_requested", - provider: "claude", - request, - }); + const publishRequest = () => { + this.pushEvent({ + type: "permission_requested", + provider: "claude", + request, + }); + }; if (this.permissionClearingSteerUuids.size > 0) { + publishRequest(); return this.resolveDeniedPermission(request, { behavior: "deny", message: STEER_SUPERSEDED_PERMISSION_MESSAGE, @@ -5112,6 +5115,7 @@ class ClaudeAgentSession implements AgentSession { if (options?.signal) { if (options.signal.aborted) { + publishRequest(); abortHandler(); return; } @@ -5125,6 +5129,9 @@ class ClaudeAgentSession implements AgentSession { reject, cleanup, }); + // Subscribers can respond synchronously, so the request and its abort + // cleanup must be installed before it becomes visible. + publishRequest(); }); };