From 64416cb14a71b4fe03fde9fa26ac746fb46be5c0 Mon Sep 17 00:00:00 2001 From: Derek Trimm <275381468+derektrimm@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:43:06 -0500 Subject: [PATCH] fix(server): close Claude approval cards the CLI withdraws Claude Code sends T3 its permission request at the same time as it runs PermissionRequest hooks (and Auto mode's classifier), then cancels T3's request when one of them answers first. The adapter returned, but the card it had drawn stayed pending: Approve failed with "No pending Claude runtime request", and until the server restarted the pending request kept the thread from being settled, snoozed, resumed or idle-compacted. When the request is aborted before anyone answered it, the adapter now marks its request, node and turn item cancelled, as the question path in --- .../Adapters/ClaudeAdapterV2.test.ts | 123 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 25 ++++ 2 files changed, 148 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5dfbffa595908..96cfcc83c707d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3280,6 +3280,129 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + describe("approvals Claude withdraws", () => { + const startFullAccessTurn = Effect.fn("startFullAccessTurn")(function* ( + harness: Effect.Success, + attempt: string, + ) { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(attempt), + text: "Clear the render folder.", + attachments: [], + }), + ); + }); + + const openApproval = Effect.fn("openApproval")(function* ( + harness: Effect.Success, + toolUseID: string, + ) { + const controller = new AbortController(); + const callback = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "F=$(cat dir.txt); rm -f $F/*" }, + { + signal: controller.signal, + toolUseID, + requestId: `${toolUseID}-request`, + decisionReason: "Dangerous rm operation on possibly-empty variable path", + }, + ), + ).pipe(Effect.forkScoped); + let opened = yield* Queue.take(harness.runtimeRequestReceipts); + while (opened.runtimeRequest.nativeRequestRef?.nativeId !== toolUseID) { + opened = yield* Queue.take(harness.runtimeRequestReceipts); + } + assert.equal(opened.runtimeRequest.kind, "command"); + assert.equal(opened.runtimeRequest.status, "pending"); + return { controller, callback, request: opened.runtimeRequest }; + }); + + // Events reach the harness in emission order, so once a later card has arrived every + // update the adapter made to an earlier one has too. + const updatesAfterBarrier = Effect.fn("updatesAfterBarrier")(function* ( + harness: Effect.Success, + request: Effect.Success>["request"], + ) { + yield* openApproval(harness, "barrier-tool"); + return harness.events.flatMap((event) => { + if (event.type === "runtime_request.updated") { + return event.runtimeRequest.id === request.id + ? [`request:${event.runtimeRequest.status}`] + : []; + } + if (event.type === "node.updated") { + return event.node.id === request.nodeId ? [`node:${event.node.status}`] : []; + } + if (event.type === "turn_item.updated" && event.turnItem.type === "approval_request") { + return event.turnItem.requestId === request.id ? [`item:${event.turnItem.status}`] : []; + } + return []; + }); + }); + + // A PermissionRequest hook or Auto mode's classifier answers while T3's card is open, and + // the CLI withdraws the request it sent T3. + it.effect("closes the card when the CLI cancels its request", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* startFullAccessTurn(harness, "attempt-withdrawn-approval"); + const approval = yield* openApproval(harness, "withdrawn-tool"); + + approval.controller.abort(); + assert.equal((yield* Fiber.join(approval.callback))?.behavior, "deny"); + assert.deepEqual(yield* updatesAfterBarrier(harness, approval.request), [ + "node:waiting", + "request:pending", + "item:waiting", + "request:cancelled", + "node:cancelled", + "item:cancelled", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("leaves a card the user answered to the user's decision", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* startFullAccessTurn(harness, "attempt-cancelled-by-user"); + const approval = yield* openApproval(harness, "cancelled-by-user-tool"); + + yield* harness.runtime.respondToRuntimeRequest({ + requestId: approval.request.id, + decision: "cancel", + }); + assert.deepEqual(yield* Fiber.join(approval.callback), { + behavior: "deny", + message: "User cancelled tool execution.", + toolUseID: "cancelled-by-user-tool", + decisionClassification: "user_reject", + interrupt: true, + }); + // The interrupt the answer asked for then aborts the answered request. + approval.controller.abort(); + assert.deepEqual(yield* updatesAfterBarrier(harness, approval.request), [ + "node:waiting", + "request:pending", + "item:waiting", + ]); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); + describe("Claude settings restrictions", () => { it.effect("resolves cached managed policy from the configured home through the real SDK", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index e15d52e07f1c3..6e1314fd3fc7b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -9388,6 +9388,31 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); + // The CLI withdrew the request (a PermissionRequest hook or Auto mode answered it), + // so nobody can answer this card any more. + if (!(yield* Deferred.isDone(decision))) { + const resolvedAt = yield* DateTime.now; + yield* emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt }, + }); + yield* emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { ...artifacts.node, status: "cancelled", completedAt: resolvedAt }, + }); + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status: "cancelled", + completedAt: resolvedAt, + updatedAt: resolvedAt, + }, + }); + } return permissionResultFromDecision({ toolName,