diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fb17c34713fd8..d271e65114ec0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -9044,6 +9044,96 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + 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; + 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!(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"); + + // The turn can end right after the withdrawal, before the callback returns. + withdrawal.abort(); + 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); + 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") => + 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 }] + : 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,