From 606513a366a7f78935fe87b2edb0477bfbdf4e7f Mon Sep 17 00:00:00 2001 From: Gilbert Leung Date: Sun, 26 Jul 2026 14:47:36 +0800 Subject: [PATCH 1/5] fix: persist session config in Codex threads (cherry picked from commit a943bcef232d68b2e05705411bd7575a4d2026c6) --- src/AgentMode.ts | 12 +++++++ src/CodexAcpClient.ts | 24 ++++++++++++++ src/CodexAcpServer.ts | 31 ++++++++++++------ src/CodexAppServerClient.ts | 13 ++++++-- .../CodexACPAgent/CodexAcpClient.test.ts | 8 ++++- .../CodexACPAgent/fast-mode-config.test.ts | 1 + .../session-config-options.test.ts | 32 ++++++++++++++----- 7 files changed, 99 insertions(+), 22 deletions(-) diff --git a/src/AgentMode.ts b/src/AgentMode.ts index aed9710c..d7f19427 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -96,6 +96,18 @@ export class AgentMode { return match ?? null; } + static fromSettings( + approvalPolicy: AskForApproval | undefined, + sandboxPolicy: SandboxPolicy | undefined, + ): AgentMode | null { + if (!sandboxPolicy) return null; + const match = AgentMode.all().find(mode => + mode.approvalPolicy === approvalPolicy + && mode.sandboxPolicy.type === sandboxPolicy.type + ); + return match ?? null; + } + static getInitialAgentMode(): AgentMode { const predefinedAgentMode = process.env["INITIAL_AGENT_MODE"]; if (predefinedAgentMode) { diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d42199cb..75ef5ac4 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -403,6 +403,8 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + agentMode: AgentMode.fromSettings(response.approvalPolicy, response.sandbox) + ?? AgentMode.getInitialAgentMode(), collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, @@ -431,6 +433,8 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + agentMode: AgentMode.fromSettings(response.approvalPolicy, response.sandbox) + ?? AgentMode.getInitialAgentMode(), collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, @@ -458,6 +462,8 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + agentMode: AgentMode.fromSettings(response.approvalPolicy, response.sandbox) + ?? AgentMode.getInitialAgentMode(), collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, @@ -789,6 +795,23 @@ export class CodexAcpClient { }); } + async setAgentMode(sessionId: string, mode: AgentMode): Promise { + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + approvalPolicy: mode.approvalPolicy, + sandboxPolicy: mode.sandboxPolicy, + }); + } + + async setModelAndEffort(sessionId: string, currentModelId: string): Promise { + const modelId = ModelId.fromString(currentModelId); + await this.codexClient.threadSettingsUpdate({ + threadId: sessionId, + model: modelId.model, + effort: modelId.effort as ReasoningEffort, + }); + } + private getCollaborationMode(sessionId: string): ModeKind { return this.codexClient.getThreadSettings(sessionId)?.collaborationMode.mode ?? "default"; } @@ -966,6 +989,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + agentMode?: AgentMode, collaborationMode: ModeKind, modelProvider?: string | null, currentServiceTier?: ServiceTier | null, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index dc21a60a..3839e035 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -557,7 +557,7 @@ export class CodexAcpServer { availableModels: models, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], - agentMode: AgentMode.getInitialAgentMode(), + agentMode: sessionMetadata.agentMode ?? AgentMode.getInitialAgentMode(), collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, @@ -853,7 +853,7 @@ export class CodexAcpServer { const sessionState = this.sessions.get(_params.sessionId); if (!sessionState) throw new Error(`Session ${_params.sessionId} not found`); - this.applyModeChange(sessionState, _params.modeId); + await this.applyModeChange(sessionState, _params.modeId); return {}; } @@ -878,16 +878,16 @@ export class CodexAcpServer { this.applyFastModeChange(sessionState, params); break; case MODE_CONFIG_ID: - this.applyModeChange(sessionState, this.stringConfigValue(params)); + await this.applyModeChange(sessionState, this.stringConfigValue(params)); break; case COLLABORATION_MODE_CONFIG_ID: await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params)); break; case MODEL_CONFIG_ID: - this.applyModelChange(sessionState, this.stringConfigValue(params)); + await this.applyModelChange(sessionState, this.stringConfigValue(params)); break; case REASONING_EFFORT_CONFIG_ID: - this.applyReasoningEffortChange(sessionState, this.stringConfigValue(params)); + await this.applyReasoningEffortChange(sessionState, this.stringConfigValue(params)); break; default: throw RequestError.invalidParams(); @@ -913,11 +913,12 @@ export class CodexAcpServer { return params.value; } - private applyModeChange(sessionState: SessionState, value: string): void { + private async applyModeChange(sessionState: SessionState, value: string): Promise { const newMode = AgentMode.find(value); if (!newMode) { throw RequestError.invalidParams(); } + await this.codexAcpClient.setAgentMode(sessionState.sessionId, newMode); sessionState.agentMode = newMode; } @@ -930,7 +931,7 @@ export class CodexAcpServer { sessionState.collaborationMode = mode; } - private applyModelChange(sessionState: SessionState, value: string): void { + private async applyModelChange(sessionState: SessionState, value: string): Promise { const model = sessionState.availableModels.find(m => m.id === value); if (!model) { const currentModel = ModelId.fromString(sessionState.currentModelId).model; @@ -942,16 +943,22 @@ export class CodexAcpServer { const currentEffort = ModelId.fromString(sessionState.currentModelId).effort; const effort = findSupportedEffort(model.supportedReasoningEfforts, currentEffort) ?? model.defaultReasoningEffort; + await this.codexAcpClient.setModelAndEffort( + sessionState.sessionId, + ModelId.fromComponents(model, effort).toString(), + ); this.applyModelAndEffort(sessionState, model, effort); } - private applyReasoningEffortChange(sessionState: SessionState, value: string): void { + private async applyReasoningEffortChange(sessionState: SessionState, value: string): Promise { const effort = findSupportedEffort(sessionState.supportedReasoningEfforts, value); if (!effort) { throw RequestError.invalidParams(); } const {model} = ModelId.fromString(sessionState.currentModelId); - sessionState.currentModelId = ModelId.create(model, effort).toString(); + const currentModelId = ModelId.create(model, effort).toString(); + await this.codexAcpClient.setModelAndEffort(sessionState.sessionId, currentModelId); + sessionState.currentModelId = currentModelId; } private applyModelAndEffort(sessionState: SessionState, model: Model, effort: ReasoningEffort): void { @@ -987,6 +994,10 @@ export class CodexAcpServer { } sessionState.availableModels = models; + await this.codexAcpClient.setModelAndEffort( + sessionState.sessionId, + ModelId.fromComponents(model, reasoningEffort).toString(), + ); this.applyModelAndEffort(sessionState, model, reasoningEffort); return {}; @@ -1446,7 +1457,7 @@ export class CodexAcpServer { availableModels: models, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], - agentMode: AgentMode.getInitialAgentMode(), + agentMode: sessionMetadata.agentMode ?? AgentMode.getInitialAgentMode(), collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 50a95e42..4cd1baec 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -3,9 +3,11 @@ import type { ClientRequest, InitializeParams, InitializeResponse, + ReasoningEffort, ServerNotification } from "./app-server"; import type { + AskForApproval, CancelLoginAccountParams, CancelLoginAccountResponse, ConfigReadParams, @@ -65,6 +67,7 @@ import type { TurnStartResponse, TurnSteerParams, TurnSteerResponse, + SandboxPolicy, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeRequestApprovalParams, @@ -534,7 +537,7 @@ export class CodexAppServerClient { return this.threadSettings.get(threadId); } - async threadSettingsUpdate(params: ExperimentalThreadSettingsUpdateParams): Promise { + async threadSettingsUpdate(params: ThreadSettingsUpdateParams): Promise { await this.connection.sendRequest("thread/settings/update", params); } @@ -980,9 +983,13 @@ type DistributiveOmit = T extends any ? Omit : never; -export interface ExperimentalThreadSettingsUpdateParams { +export interface ThreadSettingsUpdateParams { threadId: string; - collaborationMode: { + approvalPolicy?: AskForApproval; + sandboxPolicy?: SandboxPolicy; + model?: string; + effort?: ReasoningEffort; + collaborationMode?: { mode: "default" | "plan"; settings: { model: string; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d1df6999..c87bdfca 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -533,7 +533,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); - it('restores collaboration mode for resumed and loaded sessions', async () => { + it('restores collaboration and agent modes for resumed and loaded sessions', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -562,6 +562,8 @@ describe('ACP server test', { timeout: 40_000 }, () => { modelProvider: "openai", reasoningEffort: "medium", serviceTier: null, + approvalPolicy: "never", + sandbox: {type: "dangerFullAccess"}, } as any; }); vi.spyOn(codexAppServerClient, "threadRead").mockImplementation(async ({threadId}) => ({ @@ -584,8 +586,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(codexAcpAgent.getSessionState("resume-id").collaborationMode).toBe("plan"); expect(codexAcpAgent.getSessionState("load-id").collaborationMode).toBe("plan"); + expect(codexAcpAgent.getSessionState("resume-id").agentMode).toBe(AgentMode.AgentFullAccess); + expect(codexAcpAgent.getSessionState("load-id").agentMode).toBe(AgentMode.AgentFullAccess); expect(resumed.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); expect(loaded.configOptions?.find(option => option.id === "collaboration_mode")).toMatchObject({currentValue: "plan"}); + expect(resumed.configOptions?.find(option => option.id === "mode")).toMatchObject({currentValue: "agent-full-access"}); + expect(loaded.configOptions?.find(option => option.id === "mode")).toMatchObject({currentValue: "agent-full-access"}); }); it('uses configured model provider when resuming sessions without an explicit provider', async () => { diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 6007d529..11fd8ced 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -47,6 +47,7 @@ describe("Fast mode session config", () => { currentServiceTier, additionalDirectories: [], }); + vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); await codexAcpAgent.initialize({ protocolVersion: acp.PROTOCOL_VERSION, diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index ab669916..4c5a8735 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -49,9 +49,11 @@ async function createSession(currentModelId: string, availableModels: Array { @@ -141,7 +143,7 @@ describe("Session config options", () => { it("changes the agent mode via setSessionConfigOption", async () => { const {fast} = buildModels(); - const {codexAcpAgent} = await createSession("fast-model[medium]", [fast]); + const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); const result = await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -150,14 +152,18 @@ describe("Session config options", () => { }); expect(codexAcpAgent.getSessionState("session-id").agentMode).toBe(AgentMode.ReadOnly); + expect(update).toHaveBeenCalledWith({ + threadId: "session-id", + approvalPolicy: AgentMode.ReadOnly.approvalPolicy, + sandboxPolicy: AgentMode.ReadOnly.sandboxPolicy, + }); const modeOption = result.configOptions?.find(o => o.id === MODE_CONFIG_ID); expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.id); }); it("changes collaboration mode without starting a model turn", async () => { const {fast} = buildModels(); - const {codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); - const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); const result = await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -175,8 +181,7 @@ describe("Session config options", () => { it("toggles collaboration mode with /plan without starting a model turn", async () => { const {fast} = buildModels(); - const {fixture, codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); - const update = vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); + const {fixture, codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); const turnStart = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart"); const enabledResponse = await codexAcpAgent.prompt({ @@ -230,7 +235,7 @@ describe("Session config options", () => { it("changes the model and keeps the current reasoning effort when supported", async () => { const {fast, slow} = buildModels(); - const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow]); + const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast, slow]); await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -239,6 +244,11 @@ describe("Session config options", () => { }); expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("slow-model[medium]"); + expect(update).toHaveBeenCalledWith({ + threadId: "session-id", + model: "slow-model", + effort: "medium", + }); }); it("falls back to the new model's default effort when the current effort is unsupported", async () => { @@ -256,7 +266,7 @@ describe("Session config options", () => { it("changes only the reasoning effort", async () => { const {fast} = buildModels(); - const {codexAcpAgent} = await createSession("fast-model[medium]", [fast]); + const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -265,6 +275,11 @@ describe("Session config options", () => { }); expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("fast-model[high]"); + expect(update).toHaveBeenCalledWith({ + threadId: "session-id", + model: "fast-model", + effort: "high", + }); }); it("refreshes the cached model list when unstable_setSessionModel picks a freshly fetched model", async () => { @@ -292,6 +307,7 @@ describe("Session config options", () => { defaultReasoningEffort: "medium", }); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue([fast, extraModel]); + vi.spyOn((codexAcpClient as any).codexClient, "threadSettingsUpdate").mockResolvedValue(undefined); await codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", From ab240af85ca1bc6a9dd7f595cc230e88b1552fe9 Mon Sep 17 00:00:00 2001 From: Gilbert Leung Date: Mon, 27 Jul 2026 10:38:32 +0800 Subject: [PATCH 2/5] fix: apply agent mode before persisting (cherry picked from commit edd908bbd561498a1e82f01f9ef90934eecc20d3) --- src/CodexAcpServer.ts | 8 ++- .../session-config-options.test.ts | 62 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 3839e035..04ab15de 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -918,8 +918,14 @@ export class CodexAcpServer { if (!newMode) { throw RequestError.invalidParams(); } - await this.codexAcpClient.setAgentMode(sessionState.sessionId, newMode); + const previousMode = sessionState.agentMode; sessionState.agentMode = newMode; + try { + await this.codexAcpClient.setAgentMode(sessionState.sessionId, newMode); + } catch (error) { + sessionState.agentMode = previousMode; + throw error; + } } private async applyCollaborationModeChange(sessionState: SessionState, value: string): Promise { diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 4c5a8735..8bd843ee 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -1,5 +1,9 @@ import {describe, expect, it, vi} from "vitest"; -import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; +import { + createCodexMockTestFixture, + createTestModel, + mockPromptTurn, +} from "../acp-test-utils"; import {AgentMode, MODE_CONFIG_ID} from "../../AgentMode"; import { MODEL_CONFIG_ID, @@ -16,6 +20,17 @@ const lowEffort: ReasoningEffortOption = {reasoningEffort: "low", description: " const mediumEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; const highEffort: ReasoningEffortOption = {reasoningEffort: "high", description: "Thorough"}; +function deferred(): { + promise: Promise; + resolve: (value: T | PromiseLike) => void; +} { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return {promise, resolve}; +} + function buildModels(): {fast: Model; slow: Model} { const fast = createTestModel({ id: "fast-model", @@ -161,6 +176,51 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.id); }); + it("uses a new agent mode for prompts while thread persistence is pending", async () => { + const {fast} = buildModels(); + const {fixture, codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); + const sessionState = codexAcpAgent.getSessionState("session-id"); + const turnStartSpy = mockPromptTurn(fixture, sessionState.sessionId); + const persistence = deferred(); + update.mockReturnValue(persistence.promise); + + const modeChange = codexAcpAgent.setSessionConfigOption({ + sessionId: sessionState.sessionId, + configId: MODE_CONFIG_ID, + value: AgentMode.AgentFullAccess.id, + }); + + expect(sessionState.agentMode).toBe(AgentMode.AgentFullAccess); + + await codexAcpAgent.prompt({ + sessionId: sessionState.sessionId, + prompt: [{type: "text", text: "test"}], + }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + approvalPolicy: AgentMode.AgentFullAccess.approvalPolicy, + sandboxPolicy: AgentMode.AgentFullAccess.sandboxPolicy, + })); + + persistence.resolve(); + await modeChange; + }); + + it("rolls back the agent mode when thread persistence fails", async () => { + const {fast} = buildModels(); + const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); + const sessionState = codexAcpAgent.getSessionState("session-id"); + update.mockRejectedValue(new Error("settings update failed")); + + await expect(codexAcpAgent.setSessionConfigOption({ + sessionId: sessionState.sessionId, + configId: MODE_CONFIG_ID, + value: AgentMode.AgentFullAccess.id, + })).rejects.toThrow("settings update failed"); + + expect(sessionState.agentMode).toBe(AgentMode.Agent); + }); + it("changes collaboration mode without starting a model turn", async () => { const {fast} = buildModels(); const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); From 756a1f4ef6138664962919934b4e82a3fdb7660b Mon Sep 17 00:00:00 2001 From: Gilbert Leung Date: Mon, 27 Jul 2026 10:52:37 +0800 Subject: [PATCH 3/5] fix: return session id when loading sessions (cherry picked from commit 3487c8bcff942ef9ed83f2802fd7522cee64e243) --- src/AcpExtensions.ts | 1 + src/CodexAcpServer.ts | 1 + src/__tests__/CodexACPAgent/load-session.test.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index a469d515..3eeec4f0 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -50,6 +50,7 @@ export type LegacyNewSessionResponse = NewSessionResponse & { } export type LegacyLoadSessionResponse = LoadSessionResponse & { + sessionId: SessionId; models?: LegacySessionModelState | null; } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 04ab15de..5f4a8a78 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -647,6 +647,7 @@ export class CodexAcpServer { availableModelCount: modelState.availableModels.length }); return { + sessionId, models: modelState, modes: modeState, ...this.createSessionConfigOptionsResponse(this.getSessionState(sessionId)), diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index c1ed9cfb..43063c1a 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -222,8 +222,9 @@ describe("CodexACPAgent - loadSession", () => { cwd: "/test/project", mcpServers: [], }; - await codexAcpAgent.loadSession(loadParams); + const response = await codexAcpAgent.loadSession(loadParams); + expect(response.sessionId).toBe(thread.id); expect(codexAppServerClient.threadRead).toHaveBeenCalledWith({ threadId: thread.id, includeTurns: true, From a9a212f59d9b37a19a48734731852c8aba3e9178 Mon Sep 17 00:00:00 2001 From: Gilbert Leung Date: Mon, 27 Jul 2026 15:15:53 +0800 Subject: [PATCH 4/5] fix: use latest agent mode when starting turns (cherry picked from commit 10f6541a68dfcb16e65ec90c47d3f8df6258a621) --- src/CodexAcpClient.ts | 3 +- src/CodexAcpServer.ts | 5 ++-- .../session-config-options.test.ts | 29 +++++++++++++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 75ef5ac4..e3e3011a 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -761,7 +761,7 @@ export class CodexAcpClient { async sendPrompt( request: acp.PromptRequest, - agentMode: AgentMode, + getAgentMode: () => AgentMode, modelId: ModelId, serviceTier: ServiceTier | null, disableSummary: boolean, @@ -776,6 +776,7 @@ export class CodexAcpClient { if (shouldCancel?.()) { return null; } + const agentMode = getAgentMode(); return await this.codexClient.runTurn({ threadId: request.sessionId, input: input, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 5f4a8a78..5c7ff725 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -2207,7 +2207,6 @@ export class CodexAcpServer { if (!sessionState.supportedInputModalities.includes("image") && effectiveParams.prompt.some(b => b.type === "image")) { throw RequestError.invalidRequest("The current model does not support image input"); } - const agentMode = sessionState.agentMode; const serviceTier = resolveFastServiceTier( sessionState.fastModeEnabled, sessionState.currentModelSupportsFast, @@ -2216,7 +2215,7 @@ export class CodexAcpServer { const sendPromptPromise = this.runWithProcessCheck( () => this.codexAcpClient.sendPrompt( effectiveParams, - agentMode, + () => sessionState.agentMode, modelId, serviceTier, disableSummary, @@ -2306,7 +2305,7 @@ export class CodexAcpServer { const implementationPromise = this.runWithProcessCheck( () => this.codexAcpClient.sendPrompt( implementationRequest, - agentMode, + () => sessionState.agentMode, modelId, serviceTier, disableSummary, diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 8bd843ee..43c142d5 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -206,6 +206,35 @@ describe("Session config options", () => { await modeChange; }); + it("uses a new agent mode when it changes during prompt preparation", async () => { + const {fast} = buildModels(); + const {fixture, codexAcpAgent} = await createSession("fast-model[medium]", [fast]); + const sessionState = codexAcpAgent.getSessionState("session-id"); + const turnStartSpy = mockPromptTurn(fixture, sessionState.sessionId); + const skillRefresh = deferred<{data: []}>(); + const listSkillsSpy = vi.spyOn(fixture.getCodexAppServerClient(), "listSkills") + .mockReturnValue(skillRefresh.promise); + + const prompt = codexAcpAgent.prompt({ + sessionId: sessionState.sessionId, + prompt: [{type: "text", text: "test"}], + }); + await vi.waitFor(() => expect(listSkillsSpy).toHaveBeenCalled()); + + await codexAcpAgent.setSessionConfigOption({ + sessionId: sessionState.sessionId, + configId: MODE_CONFIG_ID, + value: AgentMode.AgentFullAccess.id, + }); + skillRefresh.resolve({data: []}); + await prompt; + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + approvalPolicy: AgentMode.AgentFullAccess.approvalPolicy, + sandboxPolicy: AgentMode.AgentFullAccess.sandboxPolicy, + })); + }); + it("rolls back the agent mode when thread persistence fails", async () => { const {fast} = buildModels(); const {codexAcpAgent, update} = await createSession("fast-model[medium]", [fast]); From e9bd75a3b132c57b141aa063a387181d6fac7e04 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Wed, 12 Aug 2026 20:07:08 +0200 Subject: [PATCH 5/5] fix: keep thread model and effort when resuming without a configured provider --- src/CodexAcpClient.ts | 19 ++++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 41 +++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index e3e3011a..31cf3b92 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -393,7 +393,7 @@ export class CodexAcpClient { const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, - modelProvider: await this.getResumeModelProvider(), + ...(await this.resumeModelProviderParams()), threadId: request.sessionId, }); onSubscribed?.(); @@ -419,7 +419,7 @@ export class CodexAcpClient { const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), cwd: request.cwd, - modelProvider: await this.getResumeModelProvider(), + ...(await this.resumeModelProviderParams()), threadId: request.sessionId, }); onSubscribed?.(); @@ -617,10 +617,17 @@ export class CodexAcpClient { return this.gatewayConfig?.modelProvider ?? this.modelProvider; } - private async getResumeModelProvider(): Promise { - // Prefer an explicit/gateway provider, then the provider persisted in Codex config. - // Keep OpenAI as the final fallback for ChatGPT-authenticated sessions without a configured provider. - return (await this.getCurrentModelProvider()) ?? "openai"; + /** + * Resume-time provider override, as `thread/resume` params. + * + * Prefer an explicit/gateway provider, then the provider persisted in Codex config. + * When neither is configured the field is omitted entirely: supplying one makes the + * app-server re-resolve the thread's model and reasoning effort from config, which + * discards the picks stored on the thread itself. + */ + private async resumeModelProviderParams(): Promise<{modelProvider?: string}> { + const modelProvider = await this.getCurrentModelProvider(); + return modelProvider ? {modelProvider} : {}; } private async refreshSkills( diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index c87bdfca..2cfebfb2 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -634,6 +634,47 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(threadResumeSpy.mock.calls[1]![0].modelProvider).toBe("azure"); }); + it('omits the model provider when none is configured so the thread keeps its model and effort', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue(null); + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({config: {}} as any); + const threadResumeSpy = vi.spyOn(codexAppServerClient, "threadResume").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + reasoningEffort: "high", + serviceTier: null, + } as any); + vi.spyOn(codexAppServerClient, "threadRead").mockResolvedValue({ + thread: {id: "thread-id"} as any, + }); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5", defaultReasoningEffort: "medium"})], + nextCursor: null, + }); + + const resumed = await codexAcpClient.resumeSession({ + sessionId: "resume-id", + cwd: "/workspace", + }); + const loaded = await codexAcpClient.loadSession({ + sessionId: "load-id", + cwd: "/workspace", + mcpServers: [], + }); + + // Supplying a provider makes the app-server re-resolve model/effort from config, + // discarding the picks stored on the thread (issue #343). + expect(threadResumeSpy.mock.calls[0]![0]).not.toHaveProperty("modelProvider"); + expect(threadResumeSpy.mock.calls[1]![0]).not.toHaveProperty("modelProvider"); + expect(resumed.currentModelId).toBe("gpt-5[high]"); + expect(loaded.currentModelId).toBe("gpt-5[high]"); + }); + it('tracks configured model provider auth state for resumed and loaded sessions', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent();