From 961437504d0d4c4599dd6945de6b351458ac1540 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 30 Apr 2026 13:10:00 +0200 Subject: [PATCH 01/13] =?UTF-8?q?feat:=20LLM-26878=20Add=20=E2=80=9CFast?= =?UTF-8?q?=E2=80=9D=20mode=20for=20Codex-agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodexAcpClient.ts | 1 + src/CodexAcpServer.ts | 33 +++++-- src/ModelId.ts | 32 ++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 28 ++++++ .../CodexACPAgent/data/model-filtering.json | 10 +++ .../data/send-attachments-turn-start.json | 3 +- .../CodexACPAgent/model-filtering.test.ts | 86 ++++++++++++++++++- src/__tests__/ModelId.test.ts | 23 +++++ 8 files changed, 192 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d5b59f35..1d2069c8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -412,6 +412,7 @@ export class CodexAcpClient { cwd: null, effort: effort, model: modelId.model, + serviceTier: modelId.serviceTier, }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 1a80d6ca..c26b34ff 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -310,13 +310,16 @@ export class CodexAcpServer implements acp.Agent { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); - const requestedModelId= ModelId.fromString(params.modelId); + const requestedModelId = ModelId.fromString(params.modelId); const requestedModelName = requestedModelId.model; const requestedEffort = requestedModelId.effort; const models = await this.codexAcpClient.fetchAvailableModels(); const model = models.find(m => m.id === requestedModelName); if (!model) throw new Error(`Unknown model ${params.modelId}`); + if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { + throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); + } const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; let reasoningEffort: ReasoningEffort; @@ -334,7 +337,7 @@ export class CodexAcpServer implements acp.Agent { reasoningEffort = model.defaultReasoningEffort; } - sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); + sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -353,11 +356,24 @@ export class CodexAcpServer implements acp.Agent { private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState { const allowedModels = availableModels .flatMap((model) => - model.supportedReasoningEfforts.map((effort) => ({ - modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), - name: `${model.displayName} (${effort.reasoningEffort})`, - description: `${model.description} ${effort.description}`, - })) + model.supportedReasoningEfforts.flatMap((effort) => { + const standardModel = { + modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), + name: `${model.displayName} (${effort.reasoningEffort})`, + description: `${model.description} ${effort.description}`, + }; + if (!model.additionalSpeedTiers.includes("fast")) { + return [standardModel]; + } + return [ + standardModel, + { + modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), + name: `${model.displayName} (${effort.reasoningEffort}, fast)`, + description: `${model.description} ${effort.description} Fast service tier.`, + }, + ]; + }) ); return { availableModels: allowedModels, @@ -812,8 +828,7 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - // Remove the "[reasoning-level]" suffix from currentModelId if present - const modelName = sessionState.currentModelId.replace(/\[.*?]$/, ''); + const modelName = ModelId.fromString(sessionState.currentModelId).model; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) diff --git a/src/ModelId.ts b/src/ModelId.ts index 23c45531..b68d6336 100644 --- a/src/ModelId.ts +++ b/src/ModelId.ts @@ -1,42 +1,48 @@ -import type {ReasoningEffort} from "./app-server"; +import type {ReasoningEffort, ServiceTier} from "./app-server"; import type {Model} from "./app-server/v2"; /** - * ACP Model ID, combining the base model ID and its reasoning effort level. + * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. * @example * const id = ModelId.fromString("gpt-5.2[high]"); + * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); */ export class ModelId { private constructor( public readonly model: string, - public readonly effort: string + public readonly effort: string, + public readonly serviceTier: ServiceTier | null = null ) {} - static fromComponents(model: Model, effort: ReasoningEffort): ModelId { - return new ModelId(model.id, effort); + static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(model.id, effort, serviceTier); } - static create(modelId: string, effort: ReasoningEffort): ModelId { - return new ModelId(modelId, effort); + static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(modelId, effort, serviceTier); } static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+?)(?:\[(?[^\]]+)\])?$/); + const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); const model = bracketMatch?.groups?.["model"]; const effort = bracketMatch?.groups?.["effort"]; + const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort].`); + throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); } - if (model) { - return new ModelId(model, effort); + // The generated app-server ServiceTier type also includes "flex", but ACP model IDs + // only expose Fast variants for now because model/list advertises Fast support. + if (serviceTier !== null && serviceTier !== "fast") { + throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); } - throw new Error(`Invalid modelId format: ${modelId}`); + return new ModelId(model, effort, serviceTier); } toString(): string { - return `${this.model}[${this.effort}]`; + const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; + return `${this.model}[${this.effort}]${suffix}`; } } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 27ff179f..bc0136fa 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -914,6 +914,34 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null })); }); + it ('should send null service tier for normal model selections', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ + currentModelId: "model-id[effort]", + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "effort", + serviceTier: null, + })); + }); + + it ('should send fast service tier for fast model selections', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture({ + currentModelId: "model-id[effort]@fast", + }); + + await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "model-id", + effort: "effort", + serviceTier: "fast", + })); + }); + it ('should disable reasoning.summary when model lacks reasoning', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 95439da2..03af5012 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -4,11 +4,21 @@ "name": "GPT-5.2 (medium)", "description": "Allowed by id. Default effort." }, + { + "modelId": "gpt-5.2[medium]@fast", + "name": "GPT-5.2 (medium, fast)", + "description": "Allowed by id. Default effort. Fast service tier." + }, { "modelId": "gpt-5.2[low]", "name": "GPT-5.2 (low)", "description": "Allowed by id. Fast effort." }, + { + "modelId": "gpt-5.2[low]@fast", + "name": "GPT-5.2 (low, fast)", + "description": "Allowed by id. Fast effort. Fast service tier." + }, { "modelId": "other-id[medium]", "name": "gpt-5.2 (medium)", diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index 9c685ece..c4ab4c54 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -56,7 +56,8 @@ "personality": null, "cwd": "cwd", "effort": "effort", - "model": "model" + "model": "model", + "serviceTier": null } } { diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 97b658a4..c7b795ec 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -25,7 +25,7 @@ describe("Model filtering", () => { supportedReasoningEfforts: efforts, defaultReasoningEffort: "medium", supportsPersonality: false, - additionalSpeedTiers: [], + additionalSpeedTiers: ["fast"], isDefault: false, inputModalities: [] }, @@ -95,4 +95,88 @@ describe("Model filtering", () => { "data/model-filtering.json" ); }); + + it("rejects fast model selections when the model does not support fast", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + const models: Model[] = [ + { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "No fast tier.", + hidden: false, + supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: [], + isDefault: true, + inputModalities: ["text"] + }, + ]; + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: "gpt-5.2[medium]", + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + + await expect(codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2[medium]@fast", + })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); + }); + + it("stores fast model selections when the model supports fast", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + const models: Model[] = [ + { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "Fast tier.", + hidden: false, + supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: ["fast"], + isDefault: true, + inputModalities: ["text"] + }, + ]; + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: "gpt-5.2[medium]", + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2[medium]@fast", + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId) + .toBe("gpt-5.2[medium]@fast"); + }); }); diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts new file mode 100644 index 00000000..276a4d50 --- /dev/null +++ b/src/__tests__/ModelId.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, it} from "vitest"; +import {ModelId} from "../ModelId"; + +describe("ModelId", () => { + it("formats and parses normal model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]"); + expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); + }); + + it("formats and parses fast model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium", "fast"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); + expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); + }); + + it("rejects unknown service tiers", () => { + expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) + .toThrow("Unsupported service tier flex"); + }); +}); From b5f470cd298b8bd7ba2968c7111112e5531f68a6 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Wed, 6 May 2026 22:56:22 +0200 Subject: [PATCH 02/13] feat: move model related data to meta --- src/CodexAcpClient.ts | 46 +++++--- src/CodexAcpServer.ts | 106 +++++++++-------- .../CodexACPAgent/CodexAcpClient.test.ts | 37 +++--- .../CodexACPAgent/approval-events.test.ts | 2 +- .../command-action-events.test.ts | 2 +- .../data/command-status-with-rate-limits.json | 2 +- .../CodexACPAgent/data/command-status.json | 2 +- .../CodexACPAgent/data/model-filtering.json | 85 +++++++++----- .../data/send-attachments-turn-start.json | 2 +- .../e2e/acp-e2e-session-persistence.test.ts | 6 +- .../CodexACPAgent/e2e/acp-e2e.test.ts | 1 + .../e2e/spawned-agent-fixture.ts | 20 +++- .../CodexACPAgent/elicitation-events.test.ts | 2 +- .../CodexACPAgent/file-change-events.test.ts | 2 +- .../fuzzy-file-search-events.test.ts | 2 +- .../CodexACPAgent/model-filtering.test.ts | 110 +++++++++++++++++- .../model-rerouted-events.test.ts | 2 +- .../terminal-output-events.test.ts | 2 +- src/__tests__/acp-test-utils.ts | 4 +- 19 files changed, 307 insertions(+), 128 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 1d2069c8..f7ab78f8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -13,10 +13,10 @@ import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, ReasoningEffort, - ServerNotification + ServerNotification, + ServiceTier, } from "./app-server"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; -import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; @@ -215,10 +215,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: request.sessionId, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, } } @@ -237,10 +237,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: request.sessionId, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, thread: response.thread, }; @@ -266,10 +266,10 @@ export class CodexAcpClient { if (codexModels.length === 0) { throw new Error("Codex did not return any models"); } - const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); + const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); return { sessionId: response.thread.id, - currentModelId: currentModelId, + ...modelSelection, models: codexModels, }; } @@ -364,10 +364,15 @@ export class CodexAcpClient { } /** - * Resolves a ModelId using the provided ID and reasoning effort. - * Falls back to model defaults if parameters are missing or unsupported. + * Resolves a model selection using the provided ID and reasoning effort. + * Falls back to model defaults if parameters are missing. */ - createModelId(availableModels: Model[], modelId: string | null, reasoningEffort: ReasoningEffort | null): ModelId { + createModelSelection( + availableModels: Model[], + modelId: string | null, + reasoningEffort: ReasoningEffort | null, + serviceTier: ServiceTier | null, + ): ModelSelection { const selectedModel = availableModels.find(m => m.id === modelId) ?? availableModels.find(m => m.isDefault); @@ -376,7 +381,11 @@ export class CodexAcpClient { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } - return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); + return { + currentModelId: selectedModel.id, + currentReasoningEffort: reasoningEffort ?? selectedModel.defaultReasoningEffort, + currentServiceTier: serviceTier ?? null, + }; } async subscribeToSessionEvents( @@ -393,12 +402,11 @@ export class CodexAcpClient { async sendPrompt( request: acp.PromptRequest, agentMode: AgentMode, - modelId: ModelId, + modelSelection: ModelSelection, disableSummary: boolean, cwd: string, ): Promise { const input = buildPromptItems(request.prompt); - const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion await this.refreshSkills(cwd, request._meta); return await this.codexClient.runTurn({ @@ -410,9 +418,9 @@ export class CodexAcpClient { summary: disableSummary ? "none" : null, personality: null, cwd: null, - effort: effort, - model: modelId.model, - serviceTier: modelId.serviceTier, + effort: modelSelection.currentReasoningEffort, + model: modelSelection.currentModelId, + serviceTier: modelSelection.currentServiceTier, }); } @@ -605,6 +613,8 @@ export type JsonObject = { [key in string]?: JsonValue } export type SessionMetadata = { sessionId: string, currentModelId: string, + currentReasoningEffort: ReasoningEffort, + currentServiceTier: ServiceTier | null, models: Model[], } @@ -612,6 +622,8 @@ export type SessionMetadataWithThread = SessionMetadata & { thread: Thread, } +export type ModelSelection = Pick; + function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { return prompt.map((block): UserInput | null => { switch (block.type) { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index c26b34ff..91ca7421 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -4,10 +4,10 @@ import {CodexEventHandler} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods} from "./CodexAuthMethod"; -import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; +import {CodexAcpClient, type ModelSelection, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; import {ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; -import type {InputModality, ReasoningEffort} from "./app-server"; +import type {InputModality, ReasoningEffort, ServiceTier} from "./app-server"; import type { Account, CollabAgentToolCallStatus, @@ -18,7 +18,6 @@ import type { UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; -import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; @@ -36,6 +35,8 @@ import { export interface SessionState { sessionId: string, currentModelId: string, + currentReasoningEffort: ReasoningEffort, + currentServiceTier: ServiceTier | null, supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, @@ -160,12 +161,14 @@ export class CodexAcpServer implements acp.Agent { } const account = await this.getActiveAccount(); - const {sessionId, currentModelId, models} = sessionMetadata; + const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, + currentReasoningEffort: currentReasoningEffort, + currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -189,7 +192,7 @@ export class CodexAcpServer implements acp.Agent { } this.publishAvailableCommandsAsync(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); + const sessionModelState: SessionModelState = this.createModelState(models, sessionState); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return [sessionId, sessionModelState, sessionModeState]; @@ -310,34 +313,32 @@ export class CodexAcpServer implements acp.Agent { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); - const requestedModelId = ModelId.fromString(params.modelId); - const requestedModelName = requestedModelId.model; - const requestedEffort = requestedModelId.effort; - const models = await this.codexAcpClient.fetchAvailableModels(); - const model = models.find(m => m.id === requestedModelName); + const model = models.find(m => m.id === params.modelId); if (!model) throw new Error(`Unknown model ${params.modelId}`); - if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { - throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); - } - const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; - let reasoningEffort: ReasoningEffort; - if (requestedEffortValue) { + const requestedEffort = readStringMeta(params._meta, "reasoningEffort") as ReasoningEffort | null; + let reasoningEffort = model.defaultReasoningEffort; + if (requestedEffort !== null) { const matchedEffort = model.supportedReasoningEfforts.find( - (option) => option.reasoningEffort === requestedEffortValue + (option) => option.reasoningEffort === requestedEffort )?.reasoningEffort; if (!matchedEffort) { - throw new Error(`Unsupported reasoning effort ${requestedEffortValue} for model ${requestedModelName}`); + throw new Error(`Unsupported reasoning effort ${requestedEffort} for model ${model.id}`); } reasoningEffort = matchedEffort; - } else { - reasoningEffort = model.defaultReasoningEffort; } - sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString(); + const requestedServiceTier = readStringMeta(params._meta, "serviceTier") as ServiceTier | null; + if (requestedServiceTier !== null && !model.additionalSpeedTiers.includes(requestedServiceTier)) { + throw new Error(`Unsupported service tier ${requestedServiceTier} for model ${model.id}`); + } + + sessionState.currentModelId = model.id; + sessionState.currentReasoningEffort = reasoningEffort; + sessionState.currentServiceTier = requestedServiceTier; sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -349,35 +350,28 @@ export class CodexAcpServer implements acp.Agent { } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { - const modelId = ModelId.fromString(currentModelId); - return models.find(m => m.id === modelId.model); + return models.find(m => m.id === currentModelId); } - private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState { + private createModelState(availableModels: Model[], selection: ModelSelection): SessionModelState { const allowedModels = availableModels - .flatMap((model) => - model.supportedReasoningEfforts.flatMap((effort) => { - const standardModel = { - modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), - name: `${model.displayName} (${effort.reasoningEffort})`, - description: `${model.description} ${effort.description}`, - }; - if (!model.additionalSpeedTiers.includes("fast")) { - return [standardModel]; - } - return [ - standardModel, - { - modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), - name: `${model.displayName} (${effort.reasoningEffort}, fast)`, - description: `${model.description} ${effort.description} Fast service tier.`, - }, - ]; - }) - ); + .map((model) => ({ + modelId: model.id, + name: model.displayName, + description: model.description, + _meta: { + supportedReasoningEfforts: model.supportedReasoningEfforts, + defaultReasoningEffort: model.defaultReasoningEffort, + serviceTiers: model.additionalSpeedTiers, + }, + })); return { availableModels: allowedModels, - currentModelId: selectedModelId, + currentModelId: selection.currentModelId, + _meta: { + currentReasoningEffort: selection.currentReasoningEffort, + currentServiceTier: selection.currentServiceTier, + }, } } @@ -401,12 +395,14 @@ export class CodexAcpServer implements acp.Agent { ); const account = await this.getActiveAccount(); - const {sessionId, currentModelId, models, thread} = sessionMetadata; + const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models, thread} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, + currentReasoningEffort: currentReasoningEffort, + currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -430,7 +426,7 @@ export class CodexAcpServer implements acp.Agent { } await this.availableCommands.publish(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); + const sessionModelState: SessionModelState = this.createModelState(models, sessionState); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return { @@ -767,7 +763,6 @@ export class CodexAcpServer implements acp.Agent { }; } - const modelId = ModelId.fromString(sessionState.currentModelId); const modelLacksReasoning = sessionState.supportedReasoningEfforts.length > 0 && sessionState.supportedReasoningEfforts.every(e => e.reasoningEffort === "none"); @@ -784,7 +779,7 @@ export class CodexAcpServer implements acp.Agent { } const agentMode = sessionState.agentMode; const turnCompleted = await this.runWithProcessCheck( - () => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd)); + () => this.codexAcpClient.sendPrompt(params, agentMode, sessionState, disableSummary, sessionState.cwd)); // Check if turn was interrupted (cancelled) if (turnCompleted.turn.status === "interrupted") { @@ -828,7 +823,7 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - const modelName = ModelId.fromString(sessionState.currentModelId).model; + const modelName = sessionState.currentModelId; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) @@ -901,3 +896,14 @@ export class CodexAcpServer implements acp.Agent { function getRequestedMcpServerNames(mcpServers: Array): Array { return Array.from(new Set(mcpServers.map(server => server.name))); } + +function readStringMeta(meta: { [key: string]: unknown } | null | undefined, key: string): string | null { + const value = meta?.[key]; + if (value === undefined || value === null) { + return null; + } + if (typeof value !== "string") { + throw RequestError.invalidParams(`Expected _meta.${key} to be a string`); + } + return value; +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index bc0136fa..0d4bc628 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -9,7 +9,6 @@ import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; import type {ListMcpServerStatusResponse, Model, SkillsListResponse, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; -import {ModelId} from "../../ModelId"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -448,7 +447,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -473,7 +472,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -515,12 +514,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState1: SessionState = createTestSessionState({ sessionId: "session-1", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); const sessionState2: SessionState = createTestSessionState({ sessionId: "session-2", - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); @@ -853,13 +852,21 @@ describe('ACP server test', { timeout: 40_000 }, () => { ]; it('should fallback to the default model when modelId is null', () => { - const result = fixture.getCodexAcpClient().createModelId(mockModels, null, 'low'); - expect(result).toEqual(ModelId.create('5.1', 'low')); + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, null, 'low', null); + expect(result).toEqual({ + currentModelId: "5.1", + currentReasoningEffort: "low", + currentServiceTier: null, + }); }); it('should fallback to the model-specific effort when reasoningEffort is null', () => { - const result = fixture.getCodexAcpClient().createModelId(mockModels, '5.2-codex', null); - expect(result).toEqual(ModelId.create('5.2-codex', 'medium')); + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', null, null); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "medium", + currentServiceTier: null, + }); }); /** @@ -916,28 +923,32 @@ describe('ACP server test', { timeout: 40_000 }, () => { it ('should send null service tier for normal model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]", + currentModelId: "model-id", + currentReasoningEffort: "high", + currentServiceTier: null, }); await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ model: "model-id", - effort: "effort", + effort: "high", serviceTier: null, })); }); it ('should send fast service tier for fast model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]@fast", + currentModelId: "model-id", + currentReasoningEffort: "high", + currentServiceTier: "fast", }); await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ model: "model-id", - effort: "effort", + effort: "high", serviceTier: "fast", })); }); diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index 7ee43746..cdabba8d 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -28,7 +28,7 @@ describe('Approval Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/command-action-events.test.ts b/src/__tests__/CodexACPAgent/command-action-events.test.ts index bb92314d..ba2151b9 100644 --- a/src/__tests__/CodexACPAgent/command-action-events.test.ts +++ b/src/__tests__/CodexACPAgent/command-action-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - command action events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json index 13403fd0..1bd9899d 100644 --- a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json +++ b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" + "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" } } } diff --git a/src/__tests__/CodexACPAgent/data/command-status.json b/src/__tests__/CodexACPAgent/data/command-status.json index 50260582..85143d17 100644 --- a/src/__tests__/CodexACPAgent/data/command-status.json +++ b/src/__tests__/CodexACPAgent/data/command-status.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" + "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" } } } diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 03af5012..b5350b53 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -1,37 +1,68 @@ [ { - "modelId": "gpt-5.2[medium]", - "name": "GPT-5.2 (medium)", - "description": "Allowed by id. Default effort." + "modelId": "gpt-5.2", + "name": "GPT-5.2", + "description": "Allowed by id.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + }, + { + "reasoningEffort": "low", + "description": "Fast effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [ + "fast" + ] + } }, { - "modelId": "gpt-5.2[medium]@fast", - "name": "GPT-5.2 (medium, fast)", - "description": "Allowed by id. Default effort. Fast service tier." + "modelId": "other-id", + "name": "gpt-5.2", + "description": "Allowed", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } }, { - "modelId": "gpt-5.2[low]", - "name": "GPT-5.2 (low)", - "description": "Allowed by id. Fast effort." + "modelId": "gpt-5.1-codex-mini", + "name": "Other", + "description": "Allowed by id.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } }, { - "modelId": "gpt-5.2[low]@fast", - "name": "GPT-5.2 (low, fast)", - "description": "Allowed by id. Fast effort. Fast service tier." - }, - { - "modelId": "other-id[medium]", - "name": "gpt-5.2 (medium)", - "description": "Allowed Default effort." - }, - { - "modelId": "gpt-5.1-codex-mini[medium]", - "name": "Other (medium)", - "description": "Allowed by id. Default effort." - }, - { - "modelId": "gpt-4o[medium]", - "name": "gpt-4o (medium)", - "description": "Allowed. Default effort." + "modelId": "gpt-4o", + "name": "gpt-4o", + "description": "Allowed.", + "_meta": { + "supportedReasoningEfforts": [ + { + "reasoningEffort": "medium", + "description": "Default effort." + } + ], + "defaultReasoningEffort": "medium", + "serviceTiers": [] + } } ] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index c4ab4c54..d35f3cc5 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -55,7 +55,7 @@ "summary": null, "personality": null, "cwd": "cwd", - "effort": "effort", + "effort": "medium", "model": "model", "serviceTier": null } diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts index 037e3631..f9dc2ef8 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts @@ -22,7 +22,11 @@ describeE2E("E2E session persistence tests", () => { beforeRestartFixture = await createAuthenticatedFixture(); const sessionId = (await beforeRestartFixture.createSession()).sessionId; - await beforeRestartFixture.connection.unstable_setSessionModel({sessionId, modelId: OTHER_TEST_MODEL_ID.toString()}); + await beforeRestartFixture.connection.unstable_setSessionModel({ + sessionId, + modelId: OTHER_TEST_MODEL_ID.toString(), + _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, + }); const memorizedToken = "token-for-tests-123"; await beforeRestartFixture.expectPromptText( sessionId, diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index f625f0df..0b82c96b 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -51,6 +51,7 @@ describeE2E("E2E tests", () => { await fixture.connection.unstable_setSessionModel({ sessionId: session.sessionId, modelId: OTHER_TEST_MODEL_ID.toString(), + _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, }); await fixture.expectStatus(session.sessionId, {Model: OTHER_TEST_MODEL_ID}); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index 217c1125..bd590174 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -4,12 +4,26 @@ import fs from "node:fs"; import path from "node:path"; import {Readable, Writable} from "node:stream"; import {expect, vi} from "vitest"; -import {ModelId} from "../../../ModelId"; +import type {ReasoningEffort} from "../../../app-server"; import {removeDirectoryWithRetry, writeCodexHomeConfig} from "../../acp-test-utils"; import type {PermissionResponder} from "./permission-responders"; -export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); -export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.3-codex", "low"); +export interface TestModelSelection { + readonly model: string; + readonly effort: ReasoningEffort; + toString(): string; +} + +export const DEFAULT_TEST_MODEL_ID: TestModelSelection = { + model: "gpt-5.2", + effort: "none", + toString: () => "gpt-5.2", +}; +export const OTHER_TEST_MODEL_ID: TestModelSelection = { + model: "gpt-5.3-codex", + effort: "low", + toString: () => "gpt-5.3-codex", +}; export interface TestSkill { readonly name: string; diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index 32548bee..a8510c25 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -29,7 +29,7 @@ describe('Elicitation Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index ce00135a..bceba285 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -36,7 +36,7 @@ describe('CodexEventHandler - file change events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts index 02d8561c..563a5502 100644 --- a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts +++ b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - fuzzy file search events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE, }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index c7b795ec..d7bc43fe 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -82,7 +82,9 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, models, }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); @@ -91,6 +93,10 @@ describe("Model filtering", () => { const sessionModels = newSessionResponse.models; const availableModels = sessionModels?.availableModels; + expect(sessionModels?._meta).toEqual({ + currentReasoningEffort: "medium", + currentServiceTier: null, + }); await expect(JSON.stringify(availableModels, null, 2)).toMatchFileSnapshot( "data/model-filtering.json" ); @@ -123,7 +129,9 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, models, }); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); @@ -133,7 +141,8 @@ describe("Model filtering", () => { await expect(codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); }); @@ -164,7 +173,9 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", + currentModelId: "gpt-5.2", + currentReasoningEffort: "medium", + currentServiceTier: null, models, }); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); @@ -173,10 +184,97 @@ describe("Model filtering", () => { await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); await codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", + modelId: "gpt-5.2", + _meta: { serviceTier: "fast" }, }); expect(codexAcpAgent.getSessionState("session-id").currentModelId) - .toBe("gpt-5.2[medium]@fast"); + .toBe("gpt-5.2"); + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort) + .toBe("medium"); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier) + .toBe("fast"); + }); + + it("uses the model default effort when _meta.reasoningEffort is omitted", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + }); + + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("medium"); + expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBeNull(); + }); + + it("stores requested reasoning effort from _meta", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { reasoningEffort: "low" }, + }); + + expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("gpt-5.2"); + expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("low"); + }); + + it("rejects unsupported reasoning effort selections", async () => { + const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); + + await expect(codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "gpt-5.2", + _meta: { reasoningEffort: "xhigh" }, + })).rejects.toThrow("Unsupported reasoning effort xhigh for model gpt-5.2"); }); }); + +function createSelectableModel(overrides: Partial = {}): Model { + return { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "Selectable model.", + hidden: false, + supportedReasoningEfforts: [ + {reasoningEffort: "low", description: "Fast effort."}, + {reasoningEffort: "medium", description: "Default effort."}, + ], + defaultReasoningEffort: "medium", + supportsPersonality: false, + additionalSpeedTiers: ["fast"], + isDefault: true, + inputModalities: ["text"], + ...overrides, + }; +} + +async function setupModelSelectionTest(models: Model[]) { + const initialModel = models[0]; + if (!initialModel) { + throw new Error("setupModelSelectionTest requires at least one model"); + } + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: initialModel.id, + currentReasoningEffort: initialModel.defaultReasoningEffort, + currentServiceTier: null, + models, + }); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + + await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + return {fixture, codexAcpAgent, codexAcpClient}; +} diff --git a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts index 61c49400..0b961c41 100644 --- a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts +++ b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - model rerouted events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id[effort]", + currentModelId: "model-id", agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index de5ff938..20fef5f6 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - terminal output events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id[effort]', + currentModelId: 'model-id', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index a1629214..a5050974 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -318,7 +318,9 @@ export function createTestSessionState(overrides?: Partial): Sessi account: null, cwd: "/test/cwd", sessionId: "session-id", - currentModelId: "model-id[effort]", + currentModelId: "model-id", + currentReasoningEffort: "medium", + currentServiceTier: null, supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, From df12a9fa9a69d2c9b6cf6cf899220eff553f4816 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:28:49 +0200 Subject: [PATCH 03/13] fix: review --- src/CodexAcpClient.ts | 23 +++++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 45 +++++++++++++++++++ 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f7ab78f8..0e470a8f 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -373,18 +373,31 @@ export class CodexAcpClient { reasoningEffort: ReasoningEffort | null, serviceTier: ServiceTier | null, ): ModelSelection { - const selectedModel = - availableModels.find(m => m.id === modelId) ?? - availableModels.find(m => m.isDefault); + const requestedModel = availableModels.find(m => m.id === modelId); + const selectedModel = requestedModel ?? availableModels.find(m => m.isDefault); if (!selectedModel) { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } + const supportedReasoningEfforts = selectedModel.supportedReasoningEfforts ?? []; + const additionalSpeedTiers = selectedModel.additionalSpeedTiers ?? []; + const selectedReasoningEffort = reasoningEffort !== null && supportedReasoningEfforts.some( + option => option.reasoningEffort === reasoningEffort + ) + ? reasoningEffort + : selectedModel.defaultReasoningEffort; + const didSelectRequestedModel = requestedModel !== undefined; + const supportsServiceTier = serviceTier !== null && additionalSpeedTiers.includes(serviceTier); + const selectedServiceTier = + didSelectRequestedModel && supportsServiceTier + ? serviceTier + : null; + return { currentModelId: selectedModel.id, - currentReasoningEffort: reasoningEffort ?? selectedModel.defaultReasoningEffort, - currentServiceTier: serviceTier ?? null, + currentReasoningEffort: selectedReasoningEffort, + currentServiceTier: selectedServiceTier, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 0d4bc628..3d16a851 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -869,6 +869,51 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('should fallback to the model-specific effort when reasoningEffort is unsupported', () => { + const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', 'low', null); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "medium", + currentServiceTier: null, + }); + }); + + it('should drop stale service tier when falling back to the default model', () => { + const [codexModel, defaultModel] = mockModels as [Model, Model]; + const models = [ + { + ...codexModel, + additionalSpeedTiers: ["fast"], + }, + defaultModel, + ]; + + const result = fixture.getCodexAcpClient().createModelSelection(models, 'unavailable-model', 'high', 'fast'); + expect(result).toEqual({ + currentModelId: "5.1", + currentReasoningEffort: "low", + currentServiceTier: null, + }); + }); + + it('should retain service tier when selected model supports it', () => { + const [codexModel, defaultModel] = mockModels as [Model, Model]; + const models = [ + { + ...codexModel, + additionalSpeedTiers: ["fast"], + }, + defaultModel, + ]; + + const result = fixture.getCodexAcpClient().createModelSelection(models, '5.2-codex', 'high', 'fast'); + expect(result).toEqual({ + currentModelId: "5.2-codex", + currentReasoningEffort: "high", + currentServiceTier: "fast", + }); + }); + /** * Sets up a mock fixture with turnStart/awaitTurnCompleted spied on, * and a given session state. Returns the fixture and turnStart spy. From 62a221b1a1ad313cd16ab543002370b867a253a5 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:38:32 +0200 Subject: [PATCH 04/13] feat: add comment related to serviceTier logic --- src/CodexAcpClient.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0e470a8f..d324876e 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -433,6 +433,7 @@ export class CodexAcpClient { cwd: null, effort: modelSelection.currentReasoningEffort, model: modelSelection.currentModelId, + // In app-server, explicit null clears the tier; omitting serviceTier would keep the thread's existing tier. serviceTier: modelSelection.currentServiceTier, }); } From 7466a8899949d4d2b8588fc4ed35102ad840f9fb Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 7 May 2026 13:58:34 +0200 Subject: [PATCH 05/13] cleanup: remove ModelId --- src/ModelId.ts | 48 ----------------------------------- src/__tests__/ModelId.test.ts | 23 ----------------- 2 files changed, 71 deletions(-) delete mode 100644 src/ModelId.ts delete mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/ModelId.ts b/src/ModelId.ts deleted file mode 100644 index b68d6336..00000000 --- a/src/ModelId.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type {ReasoningEffort, ServiceTier} from "./app-server"; -import type {Model} from "./app-server/v2"; - -/** - * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. - * @example - * const id = ModelId.fromString("gpt-5.2[high]"); - * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); - */ -export class ModelId { - private constructor( - public readonly model: string, - public readonly effort: string, - public readonly serviceTier: ServiceTier | null = null - ) {} - - static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(model.id, effort, serviceTier); - } - - static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(modelId, effort, serviceTier); - } - - static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); - const model = bracketMatch?.groups?.["model"]; - const effort = bracketMatch?.groups?.["effort"]; - const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; - - if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); - } - - // The generated app-server ServiceTier type also includes "flex", but ACP model IDs - // only expose Fast variants for now because model/list advertises Fast support. - if (serviceTier !== null && serviceTier !== "fast") { - throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); - } - - return new ModelId(model, effort, serviceTier); - } - - toString(): string { - const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; - return `${this.model}[${this.effort}]${suffix}`; - } -} diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts deleted file mode 100644 index 276a4d50..00000000 --- a/src/__tests__/ModelId.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {describe, expect, it} from "vitest"; -import {ModelId} from "../ModelId"; - -describe("ModelId", () => { - it("formats and parses normal model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]"); - expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); - }); - - it("formats and parses fast model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium", "fast"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); - expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); - }); - - it("rejects unknown service tiers", () => { - expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) - .toThrow("Unsupported service tier flex"); - }); -}); From 58f5215a0ff55edb76edb32ab0a9845d70a96d1b Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:28:27 +0200 Subject: [PATCH 06/13] Revert "cleanup: remove ModelId" This reverts commit 7466a8899949d4d2b8588fc4ed35102ad840f9fb. --- src/ModelId.ts | 48 +++++++++++++++++++++++++++++++++++ src/__tests__/ModelId.test.ts | 23 +++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 src/ModelId.ts create mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/ModelId.ts b/src/ModelId.ts new file mode 100644 index 00000000..b68d6336 --- /dev/null +++ b/src/ModelId.ts @@ -0,0 +1,48 @@ +import type {ReasoningEffort, ServiceTier} from "./app-server"; +import type {Model} from "./app-server/v2"; + +/** + * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. + * @example + * const id = ModelId.fromString("gpt-5.2[high]"); + * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); + */ +export class ModelId { + private constructor( + public readonly model: string, + public readonly effort: string, + public readonly serviceTier: ServiceTier | null = null + ) {} + + static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(model.id, effort, serviceTier); + } + + static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { + return new ModelId(modelId, effort, serviceTier); + } + + static fromString(modelId: string): ModelId { + const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); + const model = bracketMatch?.groups?.["model"]; + const effort = bracketMatch?.groups?.["effort"]; + const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; + + if (!model || !effort) { + throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); + } + + // The generated app-server ServiceTier type also includes "flex", but ACP model IDs + // only expose Fast variants for now because model/list advertises Fast support. + if (serviceTier !== null && serviceTier !== "fast") { + throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); + } + + return new ModelId(model, effort, serviceTier); + } + + toString(): string { + const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; + return `${this.model}[${this.effort}]${suffix}`; + } +} diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts new file mode 100644 index 00000000..276a4d50 --- /dev/null +++ b/src/__tests__/ModelId.test.ts @@ -0,0 +1,23 @@ +import {describe, expect, it} from "vitest"; +import {ModelId} from "../ModelId"; + +describe("ModelId", () => { + it("formats and parses normal model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]"); + expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); + }); + + it("formats and parses fast model IDs", () => { + const modelId = ModelId.create("gpt-5.2", "medium", "fast"); + + expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); + expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); + }); + + it("rejects unknown service tiers", () => { + expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) + .toThrow("Unsupported service tier flex"); + }); +}); From 07ff020ccee2ed879cb3faf05d9dec41188e7862 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:28:27 +0200 Subject: [PATCH 07/13] Revert "feat: add comment related to serviceTier logic" This reverts commit 62a221b1a1ad313cd16ab543002370b867a253a5. --- src/CodexAcpClient.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d324876e..0e470a8f 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -433,7 +433,6 @@ export class CodexAcpClient { cwd: null, effort: modelSelection.currentReasoningEffort, model: modelSelection.currentModelId, - // In app-server, explicit null clears the tier; omitting serviceTier would keep the thread's existing tier. serviceTier: modelSelection.currentServiceTier, }); } From 6a6c3b49794b815e9f4315cf5273d63a317d82da Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:28:27 +0200 Subject: [PATCH 08/13] Revert "fix: review" This reverts commit df12a9fa9a69d2c9b6cf6cf899220eff553f4816. --- src/CodexAcpClient.ts | 23 +++------- .../CodexACPAgent/CodexAcpClient.test.ts | 45 ------------------- 2 files changed, 5 insertions(+), 63 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0e470a8f..f7ab78f8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -373,31 +373,18 @@ export class CodexAcpClient { reasoningEffort: ReasoningEffort | null, serviceTier: ServiceTier | null, ): ModelSelection { - const requestedModel = availableModels.find(m => m.id === modelId); - const selectedModel = requestedModel ?? availableModels.find(m => m.isDefault); + const selectedModel = + availableModels.find(m => m.id === modelId) ?? + availableModels.find(m => m.isDefault); if (!selectedModel) { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } - const supportedReasoningEfforts = selectedModel.supportedReasoningEfforts ?? []; - const additionalSpeedTiers = selectedModel.additionalSpeedTiers ?? []; - const selectedReasoningEffort = reasoningEffort !== null && supportedReasoningEfforts.some( - option => option.reasoningEffort === reasoningEffort - ) - ? reasoningEffort - : selectedModel.defaultReasoningEffort; - const didSelectRequestedModel = requestedModel !== undefined; - const supportsServiceTier = serviceTier !== null && additionalSpeedTiers.includes(serviceTier); - const selectedServiceTier = - didSelectRequestedModel && supportsServiceTier - ? serviceTier - : null; - return { currentModelId: selectedModel.id, - currentReasoningEffort: selectedReasoningEffort, - currentServiceTier: selectedServiceTier, + currentReasoningEffort: reasoningEffort ?? selectedModel.defaultReasoningEffort, + currentServiceTier: serviceTier ?? null, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 3d16a851..0d4bc628 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -869,51 +869,6 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); - it('should fallback to the model-specific effort when reasoningEffort is unsupported', () => { - const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', 'low', null); - expect(result).toEqual({ - currentModelId: "5.2-codex", - currentReasoningEffort: "medium", - currentServiceTier: null, - }); - }); - - it('should drop stale service tier when falling back to the default model', () => { - const [codexModel, defaultModel] = mockModels as [Model, Model]; - const models = [ - { - ...codexModel, - additionalSpeedTiers: ["fast"], - }, - defaultModel, - ]; - - const result = fixture.getCodexAcpClient().createModelSelection(models, 'unavailable-model', 'high', 'fast'); - expect(result).toEqual({ - currentModelId: "5.1", - currentReasoningEffort: "low", - currentServiceTier: null, - }); - }); - - it('should retain service tier when selected model supports it', () => { - const [codexModel, defaultModel] = mockModels as [Model, Model]; - const models = [ - { - ...codexModel, - additionalSpeedTiers: ["fast"], - }, - defaultModel, - ]; - - const result = fixture.getCodexAcpClient().createModelSelection(models, '5.2-codex', 'high', 'fast'); - expect(result).toEqual({ - currentModelId: "5.2-codex", - currentReasoningEffort: "high", - currentServiceTier: "fast", - }); - }); - /** * Sets up a mock fixture with turnStart/awaitTurnCompleted spied on, * and a given session state. Returns the fixture and turnStart spy. From 688f479993dced1bcccb415c7e633ce01e29f8be Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:28:27 +0200 Subject: [PATCH 09/13] Revert "feat: move model related data to meta" This reverts commit b5f470cd298b8bd7ba2968c7111112e5531f68a6. --- src/CodexAcpClient.ts | 46 +++----- src/CodexAcpServer.ts | 106 ++++++++--------- .../CodexACPAgent/CodexAcpClient.test.ts | 37 +++--- .../CodexACPAgent/approval-events.test.ts | 2 +- .../command-action-events.test.ts | 2 +- .../data/command-status-with-rate-limits.json | 2 +- .../CodexACPAgent/data/command-status.json | 2 +- .../CodexACPAgent/data/model-filtering.json | 85 +++++--------- .../data/send-attachments-turn-start.json | 2 +- .../e2e/acp-e2e-session-persistence.test.ts | 6 +- .../CodexACPAgent/e2e/acp-e2e.test.ts | 1 - .../e2e/spawned-agent-fixture.ts | 20 +--- .../CodexACPAgent/elicitation-events.test.ts | 2 +- .../CodexACPAgent/file-change-events.test.ts | 2 +- .../fuzzy-file-search-events.test.ts | 2 +- .../CodexACPAgent/model-filtering.test.ts | 110 +----------------- .../model-rerouted-events.test.ts | 2 +- .../terminal-output-events.test.ts | 2 +- src/__tests__/acp-test-utils.ts | 4 +- 19 files changed, 128 insertions(+), 307 deletions(-) diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f7ab78f8..1d2069c8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -13,10 +13,10 @@ import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, ReasoningEffort, - ServerNotification, - ServiceTier, + ServerNotification } from "./app-server"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; +import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; @@ -215,10 +215,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); + const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); return { sessionId: request.sessionId, - ...modelSelection, + currentModelId: currentModelId, models: codexModels, } } @@ -237,10 +237,10 @@ export class CodexAcpClient { threadId: request.sessionId, }); const codexModels = await this.fetchAvailableModels(); - const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); + const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); return { sessionId: request.sessionId, - ...modelSelection, + currentModelId: currentModelId, models: codexModels, thread: response.thread, }; @@ -266,10 +266,10 @@ export class CodexAcpClient { if (codexModels.length === 0) { throw new Error("Codex did not return any models"); } - const modelSelection = this.createModelSelection(codexModels, response.model, response.reasoningEffort, response.serviceTier); + const currentModelId = this.createModelId(codexModels, response.model, response.reasoningEffort).toString(); return { sessionId: response.thread.id, - ...modelSelection, + currentModelId: currentModelId, models: codexModels, }; } @@ -364,15 +364,10 @@ export class CodexAcpClient { } /** - * Resolves a model selection using the provided ID and reasoning effort. - * Falls back to model defaults if parameters are missing. + * Resolves a ModelId using the provided ID and reasoning effort. + * Falls back to model defaults if parameters are missing or unsupported. */ - createModelSelection( - availableModels: Model[], - modelId: string | null, - reasoningEffort: ReasoningEffort | null, - serviceTier: ServiceTier | null, - ): ModelSelection { + createModelId(availableModels: Model[], modelId: string | null, reasoningEffort: ReasoningEffort | null): ModelId { const selectedModel = availableModels.find(m => m.id === modelId) ?? availableModels.find(m => m.isDefault); @@ -381,11 +376,7 @@ export class CodexAcpClient { throw new Error(`Model selection failed: No model found for ID "${modelId}" and no default model is defined.`); } - return { - currentModelId: selectedModel.id, - currentReasoningEffort: reasoningEffort ?? selectedModel.defaultReasoningEffort, - currentServiceTier: serviceTier ?? null, - }; + return ModelId.create(selectedModel.id, reasoningEffort ?? selectedModel.defaultReasoningEffort); } async subscribeToSessionEvents( @@ -402,11 +393,12 @@ export class CodexAcpClient { async sendPrompt( request: acp.PromptRequest, agentMode: AgentMode, - modelSelection: ModelSelection, + modelId: ModelId, disableSummary: boolean, cwd: string, ): Promise { const input = buildPromptItems(request.prompt); + const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion await this.refreshSkills(cwd, request._meta); return await this.codexClient.runTurn({ @@ -418,9 +410,9 @@ export class CodexAcpClient { summary: disableSummary ? "none" : null, personality: null, cwd: null, - effort: modelSelection.currentReasoningEffort, - model: modelSelection.currentModelId, - serviceTier: modelSelection.currentServiceTier, + effort: effort, + model: modelId.model, + serviceTier: modelId.serviceTier, }); } @@ -613,8 +605,6 @@ export type JsonObject = { [key in string]?: JsonValue } export type SessionMetadata = { sessionId: string, currentModelId: string, - currentReasoningEffort: ReasoningEffort, - currentServiceTier: ServiceTier | null, models: Model[], } @@ -622,8 +612,6 @@ export type SessionMetadataWithThread = SessionMetadata & { thread: Thread, } -export type ModelSelection = Pick; - function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { return prompt.map((block): UserInput | null => { switch (block.type) { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 91ca7421..c26b34ff 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -4,10 +4,10 @@ import {CodexEventHandler} from "./CodexEventHandler"; import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods} from "./CodexAuthMethod"; -import {CodexAcpClient, type ModelSelection, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; +import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import type {McpStartupResult} from "./CodexAppServerClient"; import {ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; -import type {InputModality, ReasoningEffort, ServiceTier} from "./app-server"; +import type {InputModality, ReasoningEffort} from "./app-server"; import type { Account, CollabAgentToolCallStatus, @@ -18,6 +18,7 @@ import type { UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; +import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import type {TokenCount} from "./TokenCount"; import {toPromptUsage} from "./TokenCount"; @@ -35,8 +36,6 @@ import { export interface SessionState { sessionId: string, currentModelId: string, - currentReasoningEffort: ReasoningEffort, - currentServiceTier: ServiceTier | null, supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, @@ -161,14 +160,12 @@ export class CodexAcpServer implements acp.Agent { } const account = await this.getActiveAccount(); - const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models} = sessionMetadata; + const {sessionId, currentModelId, models} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, - currentReasoningEffort: currentReasoningEffort, - currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -192,7 +189,7 @@ export class CodexAcpServer implements acp.Agent { } this.publishAvailableCommandsAsync(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, sessionState); + const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return [sessionId, sessionModelState, sessionModeState]; @@ -313,32 +310,34 @@ export class CodexAcpServer implements acp.Agent { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); + const requestedModelId = ModelId.fromString(params.modelId); + const requestedModelName = requestedModelId.model; + const requestedEffort = requestedModelId.effort; + const models = await this.codexAcpClient.fetchAvailableModels(); - const model = models.find(m => m.id === params.modelId); + const model = models.find(m => m.id === requestedModelName); if (!model) throw new Error(`Unknown model ${params.modelId}`); + if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { + throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); + } - const requestedEffort = readStringMeta(params._meta, "reasoningEffort") as ReasoningEffort | null; - let reasoningEffort = model.defaultReasoningEffort; - if (requestedEffort !== null) { + const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; + let reasoningEffort: ReasoningEffort; + if (requestedEffortValue) { const matchedEffort = model.supportedReasoningEfforts.find( - (option) => option.reasoningEffort === requestedEffort + (option) => option.reasoningEffort === requestedEffortValue )?.reasoningEffort; if (!matchedEffort) { - throw new Error(`Unsupported reasoning effort ${requestedEffort} for model ${model.id}`); + throw new Error(`Unsupported reasoning effort ${requestedEffortValue} for model ${requestedModelName}`); } reasoningEffort = matchedEffort; + } else { + reasoningEffort = model.defaultReasoningEffort; } - const requestedServiceTier = readStringMeta(params._meta, "serviceTier") as ServiceTier | null; - if (requestedServiceTier !== null && !model.additionalSpeedTiers.includes(requestedServiceTier)) { - throw new Error(`Unsupported service tier ${requestedServiceTier} for model ${model.id}`); - } - - sessionState.currentModelId = model.id; - sessionState.currentReasoningEffort = reasoningEffort; - sessionState.currentServiceTier = requestedServiceTier; + sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -350,28 +349,35 @@ export class CodexAcpServer implements acp.Agent { } private findCurrentModel(models: Model[], currentModelId: string): Model | undefined { - return models.find(m => m.id === currentModelId); + const modelId = ModelId.fromString(currentModelId); + return models.find(m => m.id === modelId.model); } - private createModelState(availableModels: Model[], selection: ModelSelection): SessionModelState { + private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState { const allowedModels = availableModels - .map((model) => ({ - modelId: model.id, - name: model.displayName, - description: model.description, - _meta: { - supportedReasoningEfforts: model.supportedReasoningEfforts, - defaultReasoningEffort: model.defaultReasoningEffort, - serviceTiers: model.additionalSpeedTiers, - }, - })); + .flatMap((model) => + model.supportedReasoningEfforts.flatMap((effort) => { + const standardModel = { + modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), + name: `${model.displayName} (${effort.reasoningEffort})`, + description: `${model.description} ${effort.description}`, + }; + if (!model.additionalSpeedTiers.includes("fast")) { + return [standardModel]; + } + return [ + standardModel, + { + modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), + name: `${model.displayName} (${effort.reasoningEffort}, fast)`, + description: `${model.description} ${effort.description} Fast service tier.`, + }, + ]; + }) + ); return { availableModels: allowedModels, - currentModelId: selection.currentModelId, - _meta: { - currentReasoningEffort: selection.currentReasoningEffort, - currentServiceTier: selection.currentServiceTier, - }, + currentModelId: selectedModelId, } } @@ -395,14 +401,12 @@ export class CodexAcpServer implements acp.Agent { ); const account = await this.getActiveAccount(); - const {sessionId, currentModelId, currentReasoningEffort, currentServiceTier, models, thread} = sessionMetadata; + const {sessionId, currentModelId, models, thread} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, - currentReasoningEffort: currentReasoningEffort, - currentServiceTier: currentServiceTier, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], agentMode: AgentMode.getInitialAgentMode(), @@ -426,7 +430,7 @@ export class CodexAcpServer implements acp.Agent { } await this.availableCommands.publish(sessionId); - const sessionModelState: SessionModelState = this.createModelState(models, sessionState); + const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); return { @@ -763,6 +767,7 @@ export class CodexAcpServer implements acp.Agent { }; } + const modelId = ModelId.fromString(sessionState.currentModelId); const modelLacksReasoning = sessionState.supportedReasoningEfforts.length > 0 && sessionState.supportedReasoningEfforts.every(e => e.reasoningEffort === "none"); @@ -779,7 +784,7 @@ export class CodexAcpServer implements acp.Agent { } const agentMode = sessionState.agentMode; const turnCompleted = await this.runWithProcessCheck( - () => this.codexAcpClient.sendPrompt(params, agentMode, sessionState, disableSummary, sessionState.cwd)); + () => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd)); // Check if turn was interrupted (cancelled) if (turnCompleted.turn.status === "interrupted") { @@ -823,7 +828,7 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - const modelName = sessionState.currentModelId; + const modelName = ModelId.fromString(sessionState.currentModelId).model; // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) @@ -896,14 +901,3 @@ export class CodexAcpServer implements acp.Agent { function getRequestedMcpServerNames(mcpServers: Array): Array { return Array.from(new Set(mcpServers.map(server => server.name))); } - -function readStringMeta(meta: { [key: string]: unknown } | null | undefined, key: string): string | null { - const value = meta?.[key]; - if (value === undefined || value === null) { - return null; - } - if (typeof value !== "string") { - throw RequestError.invalidParams(`Expected _meta.${key} to be a string`); - } - return value; -} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 0d4bc628..bc0136fa 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -9,6 +9,7 @@ import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; import type {ListMcpServerStatusResponse, Model, SkillsListResponse, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; +import {ModelId} from "../../ModelId"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -447,7 +448,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -472,7 +473,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState: SessionState = createTestSessionState({ sessionId: "id", - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); @@ -514,12 +515,12 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionState1: SessionState = createTestSessionState({ sessionId: "session-1", - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE }); const sessionState2: SessionState = createTestSessionState({ sessionId: "session-2", - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE }); @@ -852,21 +853,13 @@ describe('ACP server test', { timeout: 40_000 }, () => { ]; it('should fallback to the default model when modelId is null', () => { - const result = fixture.getCodexAcpClient().createModelSelection(mockModels, null, 'low', null); - expect(result).toEqual({ - currentModelId: "5.1", - currentReasoningEffort: "low", - currentServiceTier: null, - }); + const result = fixture.getCodexAcpClient().createModelId(mockModels, null, 'low'); + expect(result).toEqual(ModelId.create('5.1', 'low')); }); it('should fallback to the model-specific effort when reasoningEffort is null', () => { - const result = fixture.getCodexAcpClient().createModelSelection(mockModels, '5.2-codex', null, null); - expect(result).toEqual({ - currentModelId: "5.2-codex", - currentReasoningEffort: "medium", - currentServiceTier: null, - }); + const result = fixture.getCodexAcpClient().createModelId(mockModels, '5.2-codex', null); + expect(result).toEqual(ModelId.create('5.2-codex', 'medium')); }); /** @@ -923,32 +916,28 @@ describe('ACP server test', { timeout: 40_000 }, () => { it ('should send null service tier for normal model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id", - currentReasoningEffort: "high", - currentServiceTier: null, + currentModelId: "model-id[effort]", }); await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ model: "model-id", - effort: "high", + effort: "effort", serviceTier: null, })); }); it ('should send fast service tier for fast model selections', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id", - currentReasoningEffort: "high", - currentServiceTier: "fast", + currentModelId: "model-id[effort]@fast", }); await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ model: "model-id", - effort: "high", + effort: "effort", serviceTier: "fast", })); }); diff --git a/src/__tests__/CodexACPAgent/approval-events.test.ts b/src/__tests__/CodexACPAgent/approval-events.test.ts index cdabba8d..7ee43746 100644 --- a/src/__tests__/CodexACPAgent/approval-events.test.ts +++ b/src/__tests__/CodexACPAgent/approval-events.test.ts @@ -28,7 +28,7 @@ describe('Approval Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id', + currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/command-action-events.test.ts b/src/__tests__/CodexACPAgent/command-action-events.test.ts index ba2151b9..bb92314d 100644 --- a/src/__tests__/CodexACPAgent/command-action-events.test.ts +++ b/src/__tests__/CodexACPAgent/command-action-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - command action events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id', + currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json index 1bd9899d..13403fd0 100644 --- a/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json +++ b/src/__tests__/CodexACPAgent/data/command-status-with-rate-limits.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" + "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Standard 1h limit:** 75% left \n**Fast 1d limit:** 20% left" } } } diff --git a/src/__tests__/CodexACPAgent/data/command-status.json b/src/__tests__/CodexACPAgent/data/command-status.json index 85143d17..50260582 100644 --- a/src/__tests__/CodexACPAgent/data/command-status.json +++ b/src/__tests__/CodexACPAgent/data/command-status.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "**Model:** model-id \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" + "text": "**Model:** model-id[effort] \n**Directory:** /test/cwd \n**Approval:** on-request \n**Sandbox:** workspace-write \n**Account:** not logged in \n**Session:** `session-id` \n \n**Token usage:** data not available yet \n**Context window:** data not available yet \n**Limits:** data not available yet" } } } diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index b5350b53..03af5012 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -1,68 +1,37 @@ [ { - "modelId": "gpt-5.2", - "name": "GPT-5.2", - "description": "Allowed by id.", - "_meta": { - "supportedReasoningEfforts": [ - { - "reasoningEffort": "medium", - "description": "Default effort." - }, - { - "reasoningEffort": "low", - "description": "Fast effort." - } - ], - "defaultReasoningEffort": "medium", - "serviceTiers": [ - "fast" - ] - } + "modelId": "gpt-5.2[medium]", + "name": "GPT-5.2 (medium)", + "description": "Allowed by id. Default effort." }, { - "modelId": "other-id", - "name": "gpt-5.2", - "description": "Allowed", - "_meta": { - "supportedReasoningEfforts": [ - { - "reasoningEffort": "medium", - "description": "Default effort." - } - ], - "defaultReasoningEffort": "medium", - "serviceTiers": [] - } + "modelId": "gpt-5.2[medium]@fast", + "name": "GPT-5.2 (medium, fast)", + "description": "Allowed by id. Default effort. Fast service tier." }, { - "modelId": "gpt-5.1-codex-mini", - "name": "Other", - "description": "Allowed by id.", - "_meta": { - "supportedReasoningEfforts": [ - { - "reasoningEffort": "medium", - "description": "Default effort." - } - ], - "defaultReasoningEffort": "medium", - "serviceTiers": [] - } + "modelId": "gpt-5.2[low]", + "name": "GPT-5.2 (low)", + "description": "Allowed by id. Fast effort." }, { - "modelId": "gpt-4o", - "name": "gpt-4o", - "description": "Allowed.", - "_meta": { - "supportedReasoningEfforts": [ - { - "reasoningEffort": "medium", - "description": "Default effort." - } - ], - "defaultReasoningEffort": "medium", - "serviceTiers": [] - } + "modelId": "gpt-5.2[low]@fast", + "name": "GPT-5.2 (low, fast)", + "description": "Allowed by id. Fast effort. Fast service tier." + }, + { + "modelId": "other-id[medium]", + "name": "gpt-5.2 (medium)", + "description": "Allowed Default effort." + }, + { + "modelId": "gpt-5.1-codex-mini[medium]", + "name": "Other (medium)", + "description": "Allowed by id. Default effort." + }, + { + "modelId": "gpt-4o[medium]", + "name": "gpt-4o (medium)", + "description": "Allowed. Default effort." } ] \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index d35f3cc5..c4ab4c54 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -55,7 +55,7 @@ "summary": null, "personality": null, "cwd": "cwd", - "effort": "medium", + "effort": "effort", "model": "model", "serviceTier": null } diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts index f9dc2ef8..037e3631 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e-session-persistence.test.ts @@ -22,11 +22,7 @@ describeE2E("E2E session persistence tests", () => { beforeRestartFixture = await createAuthenticatedFixture(); const sessionId = (await beforeRestartFixture.createSession()).sessionId; - await beforeRestartFixture.connection.unstable_setSessionModel({ - sessionId, - modelId: OTHER_TEST_MODEL_ID.toString(), - _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, - }); + await beforeRestartFixture.connection.unstable_setSessionModel({sessionId, modelId: OTHER_TEST_MODEL_ID.toString()}); const memorizedToken = "token-for-tests-123"; await beforeRestartFixture.expectPromptText( sessionId, diff --git a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts index 0b82c96b..f625f0df 100644 --- a/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts +++ b/src/__tests__/CodexACPAgent/e2e/acp-e2e.test.ts @@ -51,7 +51,6 @@ describeE2E("E2E tests", () => { await fixture.connection.unstable_setSessionModel({ sessionId: session.sessionId, modelId: OTHER_TEST_MODEL_ID.toString(), - _meta: { reasoningEffort: OTHER_TEST_MODEL_ID.effort }, }); await fixture.expectStatus(session.sessionId, {Model: OTHER_TEST_MODEL_ID}); }); diff --git a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts index bd590174..217c1125 100644 --- a/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts +++ b/src/__tests__/CodexACPAgent/e2e/spawned-agent-fixture.ts @@ -4,26 +4,12 @@ import fs from "node:fs"; import path from "node:path"; import {Readable, Writable} from "node:stream"; import {expect, vi} from "vitest"; -import type {ReasoningEffort} from "../../../app-server"; +import {ModelId} from "../../../ModelId"; import {removeDirectoryWithRetry, writeCodexHomeConfig} from "../../acp-test-utils"; import type {PermissionResponder} from "./permission-responders"; -export interface TestModelSelection { - readonly model: string; - readonly effort: ReasoningEffort; - toString(): string; -} - -export const DEFAULT_TEST_MODEL_ID: TestModelSelection = { - model: "gpt-5.2", - effort: "none", - toString: () => "gpt-5.2", -}; -export const OTHER_TEST_MODEL_ID: TestModelSelection = { - model: "gpt-5.3-codex", - effort: "low", - toString: () => "gpt-5.3-codex", -}; +export const DEFAULT_TEST_MODEL_ID = ModelId.create("gpt-5.2", "none"); +export const OTHER_TEST_MODEL_ID = ModelId.create("gpt-5.3-codex", "low"); export interface TestSkill { readonly name: string; diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index a8510c25..32548bee 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -29,7 +29,7 @@ describe('Elicitation Events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id', + currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE }); vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index bceba285..ce00135a 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -36,7 +36,7 @@ describe('CodexEventHandler - file change events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id', + currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts index 563a5502..02d8561c 100644 --- a/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts +++ b/src/__tests__/CodexACPAgent/fuzzy-file-search-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - fuzzy file search events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE, }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index d7bc43fe..c7b795ec 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -82,9 +82,7 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2", - currentReasoningEffort: "medium", - currentServiceTier: null, + currentModelId: "gpt-5.2[medium]", models, }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); @@ -93,10 +91,6 @@ describe("Model filtering", () => { const sessionModels = newSessionResponse.models; const availableModels = sessionModels?.availableModels; - expect(sessionModels?._meta).toEqual({ - currentReasoningEffort: "medium", - currentServiceTier: null, - }); await expect(JSON.stringify(availableModels, null, 2)).toMatchFileSnapshot( "data/model-filtering.json" ); @@ -129,9 +123,7 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2", - currentReasoningEffort: "medium", - currentServiceTier: null, + currentModelId: "gpt-5.2[medium]", models, }); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); @@ -141,8 +133,7 @@ describe("Model filtering", () => { await expect(codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2", - _meta: { serviceTier: "fast" }, + modelId: "gpt-5.2[medium]@fast", })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); }); @@ -173,9 +164,7 @@ describe("Model filtering", () => { vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", - currentModelId: "gpt-5.2", - currentReasoningEffort: "medium", - currentServiceTier: null, + currentModelId: "gpt-5.2[medium]", models, }); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); @@ -184,97 +173,10 @@ describe("Model filtering", () => { await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); await codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", - modelId: "gpt-5.2", - _meta: { serviceTier: "fast" }, + modelId: "gpt-5.2[medium]@fast", }); expect(codexAcpAgent.getSessionState("session-id").currentModelId) - .toBe("gpt-5.2"); - expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort) - .toBe("medium"); - expect(codexAcpAgent.getSessionState("session-id").currentServiceTier) - .toBe("fast"); - }); - - it("uses the model default effort when _meta.reasoningEffort is omitted", async () => { - const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); - - await codexAcpAgent.unstable_setSessionModel({ - sessionId: "session-id", - modelId: "gpt-5.2", - }); - - expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("medium"); - expect(codexAcpAgent.getSessionState("session-id").currentServiceTier).toBeNull(); - }); - - it("stores requested reasoning effort from _meta", async () => { - const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); - - await codexAcpAgent.unstable_setSessionModel({ - sessionId: "session-id", - modelId: "gpt-5.2", - _meta: { reasoningEffort: "low" }, - }); - - expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("gpt-5.2"); - expect(codexAcpAgent.getSessionState("session-id").currentReasoningEffort).toBe("low"); - }); - - it("rejects unsupported reasoning effort selections", async () => { - const {codexAcpAgent} = await setupModelSelectionTest([createSelectableModel()]); - - await expect(codexAcpAgent.unstable_setSessionModel({ - sessionId: "session-id", - modelId: "gpt-5.2", - _meta: { reasoningEffort: "xhigh" }, - })).rejects.toThrow("Unsupported reasoning effort xhigh for model gpt-5.2"); + .toBe("gpt-5.2[medium]@fast"); }); }); - -function createSelectableModel(overrides: Partial = {}): Model { - return { - id: "gpt-5.2", - model: "gpt-5.2", - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: "GPT-5.2", - description: "Selectable model.", - hidden: false, - supportedReasoningEfforts: [ - {reasoningEffort: "low", description: "Fast effort."}, - {reasoningEffort: "medium", description: "Default effort."}, - ], - defaultReasoningEffort: "medium", - supportsPersonality: false, - additionalSpeedTiers: ["fast"], - isDefault: true, - inputModalities: ["text"], - ...overrides, - }; -} - -async function setupModelSelectionTest(models: Model[]) { - const initialModel = models[0]; - if (!initialModel) { - throw new Error("setupModelSelectionTest requires at least one model"); - } - const fixture = createCodexMockTestFixture(); - const codexAcpAgent = fixture.getCodexAcpAgent(); - const codexAcpClient = fixture.getCodexAcpClient(); - - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); - vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ - sessionId: "session-id", - currentModelId: initialModel.id, - currentReasoningEffort: initialModel.defaultReasoningEffort, - currentServiceTier: null, - models, - }); - vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); - vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); - - await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); - return {fixture, codexAcpAgent, codexAcpClient}; -} diff --git a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts index 0b961c41..61c49400 100644 --- a/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts +++ b/src/__tests__/CodexACPAgent/model-rerouted-events.test.ts @@ -15,7 +15,7 @@ describe("CodexEventHandler - model rerouted events", () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: "model-id", + currentModelId: "model-id[effort]", agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts index 20fef5f6..de5ff938 100644 --- a/src/__tests__/CodexACPAgent/terminal-output-events.test.ts +++ b/src/__tests__/CodexACPAgent/terminal-output-events.test.ts @@ -15,7 +15,7 @@ describe('CodexEventHandler - terminal output events', () => { const sessionState: SessionState = createTestSessionState({ sessionId, - currentModelId: 'model-id', + currentModelId: 'model-id[effort]', agentMode: AgentMode.DEFAULT_AGENT_MODE }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index a5050974..a1629214 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -318,9 +318,7 @@ export function createTestSessionState(overrides?: Partial): Sessi account: null, cwd: "/test/cwd", sessionId: "session-id", - currentModelId: "model-id", - currentReasoningEffort: "medium", - currentServiceTier: null, + currentModelId: "model-id[effort]", supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, From bf71f6a78044b5ec1632825f1e76c30bf7c4b5b7 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:28:28 +0200 Subject: [PATCH 10/13] =?UTF-8?q?Revert=20"feat:=20LLM-26878=20Add=20?= =?UTF-8?q?=E2=80=9CFast=E2=80=9D=20mode=20for=20Codex-agent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 961437504d0d4c4599dd6945de6b351458ac1540. --- src/CodexAcpClient.ts | 1 - src/CodexAcpServer.ts | 33 ++----- src/ModelId.ts | 32 +++---- .../CodexACPAgent/CodexAcpClient.test.ts | 28 ------ .../CodexACPAgent/data/model-filtering.json | 10 --- .../data/send-attachments-turn-start.json | 3 +- .../CodexACPAgent/model-filtering.test.ts | 86 +------------------ src/__tests__/ModelId.test.ts | 23 ----- 8 files changed, 24 insertions(+), 192 deletions(-) delete mode 100644 src/__tests__/ModelId.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 1d2069c8..d5b59f35 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -412,7 +412,6 @@ export class CodexAcpClient { cwd: null, effort: effort, model: modelId.model, - serviceTier: modelId.serviceTier, }); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index c26b34ff..1a80d6ca 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -310,16 +310,13 @@ export class CodexAcpServer implements acp.Agent { const sessionState = this.sessions.get(params.sessionId); if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); - const requestedModelId = ModelId.fromString(params.modelId); + const requestedModelId= ModelId.fromString(params.modelId); const requestedModelName = requestedModelId.model; const requestedEffort = requestedModelId.effort; const models = await this.codexAcpClient.fetchAvailableModels(); const model = models.find(m => m.id === requestedModelName); if (!model) throw new Error(`Unknown model ${params.modelId}`); - if (requestedModelId.serviceTier === "fast" && !model.additionalSpeedTiers.includes("fast")) { - throw new Error(`Unsupported service tier fast for model ${requestedModelName}`); - } const requestedEffortValue = requestedEffort as ReasoningEffort | undefined; let reasoningEffort: ReasoningEffort; @@ -337,7 +334,7 @@ export class CodexAcpServer implements acp.Agent { reasoningEffort = model.defaultReasoningEffort; } - sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort, requestedModelId.serviceTier).toString(); + sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; @@ -356,24 +353,11 @@ export class CodexAcpServer implements acp.Agent { private createModelState(availableModels: Model[], selectedModelId: string): SessionModelState { const allowedModels = availableModels .flatMap((model) => - model.supportedReasoningEfforts.flatMap((effort) => { - const standardModel = { - modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), - name: `${model.displayName} (${effort.reasoningEffort})`, - description: `${model.description} ${effort.description}`, - }; - if (!model.additionalSpeedTiers.includes("fast")) { - return [standardModel]; - } - return [ - standardModel, - { - modelId: ModelId.fromComponents(model, effort.reasoningEffort, "fast").toString(), - name: `${model.displayName} (${effort.reasoningEffort}, fast)`, - description: `${model.description} ${effort.description} Fast service tier.`, - }, - ]; - }) + model.supportedReasoningEfforts.map((effort) => ({ + modelId: ModelId.fromComponents(model, effort.reasoningEffort).toString(), + name: `${model.displayName} (${effort.reasoningEffort})`, + description: `${model.description} ${effort.description}`, + })) ); return { availableModels: allowedModels, @@ -828,7 +812,8 @@ export class CodexAcpServer implements acp.Agent { private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } { const lastTokenUsage = sessionState.lastTokenUsage; - const modelName = ModelId.fromString(sessionState.currentModelId).model; + // Remove the "[reasoning-level]" suffix from currentModelId if present + const modelName = sessionState.currentModelId.replace(/\[.*?]$/, ''); // FIXME: currently all tokens are reported for the current model const modelUsage = (lastTokenUsage != null) diff --git a/src/ModelId.ts b/src/ModelId.ts index b68d6336..23c45531 100644 --- a/src/ModelId.ts +++ b/src/ModelId.ts @@ -1,48 +1,42 @@ -import type {ReasoningEffort, ServiceTier} from "./app-server"; +import type {ReasoningEffort} from "./app-server"; import type {Model} from "./app-server/v2"; /** - * ACP Model ID, combining the base model ID, reasoning effort level, and optional service tier. + * ACP Model ID, combining the base model ID and its reasoning effort level. * @example * const id = ModelId.fromString("gpt-5.2[high]"); - * const fastId = ModelId.fromString("gpt-5.2[high]@fast"); */ export class ModelId { private constructor( public readonly model: string, - public readonly effort: string, - public readonly serviceTier: ServiceTier | null = null + public readonly effort: string ) {} - static fromComponents(model: Model, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(model.id, effort, serviceTier); + static fromComponents(model: Model, effort: ReasoningEffort): ModelId { + return new ModelId(model.id, effort); } - static create(modelId: string, effort: ReasoningEffort, serviceTier: ServiceTier | null = null): ModelId { - return new ModelId(modelId, effort, serviceTier); + static create(modelId: string, effort: ReasoningEffort): ModelId { + return new ModelId(modelId, effort); } static fromString(modelId: string): ModelId { - const bracketMatch = modelId.match(/^(?[^\[]+)\[(?[^\]]+)](?:@(?.+))?$/); + const bracketMatch = modelId.match(/^(?[^\[]+?)(?:\[(?[^\]]+)\])?$/); const model = bracketMatch?.groups?.["model"]; const effort = bracketMatch?.groups?.["effort"]; - const serviceTier = bracketMatch?.groups?.["serviceTier"] ?? null; if (!model || !effort) { - throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort] or modelId[effort]@fast.`); + throw new Error(`Unsupported format of modelId: ${modelId}. Expected: modelId[effort].`); } - // The generated app-server ServiceTier type also includes "flex", but ACP model IDs - // only expose Fast variants for now because model/list advertises Fast support. - if (serviceTier !== null && serviceTier !== "fast") { - throw new Error(`Unsupported service tier ${serviceTier} for modelId: ${modelId}.`); + if (model) { + return new ModelId(model, effort); } - return new ModelId(model, effort, serviceTier); + throw new Error(`Invalid modelId format: ${modelId}`); } toString(): string { - const suffix = this.serviceTier === null ? "" : `@${this.serviceTier}`; - return `${this.model}[${this.effort}]${suffix}`; + return `${this.model}[${this.effort}]`; } } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index bc0136fa..27ff179f 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -914,34 +914,6 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ summary: null })); }); - it ('should send null service tier for normal model selections', async () => { - const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]", - }); - - await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); - - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - model: "model-id", - effort: "effort", - serviceTier: null, - })); - }); - - it ('should send fast service tier for fast model selections', async () => { - const { mockFixture, turnStartSpy } = setupPromptFixture({ - currentModelId: "model-id[effort]@fast", - }); - - await mockFixture.getCodexAcpAgent().prompt({ sessionId: "id", prompt: [{ type: "text", text: "test" }] }); - - expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ - model: "model-id", - effort: "effort", - serviceTier: "fast", - })); - }); - it ('should disable reasoning.summary when model lacks reasoning', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture({ account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, diff --git a/src/__tests__/CodexACPAgent/data/model-filtering.json b/src/__tests__/CodexACPAgent/data/model-filtering.json index 03af5012..95439da2 100644 --- a/src/__tests__/CodexACPAgent/data/model-filtering.json +++ b/src/__tests__/CodexACPAgent/data/model-filtering.json @@ -4,21 +4,11 @@ "name": "GPT-5.2 (medium)", "description": "Allowed by id. Default effort." }, - { - "modelId": "gpt-5.2[medium]@fast", - "name": "GPT-5.2 (medium, fast)", - "description": "Allowed by id. Default effort. Fast service tier." - }, { "modelId": "gpt-5.2[low]", "name": "GPT-5.2 (low)", "description": "Allowed by id. Fast effort." }, - { - "modelId": "gpt-5.2[low]@fast", - "name": "GPT-5.2 (low, fast)", - "description": "Allowed by id. Fast effort. Fast service tier." - }, { "modelId": "other-id[medium]", "name": "gpt-5.2 (medium)", diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index c4ab4c54..9c685ece 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -56,8 +56,7 @@ "personality": null, "cwd": "cwd", "effort": "effort", - "model": "model", - "serviceTier": null + "model": "model" } } { diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index c7b795ec..97b658a4 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -25,7 +25,7 @@ describe("Model filtering", () => { supportedReasoningEfforts: efforts, defaultReasoningEffort: "medium", supportsPersonality: false, - additionalSpeedTiers: ["fast"], + additionalSpeedTiers: [], isDefault: false, inputModalities: [] }, @@ -95,88 +95,4 @@ describe("Model filtering", () => { "data/model-filtering.json" ); }); - - it("rejects fast model selections when the model does not support fast", async () => { - const fixture = createCodexMockTestFixture(); - const codexAcpAgent = fixture.getCodexAcpAgent(); - const codexAcpClient = fixture.getCodexAcpClient(); - - const models: Model[] = [ - { - id: "gpt-5.2", - model: "gpt-5.2", - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: "GPT-5.2", - description: "No fast tier.", - hidden: false, - supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], - defaultReasoningEffort: "medium", - supportsPersonality: false, - additionalSpeedTiers: [], - isDefault: true, - inputModalities: ["text"] - }, - ]; - - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); - vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ - sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", - models, - }); - vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); - vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); - - await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); - - await expect(codexAcpAgent.unstable_setSessionModel({ - sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", - })).rejects.toThrow("Unsupported service tier fast for model gpt-5.2"); - }); - - it("stores fast model selections when the model supports fast", async () => { - const fixture = createCodexMockTestFixture(); - const codexAcpAgent = fixture.getCodexAcpAgent(); - const codexAcpClient = fixture.getCodexAcpClient(); - - const models: Model[] = [ - { - id: "gpt-5.2", - model: "gpt-5.2", - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: "GPT-5.2", - description: "Fast tier.", - hidden: false, - supportedReasoningEfforts: [{reasoningEffort: "medium", description: "Default effort."}], - defaultReasoningEffort: "medium", - supportsPersonality: false, - additionalSpeedTiers: ["fast"], - isDefault: true, - inputModalities: ["text"] - }, - ]; - - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); - vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ - sessionId: "session-id", - currentModelId: "gpt-5.2[medium]", - models, - }); - vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue(models); - vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); - - await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); - await codexAcpAgent.unstable_setSessionModel({ - sessionId: "session-id", - modelId: "gpt-5.2[medium]@fast", - }); - - expect(codexAcpAgent.getSessionState("session-id").currentModelId) - .toBe("gpt-5.2[medium]@fast"); - }); }); diff --git a/src/__tests__/ModelId.test.ts b/src/__tests__/ModelId.test.ts deleted file mode 100644 index 276a4d50..00000000 --- a/src/__tests__/ModelId.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {describe, expect, it} from "vitest"; -import {ModelId} from "../ModelId"; - -describe("ModelId", () => { - it("formats and parses normal model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]"); - expect(ModelId.fromString("gpt-5.2[medium]")).toEqual(modelId); - }); - - it("formats and parses fast model IDs", () => { - const modelId = ModelId.create("gpt-5.2", "medium", "fast"); - - expect(modelId.toString()).toBe("gpt-5.2[medium]@fast"); - expect(ModelId.fromString("gpt-5.2[medium]@fast")).toEqual(modelId); - }); - - it("rejects unknown service tiers", () => { - expect(() => ModelId.fromString("gpt-5.2[medium]@flex")) - .toThrow("Unsupported service tier flex"); - }); -}); From 4630cd79b5c2f90563ea7bc6ea78b1cd0bd0f9f1 Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:23:49 +0200 Subject: [PATCH 11/13] =?UTF-8?q?feat:=20LLM-26878=20Add=20=E2=80=9CFast?= =?UTF-8?q?=E2=80=9D=20mode=20for=20Codex-agent=20via=20session=20config?= =?UTF-8?q?=20option?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/CodexAcpClient.ts | 7 + src/CodexAcpServer.ts | 60 ++++- src/FastModeConfig.ts | 40 ++++ .../data/send-attachments-turn-start.json | 3 +- .../CodexACPAgent/fast-mode-config.test.ts | 215 ++++++++++++++++++ src/__tests__/acp-test-utils.ts | 2 + 6 files changed, 322 insertions(+), 5 deletions(-) create mode 100644 src/FastModeConfig.ts create mode 100644 src/__tests__/CodexACPAgent/fast-mode-config.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index d5b59f35..8578633e 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -13,6 +13,7 @@ import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, ReasoningEffort, + ServiceTier, ServerNotification } from "./app-server"; import type {JsonValue} from "./app-server/serde_json/JsonValue"; @@ -220,6 +221,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + currentServiceTier: response.serviceTier ?? null, } } @@ -242,6 +244,7 @@ export class CodexAcpClient { sessionId: request.sessionId, currentModelId: currentModelId, models: codexModels, + currentServiceTier: response.serviceTier ?? null, thread: response.thread, }; } @@ -271,6 +274,7 @@ export class CodexAcpClient { sessionId: response.thread.id, currentModelId: currentModelId, models: codexModels, + currentServiceTier: response.serviceTier ?? null, }; } @@ -394,6 +398,7 @@ export class CodexAcpClient { request: acp.PromptRequest, agentMode: AgentMode, modelId: ModelId, + serviceTier: ServiceTier | null, disableSummary: boolean, cwd: string, ): Promise { @@ -412,6 +417,7 @@ export class CodexAcpClient { cwd: null, effort: effort, model: modelId.model, + serviceTier: serviceTier, }); } @@ -605,6 +611,7 @@ export type SessionMetadata = { sessionId: string, currentModelId: string, models: Model[], + currentServiceTier?: ServiceTier | null, } export type SessionMetadataWithThread = SessionMetadata & { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 1a80d6ca..2ee88aea 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -32,6 +32,14 @@ import { createFileChangeUpdate, createMcpToolCallUpdate, } from "./CodexToolCallMapper"; +import { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + FAST_MODE_OFF, + FAST_MODE_ON, + modelSupportsFast, + resolveFastServiceTier, +} from "./FastModeConfig"; export interface SessionState { sessionId: string, @@ -46,6 +54,8 @@ export interface SessionState { rateLimits: RateLimitsMap | null; account: Account | null; cwd: string; + fastModeEnabled: boolean; + currentModelSupportsFast: boolean; sessionMcpServers?: Array; } @@ -163,6 +173,7 @@ export class CodexAcpServer implements acp.Agent { const {sessionId, currentModelId, models} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request); const currentModel = this.findCurrentModel(models, currentModelId); + const currentModelSupportsFast = modelSupportsFast(currentModel); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, @@ -176,6 +187,8 @@ export class CodexAcpServer implements acp.Agent { rateLimits: null, account: account, cwd: request.cwd, + fastModeEnabled: sessionMetadata.currentServiceTier === "fast", + currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, } this.sessions.set(sessionId, sessionState); @@ -221,7 +234,8 @@ export class CodexAcpServer implements acp.Agent { }); return { models: modelState, - modes: modeState + modes: modeState, + configOptions: this.createSessionConfigOptions(this.getSessionState(sessionId)), }; } @@ -236,7 +250,8 @@ export class CodexAcpServer implements acp.Agent { }); return { models: modelState, - modes: modeState + modes: modeState, + configOptions: this.createSessionConfigOptions(this.getSessionState(sessionId)), }; } @@ -261,7 +276,8 @@ export class CodexAcpServer implements acp.Agent { return { sessionId: sessionId, models: modelState, - modes: modeState + modes: modeState, + configOptions: this.createSessionConfigOptions(this.getSessionState(sessionId)), }; } @@ -302,6 +318,28 @@ export class CodexAcpServer implements acp.Agent { return {}; } + async setSessionConfigOption(params: acp.SetSessionConfigOptionRequest): Promise { + logger.log("Set session config option requested", { + sessionId: params.sessionId, + configId: params.configId, + }); + const sessionState = this.sessions.get(params.sessionId); + if (!sessionState) throw new Error(`Session ${params.sessionId} not found`); + + if (params.configId !== FAST_MODE_CONFIG_ID || ("type" in params && params.type === "boolean")) { + throw RequestError.invalidParams(); + } + + if (params.value !== FAST_MODE_ON && params.value !== FAST_MODE_OFF) { + throw RequestError.invalidParams(); + } + + sessionState.fastModeEnabled = params.value === FAST_MODE_ON; + return { + configOptions: this.createSessionConfigOptions(sessionState), + }; + } + async unstable_setSessionModel(params: acp.SetSessionModelRequest): Promise { logger.log("Set session model requested", { sessionId: params.sessionId, @@ -337,10 +375,17 @@ export class CodexAcpServer implements acp.Agent { sessionState.currentModelId = ModelId.fromComponents(model, reasoningEffort).toString(); sessionState.supportedReasoningEfforts = model.supportedReasoningEfforts; sessionState.supportedInputModalities = model.inputModalities; + sessionState.currentModelSupportsFast = modelSupportsFast(model); return {}; } + private createSessionConfigOptions(sessionState: SessionState): Array { + return [ + createFastModeConfigOption(sessionState.fastModeEnabled), + ]; + } + private publishAvailableCommandsAsync(sessionId: string) { void this.availableCommands.publish(sessionId); } @@ -388,6 +433,7 @@ export class CodexAcpServer implements acp.Agent { const {sessionId, currentModelId, models, thread} = sessionMetadata; const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); + const currentModelSupportsFast = modelSupportsFast(currentModel); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, @@ -401,6 +447,8 @@ export class CodexAcpServer implements acp.Agent { rateLimits: null, account: account, cwd: request.cwd, + fastModeEnabled: sessionMetadata.currentServiceTier === "fast", + currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, }; this.sessions.set(sessionId, sessionState); @@ -767,8 +815,12 @@ export class CodexAcpServer implements acp.Agent { throw RequestError.invalidRequest("The current model does not support image input"); } const agentMode = sessionState.agentMode; + const serviceTier = resolveFastServiceTier( + sessionState.fastModeEnabled, + sessionState.currentModelSupportsFast, + ); const turnCompleted = await this.runWithProcessCheck( - () => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd)); + () => this.codexAcpClient.sendPrompt(params, agentMode, modelId, serviceTier, disableSummary, sessionState.cwd)); // Check if turn was interrupted (cancelled) if (turnCompleted.turn.status === "interrupted") { diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts new file mode 100644 index 00000000..823091ba --- /dev/null +++ b/src/FastModeConfig.ts @@ -0,0 +1,40 @@ +import type {SessionConfigOption} from "@agentclientprotocol/sdk"; +import type {ServiceTier} from "./app-server"; +import type {Model} from "./app-server/v2"; + +export const FAST_MODE_CONFIG_ID = "fast-mode"; +export const FAST_MODE_ON = "on"; +export const FAST_MODE_OFF = "off"; + +const FAST_MODE_DESCRIPTION = "Use the fast service tier when it is available for the selected model. This can be more expensive."; + +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 createFastModeConfigOption(fastModeEnabled: boolean): SessionConfigOption { + return { + id: FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: FAST_MODE_DESCRIPTION, + category: FAST_MODE_CONFIG_ID, + type: "select", + currentValue: fastModeEnabled ? FAST_MODE_ON : FAST_MODE_OFF, + options: [ + { + value: FAST_MODE_OFF, + name: "Off", + description: "Use the standard service tier.", + }, + { + value: FAST_MODE_ON, + name: "On", + description: FAST_MODE_DESCRIPTION, + }, + ], + }; +} diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index 9c685ece..c4ab4c54 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -56,7 +56,8 @@ "personality": null, "cwd": "cwd", "effort": "effort", - "model": "model" + "model": "model", + "serviceTier": null } } { diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts new file mode 100644 index 00000000..f6ab0cef --- /dev/null +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -0,0 +1,215 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Model, ReasoningEffortOption} from "../../app-server/v2"; +import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + FAST_MODE_OFF, + FAST_MODE_ON, +} from "../../FastModeConfig"; + +describe("Fast mode session config", () => { + const defaultEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; + + function createModel(id: string, additionalSpeedTiers: string[] = []): Model { + return { + id, + model: id, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: id, + description: `${id} model`, + hidden: false, + supportedReasoningEfforts: [defaultEffort], + defaultReasoningEffort: "medium", + inputModalities: ["text", "image"], + supportsPersonality: false, + additionalSpeedTiers, + isDefault: true, + }; + } + + async function createSession(currentServiceTier: "fast" | "flex" | null = null) { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const fastModel = createModel("fast-model", ["fast"]); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId: "session-id", + currentModelId: "fast-model[medium]", + models: [fastModel], + currentServiceTier, + }); + + const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); + return {fixture, codexAcpAgent, codexAcpClient, response}; + } + + function setupPromptSession(fastModeEnabled: boolean, currentModelSupportsFast: boolean) { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + const sessionState = createTestSessionState({ + sessionId: "session-id", + currentModelId: "fast-model[medium]", + fastModeEnabled, + currentModelSupportsFast, + supportedReasoningEfforts: [defaultEffort], + }); + + vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({ + turn: { + id: "turn-id", + items: [], + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockResolvedValue({ + threadId: "session-id", + turn: { + id: "turn-id", + items: [], + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + + return {codexAcpAgent, turnStartSpy}; + } + + it("returns the Fast mode config option defaulted to Off for new sessions", async () => { + const {response} = await createSession(); + + expect(response.configOptions).toEqual([createFastModeConfigOption(false)]); + }); + + it("initializes Fast mode as On when the app-server session tier is fast", async () => { + const {response, codexAcpAgent} = await createSession("fast"); + + expect(response.configOptions).toEqual([createFastModeConfigOption(true)]); + expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + }); + + it("toggles Fast mode through session config options", async () => { + const {codexAcpAgent} = await createSession(); + + const onResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + value: FAST_MODE_ON, + }); + expect(onResponse.configOptions).toEqual([createFastModeConfigOption(true)]); + expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(true); + + const offResponse = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + value: FAST_MODE_OFF, + }); + expect(offResponse.configOptions).toEqual([createFastModeConfigOption(false)]); + expect(codexAcpAgent.getSessionState("session-id").fastModeEnabled).toBe(false); + }); + + it("rejects unknown Fast mode config ids and values", async () => { + const {codexAcpAgent} = await createSession(); + + await expect(codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: "unknown-config", + value: FAST_MODE_ON, + })).rejects.toThrow(); + + await expect(codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: FAST_MODE_CONFIG_ID, + value: "turbo", + })).rejects.toThrow(); + }); + + it("sends the fast service tier when Fast mode is enabled for a fast-capable model", async () => { + const {codexAcpAgent, turnStartSpy} = setupPromptSession(true, true); + + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + serviceTier: "fast", + })); + }); + + it("explicitly clears service tier when Fast mode is off", async () => { + const {codexAcpAgent, turnStartSpy} = setupPromptSession(false, true); + + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + serviceTier: null, + })); + }); + + it("explicitly clears service tier when the selected model does not support fast", async () => { + const {codexAcpAgent, turnStartSpy} = setupPromptSession(true, false); + + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + serviceTier: null, + })); + }); + + it("keeps Fast mode selected across model switches but stops applying it for non-fast models", async () => { + const {codexAcpAgent, codexAcpClient, fixture} = await createSession("fast"); + const slowModel = createModel("slow-model"); + vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue([slowModel]); + const turnStartSpy = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({ + turn: { + id: "turn-id", + items: [], + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + vi.spyOn(fixture.getCodexAppServerClient(), "awaitTurnCompleted").mockResolvedValue({ + threadId: "session-id", + turn: { + id: "turn-id", + items: [], + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + + await codexAcpAgent.unstable_setSessionModel({ + sessionId: "session-id", + modelId: "slow-model[medium]", + }); + + const sessionState = codexAcpAgent.getSessionState("session-id"); + expect(sessionState.fastModeEnabled).toBe(true); + expect(sessionState.currentModelSupportsFast).toBe(false); + + await codexAcpAgent.prompt({sessionId: "session-id", prompt: [{type: "text", text: "test"}]}); + + expect(turnStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + model: "slow-model", + serviceTier: null, + })); + }); +}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index a1629214..0deb8d5f 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -322,6 +322,8 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, + fastModeEnabled: false, + currentModelSupportsFast: false, ...overrides, }; } From ee021901f6a757de3a8fa4b1ba3a28dec8f1113f Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 14:43:00 +0200 Subject: [PATCH 12/13] feat: change text to match codex --- src/FastModeConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FastModeConfig.ts b/src/FastModeConfig.ts index 823091ba..4187aaa3 100644 --- a/src/FastModeConfig.ts +++ b/src/FastModeConfig.ts @@ -6,7 +6,7 @@ export const FAST_MODE_CONFIG_ID = "fast-mode"; export const FAST_MODE_ON = "on"; export const FAST_MODE_OFF = "off"; -const FAST_MODE_DESCRIPTION = "Use the fast service tier when it is available for the selected model. This can be more expensive."; +const FAST_MODE_DESCRIPTION = "1.5x speed, increased usage"; export function modelSupportsFast(model: Model | undefined): boolean { return model?.additionalSpeedTiers?.includes("fast") ?? false; @@ -28,7 +28,7 @@ export function createFastModeConfigOption(fastModeEnabled: boolean): SessionCon { value: FAST_MODE_OFF, name: "Off", - description: "Use the standard service tier.", + description: "Default speed, normal usage", }, { value: FAST_MODE_ON, From 7642adac3d5bf704dbe65782323aa1b1ae6ba08d Mon Sep 17 00:00:00 2001 From: "Nikolai.Sviridov" Date: Thu, 14 May 2026 15:00:53 +0200 Subject: [PATCH 13/13] chore: refactor test --- .../CodexACPAgent/fast-mode-config.test.ts | 94 +++---------------- src/__tests__/acp-test-utils.ts | 62 ++++++++++++ 2 files changed, 76 insertions(+), 80 deletions(-) diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index f6ab0cef..0e103695 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -1,6 +1,10 @@ import {describe, expect, it, vi} from "vitest"; -import type {Model, ReasoningEffortOption} from "../../app-server/v2"; -import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; +import { + createCodexMockTestFixture, + createTestModel, + mockPromptTurn, + setupPromptTestSession, +} from "../acp-test-utils"; import { createFastModeConfigOption, FAST_MODE_CONFIG_ID, @@ -9,32 +13,14 @@ import { } from "../../FastModeConfig"; describe("Fast mode session config", () => { - const defaultEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; - - function createModel(id: string, additionalSpeedTiers: string[] = []): Model { - return { - id, - model: id, - upgrade: null, - upgradeInfo: null, - availabilityNux: null, - displayName: id, - description: `${id} model`, - hidden: false, - supportedReasoningEfforts: [defaultEffort], - defaultReasoningEffort: "medium", - inputModalities: ["text", "image"], - supportsPersonality: false, - additionalSpeedTiers, - isDefault: true, - }; - } - async function createSession(currentServiceTier: "fast" | "flex" | null = null) { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); - const fastModel = createModel("fast-model", ["fast"]); + const fastModel = createTestModel({ + id: "fast-model", + additionalSpeedTiers: ["fast"], + }); vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); @@ -50,43 +36,13 @@ describe("Fast mode session config", () => { } function setupPromptSession(fastModeEnabled: boolean, currentModelSupportsFast: boolean) { - const fixture = createCodexMockTestFixture(); - const codexAcpAgent = fixture.getCodexAcpAgent(); - const codexAppServerClient = fixture.getCodexAppServerClient(); - const sessionState = createTestSessionState({ + const {mockFixture, turnStartSpy} = setupPromptTestSession({ sessionId: "session-id", currentModelId: "fast-model[medium]", fastModeEnabled, currentModelSupportsFast, - supportedReasoningEfforts: [defaultEffort], }); - - vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); - const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({ - turn: { - id: "turn-id", - items: [], - status: "inProgress", - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - } - }); - vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockResolvedValue({ - threadId: "session-id", - turn: { - id: "turn-id", - items: [], - status: "completed", - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - } - }); - - return {codexAcpAgent, turnStartSpy}; + return {codexAcpAgent: mockFixture.getCodexAcpAgent(), turnStartSpy}; } it("returns the Fast mode config option defaulted to Off for new sessions", async () => { @@ -170,31 +126,9 @@ describe("Fast mode session config", () => { it("keeps Fast mode selected across model switches but stops applying it for non-fast models", async () => { const {codexAcpAgent, codexAcpClient, fixture} = await createSession("fast"); - const slowModel = createModel("slow-model"); + const slowModel = createTestModel({id: "slow-model"}); vi.spyOn(codexAcpClient, "fetchAvailableModels").mockResolvedValue([slowModel]); - const turnStartSpy = vi.spyOn(fixture.getCodexAppServerClient(), "turnStart").mockResolvedValue({ - turn: { - id: "turn-id", - items: [], - status: "inProgress", - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - } - }); - vi.spyOn(fixture.getCodexAppServerClient(), "awaitTurnCompleted").mockResolvedValue({ - threadId: "session-id", - turn: { - id: "turn-id", - items: [], - status: "completed", - error: null, - startedAt: null, - completedAt: null, - durationMs: null, - } - }); + const turnStartSpy = mockPromptTurn(fixture, "session-id"); await codexAcpAgent.unstable_setSessionModel({ sessionId: "session-id", diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 0deb8d5f..ec510dc0 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -10,6 +10,7 @@ import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; import {expect, vi} from "vitest"; +import type {Model, ReasoningEffortOption} from "../app-server/v2"; export type MethodCallEvent = { method: string; args: any[] }; @@ -328,6 +329,67 @@ export function createTestSessionState(overrides?: Partial): Sessi }; } +export function createTestModel(overrides?: Partial): Model { + const id = overrides?.id ?? "model-id"; + const defaultEffort: ReasoningEffortOption = {reasoningEffort: "medium", description: "Balanced"}; + return { + id, + model: id, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: id, + description: `${id} model`, + hidden: false, + supportedReasoningEfforts: [defaultEffort], + defaultReasoningEffort: "medium", + inputModalities: ["text", "image"], + supportsPersonality: false, + additionalSpeedTiers: [], + isDefault: true, + ...overrides, + }; +} + +export function setupPromptTestSession(sessionOverrides?: Partial) { + const mockFixture = createCodexMockTestFixture(); + const sessionState = createTestSessionState(sessionOverrides); + + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = mockPromptTurn(mockFixture, sessionState.sessionId); + + return {mockFixture, sessionState, turnStartSpy}; +} + +export function mockPromptTurn(fixture: CodexMockTestFixture, sessionId: string) { + const codexAppServerClient = fixture.getCodexAppServerClient(); + const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({ + turn: { + id: "turn-id", + items: [], + status: "inProgress", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockResolvedValue({ + threadId: sessionId, + turn: { + id: "turn-id", + items: [], + status: "completed", + error: null, + startedAt: null, + completedAt: null, + durationMs: null, + } + }); + + return turnStartSpy; +} + export async function setupPromptAndSendNotifications( fixture: CodexMockTestFixture, sessionId: string,