diff --git a/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts b/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts new file mode 100644 index 0000000000..adfc6415f9 --- /dev/null +++ b/packages/agent/src/adapters/claude/claude-agent.permission-mode.test.ts @@ -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; + +interface ClientMocks { + sessionUpdate: ReturnType; + extNotification: ReturnType; +} + +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"); + }); +}); diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 9e2716888a..2ed43af846 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -135,6 +135,7 @@ import { CODE_EXECUTION_MODES, type CodeExecutionMode, getAvailableModes, + toSdkPermissionMode, } from "./tools"; import type { BackgroundTerminal, @@ -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) { diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index b167b5594e..854bc461ff 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -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({ diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 25a3817f2b..547f21eda9 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -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"; @@ -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, diff --git a/packages/agent/src/adapters/claude/tools.test.ts b/packages/agent/src/adapters/claude/tools.test.ts new file mode 100644 index 0000000000..6f87485db4 --- /dev/null +++ b/packages/agent/src/adapters/claude/tools.test.ts @@ -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([ + "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); + }, + ); +}); diff --git a/packages/agent/src/adapters/claude/tools.ts b/packages/agent/src/adapters/claude/tools.ts index eaccc7d218..1911b037fd 100644 --- a/packages/agent/src/adapters/claude/tools.ts +++ b/packages/agent/src/adapters/claude/tools.ts @@ -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"; @@ -55,6 +56,12 @@ const AUTO_ALLOWED_TOOLS: Record> = { plan: new Set(BASE_ALLOWED_TOOLS), }; +export function toSdkPermissionMode( + mode: CodeExecutionMode, +): SdkPermissionMode { + return mode === "auto" ? "default" : mode; +} + export function isToolAllowedForMode( toolName: string, mode: CodeExecutionMode,