diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 18a16e2f63102..c1e64efb878dd 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3270,6 +3270,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly cache_read_input_tokens: number; }; readonly modelUsage?: SDKResultMessage["modelUsage"]; + readonly permissionDenials?: SDKResultMessage["permission_denials"]; }) => claudeSdkFrame({ type: "result", @@ -3288,7 +3289,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, modelUsage: input.modelUsage ?? {}, - permission_denials: [], + permission_denials: input.permissionDenials ?? [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, ...(input.origin === undefined ? {} : { origin: input.origin }), @@ -7368,6 +7369,117 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("does not report a turn the CLI's permission check blocked as completed", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-cli-permission-denial"), + text: "Run printf T3_DENIAL_PROBE_ok.", + attachments: [], + }), + ); + yield* harness.offerAndWait(wakeTaskStarted); + // Auto mode denies inside the CLI, so T3 never sees a permission request; + // the denial only shows up on the result. + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000697", + result: "The Bash command was denied, so I stopped.", + permissionDenials: [ + { + tool_name: "Bash", + tool_use_id: "toolu_cli_denied_bash", + tool_input: { command: "printf T3_DENIAL_PROBE_ok" }, + }, + ], + }), + ); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.notEqual(terminal.status, "completed"); + assert.equal(terminal.failure?.class, "permission_error"); + assert.include(terminal.failure?.message, "Bash: printf T3_DENIAL_PROBE_ok"); + // The process still runs its background shell, so the run and the turn keep tracking it. + assert.isTrue(terminal.status === "failed" && terminal.childTurnsContinue); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("completes a turn whose only denial the user declined in T3", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("attempt-claude-host-declined"), + text: "Run git status.", + attachments: [], + }), + ); + const decision = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "Bash", + { command: "git status" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_host_declined_bash", + requestId: "request-host-declined-bash", + }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decision))?.behavior, "deny"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000698", + result: "You declined git status, so I stopped.", + permissionDenials: [ + { + tool_name: "Bash", + tool_use_id: "toolu_host_declined_bash", + tool_input: { command: "git status" }, + }, + ], + }), + ); + + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each([ { name: "an unflagged result with surrounding whitespace", diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 16a697a7e1353..2a52bb6c1b19f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4430,6 +4430,34 @@ function claudeRewrittenVersion( : undefined; } +// Auto mode's classifier and deny rules refuse tool calls inside the CLI, so +// T3 never sees a request; the result's permission_denials is the only record. +function cliPermissionDenialFailure( + message: SDKResultMessage, + hostDecidedToolUseIds: ReadonlySet, +): OrchestrationV2ProviderFailure | null { + const denied = message.permission_denials.filter( + (denial) => !hostDecidedToolUseIds.has(denial.tool_use_id), + ); + if (denied.length === 0) { + return null; + } + return makeProviderFailure({ + class: "permission_error", + code: "permission_denied", + retryable: false, + message: [ + "Claude's permission settings blocked:", + ...denied.map((denial) => + summarizeClaudeToolRequest( + denial.tool_name, + claudeNativeToolInputFromRecord(denial.tool_input), + ), + ), + ].join("\n"), + }); +} + function providerFailureFromResult( message: SDKResultMessage, receivedAt: DateTime.Utc, @@ -4765,6 +4793,8 @@ interface ActiveClaudeTurnContext { >; // Claude may settle a turn with a result that echoes only a steer's uuid. readonly steerUuids: Set; + // Tool calls T3's canUseTool decided; the result's permission_denials also lists those. + readonly hostDecidedToolUseIds: Set; // Claude can run another model than requested, such as when an organization disallows it. executedModel: string | undefined; } @@ -8634,9 +8664,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( >; readonly completedAt: DateTime.Utc; readonly failure?: OrchestrationV2ProviderFailure; + // Reports the turn as failed without the cleanup a stopped turn gets: the process and its background work live on. + readonly reportedFailure?: OrchestrationV2ProviderFailure; readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { + const reportedStatus = input.reportedFailure === undefined ? input.status : "failed"; input.context.steersClosed = true; yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); if (input.status === "completed") { @@ -8836,7 +8869,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const threadDisposition = input.threadDisposition ?? "reusable"; const terminalEvent: ProviderAdapter.ProviderAdapterV2Event = - input.status === "failed" + reportedStatus === "failed" ? { type: "turn.terminal", driver: CLAUDE_PROVIDER, @@ -8847,14 +8880,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( input.context, `terminal-failure:${input.context.providerTurnId}`, ), - status: input.status, - failure: input.failure ?? makeProviderFailure({ class: "provider_error" }), + status: reportedStatus, + failure: + input.reportedFailure ?? + input.failure ?? + makeProviderFailure({ class: "provider_error" }), ...(providerRetry === undefined ? {} : { retry: providerRetry.retry, retryStartedAt: providerRetry.startedAt, }), + ...(input.reportedFailure === undefined ? {} : { childTurnsContinue: true }), threadDisposition, } : { @@ -8863,7 +8900,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerThreadId: input.context.input.providerThread.id, providerTurnId: input.context.providerTurnId, runOrdinal: input.context.input.runOrdinal, - status: input.status, + status: reportedStatus, failure: null, threadDisposition, }; @@ -8875,7 +8912,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( providerTurn: { ...providerTurnPayload({ context: input.context, - status: input.status, + status: reportedStatus, completedAt: input.completedAt, }), turnTokenUsage: normalizeClaudeTurnTokenUsage( @@ -11226,20 +11263,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( failureHint, usageLimited, ); + const resultStatus = interrupted + ? "interrupted" + : limitedCompaction !== null + ? "failed" + : terminalStatusFromResult(message, failureHint); + const permissionDenialFailure = + resultStatus === "completed" + ? cliPermissionDenialFailure(message, context.hostDecidedToolUseIds) + : null; const terminalFailure = resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; yield* finalizeActiveTurn({ context, - status: interrupted - ? "interrupted" - : limitedCompaction !== null - ? "failed" - : terminalStatusFromResult(message, failureHint), + status: resultStatus, completedAt, result: message, ...(terminalFailure === null ? {} : { failure: terminalFailure }), + ...(permissionDenialFailure === null + ? {} + : { reportedFailure: permissionDenialFailure }), }); } }); @@ -11475,6 +11520,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } const nativeRequestId = callbackOptions.toolUseID; + context.hostDecidedToolUseIds.add(nativeRequestId); const nativeToolInput = claudeNativeToolInputFromRecord(toolInput); const heldForEcho = context.heldRootFrames.length > 0; const startToolCall = toolName !== "Agent" && options?.startToolCall !== false; @@ -12479,6 +12525,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( heldRootFrames: [], pendingSteers: new Map(), steerUuids: new Set(), + hostDecidedToolUseIds: new Set(), executedModel: undefined, }; const attachTurn = Effect.gen(function* () {