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
23 changes: 23 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,29 @@ Run from binaries
}
```

### Native conversation renaming

New sessions expose the eager `set_thread_title({title: "A short title"})` dynamic tool.
It takes exactly one non-empty string, normalizes whitespace, and applies the existing
256-code-unit display limit without splitting surrogate pairs. The app-server supplies
the current thread and turn; the tool cannot select another thread. It needs no AIR
capability or MCP service. Successful calls persist through `thread/name/set` and send
an ACP `session_info_update` for both protocol versions.

Registration uses the experimental `thread/start.dynamicTools` field, verified with bundled
Codex 0.165.0. Threads keep the tool after resume. Threads created without it do not gain it
when loaded; start a new thread or use `/rename`.

Explicit renames share the automatic-title write queue. A rejected write lets automatic
titles continue. An uncertain or saved-but-not-delivered write keeps them blocked, and the
tool result says so; check the saved title before retrying.

Check it with:

```bash
npm run codex-test -- -p 'Call set_thread_title with title "Native rename verification". Do not use any other tool.'
```

### Build binaries

Building standalone binaries requires [bun](https://bun.com/docs/installation).
Expand Down
2 changes: 2 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {isDeepStrictEqual} from "node:util";
import {logger} from "./Logger";
import {isAccountReadAuthFailureError, isAccountReadUnavailableError} from "./CodexThreadErrors";
import {listedSessionTitle, normalizeSessionTitle} from "./SessionTitle";
import {SESSION_TITLE_TOOL} from "./SessionTitleTool";
import {type AcpMcpServer, type WithAcpMcpServers, normalizeMcpServers, toCodexMcpServerConfig} from "./McpServerConfig";
import type {
AccountUpdatedNotification,
Expand Down Expand Up @@ -867,6 +868,7 @@ export class CodexAcpClient {
await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
const response = await this.codexClient.threadStart({
config,
dynamicTools: [SESSION_TITLE_TOOL],
modelProvider: this.getModelProvider(),
cwd: request.cwd,
...(developerInstructions !== undefined && {developerInstructions}),
Expand Down
49 changes: 47 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ import type {
import type {RateLimitsMap} from "./RateLimitsMap";
import {ModelId} from "./ModelId";
import {normalizeSessionTitle} from "./SessionTitle";
import {sessionTitleArgument, sessionTitleToolResult} from "./SessionTitleTool";
import {SessionTitleWriteError} from "./SessionIndexTitles";
import {AgentMode, MODE_CONFIG_ID} from "./AgentMode";
import {
COLLABORATION_MODE_CONFIG_ID,
Expand Down Expand Up @@ -648,6 +650,49 @@ export class CodexAcpServer {
this.currentAuthStatus = null;
this.availableCommands = this.createAvailableCommands(codexAcpClient);
this.recovery = this.createRecovery();
this.bindSessionTitleTool(codexAcpClient);
}

private setCodexClient(client: CodexAcpClient): void {
this.codexAcpClient = client;
this.bindSessionTitleTool(client);
}

private bindSessionTitleTool(client: CodexAcpClient): void {
client.appServerClient.onDynamicToolCall(async (params) => {
if (params.tool !== "set_thread_title" || params.namespace != null) {
return sessionTitleToolResult(false, "Unsupported dynamic tool.");
}
const title = sessionTitleArgument(params.arguments);
if (title === null) return sessionTitleToolResult(false, "Expected exactly one non-empty string property: title.");
const state = this.sessions.get(params.threadId);
const generation = this.getSessionGeneration(params.threadId);
const assertCurrent = () => {
if (!state || this.sessions.get(params.threadId) !== state
|| this.codexAcpClient !== client || this.sessionIsClosing(params.threadId)
|| this.getSessionGeneration(params.threadId) !== generation
|| !client.appServerClient.isCurrentToolTurn(params.threadId, params.turnId)) {
throw new Error("The session or turn is no longer current.");
}
};
try {
assertCurrent();
const saved = await this.sessionIndex.renameTitle(params.threadId, title, {client, assertCurrent});
return sessionTitleToolResult(true, `Session title saved as ${JSON.stringify(saved)}.`);
} catch (err) {
if (err instanceof SessionTitleWriteError) {
switch (err.outcome) {
case "saved":
return sessionTitleToolResult(false, `Session title saved as ${JSON.stringify(err.title)}, but the session changed or the client update failed. Do not retry the rename.`);
case "uncertain":
return sessionTitleToolResult(false, "The rename outcome is uncertain; it may have been saved. Automatic title writes are blocked. Check the saved title before retrying.");
case "rejected":
return sessionTitleToolResult(false, `Session title was not changed: ${err.message}`);
}
}
return sessionTitleToolResult(false, `Session title was not changed: ${err instanceof Error ? err.message : String(err)}`);
}
});
}

private createAvailableCommands(client: CodexAcpClient): CodexCommands {
Expand Down Expand Up @@ -2374,7 +2419,7 @@ export class CodexAcpServer {
throw new Error("Cannot restart Codex app-server before ACP initialization");
}
await replacement.initialize(this.initializeRequest);
this.codexAcpClient = replacement;
this.setCodexClient(replacement);
this.sessionIndex.observe(replacement);
this.availableCommands = this.createAvailableCommands(replacement);

Expand Down Expand Up @@ -2605,7 +2650,7 @@ export class CodexAcpServer {
private installCodexClient(client: CodexAcpClient): void {
// A `providers/set` can have changed the routing of the old client while the restart ran.
client.adoptRoutingFrom(this.codexAcpClient);
this.codexAcpClient = client;
this.setCodexClient(client);
// The session index watches the thread notifications of the app-server that runs now.
this.sessionIndex.observe(client);
this.availableCommands = this.createAvailableCommands(client);
Expand Down
25 changes: 24 additions & 1 deletion src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import type {
} from "./app-server";
import type {
ActivePermissionProfile,
DynamicToolSpec,
DynamicToolCallParams,
DynamicToolCallResponse,
CancelLoginAccountParams,
CancelLoginAccountResponse,
ConfigReadParams,
Expand Down Expand Up @@ -157,6 +160,8 @@ const ToolRequestUserInputRequest = new RequestType<

const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000;

const DynamicToolCallRequest = new RequestType<DynamicToolCallParams, DynamicToolCallResponse, void>('item/tool/call');

/** The number of turns in one page of a full history read. */
const HISTORY_PAGE_TURNS = 5;

Expand All @@ -177,6 +182,7 @@ export class CodexAppServerClient {
readonly connection: MessageConnection;
private approvalHandlers = new Map<string, ApprovalHandler>();
private elicitationHandlers = new Map<string, ElicitationHandler>();
private dynamicToolHandler: ((params: DynamicToolCallParams) => Promise<DynamicToolCallResponse>) | undefined;
readonly mcpStartup = new McpStartupTracker();
readonly mcpOauthCompletions = new McpOauthCompletions();
private readonly pendingTurnCompletionResolvers = new Map<string, Map<string, (event: TurnCompletedNotification) => void>>();
Expand Down Expand Up @@ -302,6 +308,22 @@ export class CodexAppServerClient {
}
return await handler.handleUserInput(params);
});

this.connection.onRequest(DynamicToolCallRequest, async (params) => {
if (!this.isCurrentToolTurn(params.threadId, params.turnId) || !this.dynamicToolHandler) {
return {success: false, contentItems: [{type: "inputText", text: "The tool is unavailable for this session or turn."}]};
}
return await this.dynamicToolHandler(params);
});
}

onDynamicToolCall(handler: (params: DynamicToolCallParams) => Promise<DynamicToolCallResponse>): void {
this.dynamicToolHandler = handler;
}

isCurrentToolTurn(threadId: string, turnId: string): boolean {
return !this.connectionLoss.lost && !this.isStaleTurn(threadId, turnId)
&& this.runningTurnIds.get(threadId) === turnId;
}

onApprovalRequest(threadId: string, handler: ApprovalHandler): void {
Expand Down Expand Up @@ -676,7 +698,7 @@ export class CodexAppServerClient {
this.staleTurnIds.set(threadId, threadStaleTurns);
}

async threadStart(params: ThreadStartParams): Promise<ExperimentalThreadStartResponse> {
async threadStart(params: ThreadStartParams & {dynamicTools?: DynamicToolSpec[]}): Promise<ExperimentalThreadStartResponse> {
return await this.sendRequest({ method: "thread/start", params: params });
}

Expand Down Expand Up @@ -1297,6 +1319,7 @@ export class CodexAppServerClient {
}

private disposeMcpWaits(): void {
this.dynamicToolHandler = undefined;
this.mcpStartup.dispose();
this.mcpOauthCompletions.dispose();
}
Expand Down
15 changes: 12 additions & 3 deletions src/SessionIndexService.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* The AIR `sessionIndex` extension as `CodexAcpServer` serves it: the session list, `_session/rename`,
* `_session/archive`, `_session/unarchive`, `session/delete` and `_session/list/subscribe`.
* `session/set_title` shares its title writer without requiring AIR capabilities.
* `session/set_title` and the native rename tool share its title writer without requiring AIR capabilities.
* See `SessionIndex.ts` and `docs/air-extensions.md`.
*/

Expand Down Expand Up @@ -43,7 +43,7 @@ import {
type SessionRenameRequest,
} from "./SessionIndex";
import {deleteThread, sessionIndexRequestError, setThreadArchived} from "./SessionIndexMutations";
import {SessionIndexTitles} from "./SessionIndexTitles";
import {SessionIndexTitles, SessionTitleWriteError, type SessionTitleWriteContext} from "./SessionIndexTitles";
import {canonicalCwds, CodexWorktreeMarks, isUncPath, linkedWorktreeCwds} from "./SessionIndexWorktrees";
import {SessionForkOrigins} from "./SessionForkOrigins";
import {SessionListSubscriptions, type SessionListSubscriptionTimings, type ThreadEntry} from "./SessionListSubscriptions";
Expand Down Expand Up @@ -244,7 +244,16 @@ export class SessionIndexService {
/** Standard title requests share persistence, normalization and automatic-title suppression with AIR. */
async setTitle({sessionId, title}: acp.SetSessionTitleRequest): Promise<acp.SetSessionTitleResponse> {
await this.host.ensureAppServer();
return {title: await this.titles.rename(sessionId, title)};
try {
return {title: await this.titles.rename(sessionId, title)};
} catch (err) {
throw err instanceof SessionTitleWriteError ? err.cause : err;
}
}

/** Internal tool write; the caller fences its live session without AIR negotiation. */
renameTitle(sessionId: string, title: string, context: SessionTitleWriteContext): Promise<string> {
return this.titles.rename(sessionId, title, context);
}

/**
Expand Down
Loading