Skip to content

Commit d3ffcd3

Browse files
fix(agent): stop Auto Mode from re-prompting via the SDK's native auto mode (#3362)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent 65c8cac commit d3ffcd3

6 files changed

Lines changed: 217 additions & 3 deletions

File tree

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
2+
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import {
4+
CODE_EXECUTION_MODES,
5+
type CodeExecutionMode,
6+
} from "../../execution-mode";
7+
import { createMockQuery, type MockQuery } from "../../test/mocks/claude-sdk";
8+
import { Pushable } from "../../utils/streams";
9+
import { toSdkPermissionMode } from "./tools";
10+
11+
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
12+
query: vi.fn(),
13+
}));
14+
15+
vi.mock("./mcp/tool-metadata", () => ({
16+
fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined),
17+
getConnectedMcpServerNames: vi.fn().mockReturnValue([]),
18+
setMcpToolApprovalStates: vi.fn(),
19+
isMcpToolReadOnly: vi.fn().mockReturnValue(false),
20+
getMcpToolMetadata: vi.fn().mockReturnValue(undefined),
21+
getMcpToolApprovalState: vi.fn().mockReturnValue(undefined),
22+
}));
23+
24+
const { ClaudeAcpAgent } = await import("./claude-agent");
25+
type Agent = InstanceType<typeof ClaudeAcpAgent>;
26+
27+
interface ClientMocks {
28+
sessionUpdate: ReturnType<typeof vi.fn>;
29+
extNotification: ReturnType<typeof vi.fn>;
30+
}
31+
32+
function makeAgent(): { agent: Agent; client: ClientMocks } {
33+
const client: ClientMocks = {
34+
sessionUpdate: vi.fn().mockResolvedValue(undefined),
35+
extNotification: vi.fn().mockResolvedValue(undefined),
36+
};
37+
const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection);
38+
return { agent, client };
39+
}
40+
41+
function installFakeSession(
42+
agent: Agent,
43+
sessionId: string,
44+
permissionMode: CodeExecutionMode = "default",
45+
): MockQuery {
46+
const query = createMockQuery();
47+
const input = new Pushable();
48+
const abortController = new AbortController();
49+
50+
const session = {
51+
query,
52+
queryOptions: { sessionId, cwd: "/tmp/repo", abortController },
53+
buildInProcessMcpServers: () => ({}),
54+
localToolsServerNames: [] as string[],
55+
input,
56+
cancelled: false,
57+
interruptReason: undefined,
58+
settingsManager: { dispose: vi.fn(), getRepoRoot: () => "/tmp/repo" },
59+
permissionMode,
60+
abortController,
61+
accumulatedUsage: {
62+
inputTokens: 0,
63+
outputTokens: 0,
64+
cachedReadTokens: 0,
65+
cachedWriteTokens: 0,
66+
},
67+
sessionResources: new Set(),
68+
configOptions: [],
69+
turnQueue: [],
70+
activeTurn: null,
71+
pendingOrphanResults: 0,
72+
queryGeneration: 0,
73+
cwd: "/tmp/repo",
74+
notificationHistory: [] as unknown[],
75+
taskRunId: "run-1",
76+
lastContextWindowSize: 200_000,
77+
modelId: "claude-sonnet-4-6",
78+
knownSlashCommands: undefined,
79+
};
80+
81+
(agent as unknown as { session: typeof session }).session = session;
82+
(agent as unknown as { sessionId: string }).sessionId = sessionId;
83+
84+
return query;
85+
}
86+
87+
describe("ClaudeAcpAgent.setSessionMode — SDK permission-mode translation", () => {
88+
beforeEach(() => {
89+
vi.clearAllMocks();
90+
});
91+
92+
it.each(CODE_EXECUTION_MODES)(
93+
"maps modeId %s to the SDK's permission mode at the setPermissionMode call site",
94+
async (modeId) => {
95+
const { agent } = makeAgent();
96+
const query = installFakeSession(agent, "s-mode");
97+
98+
await agent.setSessionMode({ sessionId: "s-mode", modeId });
99+
100+
expect(query.setPermissionMode).toHaveBeenCalledWith(
101+
toSdkPermissionMode(modeId),
102+
);
103+
expect(
104+
(agent as unknown as { session: { permissionMode: string } }).session
105+
.permissionMode,
106+
).toBe(modeId);
107+
},
108+
);
109+
110+
it("reverts session.permissionMode to the previous mode when the SDK rejects", async () => {
111+
const { agent } = makeAgent();
112+
const query = installFakeSession(agent, "s-mode", "default");
113+
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(
114+
new Error("sdk rejected"),
115+
);
116+
117+
await expect(
118+
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
119+
).rejects.toThrow("sdk rejected");
120+
121+
expect(
122+
(agent as unknown as { session: { permissionMode: string } }).session
123+
.permissionMode,
124+
).toBe("default");
125+
});
126+
127+
it("falls back to a generic error message when the SDK rejection has none", async () => {
128+
const { agent } = makeAgent();
129+
const query = installFakeSession(agent, "s-mode", "default");
130+
vi.mocked(query.setPermissionMode).mockRejectedValueOnce(new Error());
131+
132+
await expect(
133+
agent.setSessionMode({ sessionId: "s-mode", modeId: "auto" }),
134+
).rejects.toThrow("Invalid Mode");
135+
});
136+
137+
it("records modeBeforePlan using the host mode, unaffected by the SDK translation", async () => {
138+
const { agent } = makeAgent();
139+
installFakeSession(agent, "s-mode", "auto");
140+
141+
await agent.setSessionMode({ sessionId: "s-mode", modeId: "plan" });
142+
143+
const session = (
144+
agent as unknown as {
145+
session: { permissionMode: string; modeBeforePlan?: string };
146+
}
147+
).session;
148+
expect(session.permissionMode).toBe("plan");
149+
expect(session.modeBeforePlan).toBe("auto");
150+
});
151+
});

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ import {
135135
CODE_EXECUTION_MODES,
136136
type CodeExecutionMode,
137137
getAvailableModes,
138+
toSdkPermissionMode,
138139
} from "./tools";
139140
import type {
140141
BackgroundTerminal,
@@ -1778,7 +1779,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
17781779
this.session.modeBeforePlan = previousMode;
17791780
}
17801781
try {
1781-
await this.session.query.setPermissionMode(modeId as CodeExecutionMode);
1782+
await this.session.query.setPermissionMode(
1783+
toSdkPermissionMode(modeId as CodeExecutionMode),
1784+
);
17821785
} catch (error) {
17831786
this.session.permissionMode = previousMode;
17841787
if (error instanceof Error) {

packages/agent/src/adapters/claude/session/options.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,25 @@ describe("buildSessionOptions", () => {
5757
},
5858
);
5959

60+
it("maps the custom auto mode to the SDK's default mode", () => {
61+
const options = buildSessionOptions({
62+
...makeParams(),
63+
permissionMode: "auto",
64+
});
65+
expect(options.permissionMode).toBe("default");
66+
});
67+
68+
it.each(["default", "acceptEdits", "plan", "bypassPermissions"] as const)(
69+
"passes native SDK mode %s through to options.permissionMode",
70+
(mode) => {
71+
const options = buildSessionOptions({
72+
...makeParams(),
73+
permissionMode: mode,
74+
});
75+
expect(options.permissionMode).toBe(mode);
76+
},
77+
);
78+
6079
it("preserves caller-provided agents alongside defaults", () => {
6180
const params = makeParams();
6281
const options = buildSessionOptions({

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
type EnrichedReadCache,
2525
type OnModeChange,
2626
} from "../hooks";
27-
import type { CodeExecutionMode } from "../tools";
27+
import { type CodeExecutionMode, toSdkPermissionMode } from "../tools";
2828
import type { EffortLevel } from "../types";
2929
import { APPENDED_INSTRUCTIONS } from "./instructions";
3030
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
@@ -445,7 +445,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
445445
cwd: params.cwd,
446446
includePartialMessages: true,
447447
allowDangerouslySkipPermissions: !IS_ROOT || !!process.env.IS_SANDBOX,
448-
permissionMode: params.permissionMode,
448+
permissionMode: toSdkPermissionMode(params.permissionMode),
449449
canUseTool: params.canUseTool,
450450
tools,
451451
agents,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { CodeExecutionMode } from "../../execution-mode";
3+
import { isToolAllowedForMode, toSdkPermissionMode } from "./tools";
4+
5+
describe("toSdkPermissionMode", () => {
6+
it("maps the custom auto mode to the SDK's default mode", () => {
7+
expect(toSdkPermissionMode("auto")).toBe("default");
8+
});
9+
10+
it.each<CodeExecutionMode>([
11+
"default",
12+
"acceptEdits",
13+
"plan",
14+
"bypassPermissions",
15+
])("passes native SDK mode %s through unchanged", (mode) => {
16+
expect(toSdkPermissionMode(mode)).toBe(mode);
17+
});
18+
});
19+
20+
describe("isToolAllowedForMode stays authoritative for auto", () => {
21+
it.each(["Bash", "Edit", "Write", "NotebookEdit", "BashOutput", "KillShell"])(
22+
"auto-allows %s in auto mode",
23+
(tool) => {
24+
expect(isToolAllowedForMode(tool, "auto")).toBe(true);
25+
},
26+
);
27+
28+
it.each(["Bash", "Edit", "Write"])(
29+
"still gates %s in default mode",
30+
(tool) => {
31+
expect(isToolAllowedForMode(tool, "default")).toBe(false);
32+
},
33+
);
34+
});

packages/agent/src/adapters/claude/tools.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
type ModeInfo,
66
} from "../../execution-mode";
77

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

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

59+
export function toSdkPermissionMode(
60+
mode: CodeExecutionMode,
61+
): SdkPermissionMode {
62+
return mode === "auto" ? "default" : mode;
63+
}
64+
5865
export function isToolAllowedForMode(
5966
toolName: string,
6067
mode: CodeExecutionMode,

0 commit comments

Comments
 (0)