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
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CODE_EXECUTION_MODES,
type CodeExecutionMode,
} from "../../execution-mode";
import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk";
import { Pushable } from "../../utils/streams";
import { toSdkPermissionMode } from "./tools";

vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: vi.fn(),
}));

vi.mock("./mcp/tool-metadata", () => ({
fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
setMcpToolApprovalStates: vi.fn(),
isMcpToolReadOnly: vi.fn().mockReturnValue(false),
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
getMcpToolApprovalState: vi.fn().mockReturnValue(undefined),
}));

const { ClaudeAcpAgent } = await import("./claude-agent");
type Agent = InstanceType<typeof ClaudeAcpAgent>;

interface ClientMocks {
sessionUpdate: ReturnType<typeof vi.fn>;
extNotification: ReturnType<typeof vi.fn>;
}

function makeAgent(): { agent: Agent; client: ClientMocks } {
const client: ClientMocks = {
sessionUpdate: vi.fn().mockResolvedValue(undefined),
extNotification: vi.fn().mockResolvedValue(undefined),
};
const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection);
return { agent, client };
}

function installFakeSession(
agent: Agent,
sessionId: string,
permissionMode: CodeExecutionMode = "default",
): MockQuery {
const query = createMockQuery();
const input = new Pushable();
const abortController = new AbortController();

const session = {
query,
queryOptions: { sessionId, cwd: "/tmp/repo", abortController },
buildInProcessMcpServers: () => ({}),
localToolsServerNames: [] as string[],
input,
cancelled: false,
interruptReason: undefined,
settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" },
permissionMode,
abortController,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
sessionResources: new Set(),
configOptions: [],
turnQueue: [],
activeTurn: null,
pendingOrphanResults: 0,
queryGeneration: 0,
cwd: "/tmp/repo",
notificationHistory: [] as unknown[],
taskRunId: "run-1",
lastContextWindowSize: 200_000,
modelId: "claude-sonnet-4-6",
knownSlashCommands: undefined,
};

(agent as unknown as { session: typeof session }).session = session;
(agent as unknown as { sessionId: string }).sessionId = sessionId;

return query;
}

