diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 648817534ef7c..96bce24c5ab91 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2234,6 +2234,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly apiErrorStatus?: number; // null omits the field, as the CLI does on a zero-turn result. readonly terminalReason?: SDKResultMessage["terminal_reason"] | null; + readonly permissionDenials?: SDKResultMessage["permission_denials"]; readonly usage?: { readonly input_tokens: number; readonly output_tokens: number; @@ -2258,7 +2259,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { cache_read_input_tokens: 0, }, modelUsage: {}, - permission_denials: [], + permission_denials: input.permissionDenials ?? [], uuid: input.uuid, session_id: WAKE_NATIVE_SESSION, ...(input.origin === undefined ? {} : { origin: input.origin }), @@ -2372,6 +2373,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2473,6 +2478,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } }), ), Effect.forkScoped, @@ -2509,6 +2517,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -4258,6 +4267,234 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + describe("permission denials Claude Code made on its own", () => { + const AUTO_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", + }); + const APPROVAL_RUNTIME_POLICY = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }); + const CLASSIFIER_REASON = + "Permission for this action has been denied. Reason: cannot assess the action due to earlier conversation content."; + + const makeDenialHarness = Effect.gen(function* () { + const harness = yield* makeWakeHarness; + let sequence = 900; + let turn = 0; + const nextUuid = () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`; + const startTurn = Effect.fnUntraced(function* ( + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, + ) { + turn++; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`attempt-claude-denials-${turn}`), + providerTurnOrdinal: turn, + text: "Run the checks.", + attachments: [], + runtimePolicy, + }), + ); + }); + // A tool call Claude made and the failed tool_result it got back. + const failedToolCall = Effect.fnUntraced(function* (input: { + readonly toolUseId: string; + readonly toolName: string; + readonly error: string; + }) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + message: { + id: `msg_${nextUuid()}`, + model: "claude-sonnet-4-6", + type: "message", + role: "assistant", + content: [{ type: "tool_use", id: input.toolUseId, name: input.toolName, input: {} }], + stop_reason: "tool_use", + stop_sequence: null, + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: input.toolUseId, + content: input.error, + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: nextUuid(), + session_id: WAKE_NATIVE_SESSION, + }), + ); + }); + const finishTurn = Effect.fnUntraced(function* ( + denials: SDKResultMessage["permission_denials"], + ) { + yield* harness.offerAndWait( + makeResultFrame({ + uuid: nextUuid(), + result: "I could not run the checks.", + permissionDenials: denials, + }), + ); + return yield* Queue.take(harness.terminalReceipts); + }); + const notices = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem] + : [], + ); + const canUseTool = ( + toolName: string, + toolInput: Record, + toolUseID: string, + signal = new AbortController().signal, + ) => + Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!(toolName, toolInput, { + signal, + toolUseID, + requestId: `request-${toolUseID}`, + }), + ); + return { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool }; + }); + + it.effect( + "flags classifier denials with their reason, and only on the turn that had them", + () => + Effect.gen(function* () { + const { startTurn, failedToolCall, finishTurn, notices } = yield* makeDenialHarness; + yield* startTurn(AUTO_RUNTIME_POLICY); + for (const toolUseId of ["toolu-classifier-1", "toolu-classifier-2"]) { + yield* failedToolCall({ toolUseId, toolName: "Bash", error: CLASSIFIER_REASON }); + } + const terminal = yield* finishTurn([ + { tool_name: "Bash", tool_use_id: "toolu-classifier-1", tool_input: { command: "ls" } }, + { + tool_name: "Bash", + tool_use_id: "toolu-classifier-2", + tool_input: { command: "pwd" }, + }, + ]); + + assert.equal(terminal.status, "completed"); + const [notice, ...rest] = notices(); + assert.lengthOf(rest, 0); + assert.equal(notice?.type, "system_notice"); + if (notice?.type !== "system_notice") return; + assert.equal(notice.message, `Auto mode denied 2 actions (Bash): ${CLASSIFIER_REASON}`); + assert.isTrue(notice.needsAttention); + assert.equal(notice.providerTurnId, terminal.providerTurnId); + + // The next turn has no denials, so it adds nothing. + yield* startTurn(AUTO_RUNTIME_POLICY); + assert.equal((yield* finishTurn([])).status, "completed"); + assert.lengthOf(notices(), 1); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect("leaves out the calls T3 denied itself", () => + Effect.gen(function* () { + const { harness, startTurn, failedToolCall, finishTurn, notices, canUseTool } = + yield* makeDenialHarness; + yield* startTurn(APPROVAL_RUNTIME_POLICY); + + // ExitPlanMode: T3 captures the plan and denies the tool. + assert.equal( + (yield* canUseTool("ExitPlanMode", { plan: "# Plan" }, "toolu-plan"))?.behavior, + "deny", + ); + // A question the CLI cancelled before the user answered. + const cancelled = new AbortController(); + cancelled.abort(); + assert.equal( + (yield* canUseTool( + "AskUserQuestion", + { questions: [] }, + "toolu-question", + cancelled.signal, + ))?.behavior, + "deny", + ); + // An approval the user declined. + const decline = yield* canUseTool("Write", { file_path: "/tmp/x" }, "toolu-write").pipe( + Effect.forkScoped, + ); + // The cancelled question raised a request first; skip to the Write one. + let approval = yield* Queue.take(harness.runtimeRequestReceipts); + while (approval.runtimeRequest.nativeRequestRef?.nativeId !== "toolu-write") { + approval = yield* Queue.take(harness.runtimeRequestReceipts); + } + yield* harness.runtime.respondToRuntimeRequest({ + requestId: approval.runtimeRequest.id, + decision: "decline", + }); + assert.equal((yield* Fiber.join(decline))?.behavior, "deny"); + yield* failedToolCall({ + toolUseId: "toolu-write", + toolName: "Write", + error: "User declined tool execution.", + }); + + // A deny rule Claude Code applied on its own, in the same turn. + yield* failedToolCall({ + toolUseId: "toolu-rule", + toolName: "Bash", + error: "Denied by rule.", + }); + + const terminal = yield* finishTurn([ + { tool_name: "ExitPlanMode", tool_use_id: "toolu-plan", tool_input: {} }, + { tool_name: "AskUserQuestion", tool_use_id: "toolu-question", tool_input: {} }, + { tool_name: "Write", tool_use_id: "toolu-write", tool_input: {} }, + { tool_name: "Bash", tool_use_id: "toolu-rule", tool_input: {} }, + ]); + assert.equal(terminal.status, "completed"); + assert.deepEqual( + notices().map((notice) => (notice.type === "system_notice" ? notice.message : null)), + ["Claude denied 1 action (Bash): Denied by rule."], + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); + it.effect("removes deleted tasks without resurrecting them on later updates or turns", () => Effect.gen(function* () { const harness = yield* makeTaskToolHarness; @@ -5552,7 +5789,24 @@ describe("ClaudeAdapterV2 background wake turns", () => { "The client captured your proposed plan", ); yield* Queue.offer(harness.sdkMessages, planToolResult); - yield* Queue.offer(harness.sdkMessages, wakeResult); + // The wake result lists the captured plan among its denials. T3 made + // that denial while the frame was held, so the turn it lands in + // must still recognize it. + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "00000000-0000-4000-8000-000000000745", + result: WAKE_RESULT_TEXT, + origin: { kind: "task-notification" }, + permissionDenials: [ + { + tool_name: "ExitPlanMode", + tool_use_id: planToolUseId, + tool_input: { plan: planMarkdown }, + }, + ], + }), + ); yield* awaitUntil( () => harness.continuationRequests.length === 1, "continuation request", @@ -5641,6 +5895,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { userRunItems.map((item) => item.type), ["assistant_message"], ); + assert.notInclude( + [...latestItems.values()].map((item) => item.type), + "system_notice", + ); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index d0b63350f1d4e..4736e50e946b3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6,6 +6,7 @@ import { } from "@t3tools/shared/toolActivity"; import { field, text } from "../unknownField.ts"; import { readWorkflowAgentAnswers } from "../workflowAgentAnswers.ts"; +import { claudePermissionDenialNotice } from "./claudePermissionDenials.ts"; import { CLAUDE_WORKFLOW_TASK_TYPE, hasClaudeWorkflowSnapshot, @@ -2912,6 +2913,12 @@ interface ActiveClaudeTurnContext { >; }; readonly toolCalls: Map; + // Error text of this turn's failed tool calls, by tool_use id: the only + // record of why Claude Code denied a call on its own. + readonly toolErrorTexts: Map; + // Tool calls T3's permission callback denied in this turn, so its result's + // permission_denials can tell them from Claude Code's own denials. + readonly deniedByT3ToolUseIds: Set; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; authenticationFailureMessage: string | undefined; @@ -3670,6 +3677,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // tool_use frame is handled, in whichever run that frame is routed // to (the prompt's turn, or the continuation that drains a wake). const heldProposedPlansByToolUseId = new Map(); + // T3 denials whose tool_use frame is held for a prompt echo, like + // held plans: each joins the turn its frame is routed to. + const heldDeniedByT3ToolUseIds = new Set(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -7370,6 +7380,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolName: toolUse.name.replace(/^mcp__claude_ai_/u, "mcp__"), }), }); + if (heldDeniedByT3ToolUseIds.delete(toolUse.id)) { + context.deniedByT3ToolUseIds.add(toolUse.id); + } const heldPlan = heldProposedPlansByToolUseId.get(toolUse.id); if (heldPlan !== undefined) { heldProposedPlansByToolUseId.delete(toolUse.id); @@ -7383,6 +7396,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) { + if (isClaudeToolResultError(toolResult)) { + context.toolErrorTexts.set(toolResult.tool_use_id, claudeSubagentResultText(output)); + } const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); // Independent of the branches below: unlike the Agent tool, a @@ -7703,6 +7719,51 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; + const denialNotice = interrupted + ? null + : claudePermissionDenialNotice({ + denials: message.permission_denials ?? [], + deniedByT3: context.deniedByT3ToolUseIds, + toolErrorTexts: context.toolErrorTexts, + permissionMode: claudeRuntimeQueryPolicyForRuntimePolicy( + context.input.runtimePolicy, + ).permissionMode, + }); + if (denialNotice !== null) { + // The turn still completes. The notice keeps the thread out of + // auto-settlement until another run starts. + const nativeItemId = `permission-denials:${context.providerTurnId}`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + threadId: context.input.threadId, + runId: context.input.runId, + nodeId: context.input.rootNodeId, + providerThreadId: context.input.providerThread.id, + providerTurnId: context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: nativeItemId, + strength: "weak", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(context, nativeItemId), + type: "system_notice", + status: "completed", + title: denialNotice, + message: denialNotice, + needsAttention: true, + startedAt: completedAt, + completedAt, + updatedAt: completedAt, + }, + }); + } yield* finalizeActiveTurn({ context, status: interrupted ? "interrupted" : terminalStatusFromResult(message, failureHint), @@ -8169,7 +8230,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => - runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + runPromise( + canUseToolEffect(toolName, toolInput, callbackOptions).pipe( + Effect.tap((result) => + Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + // With no active turn, no result will list this call. + if (result.behavior !== "deny" || context === null) return; + if (heldToolUseIds(context).has(callbackOptions.toolUseID)) { + heldDeniedByT3ToolUseIds.add(callbackOptions.toolUseID); + } else { + context.deniedByT3ToolUseIds.add(callbackOptions.toolUseID); + } + }), + ), + ), + ); const onUserDialog: NonNullable = ( request, @@ -8567,6 +8643,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( blocks: new Map(), }, toolCalls: new Map(), + toolErrorTexts: new Map(), + deniedByT3ToolUseIds: new Set(), ignoredTaskIds: new Set(), announcedUsageLimits: new Set(), authenticationFailureMessage: undefined, diff --git a/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts b/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts new file mode 100644 index 0000000000000..129e0c199c048 --- /dev/null +++ b/apps/server/src/orchestration-v2/Adapters/claudePermissionDenials.ts @@ -0,0 +1,42 @@ +import type { PermissionMode, SDKPermissionDenial } from "@anthropic-ai/claude-agent-sdk"; + +const MAX_REASON_LENGTH = 300; + +function oneLine(text: string): string { + const collapsed = text.replace(/\s+/gu, " ").trim(); + return collapsed.length > MAX_REASON_LENGTH + ? `${collapsed.slice(0, MAX_REASON_LENGTH - 1)}…` + : collapsed; +} + +/** + * The notice for tool calls Claude Code denied on its own, such as Auto + * mode's classifier, which decides before T3's permission callback runs. + * T3's own denials (a captured ExitPlanMode plan, a declined approval, a + * cancelled question) are already visible to the user and are left out. + * The SDK's denial entries carry no reason, so it comes from the tool_result + * error text Claude received for that call. Null when nothing is left. + */ +export function claudePermissionDenialNotice(input: { + readonly denials: ReadonlyArray; + readonly deniedByT3: ReadonlySet; + readonly toolErrorTexts: ReadonlyMap; + readonly permissionMode: PermissionMode; +}): string | null { + const denials = input.denials.filter((denial) => !input.deniedByT3.has(denial.tool_use_id)); + if (denials.length === 0) return null; + const count = `${denials.length} action${denials.length === 1 ? "" : "s"}`; + const title = `${input.permissionMode === "auto" ? "Auto mode" : "Claude"} denied ${count}`; + const tools = [...new Set(denials.map((denial) => denial.tool_name))].join(", "); + const reasons = [ + ...new Set( + denials.flatMap((denial) => { + const text = input.toolErrorTexts.get(denial.tool_use_id); + return text === undefined || text.trim().length === 0 ? [] : [oneLine(text)]; + }), + ), + ]; + return reasons.length === 0 + ? `${title} (${tools}).` + : `${title} (${tools}): ${reasons.join(" ")}`; +} diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 8dcb13ceda9e9..492f75696c354 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -76,11 +76,12 @@ const createRun = Effect.fn(function* ( threadId: ThreadId, status: OrchestrationV2Run["status"] = "completed", ordinal = 1, + overrides: Partial = {}, ) { const store = yield* ProjectionStore.ProjectionStoreV2; const runId = RunId.make(`run:${threadId}:${ordinal}`); yield* store.apply({ - id: EventId.make(`event:${runId}`), + id: EventId.make(`event:${runId}:${status}`), type: "run.created", threadId, occurredAt: old, @@ -100,6 +101,7 @@ const createRun = Effect.fn(function* ( completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null, checkpointId: null, contextHandoffId: null, + ...overrides, }, }); return runId; @@ -140,6 +142,80 @@ const createItem = Effect.fn(function* ( }); }); +const createNotice = Effect.fn(function* ( + threadId: ThreadId, + runId: RunId, + needsAttention: boolean, +) { + const store = yield* ProjectionStore.ProjectionStoreV2; + yield* store.apply({ + id: EventId.make(`event:notice:${runId}`), + type: "turn-item.updated", + threadId, + occurredAt: old, + payload: { + id: TurnItemId.make(`notice:${runId}`), + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + type: "system_notice", + status: "completed", + title: null, + message: "Auto mode denied 1 action (Bash): blocked by the classifier.", + ...(needsAttention ? { needsAttention: true } : {}), + startedAt: old, + completedAt: old, + updatedAt: old, + }, + }); +}); + +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)( + "%s: a run that ended on denied actions holds settlement until another run starts", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const denied = yield* createThread("denied"); + yield* createNotice(denied, yield* createRun(denied), true); + const notice = yield* createThread("plain-notice"); + yield* createNotice(notice, yield* createRun(notice), false); + const retried = yield* createThread("retried"); + yield* createNotice(retried, yield* createRun(retried), true); + yield* createRun(retried, "completed", 2); + + // Follow-ups that never started keep the hold: a held queue, an + // ordinary queued message, and one cancelled before it started. + const waiting = yield* createThread("waiting-follow-ups"); + yield* createNotice(waiting, yield* createRun(waiting), true); + yield* createRun(waiting, "queued", 2, { queueHeld: true }); + yield* createRun(waiting, "queued", 3); + yield* createRun(waiting, "cancelled", 4, { startedAt: null }); + + const candidates = yield* store.getSettlementCandidates(); + assert.deepEqual( + candidates.map((thread) => thread.id).toSorted(), + [notice, retried].toSorted(), + ); + assert.deepEqual(yield* store.getSettlementCandidates(denied), []); + assert.deepEqual(yield* store.getSettlementCandidates(waiting), []); + + // The held follow-up starts, so the user has moved on. + yield* createRun(waiting, "completed", 2, { startedAt: now, completedAt: now }); + assert.deepEqual( + (yield* store.getSettlementCandidates(waiting)).map((thread) => thread.id), + [waiting], + ); + }).pipe(Effect.provide(testLayer)), +); + it.effect.each([ ["sql", layerSql], ["memory", ProjectionStore.layerMemory], @@ -459,8 +535,20 @@ it.effect( ); assert.isTrue(queries.every(([query]) => !query.includes("COUNT("))); const pendingQuery = queries.find(([query]) => - query.includes("FROM orchestration_v2_projection_turn_items"), + query.includes("FROM orchestration_v2_projection_turn_items i"), + ); + const candidateQuery = queries.find(([query]) => + query.includes("FROM orchestration_v2_projection_turn_items notice"), + ); + assert.isDefined(candidateQuery); + const candidatePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${candidateQuery![0]}`, + candidateQuery![1], ); + // The latest run's notices are an index lookup, not a history scan. + const noticeLookups = candidatePlan.filter((row) => row.detail.includes(" notice ")); + assert.isNotEmpty(noticeLookups); + assert.isTrue(noticeLookups.every((row) => row.detail.startsWith("SEARCH notice USING"))); assert.isDefined(pendingQuery); const metadata = yield* store.getThread(threadId); assert.equal(metadata.id, threadId); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 7c156ad71361d..5cc5de532d9c1 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1383,6 +1383,31 @@ function buildVisibleTurnItems(input: { ]); } +/** + * The run whose needs-attention notices hold a thread out of auto-settlement: + * the one that started last. Queued runs, held or not, and runs cancelled + * before they started never release the hold; any run that starts does, + * including an automatic continuation. The SQL settlement query mirrors this. + */ +function attentionAnchorRunId( + runs: ReadonlyArray, +): RunId | null { + let anchor: { readonly id: RunId; readonly ordinal: number; readonly at: number } | null = null; + for (const run of runs) { + if (run.startedAt === null) continue; + const at = DateTime.toEpochMillis(run.startedAt); + if ( + anchor === null || + at > anchor.at || + (at === anchor.at && + (run.ordinal > anchor.ordinal || (run.ordinal === anchor.ordinal && run.id > anchor.id))) + ) { + anchor = { id: run.id, ordinal: run.ordinal, at }; + } + } + return anchor?.id ?? null; +} + /** * An agent waiting on a secret is waiting on the user just like a question, * so the shell reports it as pending user input. Secret requests have no @@ -5551,6 +5576,22 @@ export const layer: Layer.Layer = SELECT 1 FROM orchestration_v2_projection_runtime_requests request WHERE request.thread_id = t.thread_id AND request.status = 'pending' ) + -- A needs-attention notice on the latest started run holds the + -- thread like a pending request; see attentionAnchorRunId. + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items notice + WHERE notice.thread_id = t.thread_id + AND notice.run_id = ( + SELECT anchor.run_id FROM orchestration_v2_projection_runs anchor + WHERE anchor.thread_id = t.thread_id + AND json_extract(anchor.payload_json, '$.startedAt') IS NOT NULL + ORDER BY json_extract(anchor.payload_json, '$.startedAt') DESC, + anchor.ordinal DESC, anchor.run_id DESC + LIMIT 1 + ) + AND notice.type = 'system_notice' + AND json_extract(notice.payload_json, '$.needsAttention') = 1 + ) ORDER BY t.updated_at ASC, t.thread_id ASC `; if (rows.length === 0) return []; @@ -6112,6 +6153,15 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) + .filter(({ runs, turnItems }) => { + const anchorRunId = attentionAnchorRunId(runs); + return !turnItems.some( + (item) => + item.runId === anchorRunId && + item.type === "system_notice" && + item.needsAttention === true, + ); + }) .map((projection) => { const shell = threadShellFromProjection(projection); return { diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts index 558dc7167cd54..f63beaf7f3d2a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/claude_output.ts @@ -41,4 +41,9 @@ export function assertToolCallDeniedWriteClaudeOutput( const writes = projection.turnItems.filter((item) => item.type === "file_change"); assert.lengthOf(writes, 1); assert.equal(writes[0]?.status, "failed"); + // The result lists this decline in permission_denials, but the user made it. + assert.deepEqual( + projection.turnItems.filter((item) => item.type === "system_notice"), + [], + ); } diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b676..3f076d5f4e91d 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -148,7 +148,8 @@ Press `Escape` while dragging to cancel. By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle -thread. Work in progress, pending questions or approvals, and live background work +thread. Work in progress, pending questions or approvals, live background work, and a +last turn where Claude blocked actions without asking you, for example in Auto mode, prevent automatic settlement. An open pull request does not prevent inactivity settlement, but an old closed or merged pull request does not settle work you resumed after it closed. Only your own messages count as resuming. A turn that diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 028d536d3758f..967fd89822687 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1579,6 +1579,15 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + /** + * The notice reports something the agent could not get past without the + * user, such as tool calls Claude blocked without asking. While its run is + * the thread's latest started run, the thread is held out of + * auto-settlement. Queued runs (held or not) and runs cancelled before + * starting keep the hold; any run that starts, including an automatic + * continuation, releases it. + */ + needsAttention: Schema.optional(Schema.Boolean), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2363,6 +2372,7 @@ export const OrchestrationV2TurnItemJson = Schema.Union([ ...OrchestrationV2TurnItemJsonBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + needsAttention: Schema.optional(Schema.Boolean), }), Schema.Struct({ ...OrchestrationV2TurnItemJsonBaseFields,