diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 18fdf195..d22a623e 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -102,6 +102,8 @@ export interface SessionState { sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + sessionTitle: string | null; + sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } interface ActiveAuthState { @@ -416,6 +418,8 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + sessionTitle: null, + sessionTitleSource: "sessionId" in request ? "unknown" : "unset", }; this.sessions.set(sessionId, sessionState); resumeSubscribed = false; @@ -945,6 +949,8 @@ export class CodexAcpServer { currentModelSupportsFast: currentModelSupportsFast, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + sessionTitle: null, + sessionTitleSource: "unset", }; this.sessions.set(sessionId, sessionState); subscribed = false; @@ -972,6 +978,7 @@ export class CodexAcpServer { private async streamThreadHistory(sessionId: string, thread: Thread): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const sessionState = this.getSessionState(sessionId); + await this.publishThreadHistoryTitle(session, sessionState, thread); const responseItemFallbackUpdates = await createResponseItemHistoryFallbackUpdates( thread, sessionState.terminalOutputMode, @@ -993,6 +1000,67 @@ export class CodexAcpServer { } } + private async publishThreadHistoryTitle( + session: ACPSessionConnection, + sessionState: SessionState, + thread: Thread, + ): Promise { + const explicitTitle = this.normalizeSessionTitle(thread.name); + if (explicitTitle) { + sessionState.sessionTitle = explicitTitle; + sessionState.sessionTitleSource = "explicit"; + await session.update({ + sessionUpdate: "session_info_update", + title: explicitTitle, + }); + return; + } + + const historyTitle = this.findFirstUserMessageTitle(thread) + ?? this.normalizeSessionTitle(thread.preview); + await this.publishFallbackSessionTitle(sessionState, historyTitle); + } + + private findFirstUserMessageTitle(thread: Thread): string | null { + for (const turn of thread.turns) { + for (const item of turn.items) { + if (item.type !== "userMessage") continue; + const title = this.normalizeSessionTitle(item.content + .filter((input): input is Extract => input.type === "text") + .map(input => input.text) + .join(" ")); + if (title) return title; + } + } + return null; + } + + private async publishFallbackSessionTitle( + sessionState: SessionState, + title: string | null, + ): Promise { + if (sessionState.sessionTitleSource !== "unset" || !title) return; + sessionState.sessionTitle = title; + sessionState.sessionTitleSource = "fallback"; + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "session_info_update", + title, + }); + } + + private createPromptFallbackTitle(prompt: acp.ContentBlock[]): string | null { + return this.normalizeSessionTitle(prompt + .filter((block): block is Extract => block.type === "text") + .map(block => block.text) + .join(" ")); + } + + private normalizeSessionTitle(title: string | null | undefined): string | null { + const normalized = title?.replace(/\s+/g, " ").trim() ?? ""; + return normalized.length > 0 ? normalized : null; + } + private async createHistoryUpdates(item: ThreadItem, sessionState: SessionState): Promise { switch (item.type) { case "userMessage": @@ -1597,6 +1665,11 @@ export class CodexAcpServer { throw error; } + await this.publishFallbackSessionTitle( + sessionState, + this.createPromptFallbackTitle(params.prompt), + ); + return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index c2a4d672..5a6aea19 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -122,6 +122,10 @@ export class CodexEventHandler { case "thread/tokenUsage/updated": return this.createUsageUpdate(notification.params); case "thread/name/updated": + this.sessionState.sessionTitle = notification.params.threadName ?? null; + this.sessionState.sessionTitleSource = notification.params.threadName == null + ? "unset" + : "explicit"; return { sessionUpdate: "session_info_update", title: notification.params.threadName ?? null, diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 454bcf7d..060a474d 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -64,6 +64,18 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "session_info_update", + "title": "Saved title" + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 3fd4f914..e960c459 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -64,6 +64,18 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "session_info_update", + "title": "List the files" + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json b/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json new file mode 100644 index 00000000..26871a0f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json @@ -0,0 +1,12 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "session_info_update", + "title": "Fix the flaky test in CI" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 8fb5dfee..9e7c8d85 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -66,7 +66,7 @@ describe("CodexACPAgent - loadSession", () => { agentNickname: null, agentRole: null, gitInfo: null, - name: null, + name: "Saved title", turns: [ { id: "turn-1", diff --git a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts index fdf771e7..fe12e429 100644 --- a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts +++ b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts @@ -5,6 +5,40 @@ import { setupPromptTestSession } from "../acp-test-utils"; describe("CodexEventHandler - session info updates", () => { const sessionId = "test-session-id"; + it("uses the first user prompt as a fallback session title", async () => { + const { mockFixture } = setupPromptTestSession({ + sessionId, + sessionTitleSource: "unset", + }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [ + { type: "text", text: " Fix the flaky\n test " }, + { type: "text", text: "in CI" }, + ], + }); + + await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot( + "data/session-info-update-fallback-title.json" + ); + }); + + it("does not replace an explicit session title with the prompt fallback", async () => { + const { mockFixture } = setupPromptTestSession({ + sessionId, + sessionTitle: "Explicit title", + sessionTitleSource: "explicit", + }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "Fallback title" }], + }); + + expect(mockFixture.getAcpConnectionEvents([])).toEqual([]); + }); + it("maps thread name updates to ACP session info updates", async () => { const { mockFixture } = setupPromptTestSession({ sessionId }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 456701f0..13505155 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -386,6 +386,8 @@ export function createTestSessionState(overrides?: Partial): Sessi fastModeEnabled: false, currentModelSupportsFast: false, terminalOutputMode: "terminal_output_delta", + sessionTitle: null, + sessionTitleSource: "unknown", ...overrides, }; }