Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2542,6 +2542,14 @@ describe("ClaudeAdapterV2 background wake turns", () => {
yield* Queue.unbounded<
Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "turn_item.updated" }>
>();
const runtimeRequestReceipts =
yield* Queue.unbounded<
Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "runtime_request.updated" }>
>();
const planReceipts =
yield* Queue.unbounded<
Extract<ProviderAdapter.ProviderAdapterV2Event, { type: "plan.updated" }>
>();
let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
Expand Down Expand Up @@ -2649,6 +2657,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);
}
Expand Down Expand Up @@ -2688,13 +2702,161 @@ describe("ClaudeAdapterV2 background wake turns", () => {
terminalReceipts,
subagentReceipts,
systemNoticeReceipts,
runtimeRequestReceipts,
planReceipts,
getOpenedOptions: () => openedOptions,
terminalEvents,
hasPendingBackgroundWork,
};
});
const makeWakeHarness = makeWakeHarnessWithOptions();

describe("Plan-mode tool approval", () => {
const openPlanTurn = Effect.fn("openPlanTurn")(function* (
runtimeMode: "auto" | "full-access",
approvalPolicy?: "never",
) {
const harness = yield* makeWakeHarness;
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode,
interactionMode: "plan",
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: "plan" },
});
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<ReturnType<typeof openPlanTurn>>,
toolName: string,
toolInput: Record<string, unknown>,
) {
const decision = yield* Effect.promise(() =>
turn.canUseTool(toolName, toolInput, {
signal: new AbortController().signal,
toolUseID: "tool-plan-write",
requestId: "request-plan-write",
}),
).pipe(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([
{
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, toolInput, kind, decision, behavior }) =>
Effect.gen(function* () {
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, kind);
yield* turn.runtime.respondToRuntimeRequest({ requestId: request.id, decision });
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(
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("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([
{ isError: false, title: "Check weather" },
{ isError: true, title: "Check weather" },
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1952,7 +1952,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,
};
}

Expand Down Expand Up @@ -8471,6 +8472,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",
Expand Down
Loading