From 93978a892a8bed61f5761a9f9d1c1dc928d849a7 Mon Sep 17 00:00:00 2001 From: Daniel Stojanovic Date: Mon, 29 Jun 2026 02:07:06 +0200 Subject: [PATCH] Add session rename support --- src/AcpExtensions.ts | 17 +++++- src/CodexAcpClient.ts | 7 +++ src/CodexAcpServer.ts | 43 ++++++++++++--- src/CodexAppServerClient.ts | 6 +++ src/CodexCommands.ts | 18 +++++++ .../CodexACPAgent/CodexAcpClient.test.ts | 54 +++++++++++++++++++ .../data/available-commands-build-in.json | 7 +++ .../data/available-commands-skills.json | 7 +++ .../data/load-session-history.json | 7 +++ ...ession-response-item-history-fallback.json | 7 +++ .../CodexACPAgent/initialize.test.ts | 1 + src/index.ts | 8 ++- 12 files changed, 173 insertions(+), 9 deletions(-) diff --git a/src/AcpExtensions.ts b/src/AcpExtensions.ts index 2a6b359e..5cccbe42 100644 --- a/src/AcpExtensions.ts +++ b/src/AcpExtensions.ts @@ -7,6 +7,7 @@ import type { } from "@agentclientprotocol/sdk"; export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model"; +export const SET_SESSION_TITLE_METHOD = "session/setTitle"; export type LegacySessionModel = { modelId: string; @@ -26,6 +27,13 @@ export type LegacySetSessionModelRequest = { export type LegacySetSessionModelResponse = {} +export type SetSessionTitleRequest = { + sessionId: SessionId; + title: string; +} + +export type SetSessionTitleResponse = {} + export type LegacyNewSessionResponse = NewSessionResponse & { models?: LegacySessionModelState | null; } @@ -42,11 +50,13 @@ export type ExtMethodRequest = AuthenticationStatusRequest | AuthenticationLogoutRequest | LegacySetSessionModelExtRequest + | SetSessionTitleExtRequest export function isExtMethodRequest(request: { method: string, params: Record }): request is ExtMethodRequest { return request.method === "authentication/status" || request.method === "authentication/logout" - || request.method === LEGACY_SET_SESSION_MODEL_METHOD; + || request.method === LEGACY_SET_SESSION_MODEL_METHOD + || request.method === SET_SESSION_TITLE_METHOD; } export type AuthenticationStatusRequest = { method: "authentication/status", params: {} } @@ -60,6 +70,11 @@ export type LegacySetSessionModelExtRequest = { params: LegacySetSessionModelRequest; } +export type SetSessionTitleExtRequest = { + method: typeof SET_SESSION_TITLE_METHOD; + params: SetSessionTitleRequest; +} + export async function legacySetSessionModel( connection: Pick, params: LegacySetSessionModelRequest, diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 6f43c0c8..980932f8 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -318,6 +318,13 @@ export class CodexAcpClient { await this.codexClient.threadArchive({threadId: sessionId}); } + async setSessionTitle(sessionId: string, title: string): Promise { + await this.codexClient.threadSetName({ + threadId: sessionId, + name: title, + }); + } + async runReview( sessionId: string, target: ReviewTarget, diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f5d628a0..a67564eb 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -40,8 +40,11 @@ import { type LegacySessionModelState, type LegacySetSessionModelRequest, type LegacySetSessionModelResponse, + type SetSessionTitleRequest, + type SetSessionTitleResponse, isExtMethodRequest, LEGACY_SET_SESSION_MODEL_METHOD, + SET_SESSION_TITLE_METHOD, } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, @@ -175,6 +178,14 @@ export class CodexAcpServer { this.clientInfo = _params.clientInfo ?? null; this.terminalOutputMode = resolveTerminalOutputMode(_params.clientCapabilities); await this.runWithProcessCheck(() => this.codexAcpClient.initialize(_params)); + const sessionCapabilities = { + resume: { }, + list: { }, + close: { }, + delete: { }, + additionalDirectories: {}, + setTitle: {}, + }; return { protocolVersion: acp.PROTOCOL_VERSION, agentInfo: { @@ -191,13 +202,7 @@ export class CodexAcpServer { embeddedContext: true, image: true }, - sessionCapabilities: { - resume: { }, - list: { }, - close: { }, - delete: { }, - additionalDirectories: {}, - }, + sessionCapabilities, mcpCapabilities: { acp: false, http: true, @@ -222,6 +227,8 @@ export class CodexAcpServer { } case LEGACY_SET_SESSION_MODEL_METHOD: return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params)); + case SET_SESSION_TITLE_METHOD: + return await this.setSessionTitle(this.parseSetSessionTitleParams(methodRequest.params)); } } @@ -676,6 +683,16 @@ export class CodexAcpServer { }; } + async setSessionTitle(params: SetSessionTitleRequest): Promise { + logger.log("Set session title requested", { + sessionId: params.sessionId, + titleLength: params.title.length, + }); + await this.checkAuthorization(); + await this.runWithProcessCheck(() => this.codexAcpClient.setSessionTitle(params.sessionId, params.title)); + return {}; + } + private applyFastModeChange(sessionState: SessionState, value: string): void { if (value !== FAST_MODE_ON && value !== FAST_MODE_OFF) { throw RequestError.invalidParams(); @@ -765,6 +782,18 @@ export class CodexAcpServer { }; } + private parseSetSessionTitleParams(params: Record): SetSessionTitleRequest { + const sessionId = params["sessionId"]; + const title = params["title"]; + if (typeof sessionId !== "string" || typeof title !== "string") { + throw RequestError.invalidParams(); + } + return { + sessionId: sessionId, + title: title, + }; + } + private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 6a5a0b51..e9941325 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -38,6 +38,8 @@ import type { ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadSetNameParams, + ThreadSetNameResponse, ThreadStartParams, ThreadStartResponse, ThreadUnsubscribeParams, @@ -309,6 +311,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/archive", params: params }); } + async threadSetName(params: ThreadSetNameParams): Promise { + return await this.sendRequest({ method: "thread/name/set", params: params }); + } + async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise { return await this.sendRequest({ method: "thread/unsubscribe", params: params }); } diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 457d911e..dc259cd0 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -101,6 +101,11 @@ export class CodexCommands { description: "Display session configuration and token usage.", input: null }, + { + name: "rename", + description: "Rename this session.", + input: { hint: "new session title" } + }, { name: "review", description: "Review uncommitted changes, or review with custom instructions.", @@ -201,6 +206,19 @@ export class CodexCommands { }); return { handled: true }; } + case "rename": { + if (command.rest.length === 0) { + await this.sendCommandUsageMessage(commandName, "new session title", sessionId); + return { handled: true }; + } + await this.runWithProcessCheck(() => this.codexAcpClient.setSessionTitle(sessionId, command.rest)); + const session = new ACPSessionConnection(this.connection, sessionId); + await session.update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `Renamed session to ${JSON.stringify(command.rest)}.` } + }); + return { handled: true }; + } case "logout": { await this.runWithProcessCheck(() => this.codexAcpClient.logout()); await this.onLogout(); diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index dd68685c..0d72c03d 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -16,6 +16,7 @@ import {AgentMode} from "../../AgentMode"; import type {Model, ReviewStartResponse, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; +import {SET_SESSION_TITLE_METHOD} from "../../AcpExtensions"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -1291,6 +1292,26 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-status.json"); }); + it('handles rename slash command through Codex app server', async () => { + const { mockFixture, turnStartSpy } = setupPromptFixture(); + const threadSetNameSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadSetName") + .mockResolvedValue({}); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/rename Quarterly review" }], + }); + + expect(threadSetNameSpy).toHaveBeenCalledWith({ + threadId: "session-id", + name: "Quarterly review", + }); + expect(turnStartSpy).not.toHaveBeenCalled(); + const [event] = mockFixture.getAcpConnectionEvents([]); + expect(event).toBeDefined(); + expect(event!.args[0].update.content.text).toBe('Renamed session to "Quarterly review".'); + }); + it('passes skill slash commands through to Codex', async () => { const { mockFixture, turnStartSpy } = setupPromptFixture(); @@ -1519,6 +1540,39 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(event!.args[0].update.content.text).toBe('Command "/review-branch" requires branch name.'); }); + it('reports missing rename slash command input', async () => { + const { mockFixture } = setupPromptFixture(); + const threadSetNameSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadSetName") + .mockResolvedValue({}); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{ type: "text", text: "/rename" }], + }); + + expect(threadSetNameSpy).not.toHaveBeenCalled(); + const [event] = mockFixture.getAcpConnectionEvents([]); + expect(event).toBeDefined(); + expect(event!.args[0].update.content.text).toBe('Command "/rename" requires new session title.'); + }); + + it('handles session setTitle extension through Codex app server', async () => { + const mockFixture = createCodexMockTestFixture(); + vi.spyOn(mockFixture.getCodexAcpClient(), "authRequired").mockResolvedValue(false); + const threadSetNameSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "threadSetName") + .mockResolvedValue({}); + + await expect(mockFixture.getCodexAcpAgent().extMethod(SET_SESSION_TITLE_METHOD, { + sessionId: "thread-id", + title: "Renamed from client", + })).resolves.toEqual({}); + + expect(threadSetNameSpy).toHaveBeenCalledWith({ + threadId: "thread-id", + name: "Renamed from client", + }); + }); + it('handles logout command', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index d805f6b0..de6f1787 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -21,6 +21,13 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "rename", + "description": "Rename this session.", + "input": { + "hint": "new session title" + } + }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index dc1b0101..c06a28c3 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -21,6 +21,13 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "rename", + "description": "Rename this session.", + "input": { + "hint": "new session title" + } + }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index c8d0b1eb..d8859dfa 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -21,6 +21,13 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "rename", + "description": "Rename this session.", + "input": { + "hint": "new session title" + } + }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", 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 94a65ad8..8e4e83f2 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 @@ -21,6 +21,13 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "rename", + "description": "Rename this session.", + "input": { + "hint": "new session title" + } + }, { "name": "review", "description": "Review uncommitted changes, or review with custom instructions.", diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e7050101..1f4278b1 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -52,6 +52,7 @@ describe('CodexACPAgent - initialize', () => { close: {}, delete: {}, additionalDirectories: {}, + setTitle: {}, }, mcpCapabilities: { acp: false, diff --git a/src/index.ts b/src/index.ts index 5f8a83dd..a5837a56 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import packageJson from "../package.json"; import {logger} from "./Logger"; import {runLoginCommand} from "./login"; import {runCodexCli} from "./CodexCli"; -import {LEGACY_SET_SESSION_MODEL_METHOD} from "./AcpExtensions"; +import {LEGACY_SET_SESSION_MODEL_METHOD, SET_SESSION_TITLE_METHOD} from "./AcpExtensions"; const emptyExtensionParamsParser = z.preprocess( (params) => params ?? {}, @@ -24,6 +24,11 @@ const legacySetSessionModelParamsParser = z.object({ modelId: z.string(), }).passthrough(); +const setSessionTitleParamsParser = z.object({ + sessionId: z.string(), + title: z.string(), +}).passthrough(); + if (process.argv.includes("--version")) { console.log(`${packageJson.name} ${packageJson.version}`); process.exit(0); @@ -129,5 +134,6 @@ function startAcpServer() { .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params)) .onRequest("authentication/logout", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/logout", ctx.params)) .onRequest(LEGACY_SET_SESSION_MODEL_METHOD, legacySetSessionModelParamsParser, (ctx) => getAgent().extMethod(LEGACY_SET_SESSION_MODEL_METHOD, ctx.params)) + .onRequest(SET_SESSION_TITLE_METHOD, setSessionTitleParamsParser, (ctx) => getAgent().extMethod(SET_SESSION_TITLE_METHOD, ctx.params)) .connect(acpJsonStream); }