Skip to content
Open
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
245 changes: 127 additions & 118 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,130 +1049,139 @@ describe("ClaudeAdapterV2 session permissions", () => {
});
});

describe("ClaudeAdapterV2 Auto-accept edits", () => {
it.effect("asks before a command instead of allowing it", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-accept-edits-",
});
let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CLAUDE_SETTINGS,
environment: {},
attachmentsDir,
fileSystem,
path: yield* Path.Path,
random: yield* SecureRandom.SecureRandom,
idAllocator,
queryRunner: {
allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
open: (input) =>
Effect.sync(() => {
openedOptions = input.options;
return {
messages: Stream.never,
offer: () => Effect.void,
setModel: () => Effect.void,
applyFlagSettings: () => Effect.void,
setPermissionMode: () => Effect.void,
backgroundTasks: () => Effect.succeed(false),
mcpServerStatus: Effect.succeed([]),
readMcpResource: () => Effect.die("unexpected MCP resource read"),
callMcpTool: () => Effect.die("unexpected MCP tool call"),
listPermissionRules: Effect.die("unexpected permission rules read"),
stopTask: () => Effect.void,
interrupt: Effect.void,
close: Effect.void,
};
}),
forkSession: () => Effect.die("unused"),
subagentLaunchToolUseId: () => Effect.succeed(null),
subagentModel: () => Effect.succeed(null),
assertComplete: Effect.void,
},
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode: "auto-accept-edits",
interactionMode: "default",
cwd: "/workspace",
});
const threadId = ThreadId.make("thread-claude-accept-edits");
const runtime = yield* adapter.openSession({
threadId,
providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"),
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
});
const now = yield* DateTime.now;
yield* runtime.startTurn(
makeClaudeTestTurnInput({
describe("ClaudeAdapterV2 Auto and Auto-accept edits", () => {
it.effect.each([
{ runtimeMode: "auto-accept-edits", permissionMode: "acceptEdits" },
{ runtimeMode: "auto", permissionMode: "auto" },
] as const)(
"asks before a command in $runtimeMode instead of allowing it",
({ runtimeMode, permissionMode }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-claude-accept-edits-",
});
let openedOptions: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined;
const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({
instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CLAUDE_SETTINGS,
environment: {},
attachmentsDir,
fileSystem,
path: yield* Path.Path,
random: yield* SecureRandom.SecureRandom,
idAllocator,
queryRunner: {
allocateSessionId: Effect.succeed("native-thread-claude-accept-edits"),
open: (input) =>
Effect.sync(() => {
openedOptions = input.options;
return {
messages: Stream.never,
offer: () => Effect.void,
setModel: () => Effect.void,
applyFlagSettings: () => Effect.void,
setPermissionMode: () => Effect.void,
backgroundTasks: () => Effect.succeed(false),
mcpServerStatus: Effect.succeed([]),
readMcpResource: () => Effect.die("unexpected MCP resource read"),
callMcpTool: () => Effect.die("unexpected MCP tool call"),
listPermissionRules: Effect.die("unexpected permission rules read"),
stopTask: () => Effect.void,
interrupt: Effect.void,
close: Effect.void,
};
}),
forkSession: () => Effect.die("unused"),
subagentLaunchToolUseId: () => Effect.succeed(null),
subagentModel: () => Effect.succeed(null),
assertComplete: Effect.void,
},
});
const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({
runtimeMode,
interactionMode: "default",
cwd: "/workspace",
});
const threadId = ThreadId.make("thread-claude-accept-edits");
const runtime = yield* adapter.openSession({
threadId,
providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-accept-edits"),
text: "Run node.",
attachments: [],
providerSessionId: ProviderSessionId.make("provider-session-claude-accept-edits"),
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
}),
);
assert.equal(openedOptions?.permissionMode, "acceptEdits");
const canUseTool = openedOptions?.canUseTool;
assert.isFunction(canUseTool);
});
const providerThread = yield* runtime.ensureThread({
threadId,
modelSelection: CLAUDE_TEST_MODEL_SELECTION,
runtimePolicy,
});
const now = yield* DateTime.now;
yield* runtime.startTurn(
makeClaudeTestTurnInput({
threadId,
providerThread,
now,
attemptId: RunAttemptId.make("attempt-claude-accept-edits"),
text: "Run node.",
attachments: [],
runtimePolicy,
}),
);
assert.equal(openedOptions?.permissionMode, permissionMode);
const canUseTool = openedOptions?.canUseTool;
assert.isFunction(canUseTool);

const requestEvent = yield* runtime.events.pipe(
Stream.takeUntil(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
),
Stream.runCollect,
Effect.forkScoped,
);
const command = { command: "node -e 'console.log(42)'" };
const decision = yield* Effect.promise(() =>
canUseTool!("Bash", command, {
signal: new AbortController().signal,
toolUseID: "tool-bash-accept-edits",
requestId: "request-bash-accept-edits",
description: "Check the Node runtime",
}),
).pipe(Effect.forkScoped);
const first = yield* Effect.raceFirst(
Fiber.join(requestEvent).pipe(Effect.map((event) => ({ type: "request", event }) as const)),
Fiber.join(decision).pipe(Effect.map((result) => ({ type: "decision", result }) as const)),
);
assert.equal(first.type, "request", "the command ran without asking");
if (first.type !== "request") return;
const event = first.event.find((event) => event.type === "runtime_request.updated");
assert.equal(event?.runtimeRequest.kind, "command");
const approval = first.event.findLast((event) => event.type === "turn_item.updated");
assert.equal(
approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined,
command.command,
);
const requestEvent = yield* runtime.events.pipe(
Stream.takeUntil(
(event) =>
event.type === "turn_item.updated" && event.turnItem.type === "approval_request",
),
Stream.runCollect,
Effect.forkScoped,
);
const command = { command: "node -e 'console.log(42)'" };
const decision = yield* Effect.promise(() =>
canUseTool!("Bash", command, {
signal: new AbortController().signal,
toolUseID: "tool-bash-accept-edits",
requestId: "request-bash-accept-edits",
description: "Check the Node runtime",
}),
).pipe(Effect.forkScoped);
const first = yield* Effect.raceFirst(
Fiber.join(requestEvent).pipe(
Effect.map((event) => ({ type: "request", event }) as const),
),
Fiber.join(decision).pipe(
Effect.map((result) => ({ type: "decision", result }) as const),
),
);
assert.equal(first.type, "request", "the command ran without asking");
if (first.type !== "request") return;
const event = first.event.find((event) => event.type === "runtime_request.updated");
assert.equal(event?.runtimeRequest.kind, "command");
const approval = first.event.findLast((event) => event.type === "turn_item.updated");
assert.equal(
approval?.turnItem.type === "approval_request" ? approval.turnItem.prompt : undefined,
command.command,
);

yield* runtime.respondToRuntimeRequest({
requestId: event!.runtimeRequest.id,
decision: "accept",
});
assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
yield* runtime.respondToRuntimeRequest({
requestId: event!.runtimeRequest.id,
decision: "accept",
});
assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
}).pipe(
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
),
);
});

Expand Down
5 changes: 2 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3262,12 +3262,11 @@ export function claudeRuntimeQueryPolicyForRuntimePolicy(
readOnlyTools !== undefined && readOnlyPolicyAllowsGlobalReads(runtimePolicy)
? readOnlyTools
: undefined;
// acceptEdits approves edits before the callback; the callback must ask for everything else.
// acceptEdits and auto approve routine actions before the callback; the callback must ask for the rest.
const installPermissionCallback =
downgraded ||
(runtimePolicy.approvalPolicy === undefined
? runtimePolicy.runtimeMode === "approval-required" ||
runtimePolicy.runtimeMode === "auto-accept-edits"
? runtimePolicy.runtimeMode !== "full-access"
: runtimePolicy.approvalPolicy !== "never");

if (permissionMode === "plan") {
Expand Down
Loading