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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -840,6 +840,7 @@ export class CodexAcpServer {
sessionId,
sessionState.cwd,
() => sessionState.sessionTitleSource,
() => this.authProviderUsesOpenAiAccount(sessionState.authProvider),
);
this.installSessionState(sessionState);
resumeSubscribed = false;
Expand Down Expand Up @@ -2267,6 +2268,7 @@ export class CodexAcpServer {
sessionId,
sessionState.cwd,
() => sessionState.sessionTitleSource,
() => this.authProviderUsesOpenAiAccount(sessionState.authProvider),
);
this.installSessionState(sessionState);
if (knownAccount === "pending") {
Expand Down
5 changes: 4 additions & 1 deletion src/TitleGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export class TitleGenerator {
private readonly mainThreadId: string,
private readonly cwd: string,
private readonly getSessionTitleSource: () => string,
private readonly sessionUsesOpenAiProvider: () => boolean,
) {}

/**
Expand All @@ -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).
Expand All @@ -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
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/CodexACPAgent/title-generation-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof appServer.threadStart>>);
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};
}
37 changes: 35 additions & 2 deletions src/__tests__/TitleGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,14 @@ import {TitleGenerator} from "../TitleGenerator";
import type {CodexAppServerClient} from "../CodexAppServerClient";
import {deferred} from "./acp-test-utils";

function createGenerator(client: Partial<CodexAppServerClient>) {
return new TitleGenerator(client as CodexAppServerClient, "thread-id", "/test/cwd", () => "unset");
function createGenerator(client: Partial<CodexAppServerClient>, sessionUsesOpenAiProvider = true) {
return new TitleGenerator(
client as CodexAppServerClient,
"thread-id",
"/test/cwd",
() => "unset",
() => sessionUsesOpenAiProvider,
);
}

describe("TitleGenerator.waitForIdle", () => {
Expand Down Expand Up @@ -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<CodexAppServerClient>);

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<CodexAppServerClient>, false);

generator.onTurnCompleted("hello");
generator.onTurnCompleted("second turn");
await generator.waitForIdle(1_000);

expect(threadStart).not.toHaveBeenCalled();
expect(runTurn).not.toHaveBeenCalled();
});
});