Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down
12 changes: 5 additions & 7 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -95,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. */
Expand Down Expand Up @@ -672,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.
Expand Down Expand Up @@ -712,7 +710,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,
...(response.serviceTier !== undefined ? {currentServiceTier: response.serviceTier} : {}),
additionalDirectories,
skippedMcpServers: sessionConfig.skippedMcpServers,
...(instructions ? {instructions} : {}),
Expand Down Expand Up @@ -792,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 as ServiceTier ?? null,
...(response.serviceTier !== undefined ? {currentServiceTier: response.serviceTier} : {}),
thread,
history,
additionalDirectories,
Expand Down Expand Up @@ -852,7 +850,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} : {}),
Expand Down Expand Up @@ -1288,7 +1286,7 @@ export class CodexAcpClient {
request: acp.PromptRequest,
agentMode: AgentMode,
modelId: ModelId,
serviceTier: ServiceTier | null,
serviceTier: string | null,
disableSummary: boolean,
cwd: string,
additionalDirectories: string[],
Expand Down
23 changes: 14 additions & 9 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,9 @@ import {
FAST_MODE_CONFIG_ID,
FAST_MODE_OFF,
FAST_MODE_ON,
isFastServiceTier,
modelSupportsFast,
resolveFastServiceTier,
resolveServiceTier,
} from "./FastModeConfig";
import packageJson from "../package.json";
import {isJetBrains2026_1Client} from "./JBUtils";
Expand Down Expand Up @@ -262,7 +263,7 @@ export interface SessionState extends SessionIndexTitleState {
cwd: string;
additionalDirectories: string[];
mcpServers?: Array<AcpMcpServer>;
fastModeEnabled: boolean;
currentServiceTier: string | null;
currentModelSupportsFast: boolean;
sessionMcpServers?: Array<string>;
/** The capability choices of the client for tool call and plan reports. */
Expand Down Expand Up @@ -1201,7 +1202,9 @@ export class CodexAcpServer {
cwd: request.cwd,
additionalDirectories: sessionMetadata.additionalDirectories,
mcpServers: requestedMcpServers,
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
currentServiceTier: sessionMetadata.currentServiceTier === undefined
? this.sessions.get(sessionId)?.currentServiceTier ?? null
: sessionMetadata.currentServiceTier,
currentModelSupportsFast: currentModelSupportsFast,
sessionMcpServers: sessionMcpServers,
clientCapabilities: this.capabilities,
Expand Down Expand Up @@ -2554,13 +2557,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 {
Expand Down Expand Up @@ -2987,7 +2990,7 @@ export class CodexAcpServer {
}
if (sessionState.currentModelSupportsFast) {
configOptions.push(createFastModeConfigOption(
sessionState.fastModeEnabled,
isFastServiceTier(sessionState.currentServiceTier),
this.booleanConfigOptionsSupported,
));
}
Expand Down Expand Up @@ -3259,7 +3262,9 @@ export class CodexAcpServer {
cwd: request.cwd,
additionalDirectories: sessionMetadata.additionalDirectories,
mcpServers: requestedMcpServers,
fastModeEnabled: sessionMetadata.currentServiceTier === "fast",
currentServiceTier: sessionMetadata.currentServiceTier === undefined
? this.sessions.get(sessionId)?.currentServiceTier ?? null
: sessionMetadata.currentServiceTier,
currentModelSupportsFast: currentModelSupportsFast,
sessionMcpServers: sessionMcpServers,
clientCapabilities: this.capabilities,
Expand Down Expand Up @@ -4696,8 +4701,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);
Expand Down
11 changes: 8 additions & 3 deletions src/FastModeConfig.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -14,8 +13,14 @@ 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 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 isFastServiceTier(serviceTier) && !currentModelSupportsFast ? null : serviceTier;
}

export function clientSupportsBooleanConfigOptions(clientCapabilities?: acp.ClientCapabilities | null): boolean {
Expand Down
3 changes: 1 addition & 2 deletions src/SessionFork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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} : {}),
Expand Down
4 changes: 2 additions & 2 deletions src/SessionMetadata.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -9,7 +8,8 @@ export type SessionMetadata = {
models: Model[],
collaborationMode: ModeKind,
modelProvider?: string | null,
currentServiceTier?: ServiceTier | null,
/** Omitted when Codex cannot report the tier; null explicitly clears it. */
currentServiceTier?: string | null,
threadStatus?: ThreadStatus,
additionalDirectories: string[],
skippedMcpServers?: string[],
Expand Down
46 changes: 34 additions & 12 deletions src/__tests__/CodexACPAgent/fast-mode-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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").fastModeEnabled).toBe(true);
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 () => {
Expand Down Expand Up @@ -164,15 +167,15 @@ 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",
configId: FAST_MODE_CONFIG_ID,
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 () => {
Expand All @@ -185,7 +188,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",
Expand All @@ -194,7 +197,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 () => {
Expand All @@ -213,6 +216,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);

Expand Down Expand Up @@ -261,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");
Expand All @@ -273,7 +295,7 @@ describe("Fast mode session config", () => {
});

const sessionState = codexAcpAgent.getSessionState("session-id");
expect(sessionState.fastModeEnabled).toBe(true);
expect(sessionState.currentServiceTier).toBe(tier);
expect(sessionState.currentModelSupportsFast).toBe(false);

await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]});
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/CodexACPAgent/service-tier.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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().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);
await agent.initialize({protocolVersion: acp.PROTOCOL_VERSION});

const request = {sessionId: thread.id, cwd: "/workspace", mcpServers: []};
switch (operation) {
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;
}
for (const text of ["first prompt", "next prompt"]) {
await agent.prompt({sessionId: thread.id, prompt: [{type: "text", text}]});
}

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}),
]);
});
});
Loading