Skip to content

Commit 74e3d02

Browse files
authored
chore(agent): preserve codex native cloud modes (#1689)
this preserves Codex-native permission modes through cloud session creation, loading, and reconnects. we want cloud Codex session behavior line up with local Codex instead of translating everything through Claude-style modes.
1 parent 50d2e71 commit 74e3d02

5 files changed

Lines changed: 254 additions & 37 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { Readable, Writable } from "node:stream";
2+
import type {
3+
AgentSideConnection,
4+
LoadSessionResponse,
5+
NewSessionResponse,
6+
} from "@agentclientprotocol/sdk";
7+
import { beforeEach, describe, expect, it, vi } from "vitest";
8+
9+
const mockCodexConnection = {
10+
initialize: vi.fn(),
11+
newSession: vi.fn(),
12+
loadSession: vi.fn(),
13+
setSessionMode: vi.fn(),
14+
listSessions: vi.fn(),
15+
prompt: vi.fn(),
16+
setSessionConfigOption: vi.fn(),
17+
};
18+
19+
const mockKill = vi.fn();
20+
21+
vi.mock("@agentclientprotocol/sdk", async () => {
22+
const actual = await vi.importActual("@agentclientprotocol/sdk");
23+
24+
return {
25+
...actual,
26+
ClientSideConnection: vi.fn(() => mockCodexConnection),
27+
ndJsonStream: vi.fn(() => ({}) as object),
28+
};
29+
});
30+
31+
vi.mock("./spawn", () => ({
32+
spawnCodexProcess: vi.fn(() => ({
33+
process: { pid: 1234 },
34+
stdin: new Writable({
35+
write(_chunk, _encoding, callback) {
36+
callback();
37+
},
38+
}),
39+
stdout: new Readable({
40+
read() {},
41+
}),
42+
kill: mockKill,
43+
})),
44+
}));
45+
46+
vi.mock("./settings", () => ({
47+
CodexSettingsManager: vi.fn().mockImplementation((cwd: string) => ({
48+
initialize: vi.fn(),
49+
dispose: vi.fn(),
50+
getCwd: () => cwd,
51+
setCwd: vi.fn(),
52+
getSettings: () => ({}),
53+
})),
54+
}));
55+
56+
import { CodexAcpAgent } from "./codex-agent";
57+
58+
describe("CodexAcpAgent", () => {
59+
beforeEach(() => {
60+
vi.clearAllMocks();
61+
});
62+
63+
function createAgent(): CodexAcpAgent {
64+
const client = {
65+
extNotification: vi.fn(),
66+
} as unknown as AgentSideConnection;
67+
68+
return new CodexAcpAgent(client, {
69+
codexProcessOptions: {
70+
cwd: process.cwd(),
71+
},
72+
});
73+
}
74+
75+
it("applies the requested initial mode for a new session", async () => {
76+
const agent = createAgent();
77+
mockCodexConnection.newSession.mockResolvedValue({
78+
sessionId: "session-1",
79+
modes: { currentModeId: "auto", availableModes: [] },
80+
configOptions: [],
81+
} satisfies Partial<NewSessionResponse>);
82+
83+
await agent.newSession({
84+
cwd: process.cwd(),
85+
_meta: { permissionMode: "read-only" },
86+
} as never);
87+
88+
expect(mockCodexConnection.setSessionMode).toHaveBeenCalledWith({
89+
sessionId: "session-1",
90+
modeId: "read-only",
91+
});
92+
expect(
93+
(agent as unknown as { sessionState: { permissionMode: string } })
94+
.sessionState.permissionMode,
95+
).toBe("read-only");
96+
});
97+
98+
it("preserves the live session mode when loading an existing session", async () => {
99+
const agent = createAgent();
100+
mockCodexConnection.loadSession.mockResolvedValue({
101+
modes: { currentModeId: "read-only", availableModes: [] },
102+
configOptions: [],
103+
} satisfies Partial<LoadSessionResponse>);
104+
105+
await agent.loadSession({
106+
sessionId: "session-1",
107+
cwd: process.cwd(),
108+
_meta: { permissionMode: "auto" },
109+
} as never);
110+
111+
expect(mockCodexConnection.setSessionMode).not.toHaveBeenCalled();
112+
expect(
113+
(agent as unknown as { sessionState: { permissionMode: string } })
114+
.sessionState.permissionMode,
115+
).toBe("read-only");
116+
});
117+
});

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

Lines changed: 90 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ import {
3636
import packageJson from "../../../package.json" with { type: "json" };
3737
import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions";
3838
import {
39-
CODE_EXECUTION_MODES,
4039
type CodeExecutionMode,
40+
type CodexNativeMode,
41+
isCodeExecutionMode,
42+
isCodexNativeMode,
43+
type PermissionMode,
4144
} from "../../execution-mode";
4245
import type { ProcessSpawnedCallback } from "../../types";
4346
import { Logger } from "../../utils/logger";
@@ -83,20 +86,41 @@ type CodexSession = BaseSession & {
8386
settingsManager: CodexSettingsManager;
8487
};
8588

86-
function toCodeExecutionMode(mode?: string): CodeExecutionMode {
87-
if (mode && (CODE_EXECUTION_MODES as readonly string[]).includes(mode)) {
88-
return mode as CodeExecutionMode;
89+
function toCodexPermissionMode(mode?: string): PermissionMode {
90+
if (mode && (isCodexNativeMode(mode) || isCodeExecutionMode(mode))) {
91+
return mode;
8992
}
90-
return "default";
93+
return "auto";
9194
}
9295

93-
const CODEX_NATIVE_MODE: Record<CodeExecutionMode, string> = {
94-
default: "default",
95-
acceptEdits: "default",
96-
plan: "plan",
97-
bypassPermissions: "default",
96+
const CODEX_NATIVE_MODE: Record<CodeExecutionMode, CodexNativeMode> = {
97+
default: "auto",
98+
acceptEdits: "auto",
99+
plan: "read-only",
100+
bypassPermissions: "full-access",
98101
};
99102

103+
function toCodexNativeMode(mode?: string): CodexNativeMode {
104+
if (mode && isCodexNativeMode(mode)) {
105+
return mode;
106+
}
107+
if (mode && isCodeExecutionMode(mode)) {
108+
return CODEX_NATIVE_MODE[mode];
109+
}
110+
return "auto";
111+
}
112+
113+
function getCurrentPermissionMode(
114+
currentModeId?: string,
115+
fallbackMode?: string,
116+
): PermissionMode {
117+
if (currentModeId && isCodexNativeMode(currentModeId)) {
118+
return currentModeId;
119+
}
120+
121+
return toCodexPermissionMode(fallbackMode);
122+
}
123+
100124
export class CodexAcpAgent extends BaseAcpAgent {
101125
readonly adapterName = "codex";
102126
declare session: CodexSession;
@@ -179,20 +203,27 @@ export class CodexAcpAgent extends BaseAcpAgent {
179203

180204
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
181205
const meta = params._meta as NewSessionMeta | undefined;
206+
const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
182207

183208
const response = await this.codexConnection.newSession(params);
184209

185210
// Initialize session state
186211
this.sessionState = createSessionState(response.sessionId, params.cwd, {
187212
taskRunId: meta?.taskRunId,
188213
taskId: meta?.taskId ?? meta?.persistence?.taskId,
189-
modeId: response.modes?.currentModeId ?? "default",
214+
modeId: response.modes?.currentModeId ?? "auto",
190215
modelId: response.models?.currentModelId,
191-
permissionMode: toCodeExecutionMode(meta?.permissionMode),
216+
permissionMode: requestedPermissionMode,
192217
});
193218
this.sessionId = response.sessionId;
194219
this.sessionState.configOptions = response.configOptions ?? [];
195220

221+
await this.applyInitialPermissionMode(
222+
response.sessionId,
223+
meta?.permissionMode,
224+
response.modes?.currentModeId,
225+
);
226+
196227
// Emit _posthog/sdk_session so the app can track the session
197228
if (meta?.taskRunId) {
198229
await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, {
@@ -213,9 +244,14 @@ export class CodexAcpAgent extends BaseAcpAgent {
213244
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
214245
const response = await this.codexConnection.loadSession(params);
215246
const meta = params._meta as NewSessionMeta | undefined;
247+
const currentPermissionMode = getCurrentPermissionMode(
248+
response.modes?.currentModeId,
249+
meta?.permissionMode,
250+
);
216251

217252
this.sessionState = createSessionState(params.sessionId, params.cwd, {
218-
permissionMode: toCodeExecutionMode(meta?.permissionMode),
253+
modeId: response.modes?.currentModeId ?? "auto",
254+
permissionMode: currentPermissionMode,
219255
});
220256
this.sessionId = params.sessionId;
221257
this.sessionState.configOptions = response.configOptions ?? [];
@@ -234,10 +270,15 @@ export class CodexAcpAgent extends BaseAcpAgent {
234270
});
235271

236272
const meta = params._meta as NewSessionMeta | undefined;
273+
const currentPermissionMode = getCurrentPermissionMode(
274+
loadResponse.modes?.currentModeId,
275+
meta?.permissionMode,
276+
);
237277
this.sessionState = createSessionState(params.sessionId, params.cwd, {
238278
taskRunId: meta?.taskRunId,
239279
taskId: meta?.taskId ?? meta?.persistence?.taskId,
240-
permissionMode: toCodeExecutionMode(meta?.permissionMode),
280+
modeId: loadResponse.modes?.currentModeId ?? "auto",
281+
permissionMode: currentPermissionMode,
241282
});
242283
this.sessionId = params.sessionId;
243284
this.sessionState.configOptions = loadResponse.configOptions ?? [];
@@ -268,17 +309,49 @@ export class CodexAcpAgent extends BaseAcpAgent {
268309
});
269310

270311
const meta = params._meta as NewSessionMeta | undefined;
312+
const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode);
271313
this.sessionState = createSessionState(newResponse.sessionId, params.cwd, {
272314
taskRunId: meta?.taskRunId,
273315
taskId: meta?.taskId ?? meta?.persistence?.taskId,
274-
permissionMode: toCodeExecutionMode(meta?.permissionMode),
316+
modeId: newResponse.modes?.currentModeId ?? "auto",
317+
permissionMode: requestedPermissionMode,
275318
});
276319
this.sessionId = newResponse.sessionId;
277320
this.sessionState.configOptions = newResponse.configOptions ?? [];
278321

322+
await this.applyInitialPermissionMode(
323+
newResponse.sessionId,
324+
meta?.permissionMode,
325+
newResponse.modes?.currentModeId,
326+
);
327+
279328
return newResponse;
280329
}
281330

331+
private async applyInitialPermissionMode(
332+
sessionId: string,
333+
permissionMode?: string,
334+
currentModeId?: string,
335+
): Promise<void> {
336+
if (!permissionMode) {
337+
return;
338+
}
339+
340+
const nativeMode = toCodexNativeMode(permissionMode);
341+
if (nativeMode === currentModeId) {
342+
this.sessionState.modeId = nativeMode;
343+
this.sessionState.permissionMode = toCodexPermissionMode(permissionMode);
344+
return;
345+
}
346+
347+
await this.codexConnection.setSessionMode({
348+
sessionId,
349+
modeId: nativeMode,
350+
});
351+
this.sessionState.modeId = nativeMode;
352+
this.sessionState.permissionMode = toCodexPermissionMode(permissionMode);
353+
}
354+
282355
async listSessions(
283356
params: ListSessionsRequest,
284357
): Promise<ListSessionsResponse> {
@@ -347,8 +420,8 @@ export class CodexAcpAgent extends BaseAcpAgent {
347420
async setSessionMode(
348421
params: SetSessionModeRequest,
349422
): Promise<SetSessionModeResponse> {
350-
const requestedMode = toCodeExecutionMode(params.modeId);
351-
const nativeMode = CODEX_NATIVE_MODE[requestedMode];
423+
const requestedMode = toCodexPermissionMode(params.modeId);
424+
const nativeMode = toCodexNativeMode(params.modeId);
352425

353426
const response = await this.codexConnection.setSessionMode({
354427
...params,

packages/agent/src/adapters/codex/codex-client.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import type {
2929
WriteTextFileRequest,
3030
WriteTextFileResponse,
3131
} from "@agentclientprotocol/sdk";
32-
import type { CodeExecutionMode } from "../../execution-mode";
32+
import type { PermissionMode } from "../../execution-mode";
3333
import type { Logger } from "../../utils/logger";
3434
import type { CodexSessionState } from "./session-state";
3535

@@ -38,7 +38,7 @@ export interface CodexClientCallbacks {
3838
onUsageUpdate?: (update: Record<string, unknown>) => void;
3939
}
4040

41-
const AUTO_APPROVED_KINDS: Record<CodeExecutionMode, Set<ToolKind>> = {
41+
const AUTO_APPROVED_KINDS: Record<PermissionMode, Set<ToolKind>> = {
4242
default: new Set(["read", "search", "fetch", "think"]),
4343
acceptEdits: new Set(["read", "edit", "search", "fetch", "think"]),
4444
plan: new Set(["read", "search", "fetch", "think"]),
@@ -54,13 +54,27 @@ const AUTO_APPROVED_KINDS: Record<CodeExecutionMode, Set<ToolKind>> = {
5454
"switch_mode",
5555
"other",
5656
]),
57+
auto: new Set(["read", "search", "fetch", "think"]),
58+
"read-only": new Set(["read", "search", "fetch", "think"]),
59+
"full-access": new Set([
60+
"read",
61+
"edit",
62+
"delete",
63+
"move",
64+
"search",
65+
"execute",
66+
"think",
67+
"fetch",
68+
"switch_mode",
69+
"other",
70+
]),
5771
};
5872

5973
function shouldAutoApprove(
60-
mode: CodeExecutionMode,
74+
mode: PermissionMode,
6175
kind: ToolKind | null | undefined,
6276
): boolean {
63-
if (mode === "bypassPermissions") return true;
77+
if (mode === "bypassPermissions" || mode === "full-access") return true;
6478
if (!kind) return false;
6579
return AUTO_APPROVED_KINDS[mode]?.has(kind) ?? false;
6680
}

packages/agent/src/adapters/codex/session-state.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
7-
import type { CodeExecutionMode } from "../../execution-mode";
7+
import type { PermissionMode } from "../../execution-mode";
88

99
export interface CodexUsage {
1010
inputTokens: number;
@@ -22,7 +22,7 @@ export interface CodexSessionState {
2222
accumulatedUsage: CodexUsage;
2323
contextSize?: number;
2424
contextUsed?: number;
25-
permissionMode: CodeExecutionMode;
25+
permissionMode: PermissionMode;
2626
taskRunId?: string;
2727
taskId?: string;
2828
}
@@ -35,13 +35,13 @@ export function createSessionState(
3535
taskId?: string;
3636
modeId?: string;
3737
modelId?: string;
38-
permissionMode?: CodeExecutionMode;
38+
permissionMode?: PermissionMode;
3939
},
4040
): CodexSessionState {
4141
return {
4242
sessionId,
4343
cwd,
44-
modeId: opts?.modeId ?? "default",
44+
modeId: opts?.modeId ?? "auto",
4545
modelId: opts?.modelId,
4646
configOptions: [],
4747
accumulatedUsage: {
@@ -50,7 +50,7 @@ export function createSessionState(
5050
cachedReadTokens: 0,
5151
cachedWriteTokens: 0,
5252
},
53-
permissionMode: opts?.permissionMode ?? "default",
53+
permissionMode: opts?.permissionMode ?? "auto",
5454
taskRunId: opts?.taskRunId,
5555
taskId: opts?.taskId,
5656
};

0 commit comments

Comments
 (0)