From ef9cf453e813b1ed126b6afd6e82f83cd03439a5 Mon Sep 17 00:00:00 2001 From: Michel Tomas Date: Thu, 8 Oct 2026 17:23:14 +0200 Subject: [PATCH] fix: skip AI title generation for sessions on other model providers The title turn always asks for gpt-5.6-luna, an OpenAI model. A session on a custom model_provider (an LLM gateway, a local server) may not serve it, so every new session made one failed request. Generate the title only when the session's provider is OpenAI, the same check the adapter uses for OpenAI account state; other sessions keep the prompt-derived fallback title. --- src/CodexAcpServer.ts | 2 + src/TitleGenerator.ts | 5 +- .../title-generation-provider.test.ts | 49 +++++++++++++++++++ src/__tests__/TitleGenerator.test.ts | 37 +++++++++++++- 4 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/title-generation-provider.test.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index ead7814d2..5db6e699d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -840,6 +840,7 @@ export class CodexAcpServer { sessionId, sessionState.cwd, () => sessionState.sessionTitleSource, + () => this.authProviderUsesOpenAiAccount(sessionState.authProvider), ); this.installSessionState(sessionState); resumeSubscribed = false; @@ -2267,6 +2268,7 @@ export class CodexAcpServer { sessionId, sessionState.cwd, () => sessionState.sessionTitleSource, + () => this.authProviderUsesOpenAiAccount(sessionState.authProvider), ); this.installSessionState(sessionState); if (knownAccount === "pending") { diff --git a/src/TitleGenerator.ts b/src/TitleGenerator.ts index 76f573898..74f2ff48e 100644 --- a/src/TitleGenerator.ts +++ b/src/TitleGenerator.ts @@ -39,6 +39,7 @@ export class TitleGenerator { private readonly mainThreadId: string, private readonly cwd: string, private readonly getSessionTitleSource: () => string, + private readonly sessionUsesOpenAiProvider: () => boolean, ) {} /** @@ -62,7 +63,8 @@ export class TitleGenerator { /** * Fire-and-forget hook — call after each turn completes. - * Only acts on the first call for new sessions without an existing title. + * Only acts on the first call for new sessions without an existing title, + * and only when the session uses the OpenAI provider, which serves the title model. * * @param userPromptText The text of the user's first message (from params.prompt, * not turn.items — turn.items contains only agent output). @@ -74,6 +76,7 @@ export class TitleGenerator { // "unknown": resumed session with indeterminate history — skip if (src === "explicit" || src === "unknown") return; this.generated = true; + if (!this.sessionUsesOpenAiProvider()) return; const run = this.generateAndPersist(userPromptText) .catch(() => { // title generation is best-effort; never surface errors to the user diff --git a/src/__tests__/CodexACPAgent/title-generation-provider.test.ts b/src/__tests__/CodexACPAgent/title-generation-provider.test.ts new file mode 100644 index 000000000..473a4d176 --- /dev/null +++ b/src/__tests__/CodexACPAgent/title-generation-provider.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, it, vi} from "vitest"; +import type {Turn} from "../../app-server/v2"; +import {ModelId} from "../../ModelId"; +import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; + +const sessionId = "title-session"; +const turnId = "title-turn"; + +describe("AI session title generation", () => { + it.each([ + ["openai", 1], + ["fleet-gateway", 0], + ])("for a new session on the %s provider runs %i title turn(s)", async (modelProvider, titleTurns) => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + const model = createTestModel(); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); + vi.spyOn(client, "getAccount").mockResolvedValue({account: {type: "apiKey"}, requiresOpenaiAuth: false}); + vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(client, "newSession").mockResolvedValue({ + sessionId, + currentModelId: ModelId.create(model.id, model.defaultReasoningEffort).toString(), + models: [model], + collaborationMode: "default", + modelProvider, + additionalDirectories: [], + }); + vi.spyOn(appServer, "turnStart").mockResolvedValue({turn: createTurn("inProgress")}); + vi.spyOn(appServer, "awaitTurnCompleted").mockResolvedValue({threadId: sessionId, turn: createTurn("completed")}); + const threadStart = vi.spyOn(appServer, "threadStart") + .mockResolvedValue({thread: {id: "title-thread"}} as Awaited>); + const runTurn = vi.spyOn(appServer, "runTurn"); + + await agent.newSession({cwd: "/workspace", mcpServers: []}); + await agent.prompt({sessionId, prompt: [{type: "text", text: "Fix the failing build"}]}); + await agent.getSessionState(sessionId).titleGen?.waitForIdle(1_000); + + expect(threadStart).toHaveBeenCalledTimes(titleTurns); + const titleTurnRequests = runTurn.mock.calls.filter(([params]) => params.threadId === "title-thread"); + expect(titleTurnRequests).toHaveLength(titleTurns); + expect(titleTurnRequests.map(([params]) => params.model)).toEqual(titleTurns === 0 ? [] : ["gpt-5.6-luna"]); + }); +}); + +function createTurn(status: Turn["status"]): Turn { + return {id: turnId, items: [], itemsView: "full", status, error: null, startedAt: null, completedAt: null, durationMs: null}; +} diff --git a/src/__tests__/TitleGenerator.test.ts b/src/__tests__/TitleGenerator.test.ts index 70aaaeed9..4395d80dc 100644 --- a/src/__tests__/TitleGenerator.test.ts +++ b/src/__tests__/TitleGenerator.test.ts @@ -3,8 +3,14 @@ import {TitleGenerator} from "../TitleGenerator"; import type {CodexAppServerClient} from "../CodexAppServerClient"; import {deferred} from "./acp-test-utils"; -function createGenerator(client: Partial) { - return new TitleGenerator(client as CodexAppServerClient, "thread-id", "/test/cwd", () => "unset"); +function createGenerator(client: Partial, sessionUsesOpenAiProvider = true) { + return new TitleGenerator( + client as CodexAppServerClient, + "thread-id", + "/test/cwd", + () => "unset", + () => sessionUsesOpenAiProvider, + ); } describe("TitleGenerator.waitForIdle", () => { @@ -87,3 +93,30 @@ describe("TitleGenerator prompt", () => { expect(text.endsWith(`User's first message:\n${"a".repeat(3_999)}`)).toBe(true); }); }); + +describe("TitleGenerator model provider", () => { + it("requests a title from the title model for a session on the OpenAI provider", async () => { + const threadStart = vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}); + const runTurn = vi.fn().mockResolvedValue({turn: {items: []}}); + const generator = createGenerator({threadStart, runTurn} as unknown as Partial); + + generator.onTurnCompleted("hello"); + await generator.waitForIdle(1_000); + + expect(threadStart).toHaveBeenCalledWith({cwd: "/test/cwd", ephemeral: true}); + expect(runTurn).toHaveBeenCalledWith(expect.objectContaining({threadId: "ephemeral", model: "gpt-5.6-luna"})); + }); + + it("starts no title thread or turn for a session on another provider", async () => { + const threadStart = vi.fn().mockResolvedValue({thread: {id: "ephemeral"}}); + const runTurn = vi.fn().mockResolvedValue({turn: {items: []}}); + const generator = createGenerator({threadStart, runTurn} as unknown as Partial, false); + + generator.onTurnCompleted("hello"); + generator.onTurnCompleted("second turn"); + await generator.waitForIdle(1_000); + + expect(threadStart).not.toHaveBeenCalled(); + expect(runTurn).not.toHaveBeenCalled(); + }); +});