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(); + }); +});