Skip to content

Commit 0719568

Browse files
feat: LLM-27711 Codex /compact command
1 parent c352108 commit 0719568

11 files changed

Lines changed: 322 additions & 35 deletions

src/CodexAcpClient.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,12 @@ export class CodexAcpClient {
395395
return await this.codexClient.awaitTurnCompleted();
396396
}
397397

398+
async compactSession(sessionId: string): Promise<TurnCompletedNotification> {
399+
const turnCompleted = this.codexClient.awaitTurnCompleted(sessionId);
400+
await this.codexClient.threadCompactStart({ threadId: sessionId });
401+
return await turnCompleted;
402+
}
403+
398404
async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
399405
return this.codexClient.listSkills(params ?? {});
400406
}

src/CodexAcpServer.ts

Lines changed: 47 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {
1919
Model,
2020
Thread,
2121
ThreadItem,
22+
TurnCompletedNotification,
2223
UserInput,
2324
ReasoningEffortOption
2425
} from "./app-server/v2";
@@ -739,13 +740,20 @@ export class CodexAcpServer implements acp.Agent {
739740
approvalHandler,
740741
elicitationHandler);
741742

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

751759
const modelId = ModelId.fromString(sessionState.currentModelId);
@@ -768,22 +776,9 @@ export class CodexAcpServer implements acp.Agent {
768776
() => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd));
769777

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

789784
const error = eventHandler.getFailure()
@@ -792,11 +787,7 @@ export class CodexAcpServer implements acp.Agent {
792787
throw error;
793788
}
794789

795-
return {
796-
stopReason: "end_turn",
797-
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
798-
_meta: this.buildQuotaMeta(sessionState),
799-
};
790+
return this.createPromptResponse("end_turn", sessionState);
800791
} catch (err) {
801792
logger.error(`Prompt for session ${params.sessionId} failed`, err);
802793
throw err;
@@ -832,6 +823,35 @@ export class CodexAcpServer implements acp.Agent {
832823
return toPromptUsage(lastTokenUsage);
833824
}
834825

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

src/CodexAppServerClient.ts

Lines changed: 12 additions & 2 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,
@@ -179,6 +181,10 @@ export class CodexAppServerClient {
179181
return await this.sendRequest({ method: "thread/read", params: params });
180182
}
181183

184+
async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
185+
return await this.sendRequest({ method: "thread/compact/start", params });
186+
}
187+
182188
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
183189
return await this.sendRequest({ method: "mcpServerStatus/list", params });
184190
}
@@ -224,9 +230,13 @@ export class CodexAppServerClient {
224230
}
225231

226232
//TODO create type-safe helper
227-
async awaitTurnCompleted(): Promise<TurnCompletedNotification> {
233+
async awaitTurnCompleted(threadId?: string): Promise<TurnCompletedNotification> {
228234
return await new Promise((resolve) => {
229-
this.connection.onNotification("turn/completed", (event: TurnCompletedNotification) => {
235+
const disposable = this.connection.onNotification("turn/completed", (event: TurnCompletedNotification) => {
236+
if (threadId !== undefined && event.threadId !== threadId) {
237+
return;
238+
}
239+
disposable?.dispose();
230240
resolve(event);
231241
});
232242
});

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)