Skip to content
Merged
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
73 changes: 73 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ export interface SessionState {
sessionMcpServers?: Array<string>;
terminalOutputMode: TerminalOutputMode;
currentGoal?: ThreadGoalSnapshot | null;
sessionTitle: string | null;
sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown";
}

interface ActiveAuthState {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -972,6 +978,7 @@ export class CodexAcpServer {
private async streamThreadHistory(sessionId: string, thread: Thread): Promise<void> {
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,
Expand All @@ -993,6 +1000,67 @@ export class CodexAcpServer {
}
}

private async publishThreadHistoryTitle(
session: ACPSessionConnection,
sessionState: SessionState,
thread: Thread,
): Promise<void> {
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<UserInput, {type: "text"}> => input.type === "text")
.map(input => input.text)
.join(" "));
if (title) return title;
}
}
return null;
}

private async publishFallbackSessionTitle(
sessionState: SessionState,
title: string | null,
): Promise<void> {
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<acp.ContentBlock, {type: "text"}> => 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<UpdateSessionEvent[]> {
switch (item.type) {
case "userMessage":
Expand Down Expand Up @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/__tests__/CodexACPAgent/data/load-session-history.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-1",
"update": {
"sessionUpdate": "session_info_update",
"title": "Saved title"
}
}
]
}
{
"method": "sessionUpdate",
"args": [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@
}
]
}
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "session-legacy",
"update": {
"sessionUpdate": "session_info_update",
"title": "List the files"
}
}
]
}
{
"method": "sessionUpdate",
"args": [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"method": "sessionUpdate",
"args": [
{
"sessionId": "test-session-id",
"update": {
"sessionUpdate": "session_info_update",
"title": "Fix the flaky test in CI"
}
}
]
}
2 changes: 1 addition & 1 deletion src/__tests__/CodexACPAgent/load-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ describe("CodexACPAgent - loadSession", () => {
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
name: "Saved title",
turns: [
{
id: "turn-1",
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/CodexACPAgent/session-info-update-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });

Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/acp-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,8 @@ export function createTestSessionState(overrides?: Partial<SessionState>): Sessi
fastModeEnabled: false,
currentModelSupportsFast: false,
terminalOutputMode: "terminal_output_delta",
sessionTitle: null,
sessionTitleSource: "unknown",
...overrides,
};
}
Expand Down