Skip to content

Commit d51550a

Browse files
feat: LLM-27711 Codex /compact command
1 parent 87383d9 commit d51550a

11 files changed

Lines changed: 316 additions & 33 deletions

src/CodexAcpClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,12 @@ export class CodexAcpClient {
415415
});
416416
}
417417

418+
async compactSession(sessionId: string): Promise<TurnCompletedNotification> {
419+
const turnCompleted = this.codexClient.awaitTurnCompleted(sessionId);
420+
await this.codexClient.threadCompactStart({ threadId: sessionId });
421+
return await turnCompleted;
422+
}
423+
418424
async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
419425
return this.codexClient.listSkills(params ?? {});
420426
}

src/CodexAcpServer.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ReasoningEffortOption,
1616
Thread,
1717
ThreadItem,
18+
TurnCompletedNotification,
1819
UserInput
1920
} from "./app-server/v2";
2021
import type {RateLimitsMap} from "./RateLimitsMap";
@@ -742,13 +743,20 @@ export class CodexAcpServer implements acp.Agent {
742743
approvalHandler,
743744
elicitationHandler);
744745

745-
if (await this.availableCommands.tryHandle(params.prompt, sessionState)) {
746+
const commandResult = await this.availableCommands.tryHandle(params.prompt, sessionState);
747+
if (commandResult) {
746748
logger.log("Prompt handled by a command");
747-
return {
748-
stopReason: "end_turn",
749-
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
750-
_meta: this.buildQuotaMeta(sessionState),
751-
};
749+
if (commandResult !== true) {
750+
const interruptedResponse = await this.createInterruptedResponseIfNeeded(params.sessionId, commandResult, sessionState);
751+
if (interruptedResponse) {
752+
return interruptedResponse;
753+
}
754+
}
755+
const error = eventHandler.getFailure();
756+
if (error) {
757+
throw error;
758+
}
759+
return this.createPromptResponse("end_turn", sessionState);
752760
}
753761

754762
const modelId = ModelId.fromString(sessionState.currentModelId);
@@ -771,22 +779,9 @@ export class CodexAcpServer implements acp.Agent {
771779
() => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd));
772780

