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,