From d518b405d7d409b8330f2d1df712267aefd0d1a7 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Fri, 9 Oct 2026 23:46:22 +1300 Subject: [PATCH 1/3] fix(claude): ask before writes in Plan mode --- .../Adapters/ClaudeAdapterV2.test.ts | 210 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 15 +- 2 files changed, 224 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d80e76072388e..96d7b0c1368ef 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2345,6 +2345,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.unbounded< Extract >(); + const runtimeRequestReceipts = + yield* Queue.unbounded< + Extract + >(); + const planReceipts = + yield* Queue.unbounded< + Extract + >(); let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2440,6 +2448,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { if (event.type === "turn.terminal") { yield* Queue.offer(terminalReceipts, event); } + if (event.type === "runtime_request.updated") { + yield* Queue.offer(runtimeRequestReceipts, event); + } + if (event.type === "plan.updated") { + yield* Queue.offer(planReceipts, event); + } if (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") { yield* Queue.offer(systemNoticeReceipts, event); } @@ -2478,6 +2492,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminalReceipts, subagentReceipts, systemNoticeReceipts, + runtimeRequestReceipts, + planReceipts, getOpenedOptions: () => openedOptions, terminalEvents, hasPendingBackgroundWork, @@ -2485,6 +2501,200 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); const makeWakeHarness = makeWakeHarnessWithOptions(); + describe("Plan-mode tool approval", () => { + const openPermissionTurn = Effect.fn("openPlanPermissionTurn")(function* ( + runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], + interactionMode: "plan" | "default" = "plan", + approvalPolicy?: "never", + ) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "claude-plan-write-" }); + const target = path.join(workspace, "target.txt"); + yield* fileSystem.writeFileString(target, "before"); + const harness = yield* makeWakeHarness; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode, + interactionMode, + cwd: workspace, + ...(approvalPolicy === undefined ? {} : { approvalPolicy }), + }); + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-plan-write"), + text: "Investigate before implementing.", + attachments: [], + runtimePolicy, + }); + yield* harness.runtime.startTurn({ + ...input, + appThread: { ...input.appThread, runtimeMode, interactionMode }, + }); + return { ...harness, fileSystem, target }; + }); + + const requestWrite = Effect.fn("requestPlanWrite")(function* ( + turn: Effect.Success>, + toolName: "Edit" | "Bash", + ) { + const canUseTool = turn.getOpenedOptions()?.canUseTool; + assert.isFunction(canUseTool); + const toolInput = + toolName === "Edit" + ? { file_path: turn.target, old_string: "before", new_string: "after" } + : { command: "echo after > target.txt" }; + const decision = yield* Effect.promise(() => + canUseTool!(toolName, toolInput, { + signal: new AbortController().signal, + toolUseID: "tool-plan-write", + requestId: "request-plan-write", + }), + ).pipe( + // Model the SDK's tool executor: only an allow response can perform the write. + Effect.tap((result) => + result?.behavior === "allow" + ? turn.fileSystem.writeFileString(turn.target, "after") + : Effect.void, + ), + Effect.forkScoped, + ); + const first = yield* Effect.raceFirst( + Queue.take(turn.runtimeRequestReceipts).pipe( + Effect.map((event) => ({ type: "request", event }) as const), + ), + Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)), + ); + return { first, decision }; + }); + + it.effect.each( + (["auto", "full-access", "approval-required", "auto-accept-edits"] as const).flatMap( + (runtimeMode) => + (["Edit", "Bash"] as const).flatMap((toolName) => + (["accept", "cancel"] as const).map((decision) => ({ + runtimeMode, + toolName, + decision, + })), + ), + ), + )( + "asks before $toolName in $runtimeMode Plan, then respects $decision", + ({ runtimeMode, toolName, decision }) => + Effect.gen(function* () { + const turn = yield* openPermissionTurn(runtimeMode); + assert.equal(turn.getOpenedOptions()?.permissionMode, "plan"); + const permission = yield* requestWrite(turn, toolName); + assert.equal( + yield* turn.fileSystem.readFileString(turn.target), + "before", + "the planned write changed the file before approval", + ); + assert.equal(permission.first.type, "request", "the planned write ran without approval"); + if (permission.first.type !== "request") return; + const request = permission.first.event.runtimeRequest; + assert.equal(request.status, "pending"); + assert.equal(request.kind, toolName === "Bash" ? "command" : "file-change"); + yield* turn.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); + assert.equal( + (yield* Fiber.join(permission.decision))?.behavior, + decision === "accept" ? "allow" : "deny", + ); + assert.equal( + yield* turn.fileSystem.readFileString(turn.target), + decision === "accept" ? "after" : "before", + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each( + (["auto", "full-access"] as const).flatMap((runtimeMode) => + (["Edit", "Bash"] as const).map((toolName) => ({ runtimeMode, toolName })), + ), + )( + "denies $toolName in $runtimeMode Plan when approvals are never allowed", + ({ runtimeMode, toolName }) => + Effect.gen(function* () { + const turn = yield* openPermissionTurn(runtimeMode, "plan", "never"); + const permission = yield* requestWrite(turn, toolName); + assert.equal( + yield* turn.fileSystem.readFileString(turn.target), + "before", + "Never allowed the planned write", + ); + assert.equal(permission.first.type, "decision", "Never raised an approval request"); + if (permission.first.type !== "decision") return; + assert.equal(permission.first.result?.behavior, "deny"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each(["Edit", "Bash"] as const)( + "still allows %s in Full access outside Plan", + (toolName) => + Effect.gen(function* () { + const turn = yield* openPermissionTurn("full-access", "default"); + const permission = yield* requestWrite(turn, toolName); + assert.equal(permission.first.type, "decision"); + if (permission.first.type !== "decision") return; + assert.equal(permission.first.result?.behavior, "allow"); + assert.equal(yield* turn.fileSystem.readFileString(turn.target), "after"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + + it.effect.each( + (["auto", "full-access"] as const).flatMap((runtimeMode) => + ([undefined, "never"] as const).map((approvalPolicy) => ({ runtimeMode, approvalPolicy })), + ), + )( + "still captures the proposed plan in $runtimeMode with approvals $approvalPolicy", + ({ runtimeMode, approvalPolicy }) => + Effect.gen(function* () { + const turn = yield* openPermissionTurn(runtimeMode, "plan", approvalPolicy); + const markdown = "# Proposed work\n\nFix the calculation after approval."; + const result = yield* Effect.promise(() => + turn.getOpenedOptions()!.canUseTool!( + "ExitPlanMode", + { plan: markdown }, + { + signal: new AbortController().signal, + toolUseID: "tool-plan-capture", + requestId: "request-plan-capture", + }, + ), + ); + assert.equal(result?.behavior, "deny"); + if (result?.behavior === "deny") + assert.include(result.message, "captured your proposed plan"); + const event = yield* Queue.take(turn.planReceipts); + assert.equal(event.plan.kind, "proposed_plan"); + assert.equal(event.plan.kind === "proposed_plan" ? event.plan.markdown : "", markdown); + assert.equal(yield* turn.fileSystem.readFileString(turn.target), "before"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + }); + it.effect.each([ { isError: false, title: "Check weather" }, { isError: true, title: "Check weather" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index da344c202ff0f..83a4e167d507e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1693,7 +1693,8 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy( permissionMode, ...(readOnlyTools === undefined ? {} : { tools: readOnlyTools }), ...(allowedTools === undefined ? {} : { allowedTools }), - installPermissionCallback, + // Claude routes Plan-mode writes to the callback even in Auto or Full access. + installPermissionCallback: true, }; } @@ -7381,6 +7382,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } satisfies PermissionResult; } + if ( + context.input.runtimePolicy.interactionMode === "plan" && + context.input.runtimePolicy.approvalPolicy === "never" + ) { + return { + behavior: "deny", + message: + "This tool requires approval in Plan mode, but approvals are disabled for this thread.", + toolUseID: callbackOptions.toolUseID, + } satisfies PermissionResult; + } + if (!requiresClaudeApproval(context)) { return { behavior: "allow", From b2fa07bde58462724b1bd464096c73111e3b2607 Mon Sep 17 00:00:00 2001 From: Maxwell Young Date: Sun, 11 Oct 2026 19:24:47 +1300 Subject: [PATCH 2/3] test(claude): an aborted turn denies a pending Plan-mode write approval --- .../Adapters/ClaudeAdapterV2.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 96d7b0c1368ef..041467743fe4c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2538,6 +2538,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { const requestWrite = Effect.fn("requestPlanWrite")(function* ( turn: Effect.Success>, toolName: "Edit" | "Bash", + signal: AbortSignal = new AbortController().signal, ) { const canUseTool = turn.getOpenedOptions()?.canUseTool; assert.isFunction(canUseTool); @@ -2547,7 +2548,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { : { command: "echo after > target.txt" }; const decision = yield* Effect.promise(() => canUseTool!(toolName, toolInput, { - signal: new AbortController().signal, + signal, toolUseID: "tool-plan-write", requestId: "request-plan-write", }), @@ -2640,6 +2641,37 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect.each( + (["auto", "full-access"] as const).flatMap((runtimeMode) => + (["Edit", "Bash"] as const).map((toolName) => ({ runtimeMode, toolName })), + ), + )( + "denies a pending $toolName approval in $runtimeMode Plan when the turn aborts", + ({ runtimeMode, toolName }) => + Effect.gen(function* () { + const turn = yield* openPermissionTurn(runtimeMode); + const abort = new AbortController(); + const permission = yield* requestWrite(turn, toolName, abort.signal); + assert.equal(permission.first.type, "request"); + if (permission.first.type !== "request") return; + const request = permission.first.event.runtimeRequest; + + abort.abort(); + assert.equal((yield* Fiber.join(permission.decision))?.behavior, "deny"); + // A late approval finds no pending request and cannot allow the write. + const late = yield* turn.runtime + .respondToRuntimeRequest({ requestId: request.id, decision: "accept" }) + .pipe(Effect.exit); + assert.isTrue(Exit.isFailure(late)); + assert.equal(yield* turn.fileSystem.readFileString(turn.target), "before"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect.each(["Edit", "Bash"] as const)( "still allows %s in Full access outside Plan", (toolName) => From eb7110b1b061affa34e31222759c2537197c1c6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:33:52 +0000 Subject: [PATCH 3/3] test(claude): simplify plan-mode approval regressions --- .../Adapters/ClaudeAdapterV2.test.ts | 250 ++++++------------ 1 file changed, 85 insertions(+), 165 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f79f21f23df3b..a94608f5b63c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2650,21 +2650,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { const makeWakeHarness = makeWakeHarnessWithOptions(); describe("Plan-mode tool approval", () => { - const openPermissionTurn = Effect.fn("openPlanPermissionTurn")(function* ( - runtimeMode: ProviderAdapter.ProviderAdapterV2RuntimePolicy["runtimeMode"], - interactionMode: "plan" | "default" = "plan", + const openPlanTurn = Effect.fn("openPlanTurn")(function* ( + runtimeMode: "auto" | "full-access", approvalPolicy?: "never", ) { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "claude-plan-write-" }); - const target = path.join(workspace, "target.txt"); - yield* fileSystem.writeFileString(target, "before"); const harness = yield* makeWakeHarness; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode, - interactionMode, - cwd: workspace, + interactionMode: "plan", + cwd: "/workspace", ...(approvalPolicy === undefined ? {} : { approvalPolicy }), }); const input = makeClaudeTestTurnInput({ @@ -2678,37 +2672,26 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); yield* harness.runtime.startTurn({ ...input, - appThread: { ...input.appThread, runtimeMode, interactionMode }, + appThread: { ...input.appThread, runtimeMode, interactionMode: "plan" }, }); - return { ...harness, fileSystem, target }; + assert.equal(harness.getOpenedOptions()?.permissionMode, "plan"); + const canUseTool = harness.getOpenedOptions()?.canUseTool; + assert.isDefined(canUseTool); + return { ...harness, canUseTool: canUseTool! }; }); const requestWrite = Effect.fn("requestPlanWrite")(function* ( - turn: Effect.Success>, - toolName: "Edit" | "Bash", - signal: AbortSignal = new AbortController().signal, + turn: Effect.Success>, + toolName: string, + toolInput: Record, ) { - const canUseTool = turn.getOpenedOptions()?.canUseTool; - assert.isFunction(canUseTool); - const toolInput = - toolName === "Edit" - ? { file_path: turn.target, old_string: "before", new_string: "after" } - : { command: "echo after > target.txt" }; const decision = yield* Effect.promise(() => - canUseTool!(toolName, toolInput, { - signal, + turn.canUseTool(toolName, toolInput, { + signal: new AbortController().signal, toolUseID: "tool-plan-write", requestId: "request-plan-write", }), - ).pipe( - // Model the SDK's tool executor: only an allow response can perform the write. - Effect.tap((result) => - result?.behavior === "allow" - ? turn.fileSystem.writeFileString(turn.target, "after") - : Effect.void, - ), - Effect.forkScoped, - ); + ).pipe(Effect.forkScoped); const first = yield* Effect.raceFirst( Queue.take(turn.runtimeRequestReceipts).pipe( Effect.map((event) => ({ type: "request", event }) as const), @@ -2718,43 +2701,45 @@ describe("ClaudeAdapterV2 background wake turns", () => { return { first, decision }; }); - it.effect.each( - (["auto", "full-access", "approval-required", "auto-accept-edits"] as const).flatMap( - (runtimeMode) => - (["Edit", "Bash"] as const).flatMap((toolName) => - (["accept", "cancel"] as const).map((decision) => ({ - runtimeMode, - toolName, - decision, - })), - ), - ), - )( + it.effect.each([ + { + runtimeMode: "auto", + toolName: "Edit", + toolInput: { + file_path: "/workspace/target.txt", + old_string: "before", + new_string: "after", + }, + kind: "file-change", + decision: "accept", + behavior: "allow", + }, + { + runtimeMode: "full-access", + toolName: "Bash", + toolInput: { command: "echo after > target.txt" }, + kind: "command", + decision: "cancel", + behavior: "deny", + }, + ] as const)( "asks before $toolName in $runtimeMode Plan, then respects $decision", - ({ runtimeMode, toolName, decision }) => + ({ runtimeMode, toolName, toolInput, kind, decision, behavior }) => Effect.gen(function* () { - const turn = yield* openPermissionTurn(runtimeMode); - assert.equal(turn.getOpenedOptions()?.permissionMode, "plan"); - const permission = yield* requestWrite(turn, toolName); - assert.equal( - yield* turn.fileSystem.readFileString(turn.target), - "before", - "the planned write changed the file before approval", - ); - assert.equal(permission.first.type, "request", "the planned write ran without approval"); + const turn = yield* openPlanTurn(runtimeMode); + const permission = yield* requestWrite(turn, toolName, toolInput); + assert.equal(permission.first.type, "request", "the planned write bypassed approval"); if (permission.first.type !== "request") return; const request = permission.first.event.runtimeRequest; assert.equal(request.status, "pending"); - assert.equal(request.kind, toolName === "Bash" ? "command" : "file-change"); + assert.equal(request.kind, kind); yield* turn.runtime.respondToRuntimeRequest({ requestId: request.id, decision }); - assert.equal( - (yield* Fiber.join(permission.decision))?.behavior, - decision === "accept" ? "allow" : "deny", - ); - assert.equal( - yield* turn.fileSystem.readFileString(turn.target), - decision === "accept" ? "after" : "before", - ); + const result = yield* Fiber.join(permission.decision); + assert.equal(result?.behavior, behavior); + if (result?.behavior === "allow") { + assert.deepEqual(result.updatedInput, toolInput); + assert.equal(result.toolUseID, "tool-plan-write"); + } }).pipe( Effect.scoped, Effect.provide( @@ -2763,115 +2748,50 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect.each( - (["auto", "full-access"] as const).flatMap((runtimeMode) => - (["Edit", "Bash"] as const).map((toolName) => ({ runtimeMode, toolName })), - ), - )( - "denies $toolName in $runtimeMode Plan when approvals are never allowed", - ({ runtimeMode, toolName }) => - Effect.gen(function* () { - const turn = yield* openPermissionTurn(runtimeMode, "plan", "never"); - const permission = yield* requestWrite(turn, toolName); - assert.equal( - yield* turn.fileSystem.readFileString(turn.target), - "before", - "Never allowed the planned write", - ); - assert.equal(permission.first.type, "decision", "Never raised an approval request"); - if (permission.first.type !== "decision") return; - assert.equal(permission.first.result?.behavior, "deny"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), + it.effect("denies Plan writes without asking when approvals are never allowed", () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access", "never"); + const permission = yield* requestWrite(turn, "Bash", { + command: "echo after > target.txt", + }); + assert.equal(permission.first.type, "decision", "Never raised an approval request"); + if (permission.first.type !== "decision") return; + assert.equal(permission.first.result?.behavior, "deny"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); - - it.effect.each( - (["auto", "full-access"] as const).flatMap((runtimeMode) => - (["Edit", "Bash"] as const).map((toolName) => ({ runtimeMode, toolName })), ), - )( - "denies a pending $toolName approval in $runtimeMode Plan when the turn aborts", - ({ runtimeMode, toolName }) => - Effect.gen(function* () { - const turn = yield* openPermissionTurn(runtimeMode); - const abort = new AbortController(); - const permission = yield* requestWrite(turn, toolName, abort.signal); - assert.equal(permission.first.type, "request"); - if (permission.first.type !== "request") return; - const request = permission.first.event.runtimeRequest; - - abort.abort(); - assert.equal((yield* Fiber.join(permission.decision))?.behavior, "deny"); - // A late approval finds no pending request and cannot allow the write. - const late = yield* turn.runtime - .respondToRuntimeRequest({ requestId: request.id, decision: "accept" }) - .pipe(Effect.exit); - assert.isTrue(Exit.isFailure(late)); - assert.equal(yield* turn.fileSystem.readFileString(turn.target), "before"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), ); - it.effect.each(["Edit", "Bash"] as const)( - "still allows %s in Full access outside Plan", - (toolName) => - Effect.gen(function* () { - const turn = yield* openPermissionTurn("full-access", "default"); - const permission = yield* requestWrite(turn, toolName); - assert.equal(permission.first.type, "decision"); - if (permission.first.type !== "decision") return; - assert.equal(permission.first.result?.behavior, "allow"); - assert.equal(yield* turn.fileSystem.readFileString(turn.target), "after"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + it.effect("still captures ExitPlanMode when Plan approvals are never allowed", () => + Effect.gen(function* () { + const turn = yield* openPlanTurn("full-access", "never"); + const markdown = "# Proposed work\n\nFix the calculation after approval."; + const result = yield* Effect.promise(() => + turn.canUseTool( + "ExitPlanMode", + { plan: markdown }, + { + signal: new AbortController().signal, + toolUseID: "tool-plan-capture", + requestId: "request-plan-capture", + }, ), + ); + assert.equal(result?.behavior, "deny"); + if (result?.behavior === "deny") + assert.include(result.message, "captured your proposed plan"); + const event = yield* Queue.take(turn.planReceipts); + assert.equal(event.plan.kind, "proposed_plan"); + assert.equal(event.plan.kind === "proposed_plan" ? event.plan.markdown : "", markdown); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), ), - ); - - it.effect.each( - (["auto", "full-access"] as const).flatMap((runtimeMode) => - ([undefined, "never"] as const).map((approvalPolicy) => ({ runtimeMode, approvalPolicy })), ), - )( - "still captures the proposed plan in $runtimeMode with approvals $approvalPolicy", - ({ runtimeMode, approvalPolicy }) => - Effect.gen(function* () { - const turn = yield* openPermissionTurn(runtimeMode, "plan", approvalPolicy); - const markdown = "# Proposed work\n\nFix the calculation after approval."; - const result = yield* Effect.promise(() => - turn.getOpenedOptions()!.canUseTool!( - "ExitPlanMode", - { plan: markdown }, - { - signal: new AbortController().signal, - toolUseID: "tool-plan-capture", - requestId: "request-plan-capture", - }, - ), - ); - assert.equal(result?.behavior, "deny"); - if (result?.behavior === "deny") - assert.include(result.message, "captured your proposed plan"); - const event = yield* Queue.take(turn.planReceipts); - assert.equal(event.plan.kind, "proposed_plan"); - assert.equal(event.plan.kind === "proposed_plan" ? event.plan.markdown : "", markdown); - assert.equal(yield* turn.fileSystem.readFileString(turn.target), "before"); - }).pipe( - Effect.scoped, - Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), - ), - ), ); });