Skip to content

Commit f3f1b3c

Browse files
Emit fallback session titles (#292)
1 parent 9dee38d commit f3f1b3c

8 files changed

Lines changed: 150 additions & 1 deletion

src/CodexAcpServer.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ export interface SessionState {
103103
sessionMcpServers?: Array<string>;
104104
terminalOutputMode: TerminalOutputMode;
105105
currentGoal?: ThreadGoalSnapshot | null;
106+
sessionTitle: string | null;
107+
sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown";
106108
}
107109

108110
interface ActiveAuthState {
@@ -417,6 +419,8 @@ export class CodexAcpServer {
417419
currentModelSupportsFast: currentModelSupportsFast,
418420
sessionMcpServers: sessionMcpServers,
419421
terminalOutputMode: this.terminalOutputMode,
422+
sessionTitle: null,
423+
sessionTitleSource: "sessionId" in request ? "unknown" : "unset",
420424
};
421425
this.sessions.set(sessionId, sessionState);
422426
resumeSubscribed = false;
@@ -946,6 +950,8 @@ export class CodexAcpServer {
946950
currentModelSupportsFast: currentModelSupportsFast,
947951
sessionMcpServers: sessionMcpServers,
948952
terminalOutputMode: this.terminalOutputMode,
953+
sessionTitle: null,
954+
sessionTitleSource: "unset",
949955
};
950956
this.sessions.set(sessionId, sessionState);
951957
subscribed = false;
@@ -973,6 +979,7 @@ export class CodexAcpServer {
973979
private async streamThreadHistory(sessionId: string, thread: Thread): Promise<void> {
974980
const session = new ACPSessionConnection(this.connection, sessionId);
975981
const sessionState = this.getSessionState(sessionId);
982+
await this.publishThreadHistoryTitle(session, sessionState, thread);
976983
const responseItemFallbackUpdates = await createResponseItemHistoryFallbackUpdates(
977984
thread,
978985
sessionState.terminalOutputMode,
@@ -994,6 +1001,67 @@ export class CodexAcpServer {
9941001
}
9951002
}
9961003

1004+
private async publishThreadHistoryTitle(
1005+
session: ACPSessionConnection,
1006+
sessionState: SessionState,
1007+
thread: Thread,
1008+
): Promise<void> {
1009+
const explicitTitle = this.normalizeSessionTitle(thread.name);
1010+
if (explicitTitle) {
1011+
sessionState.sessionTitle = explicitTitle;
1012+
sessionState.sessionTitleSource = "explicit";
1013+
await session.update({
1014+
sessionUpdate: "session_info_update",
1015+
title: explicitTitle,
1016+
});
1017+
return;
1018+
}
1019+
1020+
const historyTitle = this.findFirstUserMessageTitle(thread)
1021+
?? this.normalizeSessionTitle(thread.preview);
1022+
await this.publishFallbackSessionTitle(sessionState, historyTitle);
1023+
}
1024+
1025+
private findFirstUserMessageTitle(thread: Thread): string | null {
1026+
for (const turn of thread.turns) {
1027+
for (const item of turn.items) {
1028+
if (item.type !== "userMessage") continue;
1029+
const title = this.normalizeSessionTitle(item.content
1030+
.filter((input): input is Extract<UserInput, {type: "text"}> => input.type === "text")
1031+
.map(input => input.text)
1032+
.join(" "));
1033+
if (title) return title;
1034+
}
1035+
}
1036+
return null;
1037+
}
1038+
1039+
private async publishFallbackSessionTitle(
1040+
sessionState: SessionState,
1041+
title: string | null,
1042+
): Promise<void> {
1043+
if (sessionState.sessionTitleSource !== "unset" || !title) return;
1044+
sessionState.sessionTitle = title;
1045+
sessionState.sessionTitleSource = "fallback";
1046+
const session = new ACPSessionConnection(this.connection, sessionState.sessionId);
1047+
await session.update({
1048+
sessionUpdate: "session_info_update",
1049+
title,
1050+
});
1051+
}
1052+
1053+
private createPromptFallbackTitle(prompt: acp.ContentBlock[]): string | null {
1054+
return this.normalizeSessionTitle(prompt
1055+
.filter((block): block is Extract<acp.ContentBlock, {type: "text"}> => block.type === "text")
1056+
.map(block => block.text)
1057+
.join(" "));
1058+
}
1059+
1060+
private normalizeSessionTitle(title: string | null | undefined): string | null {
1061+
const normalized = title?.replace(/\s+/g, " ").trim() ?? "";
1062+
return normalized.length > 0 ? normalized : null;
1063+
}
1064+
9971065
private async createHistoryUpdates(item: ThreadItem, sessionState: SessionState): Promise<UpdateSessionEvent[]> {
9981066
switch (item.type) {
9991067
case "userMessage":
@@ -1588,6 +1656,11 @@ export class CodexAcpServer {
15881656
throw error;
15891657
}
15901658

1659+
await this.publishFallbackSessionTitle(
1660+
sessionState,
1661+
this.createPromptFallbackTitle(params.prompt),
1662+
);
1663+
15911664
return {
15921665
stopReason: "end_turn",
15931666
usage: this.buildPromptUsage(sessionState.lastTokenUsage),

src/CodexEventHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ export class CodexEventHandler {
124124
case "thread/tokenUsage/updated":
125125
return this.createUsageUpdate(notification.params);
126126
case "thread/name/updated":
127+
this.sessionState.sessionTitle = notification.params.threadName ?? null;
128+
this.sessionState.sessionTitleSource = notification.params.threadName == null
129+
? "unset"
130+
: "explicit";
127131
return {
128132
sessionUpdate: "session_info_update",
129133
title: notification.params.threadName ?? null,

src/__tests__/CodexACPAgent/data/load-session-history.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@
6464
}
6565
]
6666
}
67+
{
68+
"method": "sessionUpdate",
69+
"args": [
70+
{
71+
"sessionId": "session-1",
72+
"update": {
73+
"sessionUpdate": "session_info_update",
74+
"title": "Saved title"
75+
}
76+
}
77+
]
78+
}
6779
{
6880
"method": "sessionUpdate",
6981
"args": [

src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@
6464
}
6565
]
6666
}
67+
{
68+
"method": "sessionUpdate",
69+
"args": [
70+
{
71+
"sessionId": "session-legacy",
72+
"update": {
73+
"sessionUpdate": "session_info_update",
74+
"title": "List the files"
75+
}
76+
}
77+
]
78+
}
6779
{
6880
"method": "sessionUpdate",
6981
"args": [
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"method": "sessionUpdate",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"update": {
7+
"sessionUpdate": "session_info_update",
8+
"title": "Fix the flaky test in CI"
9+
}
10+
}
11+
]
12+
}

src/__tests__/CodexACPAgent/load-session.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("CodexACPAgent - loadSession", () => {
6666
agentNickname: null,
6767
agentRole: null,
6868
gitInfo: null,
69-
name: null,
69+
name: "Saved title",
7070
turns: [
7171
{
7272
id: "turn-1",

src/__tests__/CodexACPAgent/session-info-update-events.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,40 @@ import { setupPromptTestSession } from "../acp-test-utils";
55
describe("CodexEventHandler - session info updates", () => {
66
const sessionId = "test-session-id";
77

8+
it("uses the first user prompt as a fallback session title", async () => {
9+
const { mockFixture } = setupPromptTestSession({
10+
sessionId,
11+
sessionTitleSource: "unset",
12+
});
13+
14+
await mockFixture.getCodexAcpAgent().prompt({
15+
sessionId,
16+
prompt: [
17+
{ type: "text", text: " Fix the flaky\n test " },
18+
{ type: "text", text: "in CI" },
19+
],
20+
});
21+
22+
await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot(
23+
"data/session-info-update-fallback-title.json"
24+
);
25+
});
26+
27+
it("does not replace an explicit session title with the prompt fallback", async () => {
28+
const { mockFixture } = setupPromptTestSession({
29+
sessionId,
30+
sessionTitle: "Explicit title",
31+
sessionTitleSource: "explicit",
32+
});
33+
34+
await mockFixture.getCodexAcpAgent().prompt({
35+
sessionId,
36+
prompt: [{ type: "text", text: "Fallback title" }],
37+
});
38+
39+
expect(mockFixture.getAcpConnectionEvents([])).toEqual([]);
40+
});
41+
842
it("maps thread name updates to ACP session info updates", async () => {
943
const { mockFixture } = setupPromptTestSession({ sessionId });
1044

src/__tests__/acp-test-utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,8 @@ export function createTestSessionState(overrides?: Partial<SessionState>): Sessi
386386
fastModeEnabled: false,
387387
currentModelSupportsFast: false,
388388
terminalOutputMode: "terminal_output_delta",
389+
sessionTitle: null,
390+
sessionTitleSource: "unknown",
389391
...overrides,
390392
};
391393
}

0 commit comments

Comments
 (0)