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..a2d73e7f4 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"; @@ -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. */ @@ -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. @@ -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} : {}), @@ -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, @@ -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} : {}), @@ -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[], diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 27d739e10..239bb3342 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -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"; @@ -262,7 +263,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 +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, @@ -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 { @@ -2987,7 +2990,7 @@ export class CodexAcpServer { } if (sessionState.currentModelSupportsFast) { configOptions.push(createFastModeConfigOption( - sessionState.fastModeEnabled, + isFastServiceTier(sessionState.currentServiceTier), this.booleanConfigOptionsSupported, )); } @@ -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, @@ -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); diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts index defab8dc6..bee139c4f 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,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 { 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..5fa9b8431 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,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[], diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index d21e7937c..ae0088e79 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}; @@ -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 () => { @@ -164,7 +167,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 +175,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 +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", @@ -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 () => { @@ -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); @@ -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"); @@ -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"}]}); diff --git a/src/__tests__/CodexACPAgent/service-tier.test.ts b/src/__tests__/CodexACPAgent/service-tier.test.ts new file mode 100644 index 000000000..cb2e32140 --- /dev/null +++ b/src/__tests__/CodexACPAgent/service-tier.test.ts @@ -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}), + ]); + }); +}); 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__/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__/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..144d21d6b --- /dev/null +++ b/src/__tests__/integration/service-tier.test.ts @@ -0,0 +1,48 @@ +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.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({ + model: "gpt-6-astra", + // Flex is supported without an account-specific model catalog; Ultrafast is not. + service_tier: "flex", + features: {fast_mode: false}, + }), + }); + 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")); + + 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: "flex", + }); + } + expectNoUnscriptedModelRequests(mock); + }); +});