From 8c60ec367d7395433da8ab62a12bd58707f46a52 Mon Sep 17 00:00:00 2001 From: Jarl Date: Fri, 16 Oct 2026 11:31:56 +0800 Subject: [PATCH 1/4] fix(claude): keep denied actions visible until the next run --- .../Adapters/ClaudeAdapterV2.test.ts | 242 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 86 ++++++- .../ProjectionSettlement.test.ts | 76 +++++- .../src/orchestration-v2/ProjectionStore.ts | 33 +++ ...estratorReplayFixtures.integration.test.ts | 11 + docs/user/thread-sidebar.md | 4 +- packages/contracts/src/orchestrationV2.ts | 2 + 7 files changed, 449 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 868e7fa0bd62f..2f74da817e50a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2247,6 +2247,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly uuid: string; readonly result: string; readonly numTurns?: number; + readonly permissionDenials?: SDKResultMessage["permission_denials"]; readonly origin?: { readonly kind: "task-notification" }; readonly subtype?: string; readonly isError?: boolean; @@ -2278,7 +2279,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 }), @@ -2393,6 +2394,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); const systemNoticeReceipts = yield* Queue.unbounded< Extract @@ -2499,6 +2504,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { Stream.runForEach((event) => Effect.gen(function* () { events.push(event); + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } if (event.type === "subagent.updated") { yield* Queue.offer(subagentReceipts, event); } @@ -2545,6 +2553,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -3139,6 +3148,222 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each(["auto", "full-access"] as const)( + "reports SDK permission denials once before completion without leaking into later turns in %s mode", + (runtimeMode) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const start = (ordinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-denials-${ordinal}`), + providerTurnOrdinal: ordinal, + text: "Continue.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode: "default", + cwd: "/workspace", + }), + }), + ); + const denial = (tool_name: string, tool_use_id: string) => ({ + tool_name, + tool_use_id, + tool_input: { secret: "NEVER_COPY_TOOL_INPUT" }, + }); + const notices = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem] + : [], + ); + yield* start(1); + const canUseTool = harness.getOpenedOptions()!.canUseTool!; + for (const [tool, id, behavior] of [ + ["ExitPlanMode", "captured-plan", "deny"], + ["Bash", "sdk-bash", "allow"], + ] as const) { + const result = yield* Effect.promise(() => + canUseTool( + tool, + {}, + { + signal: new AbortController().signal, + toolUseID: id, + requestId: id, + }, + ), + ); + assert.equal(result?.behavior, behavior); + } + const rawReason = ` Auto policy\nblocked this action. ${"Reason details. ".repeat(60)}`; + for (const id of ["sdk-bash", "sdk-bash-again"]) { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: `denial-reasons-${id}`, + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: id, + is_error: true, + content: [{ type: "text", text: rawReason }], + }, + ], + }, + tool_use_result: { message: "MISLEADING_STRUCTURED_RESULT" }, + }), + ); + } + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "denials-1", + result: "Done.", + permissionDenials: [ + denial("ExitPlanMode", "captured-plan"), + denial("Bash", "sdk-bash"), + denial("Bash", "sdk-bash-again"), + denial("ExitPlanMode", "sdk-plan"), + ], + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + assert.lengthOf(notices(), 1); + const notice = notices()[0]!; + const source = runtimeMode === "auto" ? "Auto mode" : "Claude"; + assert.equal( + notice.message, + `${source} denied 3 actions (Bash, ExitPlanMode): ${rawReason.replace(/\s+/gu, " ").trim().slice(0, 500)}`, + ); + assert.isTrue(notice.needsAttention); + assert.isBelow( + harness.events.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem.id === notice.id, + ), + harness.events.indexOf(terminal), + ); + assert.notInclude(JSON.stringify(notice), "NEVER_COPY_TOOL_INPUT"); + assert.notInclude(JSON.stringify(notice), "MISLEADING_STRUCTURED_RESULT"); + assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); + + // Reuse both IDs to prove neither the T3-denied IDs nor raw reasons survive a turn. + yield* start(2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "denials-2", + result: "Done.", + permissionDenials: [ + denial("ExitPlanMode", "captured-plan"), + denial("Bash", "sdk-bash"), + ], + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.lengthOf(notices(), 2); + assert.equal(notices()[1]!.message, `${source} denied 2 actions (ExitPlanMode, Bash)`); + yield* start(3); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "denials-3", result: "Done." }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.lengthOf(notices(), 2); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + + it.effect.each(["declined approval", "cancelled approval", "cancelled question"] as const)( + "does not announce a %s as an SDK permission denial", + (scenario) => + 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-client-denial"), + text: "Continue.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "approval-required", + interactionMode: "default", + cwd: "/workspace", + }), + }), + ); + const controller = new AbortController(); + const toolName = scenario === "cancelled question" ? "AskUserQuestion" : "Bash"; + const permission = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + toolName, + toolName === "Bash" + ? { command: "pwd" } + : { + questions: [ + { question: "Proceed?", header: "Continue", options: [], multiSelect: false }, + ], + }, + { signal: controller.signal, toolUseID: "client-denied", requestId: "client-denied" }, + ), + ).pipe(Effect.forkScoped); + const request = yield* Queue.take(harness.runtimeRequestReceipts); + if (scenario === "declined approval") { + yield* harness.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "decline", + }); + } else { + controller.abort(); + } + assert.equal((yield* Fiber.join(permission))?.behavior, "deny"); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "client-denial-result", + result: "Done.", + permissionDenials: [ + { tool_name: toolName, tool_use_id: "client-denied", tool_input: {} }, + ], + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ), + ); + assert.lengthOf( + harness.events.filter((event) => event.type === "runtime_request.updated"), + 1, + ); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("announces usage-limit pauses once per window and again on a new turn", () => Effect.gen(function* () { const harness = yield* makeWakeHarness; @@ -4054,6 +4279,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000506", result: "Plan captured.", + permissionDenials: [ + { tool_name: "ExitPlanMode", tool_use_id: "tool-exit-plan-1", tool_input: {} }, + ], }), ); yield* Queue.take(harness.terminalReceipts); @@ -4099,6 +4327,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { // The second prompt reuses the live process, which is still in the // plan mode Claude entered, so it is put back in the thread's mode. assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); + assert.isFalse([...items.values()].some((item) => item.type === "system_notice")); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -5632,7 +5861,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { "The client captured your proposed plan", ); yield* Queue.offer(harness.sdkMessages, planToolResult); - yield* Queue.offer(harness.sdkMessages, wakeResult); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...wakeResult, + permission_denials: [ + { tool_name: "ExitPlanMode", tool_use_id: planToolUseId, tool_input: {} }, + ], + }), + ); yield* awaitUntil( () => harness.continuationRequests.length === 1, "continuation request", @@ -5688,6 +5925,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { (item) => item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === planToolUseId, ); + assert.isFalse([...latestItems.values()].some((item) => item.type === "system_notice")); const proposedPlans = [...latestItems.values()].filter( (item) => item.type === "proposed_plan", ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 0e2dcb309318d..0b7afd54bea42 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3069,6 +3069,8 @@ interface ActiveClaudeTurnContext { >; }; readonly toolCalls: Map; + readonly deniedByT3ToolUseIds: Set; + readonly toolErrorTexts: Map; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; authenticationFailureMessage: string | undefined; @@ -3849,6 +3851,8 @@ 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(); + // Follow buffered frames to their eventual turn without retaining discarded frames. + const heldDeniedByT3Tools = new WeakSet(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -7586,6 +7590,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const toolPresentations = claudeToolPresentationsFromAssistantMessage(message); for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) { + if (heldDeniedByT3Tools.delete(toolUse)) { + context.deniedByT3ToolUseIds.add(toolUse.id); + } const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input); if (toolUse.name === "Agent") { rememberClaudeSubagentLaunch( @@ -7631,6 +7638,15 @@ 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(claudeNativeToolOutputFromToolResult(toolResult)) + .replace(/\s+/gu, " ") + .trim() + .slice(0, 500), + ); + } const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id); const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output)); // Independent of the branches below: unlike the Agent tool, a @@ -7952,6 +7968,55 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; + const denials = message.permission_denials.filter( + (denial) => !context.deniedByT3ToolUseIds.has(denial.tool_use_id), + ); + if (denials.length > 0) { + const tools = [...new Set(denials.map((denial) => denial.tool_name))].join(", "); + const reasons = [ + ...new Set( + denials.flatMap((denial) => { + const reason = context.toolErrorTexts.get(denial.tool_use_id); + return reason ? [reason] : []; + }), + ), + ] + .join("; ") + .slice(0, 1500); + const source = liveQuery.permissionMode === "auto" ? "Auto mode" : "Claude"; + const title = `${source} denied ${denials.length} action${denials.length === 1 ? "" : "s"} (${tools})`; + const nativeItemId = `${context.providerTurnId}:permission-denials`; + 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, + message: reasons.length > 0 ? `${title}: ${reasons}` : title, + needsAttention: true, + startedAt: completedAt, + completedAt, + updatedAt: completedAt, + }, + }); + } yield* finalizeActiveTurn({ context, status: interrupted ? "interrupted" : terminalStatusFromResult(message, failureHint), @@ -8418,7 +8483,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) => - runPromise(canUseToolEffect(toolName, toolInput, callbackOptions)); + runPromise( + Effect.gen(function* () { + const context = yield* Ref.get(activeTurn); + const result = yield* canUseToolEffect(toolName, toolInput, callbackOptions); + if (result.behavior === "deny" && context !== null) { + // Held output may belong to a queued wake rather than this prompt. + const heldTool = context.heldRootFrames + .flatMap(claudeToolUseBlocksFromAssistantMessage) + .find((tool) => tool.id === callbackOptions.toolUseID); + if (heldTool !== undefined) { + heldDeniedByT3Tools.add(heldTool); + } else { + context.deniedByT3ToolUseIds.add(callbackOptions.toolUseID); + } + } + return result; + }), + ); const onUserDialog: NonNullable = ( request, @@ -8842,6 +8924,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( blocks: new Map(), }, toolCalls: new Map(), + deniedByT3ToolUseIds: new Set(), + toolErrorTexts: new Map(), ignoredTaskIds: new Set(), announcedUsageLimits: new Set(), authenticationFailureMessage: undefined, diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 8dcb13ceda9e9..b0319c6e4719e 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -76,6 +76,7 @@ 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}`); @@ -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; @@ -354,6 +356,78 @@ it.effect.each([ }).pipe(Effect.provide(testLayer)), ); +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)( + "%s: attention notices hold settlement until another run actually starts", + ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const later = DateTime.add(old, { hours: 1 }); + for (const [name, needsAttention, nextRun, eligible] of [ + ["attention", true, null, false], + ["ordinary-notice", undefined, null, true], + ["explicit-false", false, null, true], + ["queued", true, { status: "queued", startedAt: null }, false], + ["held", true, { status: "queued", startedAt: null, queueHeld: true }, false], + ["canceled", true, { status: "cancelled", startedAt: null }, false], + ["failed-before-start", true, { status: "failed", startedAt: null }, false], + ["later-start", true, { startedAt: later }, true], + ["failed-after-start", true, { status: "failed", startedAt: later }, true], + ["canceled-after-start", true, { status: "cancelled", startedAt: later }, true], + ["lower-ordinal-later-start", true, { ordinal: 1, startedAt: later }, true], + [ + "higher-ordinal-older-start", + true, + { startedAt: DateTime.subtract(old, { hours: 1 }) }, + false, + ], + ["same-start-higher-ordinal", true, {}, true], + ["same-start-lower-ordinal", true, { ordinal: 1 }, false], + ["automatic-continuation", true, { startedAt: later, workStartedAt: old }, true], + ] satisfies ReadonlyArray< + readonly [string, boolean | undefined, Partial | null, boolean] + >) { + const threadId = yield* createThread(name); + const runId = yield* createRun(threadId, "completed", 2); + 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: 1, + type: "system_notice", + status: "completed", + title: null, + startedAt: old, + completedAt: old, + updatedAt: old, + message: "Needs attention", + ...(needsAttention === undefined ? {} : { needsAttention }), + }, + }); + if (nextRun !== null) { + yield* createRun(threadId, "completed", 3, nextRun); + } + assert.deepEqual( + (yield* store.getSettlementCandidates(threadId)).map((candidate) => candidate.id), + eligible ? [threadId] : [], + name, + ); + } + }).pipe(Effect.provide(testLayer)), +); + const pullRequestLink = (number: number) => ({ host: "github.com", repository: "owner/repository", @@ -459,7 +533,7 @@ 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"), ); assert.isDefined(pendingQuery); const metadata = yield* store.getThread(threadId); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ad92e25c8516a..461bd2b4170dd 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5663,6 +5663,21 @@ 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' ) + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items notice + INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx + WHERE notice.thread_id = t.thread_id + AND notice.run_id = ( + SELECT started.run_id FROM orchestration_v2_projection_runs started + WHERE started.thread_id = t.thread_id + AND json_extract(started.payload_json, '$.startedAt') IS NOT NULL + ORDER BY json_extract(started.payload_json, '$.startedAt') DESC, + started.ordinal DESC, started.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 []; @@ -6243,6 +6258,24 @@ export const layerMemory: Layer.Layer = Layer.effect( !runs.some(isActivityRunForShell) && !runtimeRequests.some((request) => request.status === "pending"), ) + .filter(({ runs, turnItems }) => { + // Only an actually started run supersedes an attention notice. + const latestStartedRun = runs + .filter((run) => run.startedAt !== null) + .toSorted( + (left, right) => + DateTime.toEpochMillis(right.startedAt!) - + DateTime.toEpochMillis(left.startedAt!) || + right.ordinal - left.ordinal || + right.id.localeCompare(left.id), + )[0]; + return !turnItems.some( + (item) => + item.runId === latestStartedRun?.id && + item.type === "system_notice" && + item.needsAttention === true, + ); + }) .map((projection) => { const shell = threadShellFromProjection(projection); return { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index bbb414f26f591..78cf9ce5ffb14 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -157,6 +157,17 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < assert.isDefined(projectionThreadId); const projection = result.projections.get(projectionThreadId); assert.isDefined(projection); + if (input.fixtureName === "tool_call_denied_write" && input.driver.driver === "claudeAgent") { + assert.isFalse( + result.domainEvents.some( + (event) => + event.type === "turn-item.updated" && + event.payload.type === "system_notice" && + event.payload.needsAttention === true, + ), + "a recorded user decline must not be announced as a Claude permission denial", + ); + } const latestRun = projection.runs.at(-1); assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); if (projection.runs.some((run) => run.status === "completed")) { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 2a9735f61c6da..5519a12fdc5a9 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -149,7 +149,9 @@ 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 -prevent automatic settlement. An open pull request does not prevent inactivity +prevent automatic settlement. A notice that needs your attention also prevents +it until another turn starts, including an automatic continuation. +An open pull request does not prevent inactivity settlement unless you turn on **Keep threads with a PR** in **Settings → General**, 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 f08eec7345b85..b94f17005191c 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1579,6 +1579,7 @@ export const OrchestrationV2TurnItem = Schema.Union([ ...OrchestrationV2TurnItemBaseFields, type: Schema.Literal("system_notice"), message: Schema.String, + needsAttention: Schema.optional(Schema.Boolean), }), Schema.Struct({ ...OrchestrationV2TurnItemBaseFields, @@ -2371,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, From 7b249ac4a50a5c0af2842d964c618e205a1ae004 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:27:19 +0000 Subject: [PATCH 2/4] fix(server): preserve Claude denial notices until the next run --- .../Adapters/ClaudeAdapterV2.test.ts | 290 +++++++++--------- .../Adapters/ClaudeAdapterV2.ts | 5 +- .../ProjectionSettlement.test.ts | 36 ++- .../src/orchestration-v2/ProjectionStore.ts | 29 +- ...estratorReplayFixtures.integration.test.ts | 11 - apps/server/src/persistence/Migrations.ts | 2 + .../065_ThreadAttentionNoticeLookupIndex.ts | 12 + docs/user/thread-sidebar.md | 7 +- 8 files changed, 194 insertions(+), 198 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/065_ThreadAttentionNoticeLookupIndex.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 042ba41449019..51b3afc047846 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3219,148 +3219,124 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each(["auto", "full-access"] as const)( - "reports SDK permission denials once before completion without leaking into later turns in %s mode", - (runtimeMode) => - Effect.scoped( - Effect.gen(function* () { - const harness = yield* makeWakeHarness; - const now = yield* DateTime.now; - const start = (ordinal: number) => - harness.runtime.startTurn( - makeClaudeTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now, - attemptId: RunAttemptId.make(`attempt-denials-${ordinal}`), - providerTurnOrdinal: ordinal, - text: "Continue.", - attachments: [], - runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode, - interactionMode: "default", - cwd: "/workspace", - }), - }), - ); - const denial = (tool_name: string, tool_use_id: string) => ({ - tool_name, - tool_use_id, - tool_input: { secret: "NEVER_COPY_TOOL_INPUT" }, - }); - const notices = () => - harness.events.flatMap((event) => - event.type === "turn_item.updated" && event.turnItem.type === "system_notice" - ? [event.turnItem] - : [], - ); - yield* start(1); - const canUseTool = harness.getOpenedOptions()!.canUseTool!; - for (const [tool, id, behavior] of [ - ["ExitPlanMode", "captured-plan", "deny"], - ["Bash", "sdk-bash", "allow"], - ] as const) { - const result = yield* Effect.promise(() => - canUseTool( - tool, - {}, - { - signal: new AbortController().signal, - toolUseID: id, - requestId: id, - }, - ), - ); - assert.equal(result?.behavior, behavior); - } - const rawReason = ` Auto policy\nblocked this action. ${"Reason details. ".repeat(60)}`; - for (const id of ["sdk-bash", "sdk-bash-again"]) { - yield* harness.offerAndWait( - claudeSdkFrame({ - type: "user", - uuid: `denial-reasons-${id}`, - session_id: WAKE_NATIVE_SESSION, - parent_tool_use_id: null, - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: id, - is_error: true, - content: [{ type: "text", text: rawReason }], - }, - ], - }, - tool_use_result: { message: "MISLEADING_STRUCTURED_RESULT" }, + it.effect("reports SDK-only denials with raw reasons once, scoped to their turn", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const start = (ordinal: number) => + harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-denials-${ordinal}`), + providerTurnOrdinal: ordinal, + text: "Continue.", + attachments: [], + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "auto", + interactionMode: "default", + cwd: "/workspace", }), - ); - } - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "denials-1", - result: "Done.", - permissionDenials: [ - denial("ExitPlanMode", "captured-plan"), - denial("Bash", "sdk-bash"), - denial("Bash", "sdk-bash-again"), - denial("ExitPlanMode", "sdk-plan"), - ], }), ); - const terminal = yield* Queue.take(harness.terminalReceipts); - assert.equal(terminal.status, "completed"); - assert.lengthOf(notices(), 1); - const notice = notices()[0]!; - const source = runtimeMode === "auto" ? "Auto mode" : "Claude"; - assert.equal( - notice.message, - `${source} denied 3 actions (Bash, ExitPlanMode): ${rawReason.replace(/\s+/gu, " ").trim().slice(0, 500)}`, - ); - assert.isTrue(notice.needsAttention); - assert.isBelow( - harness.events.findIndex( - (event) => event.type === "turn_item.updated" && event.turnItem.id === notice.id, - ), - harness.events.indexOf(terminal), + const notices = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem] + : [], ); - assert.notInclude(JSON.stringify(notice), "NEVER_COPY_TOOL_INPUT"); - assert.notInclude(JSON.stringify(notice), "MISLEADING_STRUCTURED_RESULT"); - assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); - - // Reuse both IDs to prove neither the T3-denied IDs nor raw reasons survive a turn. - yield* start(2); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ - uuid: "denials-2", - result: "Done.", - permissionDenials: [ - denial("ExitPlanMode", "captured-plan"), - denial("Bash", "sdk-bash"), + yield* start(1); + const capturedPlan = yield* Effect.promise(() => + harness.getOpenedOptions()!.canUseTool!( + "ExitPlanMode", + {}, + { + signal: new AbortController().signal, + toolUseID: "captured-plan", + requestId: "captured-plan", + }, + ), + ); + assert.equal(capturedPlan?.behavior, "deny"); + const rawReason = "Auto policy blocked this command: it writes outside the workspace."; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "denial-reason", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "sdk-bash", + is_error: true, + content: rawReason, + }, ], - }), - ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - assert.lengthOf(notices(), 2); - assert.equal(notices()[1]!.message, `${source} denied 2 actions (ExitPlanMode, Bash)`); - yield* start(3); - yield* Queue.offer( - harness.sdkMessages, - makeResultFrame({ uuid: "denials-3", result: "Done." }), - ); - assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); - assert.lengthOf(notices(), 2); - }).pipe( - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + }, + tool_use_result: { message: "MISLEADING_STRUCTURED_RESULT" }, + }), + ); + const denials = [ + { tool_name: "ExitPlanMode", tool_use_id: "captured-plan", tool_input: {} }, + { tool_name: "Bash", tool_use_id: "sdk-bash", tool_input: {} }, + { tool_name: "Write", tool_use_id: "sdk-write", tool_input: {} }, + ]; + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "denials-1", + result: "Done.", + permissionDenials: denials, + }), + ); + const terminal = yield* Queue.take(harness.terminalReceipts); + assert.equal(terminal.status, "completed"); + assert.lengthOf(notices(), 1); + const notice = notices()[0]!; + assert.include(notice.message, rawReason); + assert.include(notice.message, "Bash"); + assert.include(notice.message, "Write"); + assert.notInclude(notice.message, "ExitPlanMode"); + assert.notInclude(notice.message, "MISLEADING_STRUCTURED_RESULT"); + assert.isTrue(notice.needsAttention); + assert.equal(notice.runId, RunId.make("run-attempt-denials-1")); + assert.isBelow( + harness.events.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem.id === notice.id, ), + harness.events.indexOf(terminal), + ); + assert.isFalse(harness.events.some((event) => event.type === "runtime_request.updated")); + + // Reusing IDs exposes denial markers or reasons that escape their owning turn. + yield* start(2); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: "denials-2", + result: "Done.", + permissionDenials: denials, + }), + ); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); + assert.lengthOf(notices(), 2); + assert.include(notices()[1]!.message, "ExitPlanMode"); + assert.notInclude(notices()[1]!.message, rawReason); + assert.equal(notices()[1]!.runId, RunId.make("run-attempt-denials-2")); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), ), + ), ); - it.effect.each(["declined approval", "cancelled approval", "cancelled question"] as const)( + it.effect.each(["declined approval", "cancelled question"] as const)( "does not announce a %s as an SDK permission denial", (scenario) => Effect.scoped( @@ -4931,9 +4907,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000506", result: "Plan captured.", - permissionDenials: [ - { tool_name: "ExitPlanMode", tool_use_id: "tool-exit-plan-1", tool_input: {} }, - ], }), ); yield* Queue.take(harness.terminalReceipts); @@ -4977,7 +4950,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { const proposedPlan = [...plans.values()].find((plan) => plan.kind === "proposed_plan"); assert.equal(proposedPlan?.status, "active"); assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); - assert.isFalse([...items.values()].some((item) => item.type === "system_notice")); }).pipe( Effect.provide( Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), @@ -6535,7 +6507,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { 0, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 1, "first turn terminal"); + yield* Queue.take(harness.terminalReceipts); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6550,14 +6522,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); yield* Queue.offer(harness.sdkMessages, wakeNotification); - yield* Queue.offer(harness.sdkMessages, planToolUse); - // The SDK asks for permission only after streaming the tool_use. - yield* awaitUntil( - () => Queue.sizeUnsafe(harness.sdkMessages) === 0, - "the tool_use frame to be consumed", - ); - let settleYields = 0; - yield* awaitUntil(() => settleYields++ >= 50, "the tool_use frame to be handled"); + yield* harness.offerAndWait(planToolUse); const canUseTool = harness.getOpenedOptions()?.canUseTool; assert.isFunction(canUseTool); const callback = yield* Effect.promise(() => @@ -6577,19 +6542,35 @@ describe("ClaudeAdapterV2 background wake turns", () => { "The client captured your proposed plan", ); yield* Queue.offer(harness.sdkMessages, planToolResult); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + ...planToolResult, + uuid: "wake-sdk-denial-reason", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "wake-sdk-denied", + is_error: true, + content: "Wake command blocked by policy.", + }, + ], + }, + }), + ); yield* Queue.offer( harness.sdkMessages, claudeSdkFrame({ ...wakeResult, permission_denials: [ { tool_name: "ExitPlanMode", tool_use_id: planToolUseId, tool_input: {} }, + { tool_name: "Bash", tool_use_id: "wake-sdk-denied", tool_input: {} }, ], }), ); - yield* awaitUntil( - () => harness.continuationRequests.length === 1, - "continuation request", - ); + yield* Queue.take(harness.continuationReceipts); yield* Queue.offer( harness.sdkMessages, @@ -6611,7 +6592,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { 1, ), ); - yield* awaitUntil(() => harness.terminalEvents().length === 2, "user turn terminal"); + yield* Queue.take(harness.terminalReceipts); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -6627,7 +6608,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { messageCreationSource: "provider", }), ); - yield* awaitUntil(() => harness.terminalEvents().length === 3, "continuation terminal"); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "completed"); const latestItems = new Map( harness.events.flatMap((event) => @@ -6640,7 +6621,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { (item) => item.type === "dynamic_tool" && item.nativeItemRef?.nativeId === planToolUseId, ); - assert.isFalse([...latestItems.values()].some((item) => item.type === "system_notice")); + const notices = [...latestItems.values()].filter((item) => item.type === "system_notice"); + assert.lengthOf(notices, 1); + assert.equal(notices[0]!.runId, runOf(continuationAttempt)); + assert.include(notices[0]!.message, "Wake command blocked by policy."); + assert.notInclude(notices[0]!.message, "ExitPlanMode"); + assert.isTrue(notices[0]!.needsAttention); const proposedPlans = [...latestItems.values()].filter( (item) => item.type === "proposed_plan", ); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1a8de11656cbd..4c8085514e12b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3755,7 +3755,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); const requestedContinuations = yield* Ref.make(new Set()); const heldProposedPlansByToolUseId = new Map(); - // Follow buffered frames to their eventual turn without retaining discarded frames. + // Held frames can drain into a different turn. const heldDeniedByT3Tools = new WeakSet(); const runtimeContext = yield* Effect.context(); const runPromise = Effect.runPromiseWith(runtimeContext); @@ -7919,8 +7919,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ] .join("; ") .slice(0, 1500); - const source = liveQuery.permissionMode === "auto" ? "Auto mode" : "Claude"; - const title = `${source} denied ${denials.length} action${denials.length === 1 ? "" : "s"} (${tools})`; + const title = `Claude denied ${denials.length} action${denials.length === 1 ? "" : "s"} (${tools})`; const nativeItemId = `${context.providerTurnId}:permission-denials`; yield* emitProviderEvent({ type: "turn_item.updated", diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index b6f4796c00909..7b67c9ab790b0 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -367,24 +367,9 @@ it.effect.each([ for (const [name, needsAttention, nextRun, eligible] of [ ["attention", true, null, false], ["ordinary-notice", undefined, null, true], - ["explicit-false", false, null, true], ["queued", true, { status: "queued", startedAt: null }, false], - ["held", true, { status: "queued", startedAt: null, queueHeld: true }, false], - ["canceled", true, { status: "cancelled", startedAt: null }, false], - ["failed-before-start", true, { status: "failed", startedAt: null }, false], + ["canceled-before-start", true, { status: "cancelled", startedAt: null }, false], ["later-start", true, { startedAt: later }, true], - ["failed-after-start", true, { status: "failed", startedAt: later }, true], - ["canceled-after-start", true, { status: "cancelled", startedAt: later }, true], - ["lower-ordinal-later-start", true, { ordinal: 1, startedAt: later }, true], - [ - "higher-ordinal-older-start", - true, - { startedAt: DateTime.subtract(old, { hours: 1 }) }, - false, - ], - ["same-start-higher-ordinal", true, {}, true], - ["same-start-lower-ordinal", true, { ordinal: 1 }, false], - ["automatic-continuation", true, { startedAt: later, workStartedAt: old }, true], ] satisfies ReadonlyArray< readonly [string, boolean | undefined, Partial | null, boolean] >) { @@ -418,6 +403,12 @@ it.effect.each([ if (nextRun !== null) { yield* createRun(threadId, "completed", 3, nextRun); } + const notice = (yield* store.getThreadProjection(threadId)).turnItems.find( + (item) => item.id === TurnItemId.make(`notice:${runId}`), + ); + assert.equal(notice?.type, "system_notice"); + if (notice?.type !== "system_notice") return; + assert.equal(notice.needsAttention, needsAttention, name); assert.deepEqual( (yield* store.getSettlementCandidates(threadId)).map((candidate) => candidate.id), eligible ? [threadId] : [], @@ -543,6 +534,19 @@ it.effect( pendingQuery![1], ); assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx"))); + const candidateQuery = queries.find(([query]) => query.includes("AS latest_run_id")); + assert.isDefined(candidateQuery); + const candidatePlan = yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${candidateQuery![0]}`, + candidateQuery![1], + ); + assert.isTrue( + candidatePlan.some((row) => + row.detail.startsWith( + "SEARCH notice USING INDEX orchestration_v2_projection_turn_items_attention_notice_idx (thread_id=? AND run_id=?)", + ), + ), + ); const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 40419a061c6af..c7553d59fcd31 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5650,14 +5650,14 @@ export const layer: Layer.Layer = ) AND NOT EXISTS ( SELECT 1 FROM orchestration_v2_projection_turn_items notice - INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx + INDEXED BY orchestration_v2_projection_turn_items_attention_notice_idx WHERE notice.thread_id = t.thread_id AND notice.run_id = ( SELECT started.run_id FROM orchestration_v2_projection_runs started WHERE started.thread_id = t.thread_id AND json_extract(started.payload_json, '$.startedAt') IS NOT NULL ORDER BY json_extract(started.payload_json, '$.startedAt') DESC, - started.ordinal DESC, started.run_id DESC + started.ordinal DESC LIMIT 1 ) AND notice.type = 'system_notice' @@ -6242,16 +6242,21 @@ export const layerMemory: Layer.Layer = Layer.effect( !runtimeRequests.some((request) => request.status === "pending"), ) .filter(({ runs, turnItems }) => { - // Only an actually started run supersedes an attention notice. - const latestStartedRun = runs - .filter((run) => run.startedAt !== null) - .toSorted( - (left, right) => - DateTime.toEpochMillis(right.startedAt!) - - DateTime.toEpochMillis(left.startedAt!) || - right.ordinal - left.ordinal || - right.id.localeCompare(left.id), - )[0]; + // Queue order can differ from the order runs actually start. + let latestStartedRun: OrchestrationV2Run | undefined; + let latestStartedAt = -Infinity; + for (const run of runs) { + if (run.startedAt === null) continue; + const startedAt = DateTime.toEpochMillis(run.startedAt); + if ( + latestStartedRun === undefined || + startedAt > latestStartedAt || + (startedAt === latestStartedAt && run.ordinal > latestStartedRun.ordinal) + ) { + latestStartedRun = run; + latestStartedAt = startedAt; + } + } return !turnItems.some( (item) => item.runId === latestStartedRun?.id && diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts index 0eeaf103f75d4..2573e89669049 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayFixtures.integration.test.ts @@ -157,17 +157,6 @@ const runFixtureProvider = Effect.fn("runOrchestratorReplayFixture")(function* < assert.isDefined(projectionThreadId); const projection = result.projections.get(projectionThreadId); assert.isDefined(projection); - if (input.fixtureName === "tool_call_denied_write" && input.driver.driver === "claudeAgent") { - assert.isFalse( - result.domainEvents.some( - (event) => - event.type === "turn-item.updated" && - event.payload.type === "system_notice" && - event.payload.needsAttention === true, - ), - "a recorded user decline must not be announced as a Claude permission denial", - ); - } const latestRun = projection.runs.at(-1); assert.deepEqual(latestRun?.modelSelection, input.driver.modelSelection); if (projection.runs.some((run) => run.status === "completed")) { diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 2b4981e04d1b9..a56541229dd9a 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -67,6 +67,7 @@ import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts"; import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts"; import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts"; import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts"; +import Migration0065 from "./Migrations/065_ThreadAttentionNoticeLookupIndex.ts"; export const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -134,6 +135,7 @@ export const migrationEntries = [ [62, "ThreadFailureLookupIndex", Migration0062], [63, "DropUnusedV1Projections", Migration0063], [64, "CanonicalizeThreadPullRequestLinks", Migration0064], + [65, "ThreadAttentionNoticeLookupIndex", Migration0065], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/065_ThreadAttentionNoticeLookupIndex.ts b/apps/server/src/persistence/Migrations/065_ThreadAttentionNoticeLookupIndex.ts new file mode 100644 index 0000000000000..991285faf8578 --- /dev/null +++ b/apps/server/src/persistence/Migrations/065_ThreadAttentionNoticeLookupIndex.ts @@ -0,0 +1,12 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_attention_notice_idx + ON orchestration_v2_projection_turn_items(thread_id, run_id) + WHERE type = 'system_notice' AND json_extract(payload_json, '$.needsAttention') = 1 + `; +}); diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index fbb8d99b3555d..c76726e271813 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -149,10 +149,9 @@ 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 -prevent automatic settlement. A notice that needs your attention also prevents -it until another turn starts, including an automatic continuation. -An open pull request does not prevent inactivity -settlement unless you turn on **Keep threads with a PR** in **Settings → General**, +prevent automatic settlement. A notice that Claude denied an action also prevents +it until another turn starts, including an automatic continuation. An open pull +request does not prevent inactivity settlement unless you turn on **Keep threads with a PR** in **Settings → General**, 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 finished background work or a pull request watch starts on its own does not. From cf0b4c448681799dcab6581c301929664c3b8a56 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 10:37:55 +0000 Subject: [PATCH 3/4] test(server): include attention notice index in migration expectations --- .../src/persistence/Migrations/055_OrchestrationV2.test.ts | 4 +++- .../064_CanonicalizeThreadPullRequestLinks.test.ts | 6 ++++-- .../src/persistence/reconcileV2PreviewMigration.test.ts | 2 ++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts index 447c365082f27..26bec0293f052 100644 --- a/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts +++ b/apps/server/src/persistence/Migrations/055_OrchestrationV2.test.ts @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => { Effect.sync(() => { assert.deepStrictEqual( migrationEntries.map(([id]) => id), - Array.from({ length: 64 }, (_, index) => index + 1), + Array.from({ length: 65 }, (_, index) => index + 1), ); }), ); @@ -36,6 +36,7 @@ layer("055_OrchestrationV2", (it) => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ThreadAttentionNoticeLookupIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); @@ -66,6 +67,7 @@ layer("055_OrchestrationV2", (it) => { { migration_id: 62, name: "ThreadFailureLookupIndex" }, { migration_id: 63, name: "DropUnusedV1Projections" }, { migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" }, + { migration_id: 65, name: "ThreadAttentionNoticeLookupIndex" }, ]); const tables = yield* sql<{ readonly name: string }>` diff --git a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts index 706cc02760eac..69eb8eff57027 100644 --- a/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts +++ b/apps/server/src/persistence/Migrations/064_CanonicalizeThreadPullRequestLinks.test.ts @@ -123,8 +123,10 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => { 'user', ${v1Payload}, '{}', 1) `; - assert.deepEqual(yield* runMigrations(), [[64, "CanonicalizeThreadPullRequestLinks"]]); - assert.deepEqual(yield* runMigrations(), []); + assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), [ + [64, "CanonicalizeThreadPullRequestLinks"], + ]); + assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), []); const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>` SELECT thread_id, payload_json FROM orchestration_v2_projection_threads `; diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 802c5110490ac..b70f30fb8ca85 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -44,6 +44,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ThreadAttentionNoticeLookupIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -131,6 +132,7 @@ describe("V2 preview upgrade", () => { [62, "ThreadFailureLookupIndex"], [63, "DropUnusedV1Projections"], [64, "CanonicalizeThreadPullRequestLinks"], + [65, "ThreadAttentionNoticeLookupIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c9fea7efd0e735b29dbc5eb054cc13bc79890989 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 11:08:49 +0000 Subject: [PATCH 4/4] fix(claude): retain denied actions across active steering --- .../Adapters/ClaudeAdapterV2.test.ts | 77 +++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 7 +- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 51b3afc047846..018550c008e4a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3132,8 +3132,32 @@ describe("ClaudeAdapterV2 background wake turns", () => { attachments: [], }); yield* harness.runtime.startTurn(input); - if (steered) { - yield* harness.runtime.steerTurn({ + const rawReason = "Auto policy blocked this command: it writes outside the workspace."; + const permissionDenials = [ + { tool_name: "Bash", tool_use_id: "steering-denied-bash", tool_input: {} }, + { tool_name: "Write", tool_use_id: "steering-denied-write", tool_input: {} }, + ]; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "steering-denial-reason", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "steering-denied-bash", + is_error: true, + content: rawReason, + }, + ], + }, + }), + ); + const steer = (messageId: string) => + harness.runtime.steerTurn({ threadId: harness.threadId, runId: input.runId, providerThread: harness.providerThread, @@ -3144,19 +3168,21 @@ describe("ClaudeAdapterV2 background wake turns", () => { message: { createdBy: "user", creationSource: "web", - messageId: MessageId.make("message-steering-abort"), + messageId: MessageId.make(messageId), text: "Include the hierarchy mock.", attachments: [], }, }); + if (steered) { + yield* steer("message-steering-abort"); assert.equal(harness.offeredMessages[1]?.priority, "now"); } - yield* Queue.offer( - harness.sdkMessages, + yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000901", result: "", terminalReason, + permissionDenials, usage: { input_tokens: 600, output_tokens: 40, @@ -3166,6 +3192,28 @@ describe("ClaudeAdapterV2 background wake turns", () => { }), ); if (steered) { + yield* steer("message-steering-abort-again"); + yield* harness.offerAndWait( + makeResultFrame({ + uuid: "steering-abort-repeated-denial", + result: "", + terminalReason, + permissionDenials: permissionDenials.slice(0, 1), + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + ); + assert.lengthOf(harness.terminalEvents(), 0); + assert.isFalse( + harness.events.some( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ), + ); yield* Queue.offer(harness.sdkMessages, wakeAssistant); yield* Queue.offer( harness.sdkMessages, @@ -3211,6 +3259,25 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); } assert.lengthOf(harness.terminalEvents(), 1); + const notices = harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem] + : [], + ); + assert.lengthOf(notices, 1); + const notice = notices[0]!; + assert.match(notice.title ?? "", /\b2 actions\b/); + assert.include(notice.message, "Bash"); + assert.include(notice.message, "Write"); + assert.include(notice.message, rawReason); + assert.isTrue(notice.needsAttention); + assert.equal(notice.runId, input.runId); + assert.isBelow( + harness.events.findIndex( + (event) => event.type === "turn_item.updated" && event.turnItem.id === notice.id, + ), + harness.events.indexOf(terminal), + ); }).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 4c8085514e12b..8c02549aafb97 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3045,6 +3045,7 @@ interface ActiveClaudeTurnContext { streamUsage: { readonly messageId: string; usage: ClaudeMessageUsage | null } | null; readonly toolCalls: Map; readonly deniedByT3ToolUseIds: Set; + readonly permissionDenials: Map; readonly toolErrorTexts: Map; readonly ignoredTaskIds: Set; readonly announcedUsageLimits: Set; @@ -7848,6 +7849,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } if (message.type === "result") { + for (const denial of message.permission_denials) { + context.permissionDenials.set(denial.tool_use_id, denial); + } const completedAt = yield* DateTime.now; const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); @@ -7904,7 +7908,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resultFailure?.class === "usage_limit" ? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null } : resultFailure; - const denials = message.permission_denials.filter( + const denials = Array.from(context.permissionDenials.values()).filter( (denial) => !context.deniedByT3ToolUseIds.has(denial.tool_use_id), ); if (denials.length > 0) { @@ -8795,6 +8799,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( streamUsage: null, toolCalls: new Map(), deniedByT3ToolUseIds: new Set(), + permissionDenials: new Map(), toolErrorTexts: new Map(), ignoredTaskIds: new Set(), announcedUsageLimits: new Set(),