773781
// Check if turn was interrupted (cancelled)
774-
if (turnCompleted.turn.status === "interrupted") {
775-
await this.connection.sessionUpdate({
776-
sessionId: params.sessionId,
777-
update: {
778-
sessionUpdate: "agent_message_chunk",
779-
content: {
780-
type: "text",
781-
text: "*Conversation interrupted*"
782-
}
783-
}
784-
});
785-
return {
786-
stopReason: "cancelled",
787-
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
788-
_meta: this.buildQuotaMeta(sessionState),
789-
};
782+
const interruptedResponse = await this.createInterruptedResponseIfNeeded(params.sessionId, turnCompleted, sessionState);
783+
if (interruptedResponse) {
784+
return interruptedResponse;
790785
}
791786

792787
const error = eventHandler.getFailure()
@@ -795,11 +790,7 @@ export class CodexAcpServer implements acp.Agent {
795790
throw error;
796791
}
797792

798-
return {
799-
stopReason: "end_turn",
800-
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
801-
_meta: this.buildQuotaMeta(sessionState),
802-
};
793+
return this.createPromptResponse("end_turn", sessionState);
803794
} catch (err) {
804795
logger.error(`Prompt for session ${params.sessionId} failed`, err);
805796
throw err;
@@ -835,6 +826,35 @@ export class CodexAcpServer implements acp.Agent {
835826
return toPromptUsage(lastTokenUsage);
836827
}
837828

829+
private async createInterruptedResponseIfNeeded(
830+
sessionId: string,
831+
turnCompleted: TurnCompletedNotification,
832+
sessionState: SessionState
833+
): Promise<acp.PromptResponse | null> {
834+
if (turnCompleted.turn.status !== "interrupted") {
835+
return null;
836+
}
837+
await this.connection.sessionUpdate({
838+
sessionId,
839+
update: {
840+
sessionUpdate: "agent_message_chunk",
841+
content: {
842+
type: "text",
843+
text: "*Conversation interrupted*"
844+
}
845+
}
846+
});
847+
return this.createPromptResponse("cancelled", sessionState);
848+
}
849+
850+
private createPromptResponse(stopReason: acp.PromptResponse["stopReason"], sessionState: SessionState): acp.PromptResponse {
851+
return {
852+
stopReason,
853+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
854+
_meta: this.buildQuotaMeta(sessionState),
855+
};
856+
}
857+
838858
private async runWithProcessCheck<T>(operation: () => Promise<T>): Promise<T> {
839859
try {
840860
return await operation();

src/CodexAppServerClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import type {
2626
SkillsListResponse,
2727
ThreadLoadedListParams,
2828
ThreadLoadedListResponse,
29+
ThreadCompactStartParams,
30+
ThreadCompactStartResponse,
2931
ThreadListParams,
3032
ThreadListResponse,
3133
ThreadReadParams,
@@ -205,6 +207,10 @@ export class CodexAppServerClient {
205207
return await this.sendRequest({ method: "thread/read", params: params });
206208
}
207209

210+
async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
211+
return await this.sendRequest({ method: "thread/compact/start", params });
212+
}
213+
208214
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
209215
return await this.sendRequest({ method: "mcpServerStatus/list", params });
210216
}

src/CodexCommands.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {AgentSideConnection, AvailableCommand} from "@agentclientprotocol/s
33
import {ACPSessionConnection} from "./ACPSessionConnection";
44
import type {CodexAcpClient} from "./CodexAcpClient";
55
import type {RateLimitSnapshot, SkillsListEntry} from "./app-server/v2";
6+
import type {TurnCompletedNotification} from "./app-server/v2";
67
import type {SessionState} from "./CodexAcpServer";
78
import type {RateLimitsMap} from "./RateLimitsMap";
89
import type {TokenCount} from "./TokenCount";
@@ -41,7 +42,7 @@ export class CodexCommands {
4142
}
4243
}
4344

44-
async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
45+
async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<CommandHandlingResult | false> {
4546
const command = this.parseCommand(prompt);
4647
if (command) {
4748
return this.handleCommand(command, sessionState);
@@ -91,6 +92,11 @@ export class CodexCommands {
9192
description: "Display session configuration and token usage.",
9293
input: null
9394
},
95+
{
96+
name: "compact",
97+
description: "Compact conversation history to reduce context usage.",
98+
input: null
99+
},
94100
{
95101
name: "logout",
96102
description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.",
@@ -119,10 +125,12 @@ export class CodexCommands {
119125
};
120126
}
121127

122-
async handleCommand(command: ParsedCommand, sessionState: SessionState): Promise<boolean> {
128+
async handleCommand(command: ParsedCommand, sessionState: SessionState): Promise<CommandHandlingResult> {
123129
const sessionId = sessionState.sessionId;
124130

125131
switch (command.name) {
132+
case "compact":
133+
return await this.runWithProcessCheck(() => this.codexAcpClient.compactSession(sessionId));
126134
case "status": {
127135
const session = new ACPSessionConnection(this.connection, sessionId);
128136
const message = this.buildStatusMessage(sessionState);
@@ -355,3 +363,4 @@ export class CodexCommands {
355363
}
356364

357365
type ParsedCommand = { name: string; input: string | null };
366+
type CommandHandlingResult = true | TurnCompletedNotification;

src/CodexEventHandler.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,14 @@ export class CodexEventHandler {
227227
return await createMcpToolCallUpdate(event.item);
228228
case "dynamicToolCall":
229229
return await createDynamicToolCallUpdate(event.item);
230+
case "contextCompaction":
231+
return {
232+
sessionUpdate: "tool_call",
233+
toolCallId: event.item.id,
234+
kind: "other",
235+
title: "Compacting context",
236+
status: "in_progress",
237+
};
230238
case "collabAgentToolCall":
231239
case "userMessage":
232240
case "hookPrompt":
@@ -237,7 +245,6 @@ export class CodexEventHandler {
237245
case "imageGeneration":
238246
case "enteredReviewMode":
239247
case "exitedReviewMode":
240-
case "contextCompaction":
241248
case "plan":
242249
return null;
243250
}
@@ -272,6 +279,12 @@ export class CodexEventHandler {
272279
text: summary
273280
}
274281
}
282+
case "contextCompaction":
283+
return {
284+
sessionUpdate: "tool_call_update",
285+
toolCallId: event.item.id,
286+
status: "completed",
287+
};
275288
case "collabAgentToolCall":
276289
case "userMessage":
277290
case "hookPrompt":
@@ -281,7 +294,6 @@ export class CodexEventHandler {
281294
case "imageGeneration":
282295
case "enteredReviewMode":
283296
case "exitedReviewMode":
284-
case "contextCompaction":
285297
case "plan":
286298
return null;
287299
}

0 commit comments

Comments
 (0)