From f841c28cfbcb9390117e861f72da99fbb1a0cda0 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 03:09:08 +0100 Subject: [PATCH 1/3] fix(claude): approval cards close when Claude withdraws the request Fixes #27282 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 71 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 36 ++++++++++ 2 files changed, 107 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fb17c34713fd8..71d66ac544d8e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9044,6 +9044,77 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("closes an approval card when Claude withdraws its permission request", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-withdrawn-approval"), + text: "Run the probe.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }), + }), + ); + // The CLI cancels a request once a PermissionRequest hook has answered it. + // @effect-diagnostics-next-line abortControllerInEffect:off - Claude, not the test fiber, withdraws the request. + const withdrawal = new AbortController(); + const permission = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "echo probe" }, + { + signal: withdrawal.signal, + toolUseID: "toolu_01WithdrawnApproval", + requestId: "request-withdrawn-approval", + }, + ), + ).pipe(Effect.forkScoped); + const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; + assert.equal(pending.status, "pending"); + + withdrawal.abort(); + + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + // Events arrive in order, so the turn's end follows anything the withdrawal emitted. + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000790", result: "Done." }), + user_message_uuid: harness.promptUuid(0), + }), + ); + yield* harness.awaitTerminal(1); + const statusOf = (type: "runtime_request.updated" | "node.updated" | "turn_item.updated") => + harness.events + .flatMap((event): Array<{ readonly type: string; readonly status: string }> => + event.type === "runtime_request.updated" && event.runtimeRequest.id === pending.id + ? [{ type: event.type, status: event.runtimeRequest.status }] + : event.type === "node.updated" && event.node.id === pending.nodeId + ? [{ type: event.type, status: event.node.status }] + : event.type === "turn_item.updated" && event.turnItem.nodeId === pending.nodeId + ? [{ type: event.type, status: event.turnItem.status }] + : [], + ) + .findLast((entry) => entry.type === type)?.status; + assert.equal(statusOf("runtime_request.updated"), "cancelled"); + assert.equal(statusOf("node.updated"), "cancelled"); + assert.equal(statusOf("turn_item.updated"), "cancelled"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("releases output held for a prompt echo when the stream ends", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 496a0b2e77d0f..d7ce5b815f5f4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7095,6 +7095,40 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return { node, request, turnItem }; }); + // Claude withdraws a permission request it no longer needs, for example + // when a PermissionRequest hook answered it. Close the request and its + // card, which would otherwise wait for an answer nothing reads. + const cancelWithdrawnRequest = Effect.fnUntraced(function* ( + artifacts: Effect.Success>, + ) { + const now = yield* DateTime.now; + yield* Effect.all( + [ + emitProviderEvent({ + type: "runtime_request.updated", + driver: CLAUDE_PROVIDER, + runtimeRequest: { ...artifacts.request, status: "cancelled", resolvedAt: now }, + }), + emitProviderEvent({ + type: "node.updated", + driver: CLAUDE_PROVIDER, + node: { ...artifacts.node, status: "cancelled", completedAt: now }, + }), + emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + ...artifacts.turnItem, + status: "cancelled", + completedAt: now, + updatedAt: now, + }, + }), + ], + { concurrency: 1 }, + ); + }); + const ensureReasoningBlock = Effect.fnUntraced(function* ( context: ActiveClaudeTurnContext, itemId: string, @@ -9542,6 +9576,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); + if (!(yield* Deferred.isDone(answers))) yield* cancelWithdrawnRequest(artifacts); return callbackOptions.signal.aborted ? ({ behavior: "deny", @@ -9661,6 +9696,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), ), ); + if (!(yield* Deferred.isDone(decision))) yield* cancelWithdrawnRequest(artifacts); return permissionResultFromDecision({ toolName, From 26d683451cad4feddaa66eea1ca338218079e181 Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 11:53:17 +0100 Subject: [PATCH 2/3] test(claude): cover a turn ending right after Claude withdraws a request Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 71d66ac544d8e..42047a1f52fb9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9080,10 +9080,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; assert.equal(pending.status, "pending"); + // The turn can end right after the withdrawal, before the callback returns. withdrawal.abort(); - - assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); - // Events arrive in order, so the turn's end follows anything the withdrawal emitted. yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ @@ -9092,8 +9090,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* harness.awaitTerminal(1); + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + // The cancellation has to land before the turn's end finalizes the run. + const beforeTerminal = harness.events.slice( + 0, + harness.events.findIndex((event) => event.type === "turn.terminal"), + ); const statusOf = (type: "runtime_request.updated" | "node.updated" | "turn_item.updated") => - harness.events + beforeTerminal .flatMap((event): Array<{ readonly type: string; readonly status: string }> => event.type === "runtime_request.updated" && event.runtimeRequest.id === pending.id ? [{ type: event.type, status: event.runtimeRequest.status }] From 2f7a0a221c832148c136bd59b6cebb8ed20a1c8d Mon Sep 17 00:00:00 2001 From: ashx-j Date: Fri, 30 Oct 2026 12:10:50 +0100 Subject: [PATCH 3/3] test(claude): cover a withdrawn AskUserQuestion request Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 42047a1f52fb9..d271e65114ec0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9044,7 +9044,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("closes an approval card when Claude withdraws its permission request", () => + it.effect.each([ + { card: "an approval", toolName: "Bash", toolInput: { command: "echo probe" } }, + { + card: "a question", + toolName: "AskUserQuestion", + toolInput: { + questions: [ + { + header: "Target", + question: "Where should this deploy?", + options: [ + { label: "Production", description: "Deploy to production." }, + { label: "Staging", description: "Deploy to staging." }, + ], + multiSelect: false, + }, + ], + }, + }, + ])("closes $card card when Claude withdraws its request", ({ toolName, toolInput }) => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -9067,15 +9086,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { // @effect-diagnostics-next-line abortControllerInEffect:off - Claude, not the test fiber, withdraws the request. const withdrawal = new AbortController(); const permission = yield* Effect.promise(() => - harness.getOpenedOptions()!.canUseTool!( - "Bash", - { command: "echo probe" }, - { - signal: withdrawal.signal, - toolUseID: "toolu_01WithdrawnApproval", - requestId: "request-withdrawn-approval", - }, - ), + harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, { + signal: withdrawal.signal, + toolUseID: "toolu_01WithdrawnApproval", + requestId: "request-withdrawn-approval", + }), ).pipe(Effect.forkScoped); const pending = (yield* Queue.take(harness.runtimeRequestReceipts)).runtimeRequest; assert.equal(pending.status, "pending");