Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 2920d61351 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.
2026-10-23 12:20:04 +02:00
3 changed files with 141 additions and 5 deletions
@@ -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<void>((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);
@@ -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<void>((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<void> | 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();
}
});
@@ -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();
});
};