describe("ClaudeAcpAgent.setSessionMode — SDK permission-mode translation", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it.each(CODE_EXECUTION_MODES)(
"maps modeId %s to the SDK's permission mode at the setPermissionMode call site",
async (modeId) => {
const { agent } = makeAgent();
const query = installFakeSession(agent, "s-mode");

await agent.setSessionMode({ sessionId: "s-mode", modeId });

expect(query.setPermissionMode).toHaveBeenCalledWith(
toSdkPermissionMode(modeId),
);
expect(
(agent as unknown as { session: { permissionMode: string } }).session
.permissionMode,
).toBe(modeId);
},
);

it("reverts session.permissionMode to the previous mode when the SDK rejects", async () => {
const { agent } = makeAgent();
const query = installFakeSession(agent, "s-mode", "default");
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(
new Error("sdk rejected"),
);

await expect(
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
).rejects.toThrow("sdk rejected");

expect(
(agent as unknown as { session: { permissionMode: string } }).session
.permissionMode,
).toBe("default");
});

it("falls back to a generic error message when the SDK rejection has none", async () => {
const { agent } = makeAgent();
const query = installFakeSession(agent, "s-mode", "default");
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(new Error());

await expect(
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
).rejects.toThrow("Invalid Mode");
});

it("records modeBeforePlan using the host mode, unaffected by the SDK translation", async () => {
const { agent } = makeAgent();
installFakeSession(agent, "s-mode", "auto");

await agent.setSessionMode({ sessionId: "s-mode", modeId: "plan" });

const session = (
agent as unknown as {
session: { permissionMode: string; modeBeforePlan?: string };
}
).session;
expect(session.permissionMode).toBe("plan");
expect(session.modeBeforePlan).toBe("auto");
});
});
5 changes: 4 additions & 1 deletion packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import {
CODE_EXECUTION_MODES,
type CodeExecutionMode,
getAvailableModes,
toSdkPermissionMode,
} from "./tools";
import type {
BackgroundTerminal,
Expand Down Expand Up @@ -1778,7 +1779,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
this.session.modeBeforePlan = previousMode;
}
try {
await this.session.query.setPermissionMode(modeId as CodeExecutionMode);
await this.session.query.setPermissionMode(
toSdkPermissionMode(modeId as CodeExecutionMode),
);
} catch (error) {
this.session.permissionMode = previousMode;
if (error instanceof Error) {
Expand Down
19 changes: 19 additions & 0 deletions packages/agent/src/adapters/claude/session/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,25 @@ describe("buildSessionOptions", () => {
},
);

it("maps the custom auto mode to the SDK's default mode", () => {
const options = buildSessionOptions({
...makeParams(),
permissionMode: "auto",
});
expect(options.permissionMode).toBe("default");
});

it.each(["default", "acceptEdits", "plan", "bypassPermissions"] as const)(
"passes native SDK mode %s through to options.permissionMode",
(mode) => {
const options = buildSessionOptions({
...makeParams(),
permissionMode: mode,
});
expect(options.permissionMode).toBe(mode);
},
);

it("preserves caller-provided agents alongside defaults", () => {
const params = makeParams();
const options = buildSessionOptions({
Expand Down
4 changes: 2 additions & 2 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
type EnrichedReadCache,
type OnModeChange,
} from "../hooks";
import type { CodeExecutionMode } from "../tools";
import { type CodeExecutionMode, toSdkPermissionMode } from "../tools";
import type { EffortLevel } from "../types";
import { APPENDED_INSTRUCTIONS } from "./instructions";
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
Expand Down Expand Up @@ -445,7 +445,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
cwd: params.cwd,
includePartialMessages: true,
allowDangerouslySkipPermissions: !IS_ROOT || !!process.env.IS_SANDBOX,
permissionMode: params.permissionMode,
permissionMode: toSdkPermissionMode(params.permissionMode),
canUseTool: params.canUseTool,
tools,
agents,
Expand Down
34 changes: 34 additions & 0 deletions packages/agent/src/adapters/claude/tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import type { CodeExecutionMode } from "../../execution-mode";
import { isToolAllowedForMode, toSdkPermissionMode } from "./tools";

describe("toSdkPermissionMode", () => {
it("maps the custom auto mode to the SDK's default mode", () => {
expect(toSdkPermissionMode("auto")).toBe("default");
});

it.each<CodeExecutionMode>([
"default",
"acceptEdits",
"plan",
"bypassPermissions",
])("passes native SDK mode %s through unchanged", (mode) => {
expect(toSdkPermissionMode(mode)).toBe(mode);
});
});

describe("isToolAllowedForMode stays authoritative for auto", () => {
it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])(
"auto-allows %s in auto mode",
(tool) => {
expect(isToolAllowedForMode(tool, "auto")).toBe(true);
},
);

it.each(["Bash", "Edit", "Write"])(
"still gates %s in default mode",
(tool) => {
expect(isToolAllowedForMode(tool, "default")).toBe(false);
},
);
});
7 changes: 7 additions & 0 deletions packages/agent/src/adapters/claude/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
type ModeInfo,
} from "../../execution-mode";

import type { PermissionMode as SdkPermissionMode } from "@anthropic-ai/claude-agent-sdk";
import type { CodeExecutionMode } from "../../execution-mode";
import { isMcpToolReadOnly } from "./mcp/tool-metadata";

Expand Down Expand Up @@ -55,6 +56,12 @@ const AUTO_ALLOWED_TOOLS: Record<string, Set<string>> = {
plan: new Set(BASE_ALLOWED_TOOLS),
};

export function toSdkPermissionMode(
mode: CodeExecutionMode,
): SdkPermissionMode {
return mode === "auto" ? "default" : mode;
}

export function isToolAllowedForMode(
toolName: string,
mode: CodeExecutionMode,
Expand Down
Loading