From d6828e009b4073529426370059dc1691769e172f Mon Sep 17 00:00:00 2001 From: Builder <65c5c12241e0d16c991a087cd2a28546e97adddd8481cea19a442842befe1bcd@buzz.erictang.co> Date: Sat, 24 Oct 2026 10:24:52 -0700 Subject: [PATCH 1/5] fix: preserve configured service tiers across ACP prompts --- readme-dev.md | 5 +++ src/CodexAcpClient.ts | 9 ++-- src/CodexAcpServer.ts | 18 ++++---- src/FastModeConfig.ts | 6 +-- src/SessionFork.ts | 3 +- src/SessionMetadata.ts | 3 +- .../CodexACPAgent/fast-mode-config.test.ts | 35 +++++++++++---- .../CodexACPAgent/service-tier.test.ts | 45 +++++++++++++++++++ .../session-config-options-v2.test.ts | 4 +- src/__tests__/acp-test-utils.ts | 2 +- .../integration/service-tier.test.ts | 39 ++++++++++++++++ 11 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/service-tier.test.ts create mode 100644 src/__tests__/integration/service-tier.test.ts diff --git a/readme-dev.md b/readme-dev.md index 836df35d2..08ffe75af 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -15,6 +15,11 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `CODEX_ACP_APP_SERVER_CRASH_LIMIT` - how many crashes of the Codex app-server in the crash window stop its automatic restart (default `5`). - `CODEX_ACP_APP_SERVER_CRASH_WINDOW_MS` - the crash window in milliseconds (default `300000`, 5 minutes). +The adapter preserves the service tier returned by Codex for new, resumed, loaded, and forked sessions, +including tiers configured in `config.toml` or `CODEX_CONFIG` such as `{"service_tier":"ultrafast"}`. +Model and account eligibility still apply. Explicitly changing the ACP Fast mode control overrides that +tier: On selects Fast, and Off clears it. Merely displaying Fast as Off does not clear another configured tier. + ### Quick start #### Develop on Windows? diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 71e8b199e..a849269fe 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -22,7 +22,6 @@ import type { ReasoningEffort, ServerNotification } from "./app-server"; -import type {ServiceTier} from "./app-server/ServiceTier"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; @@ -712,7 +711,7 @@ export class CodexAcpClient { collaborationMode: response.collaborationMode ?? this.getCollaborationMode(response.thread.id), threadStatus: response.thread.status, modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier as ServiceTier ?? null, + currentServiceTier: response.serviceTier ?? null, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, ...(instructions ? {instructions} : {}), @@ -792,7 +791,7 @@ export class CodexAcpClient { // Codex sends no thread/settings/updated on a resume. The response holds the mode. collaborationMode: response.collaborationMode ?? this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier as ServiceTier ?? null, + currentServiceTier: response.serviceTier ?? null, thread, history, additionalDirectories, @@ -852,7 +851,7 @@ export class CodexAcpClient { models: codexModels, collaborationMode: this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier as ServiceTier ?? null, + currentServiceTier: response.serviceTier ?? null, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, ...(instructions ? {instructions} : {}), @@ -1288,7 +1287,7 @@ export class CodexAcpClient { request: acp.PromptRequest, agentMode: AgentMode, modelId: ModelId, - serviceTier: ServiceTier | null, + serviceTier: string | null, disableSummary: boolean, cwd: string, additionalDirectories: string[], diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 27d739e10..03d40cbf0 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -149,7 +149,7 @@ import { FAST_MODE_OFF, FAST_MODE_ON, modelSupportsFast, - resolveFastServiceTier, + resolveServiceTier, } from "./FastModeConfig"; import packageJson from "../package.json"; import {isJetBrains2026_1Client} from "./JBUtils"; @@ -262,7 +262,7 @@ export interface SessionState extends SessionIndexTitleState { cwd: string; additionalDirectories: string[]; mcpServers?: Array; - fastModeEnabled: boolean; + currentServiceTier: string | null; currentModelSupportsFast: boolean; sessionMcpServers?: Array; /** The capability choices of the client for tool call and plan reports. */ @@ -1201,7 +1201,7 @@ export class CodexAcpServer { cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, mcpServers: requestedMcpServers, - fastModeEnabled: sessionMetadata.currentServiceTier === "fast", + currentServiceTier: sessionMetadata.currentServiceTier ?? null, currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, clientCapabilities: this.capabilities, @@ -2554,13 +2554,13 @@ export class CodexAcpServer { private applyFastModeChange(sessionState: SessionState, params: acp.SetSessionConfigOptionRequest): void { const value = params.value; if (typeof value === "boolean") { - sessionState.fastModeEnabled = value; + sessionState.currentServiceTier = value ? "fast" : null; return; } if (value !== FAST_MODE_ON && value !== FAST_MODE_OFF) { throw RequestError.invalidParams(); } - sessionState.fastModeEnabled = value === FAST_MODE_ON; + sessionState.currentServiceTier = value === FAST_MODE_ON ? "fast" : null; } private stringConfigValue(params: acp.SetSessionConfigOptionRequest): string { @@ -2987,7 +2987,7 @@ export class CodexAcpServer { } if (sessionState.currentModelSupportsFast) { configOptions.push(createFastModeConfigOption( - sessionState.fastModeEnabled, + sessionState.currentServiceTier === "fast", this.booleanConfigOptionsSupported, )); } @@ -3259,7 +3259,7 @@ export class CodexAcpServer { cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, mcpServers: requestedMcpServers, - fastModeEnabled: sessionMetadata.currentServiceTier === "fast", + currentServiceTier: sessionMetadata.currentServiceTier ?? null, currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, clientCapabilities: this.capabilities, @@ -4696,8 +4696,8 @@ export class CodexAcpServer { throw RequestError.invalidRequest("The current model does not support image input"); } const agentMode = sessionState.agentMode; - const serviceTier = resolveFastServiceTier( - sessionState.fastModeEnabled, + const serviceTier = resolveServiceTier( + sessionState.currentServiceTier, sessionState.currentModelSupportsFast, ); this.beginPromptTokenUsage(sessionState); diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts index defab8dc6..7a09c51e9 100644 --- a/src/FastModeConfig.ts +++ b/src/FastModeConfig.ts @@ -1,6 +1,5 @@ import type {SessionConfigOption} from "@agentclientprotocol/sdk/unstable"; import type * as acp from "@agentclientprotocol/sdk/unstable"; -import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model} from "./app-server/v2"; export const FAST_MODE_CONFIG_ID = "fast-mode"; @@ -14,8 +13,9 @@ export function modelSupportsFast(model: Model | undefined): boolean { return model?.additionalSpeedTiers?.includes("fast") ?? false; } -export function resolveFastServiceTier(fastModeEnabled: boolean, currentModelSupportsFast: boolean): ServiceTier | null { - return fastModeEnabled && currentModelSupportsFast ? "fast" : null; +export function resolveServiceTier(serviceTier: string | null, currentModelSupportsFast: boolean): string | null { + // The Fast toggle must not discard other tiers selected in Codex config, such as Ultrafast. + return serviceTier === "fast" && !currentModelSupportsFast ? null : serviceTier; } export function clientSupportsBooleanConfigOptions(clientCapabilities?: acp.ClientCapabilities | null): boolean { diff --git a/src/SessionFork.ts b/src/SessionFork.ts index c2ad0accd..299d911a4 100644 --- a/src/SessionFork.ts +++ b/src/SessionFork.ts @@ -4,7 +4,6 @@ import {RequestError} from "@agentclientprotocol/sdk/unstable"; import type {SessionConfig} from "./CodexAcpClient"; import type {CodexAppServerClient} from "./CodexAppServerClient"; import type {ModeKind} from "./app-server/ModeKind"; -import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model} from "./app-server/v2"; import type {SessionMetadata} from "./SessionMetadata"; import type {AcpMcpServer, WithAcpMcpServers} from "./McpServerConfig"; @@ -68,7 +67,7 @@ export async function forkSession( models, collaborationMode: dependencies.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier as ServiceTier ?? null, + currentServiceTier: response.serviceTier ?? null, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, ...(instructions ? {instructions} : {}), diff --git a/src/SessionMetadata.ts b/src/SessionMetadata.ts index 95c892b0a..1963a5556 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -1,5 +1,4 @@ import type {ModeKind} from "./app-server/ModeKind"; -import type {ServiceTier} from "./app-server/ServiceTier"; import type {Model, Thread, ThreadItemEntry, ThreadStatus} from "./app-server/v2"; import type {SessionInstructions} from "./ClientSystemPrompt"; @@ -9,7 +8,7 @@ export type SessionMetadata = { models: Model[], collaborationMode: ModeKind, modelProvider?: string | null, - currentServiceTier?: ServiceTier | null, + currentServiceTier?: string | null, threadStatus?: ThreadStatus, additionalDirectories: string[], skippedMcpServers?: string[], diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index d21e7937c..c2276e011 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -24,7 +24,7 @@ describe("Fast mode session config", () => { }; async function createSession( - currentServiceTier: "fast" | "flex" | null = null, + currentServiceTier: string | null = null, clientInfo: acp.Implementation | null = null, clientCapabilities?: acp.ClientCapabilities, ) { @@ -62,7 +62,7 @@ describe("Fast mode session config", () => { const {mockFixture, turnStartSpy} = setupPromptTestSession({ sessionId: "session-id", currentModelId: "fast-model[medium]", - fastModeEnabled, + currentServiceTier: fastModeEnabled ? "fast" : null, currentModelSupportsFast, }); return {codexAcpAgent: mockFixture.getCodexAcpAgent(), turnStartSpy}; @@ -103,7 +103,7 @@ describe("Fast mode session config", () => { const {response, codexAcpAgent} = await createSession("fast"); expect(response.configOptions).toContainEqual(createFastModeConfigOption(true)); - expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBe("fast"); }); it("omits Fast mode config options for JetBrains 2026.1 IntelliJ clients", async () => { @@ -164,7 +164,7 @@ describe("Fast mode session config", () => { value: FAST_MODE_ON, }); expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true)); - expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBe("fast"); const offResponse = await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -172,7 +172,7 @@ describe("Fast mode session config", () => { value: FAST_MODE_OFF, }); expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false)); - expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(false); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBeNull(); }); it("toggles Fast mode through boolean session config options", async () => { @@ -185,7 +185,7 @@ describe("Fast mode session config", () => { value: true, }); expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true)); - expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBe("fast"); const offResponse = await codexAcpAgent.setSessionConfigOption({ sessionId: "session-id", @@ -194,7 +194,7 @@ describe("Fast mode session config", () => { value: false, }); expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true)); - expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(false); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBeNull(); }); it("rejects unknown Fast mode config ids and values", async () => { @@ -213,6 +213,25 @@ describe("Fast mode session config", () => { })).rejects.toThrow(); }); + it.each([ + {value: FAST_MODE_ON, expectedTier: "fast"}, + {value: FAST_MODE_OFF, expectedTier: null}, + {value: true, expectedTier: "fast"}, + {value: false, expectedTier: null}, + ])("explicit Fast mode choice $value overrides configured Ultrafast", async ({value, expectedTier}) => { + const {codexAcpAgent, fixture} = await createSession("ultrafast"); + const turnStartSpy = mockPromptTurn(fixture, "session-id"); + + await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + ...(typeof value === "boolean" ? {type: "boolean" as const, value} : {value}), + }); + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({serviceTier: expectedTier})); + }); + it("sends the fast service tier when Fast mode is enabled for a fast-capable model", async () => { const {codexAcpAgent, turnStartSpy} = setupPromptSession(true, true); @@ -273,7 +292,7 @@ describe("Fast mode session config", () => { }); const sessionState = codexAcpAgent.getSessionState("session-id"); - expect(sessionState.fastModeEnabled).toBe(true); + expect(sessionState.currentServiceTier).toBe("fast"); expect(sessionState.currentModelSupportsFast).toBe(false); await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); diff --git a/src/__tests__/CodexACPAgent/service-tier.test.ts b/src/__tests__/CodexACPAgent/service-tier.test.ts new file mode 100644 index 000000000..84b95aded --- /dev/null +++ b/src/__tests__/CodexACPAgent/service-tier.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk/unstable"; +import {createCodexMockTestFixture, createTestModel, mockPromptTurn} from "../acp-test-utils"; + +describe.each(["new", "resume", "load", "fork"] as const)("Service tier after session/%s", (operation) => { + it.each(["ultrafast", "flex", "default", null])("preserves the Codex tier %s across prompts", async (serviceTier) => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + // A model need not support Fast to honor a different tier accepted by Codex. + const model = createTestModel({id: "gpt-6-astra", additionalSpeedTiers: ["ultrafast"]}); + const thread = {id: "tier-session", historyMode: "paginated", status: {type: "idle"}, turns: []}; + const response = { + thread, model: model.id, modelProvider: "openai", cwd: "/workspace", + approvalPolicy: "never", sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, serviceTier, + }; + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + appServer.threadStart = vi.fn().mockResolvedValue(response); + appServer.threadResume = vi.fn().mockResolvedValue(response); + appServer.threadFork = vi.fn().mockResolvedValue(response); + const turnStartSpy = mockPromptTurn(fixture, thread.id); + await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION}); + + const request = {sessionId: thread.id, cwd: "/workspace", mcpServers: []}; + switch (operation) { + case "new": await agent.newSession(request); break; + case "resume": await agent.resumeSession(request); break; + case "load": await agent.loadSession(request); break; + case "fork": await agent.forkSession({...request, sessionId: "source-session"}); break; + } + for (const text of ["first prompt", "next prompt"]) { + await agent.prompt({sessionId: thread.id, prompt: [{type: "text", text}]}); + } + + expect(turnStartSpy).toHaveBeenCalledTimes(2); + for (const [params] of turnStartSpy.mock.calls) { + expect(params).toMatchObject({threadId: thread.id, serviceTier}); + } + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts b/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts index cd3c9da9a..061699cf8 100644 --- a/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options-v2.test.ts @@ -147,7 +147,7 @@ describe('Session config options over ACP v2', () => { value: AgentMode.ReadOnly.id, }); - expect(sessionState.fastModeEnabled).toBe(true); + expect(sessionState.currentServiceTier).toBe("fast"); expect(sessionState.agentMode).toBe(AgentMode.ReadOnly); await expect(dump({fastModeResponse, modeResponse})) .toMatchFileSnapshot('data/session-config-options-v2-set.json'); @@ -170,7 +170,7 @@ describe('Session config options over ACP v2', () => { value: true, })).rejects.toMatchObject({code: -32602}); expect(sessionState.agentMode).toBe(AgentMode.DEFAULT_AGENT_MODE); - expect(sessionState.fastModeEnabled).toBe(false); + expect(sessionState.currentServiceTier).toBeNull(); }); it('does not register session/set_mode on v2', async () => { diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 4608b591e..a33701cdd 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -442,7 +442,7 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, collaborationMode: DEFAULT_COLLABORATION_MODE, - fastModeEnabled: false, + currentServiceTier: null, currentModelSupportsFast: false, clientCapabilities: ClientCapabilities.from({_meta: {terminal_output_delta: true, jetbrains: {air: {version: 1}}}}), goalRevision: 0, diff --git a/src/__tests__/integration/service-tier.test.ts b/src/__tests__/integration/service-tier.test.ts new file mode 100644 index 000000000..ac54964c9 --- /dev/null +++ b/src/__tests__/integration/service-tier.test.ts @@ -0,0 +1,39 @@ +import {afterEach, expect, it} from "vitest"; +import {createSpawnedAgentFixture, type SpawnedAgentFixture} from "../CodexACPAgent/e2e/spawned-agent-fixture"; +import {connectAndLogin, describeIntegration, expectNoUnscriptedModelRequests, sendPrompt, stopAll} from "./integration-fixture"; +import {findPromptRequest} from "./model-request"; +import {type MockResponsesServer, startMockResponsesServer, textResponse} from "./mock-responses-server"; + +describeIntegration("integration: configured service tier", () => { + let mock: MockResponsesServer | undefined; + let agent: SpawnedAgentFixture | undefined; + + afterEach(async () => { + await stopAll(agent, mock); + }); + + it("sends configured Ultrafast to the model on consecutive turns with Fast disabled", async () => { + mock = await startMockResponsesServer(); + agent = await createSpawnedAgentFixture(async () => {}, { + CODEX_CONFIG: JSON.stringify({ + model: "gpt-6-astra", + service_tier: "ultrafast", + features: {fast_mode: false}, + }), + }); + await connectAndLogin(agent, mock.baseUrl); + const {sessionId} = await agent.createSession(); + // The first prompt may also generate a session title. + mock.enqueue(textResponse("ok"), textResponse("ok"), textResponse("ok")); + + for (const prompt of ["First Ultrafast prompt", "Second Ultrafast prompt"]) { + const response = await sendPrompt(agent, sessionId, prompt); + expect(response.stopReason).toBe("end_turn"); + expect(findPromptRequest(mock.requests, prompt)).toMatchObject({ + model: "gpt-6-astra", + service_tier: "ultrafast", + }); + } + expectNoUnscriptedModelRequests(mock); + }); +}); From 6b4d6bb52919bb116e5807c71e328065417fd538 Mon Sep 17 00:00:00 2001 From: Builder <65c5c12241e0d16c991a087cd2a28546e97adddd8481cea19a442842befe1bcd@buzz.erictang.co> Date: Sat, 24 Oct 2026 10:27:02 -0700 Subject: [PATCH 2/5] test: use an account-independent tier for the local gateway --- src/__tests__/integration/service-tier.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/__tests__/integration/service-tier.test.ts b/src/__tests__/integration/service-tier.test.ts index ac54964c9..e0be2260d 100644 --- a/src/__tests__/integration/service-tier.test.ts +++ b/src/__tests__/integration/service-tier.test.ts @@ -12,12 +12,13 @@ describeIntegration("integration: configured service tier", () => { await stopAll(agent, mock); }); - it("sends configured Ultrafast to the model on consecutive turns with Fast disabled", async () => { + it("sends a configured non-Fast tier to the model on consecutive turns with Fast disabled", async () => { mock = await startMockResponsesServer(); agent = await createSpawnedAgentFixture(async () => {}, { CODEX_CONFIG: JSON.stringify({ model: "gpt-6-astra", - service_tier: "ultrafast", + // Flex is supported without an account-specific model catalog; Ultrafast is not. + service_tier: "flex", features: {fast_mode: false}, }), }); @@ -26,12 +27,12 @@ describeIntegration("integration: configured service tier", () => { // The first prompt may also generate a session title. mock.enqueue(textResponse("ok"), textResponse("ok"), textResponse("ok")); - for (const prompt of ["First Ultrafast prompt", "Second Ultrafast prompt"]) { + for (const prompt of ["First Flex prompt", "Second Flex prompt"]) { const response = await sendPrompt(agent, sessionId, prompt); expect(response.stopReason).toBe("end_turn"); expect(findPromptRequest(mock.requests, prompt)).toMatchObject({ model: "gpt-6-astra", - service_tier: "ultrafast", + service_tier: "flex", }); } expectNoUnscriptedModelRequests(mock); From 8ccbf8ae6e9e9b12d4131531f28842aafc324dd4 Mon Sep 17 00:00:00 2001 From: Builder <65c5c12241e0d16c991a087cd2a28546e97adddd8481cea19a442842befe1bcd@buzz.erictang.co> Date: Sat, 24 Oct 2026 10:28:21 -0700 Subject: [PATCH 3/5] test: distinguish prompt turns from generated session titles --- src/__tests__/CodexACPAgent/service-tier.test.ts | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/__tests__/CodexACPAgent/service-tier.test.ts b/src/__tests__/CodexACPAgent/service-tier.test.ts index 84b95aded..08ae85618 100644 --- a/src/__tests__/CodexACPAgent/service-tier.test.ts +++ b/src/__tests__/CodexACPAgent/service-tier.test.ts @@ -20,7 +20,10 @@ describe.each(["new", "resume", "load", "fork"] as const)("Service tier after se vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); - appServer.threadStart = vi.fn().mockResolvedValue(response); + appServer.threadStart = vi.fn().mockImplementation(async (params: {ephemeral?: boolean | null}) => ({ + ...response, + thread: {...thread, id: params.ephemeral ? "title-session" : thread.id}, + })); appServer.threadResume = vi.fn().mockResolvedValue(response); appServer.threadFork = vi.fn().mockResolvedValue(response); const turnStartSpy = mockPromptTurn(fixture, thread.id); @@ -37,9 +40,11 @@ describe.each(["new", "resume", "load", "fork"] as const)("Service tier after se await agent.prompt({sessionId: thread.id, prompt: [{type: "text", text}]}); } - expect(turnStartSpy).toHaveBeenCalledTimes(2); - for (const [params] of turnStartSpy.mock.calls) { - expect(params).toMatchObject({threadId: thread.id, serviceTier}); - } + const promptTurns = turnStartSpy.mock.calls.map(([params]) => params).filter(params => params.threadId === thread.id); + expect(promptTurns).toHaveLength(2); + expect(promptTurns).toEqual([ + expect.objectContaining({serviceTier}), + expect.objectContaining({serviceTier}), + ]); }); }); From 0a37c04935b8b99e7dbbb8c00705ff4e94713312 Mon Sep 17 00:00:00 2001 From: Builder <65c5c12241e0d16c991a087cd2a28546e97adddd8481cea19a442842befe1bcd@buzz.erictang.co> Date: Sat, 24 Oct 2026 10:33:05 -0700 Subject: [PATCH 4/5] fix: recognize Codex priority as the Fast tier --- src/CodexAcpServer.ts | 3 ++- src/FastModeConfig.ts | 7 ++++++- .../CodexACPAgent/fast-mode-config.test.ts | 15 +++++++++------ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 03d40cbf0..22db4d6bd 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -148,6 +148,7 @@ import { FAST_MODE_CONFIG_ID, FAST_MODE_OFF, FAST_MODE_ON, + isFastServiceTier, modelSupportsFast, resolveServiceTier, } from "./FastModeConfig"; @@ -2987,7 +2988,7 @@ export class CodexAcpServer { } if (sessionState.currentModelSupportsFast) { configOptions.push(createFastModeConfigOption( - sessionState.currentServiceTier === "fast", + isFastServiceTier(sessionState.currentServiceTier), this.booleanConfigOptionsSupported, )); } diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts index 7a09c51e9..bee139c4f 100644 --- a/src/FastModeConfig.ts +++ b/src/FastModeConfig.ts @@ -13,9 +13,14 @@ export function modelSupportsFast(model: Model | undefined): boolean { return model?.additionalSpeedTiers?.includes("fast") ?? false; } +export function isFastServiceTier(serviceTier: string | null): boolean { + // Codex reports Fast as "priority"; older versions use the "fast" alias. + return serviceTier === "fast" || serviceTier === "priority"; +} + export function resolveServiceTier(serviceTier: string | null, currentModelSupportsFast: boolean): string | null { // The Fast toggle must not discard other tiers selected in Codex config, such as Ultrafast. - return serviceTier === "fast" && !currentModelSupportsFast ? null : serviceTier; + return isFastServiceTier(serviceTier) && !currentModelSupportsFast ? null : serviceTier; } export function clientSupportsBooleanConfigOptions(clientCapabilities?: acp.ClientCapabilities | null): boolean { diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index c2276e011..ae0088e79 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -99,11 +99,14 @@ describe("Fast mode session config", () => { expect(response.configOptions).toContainEqual(createFastModeConfigOption(false)); }); - it("initializes Fast mode as On when the app-server session tier is fast", async () => { - const {response, codexAcpAgent} = await createSession("fast"); + it.each(["fast", "priority"])("keeps Fast mode enabled when the app-server tier is %s", async (tier) => { + const {response, codexAcpAgent, fixture} = await createSession(tier); + const turnStartSpy = mockPromptTurn(fixture, "session-id"); expect(response.configOptions).toContainEqual(createFastModeConfigOption(true)); - expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBe("fast"); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBe(tier); + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({serviceTier: tier})); }); it("omits Fast mode config options for JetBrains 2026.1 IntelliJ clients", async () => { @@ -280,8 +283,8 @@ describe("Fast mode session config", () => { expect(slowResponse.configOptions?.some(o => o.id === FAST_MODE_CONFIG_ID)).toBe(false); }); - it("keeps Fast mode selected across model switches but stops applying it for non-fast models", async () => { - const {codexAcpAgent, codexAcpClient, fixture} = await createSession("fast"); + it.each(["fast", "priority"])("keeps Fast tier %s selected across model switches but stops applying it for non-fast models", async (tier) => { + const {codexAcpAgent, codexAcpClient, fixture} = await createSession(tier); const slowModel = createTestModel({id: "slow-model"}); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue([slowModel]); const turnStartSpy = mockPromptTurn(fixture, "session-id"); @@ -292,7 +295,7 @@ describe("Fast mode session config", () => { }); const sessionState = codexAcpAgent.getSessionState("session-id"); - expect(sessionState.currentServiceTier).toBe("fast"); + expect(sessionState.currentServiceTier).toBe(tier); expect(sessionState.currentModelSupportsFast).toBe(false); await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); From 52d47d7a022525ed19f3757191254e77d8a44310 Mon Sep 17 00:00:00 2001 From: Eric T <4793472+etang4@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:41:33 -0700 Subject: [PATCH 5/5] fix: retain service tiers when reopening empty sessions Signed-off-by: Eric T <4793472+etang4@users.noreply.github.com> --- src/CodexAcpClient.ts | 7 ++- src/CodexAcpServer.ts | 8 +++- src/SessionMetadata.ts | 1 + .../CodexACPAgent/service-tier.test.ts | 2 +- .../unmaterialized-thread.test.ts | 46 ++++++++++++++++++- .../integration/service-tier.test.ts | 10 +++- 6 files changed, 65 insertions(+), 9 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index a849269fe..a2d73e7f4 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -94,7 +94,7 @@ type ResumedThread = { model: string | null; modelProvider: string; reasoningEffort: ReasoningEffort | null; - serviceTier: string | null; + serviceTier?: string | null; itemsBackwardsCursor: string | null; materialized: boolean; /** The mode that the resume response reports. Null when the thread has no rollout yet. */ @@ -671,7 +671,6 @@ export class CodexAcpClient { model: response.thread.model, modelProvider: response.thread.modelProvider, reasoningEffort: response.thread.reasoningEffort, - serviceTier: null, // An unmaterialized thread has no persisted history to hydrate: // `thread/turns/list` rejects it outright ("not materialized // yet"), and there is nothing to list either way. @@ -711,7 +710,7 @@ export class CodexAcpClient { collaborationMode: response.collaborationMode ?? this.getCollaborationMode(response.thread.id), threadStatus: response.thread.status, modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier ?? null, + ...(response.serviceTier !== undefined ? {currentServiceTier: response.serviceTier} : {}), additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, ...(instructions ? {instructions} : {}), @@ -791,7 +790,7 @@ export class CodexAcpClient { // Codex sends no thread/settings/updated on a resume. The response holds the mode. collaborationMode: response.collaborationMode ?? this.getCollaborationMode(response.thread.id), modelProvider: response.modelProvider, - currentServiceTier: response.serviceTier ?? null, + ...(response.serviceTier !== undefined ? {currentServiceTier: response.serviceTier} : {}), thread, history, additionalDirectories, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 22db4d6bd..239bb3342 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -1202,7 +1202,9 @@ export class CodexAcpServer { cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, mcpServers: requestedMcpServers, - currentServiceTier: sessionMetadata.currentServiceTier ?? null, + currentServiceTier: sessionMetadata.currentServiceTier === undefined + ? this.sessions.get(sessionId)?.currentServiceTier ?? null + : sessionMetadata.currentServiceTier, currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, clientCapabilities: this.capabilities, @@ -3260,7 +3262,9 @@ export class CodexAcpServer { cwd: request.cwd, additionalDirectories: sessionMetadata.additionalDirectories, mcpServers: requestedMcpServers, - currentServiceTier: sessionMetadata.currentServiceTier ?? null, + currentServiceTier: sessionMetadata.currentServiceTier === undefined + ? this.sessions.get(sessionId)?.currentServiceTier ?? null + : sessionMetadata.currentServiceTier, currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, clientCapabilities: this.capabilities, diff --git a/src/SessionMetadata.ts b/src/SessionMetadata.ts index 1963a5556..5fa9b8431 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -8,6 +8,7 @@ export type SessionMetadata = { models: Model[], collaborationMode: ModeKind, modelProvider?: string | null, + /** Omitted when Codex cannot report the tier; null explicitly clears it. */ currentServiceTier?: string | null, threadStatus?: ThreadStatus, additionalDirectories: string[], diff --git a/src/__tests__/CodexACPAgent/service-tier.test.ts b/src/__tests__/CodexACPAgent/service-tier.test.ts index 08ae85618..cb2e32140 100644 --- a/src/__tests__/CodexACPAgent/service-tier.test.ts +++ b/src/__tests__/CodexACPAgent/service-tier.test.ts @@ -31,7 +31,7 @@ describe.each(["new", "resume", "load", "fork"] as const)("Service tier after se const request = {sessionId: thread.id, cwd: "/workspace", mcpServers: []}; switch (operation) { - case "new": await agent.newSession(request); break; + case "new": await agent.newSession({cwd: request.cwd, mcpServers: request.mcpServers}); break; case "resume": await agent.resumeSession(request); break; case "load": await agent.loadSession(request); break; case "fork": await agent.forkSession({...request, sessionId: "source-session"}); break; diff --git a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts index 089a77c8a..55d2ecedf 100644 --- a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts +++ b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts @@ -1,5 +1,5 @@ import {describe, expect, it, vi} from "vitest"; -import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; +import {createCodexMockTestFixture, createTestModel, mockPromptTurn} from "../acp-test-utils"; import type {Thread} from "../../app-server/v2"; // Codex materializes a thread's rollout file on the thread's first user @@ -139,6 +139,50 @@ describe("sessions Codex has not materialized on disk", () => { }); }); +describe.each(["resume", "load"] as const)("service tier after session/%s", (operation) => { + it.each([ + {initialTier: "ultrafast", reportedTier: undefined, expectedTier: "ultrafast"}, + {initialTier: "flex", reportedTier: undefined, expectedTier: "flex"}, + {initialTier: null, reportedTier: undefined, expectedTier: null}, + {initialTier: "ultrafast", reportedTier: null, expectedTier: null}, + {initialTier: "ultrafast", reportedTier: "flex", expectedTier: "flex"}, + ])("uses $expectedTier when Codex reports $reportedTier after $initialTier", async ({initialTier, reportedTier, expectedTier}) => { + const {fixture, appServer} = createFixture(); + const agent = fixture.getCodexAcpAgent(); + const response = { + thread: createLiveThread(), + model: "model-id", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: initialTier, + }; + appServer.threadStart = vi.fn().mockImplementation(async (params: {ephemeral?: boolean | null}) => ({ + ...response, + thread: {...response.thread, id: params.ephemeral ? "title-session" : threadId}, + })); + await agent.newSession({cwd: "/test/cwd", mcpServers: []}); + if (reportedTier === undefined) { + appServer.threadResume = vi.fn().mockRejectedValue(NO_ROLLOUT(threadId)); + appServer.threadRead = vi.fn().mockResolvedValue({thread: createLiveThread()}); + } else { + appServer.threadResume = vi.fn().mockResolvedValue({...response, serviceTier: reportedTier}); + } + const turnStart = mockPromptTurn(fixture, threadId); + const request = {sessionId: threadId, cwd: "/test/cwd", mcpServers: []}; + if (operation === "resume") { + await agent.resumeSession(request); + } else { + await agent.loadSession(request); + } + await agent.prompt({sessionId: threadId, prompt: [{type: "text", text: "First prompt"}]}); + + expect(turnStart).toHaveBeenCalledWith(expect.objectContaining({ + threadId, + serviceTier: expectedTier, + })); + }); +}); + describe("session/load and a title generation left over from an earlier turn", () => { it("waits for the rename to land before answering", async () => { const {fixture, appServer} = createFixture(); diff --git a/src/__tests__/integration/service-tier.test.ts b/src/__tests__/integration/service-tier.test.ts index e0be2260d..144d21d6b 100644 --- a/src/__tests__/integration/service-tier.test.ts +++ b/src/__tests__/integration/service-tier.test.ts @@ -12,7 +12,7 @@ describeIntegration("integration: configured service tier", () => { await stopAll(agent, mock); }); - it("sends a configured non-Fast tier to the model on consecutive turns with Fast disabled", async () => { + it.each(["new", "resume", "load"])("preserves a configured non-Fast tier on consecutive turns after session/%s", async (operation) => { mock = await startMockResponsesServer(); agent = await createSpawnedAgentFixture(async () => {}, { CODEX_CONFIG: JSON.stringify({ @@ -24,6 +24,14 @@ describeIntegration("integration: configured service tier", () => { }); await connectAndLogin(agent, mock.baseUrl); const {sessionId} = await agent.createSession(); + if (operation !== "new") { + // Reopen before the first prompt, while Codex has no persisted rollout. + await agent.connection.request(`session/${operation}`, { + sessionId, + cwd: agent.workspaceDir, + mcpServers: [], + }); + } // The first prompt may also generate a session title. mock.enqueue(textResponse("ok"), textResponse("ok"), textResponse("ok"));