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
4 changes: 4 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@ export class CodexAcpClient {
});
}

async compactSession(sessionId: string): Promise<TurnCompletedNotification> {
return await this.codexClient.runCompact({ threadId: sessionId });
}

async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
return this.codexClient.listSkills(params ?? {});
}
Expand Down
74 changes: 47 additions & 27 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
ReasoningEffortOption,
Thread,
ThreadItem,
TurnCompletedNotification,
UserInput
} from "./app-server/v2";
import type {RateLimitsMap} from "./RateLimitsMap";
Expand Down Expand Up @@ -742,13 +743,20 @@ export class CodexAcpServer implements acp.Agent {
approvalHandler,
elicitationHandler);

if (await this.availableCommands.tryHandle(params.prompt, sessionState)) {
const commandResult = await this.availableCommands.tryHandle(params.prompt, sessionState);
if (commandResult) {
logger.log("Prompt handled by a command");
return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
};
if (commandResult !== true) {
const interruptedResponse = await this.createInterruptedResponseIfNeeded(params.sessionId, commandResult, sessionState);
if (interruptedResponse) {
return interruptedResponse;
}
}
const error = eventHandler.getFailure();
if (error) {
throw error;
}
return this.createPromptResponse("end_turn", sessionState);
}

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

// Check if turn was interrupted (cancelled)
if (turnCompleted.turn.status === "interrupted") {
await this.connection.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "*Conversation interrupted*"
}
}
});
return {
stopReason: "cancelled",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
};
const interruptedResponse = await this.createInterruptedResponseIfNeeded(params.sessionId, turnCompleted, sessionState);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would remain if (turnCompleted.turn.status === "interrupted") here and would remove "ifNeeded" from the extracted method. This would be more explicit and even more short.

if (interruptedResponse) {
return interruptedResponse;
}

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

return {
stopReason: "end_turn",
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
};
return this.createPromptResponse("end_turn", sessionState);
} catch (err) {
logger.error(`Prompt for session ${params.sessionId} failed`, err);
throw err;
Expand Down Expand Up @@ -835,6 +826,35 @@ export class CodexAcpServer implements acp.Agent {
return toPromptUsage(lastTokenUsage);
}

private async createInterruptedResponseIfNeeded(
sessionId: string,
turnCompleted: TurnCompletedNotification,
sessionState: SessionState
): Promise<acp.PromptResponse | null> {
if (turnCompleted.turn.status !== "interrupted") {
return null;
}
await this.connection.sessionUpdate({
sessionId,
update: {
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "*Conversation interrupted*"
}
}
});
return this.createPromptResponse("cancelled", sessionState);
}

private createPromptResponse(stopReason: acp.PromptResponse["stopReason"], sessionState: SessionState): acp.PromptResponse {
return {
stopReason,
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
_meta: this.buildQuotaMeta(sessionState),
};
}

private async runWithProcessCheck<T>(operation: () => Promise<T>): Promise<T> {
try {
return await operation();
Expand Down
21 changes: 21 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ import type {
SkillsListResponse,
ThreadLoadedListParams,
ThreadLoadedListResponse,
ThreadCompactStartParams,
ThreadCompactStartResponse,
ThreadListParams,
ThreadListResponse,
ThreadReadParams,
Expand Down Expand Up @@ -181,6 +183,21 @@ export class CodexAppServerClient {
}
}

async runCompact(params: ThreadCompactStartParams): Promise<TurnCompletedNotification> {
let resolveTurnCompleted!: (event: TurnCompletedNotification) => void;
const turnCompleted = new Promise<TurnCompletedNotification>((resolve) => {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why compact needs to wait for turn/end? Won't this be done automatically by common event handling?

resolveTurnCompleted = resolve;
});
const releaseCapture = this.captureTurnCompletions(params.threadId, resolveTurnCompleted);

try {
await this.threadCompactStart(params);
return await turnCompleted;
} finally {
releaseCapture();
}
}

async turnInterrupt(params: TurnInterruptParams): Promise<TurnInterruptResponse> {
return await this.sendRequest({ method: "turn/interrupt", params: params });
}
Expand All @@ -205,6 +222,10 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/read", params: params });
}

async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
return await this.sendRequest({ method: "thread/compact/start", params });
}

async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
return await this.sendRequest({ method: "mcpServerStatus/list", params });
}
Expand Down
13 changes: 11 additions & 2 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {AgentSideConnection, AvailableCommand} from "@agentclientprotocol/s
import {ACPSessionConnection} from "./ACPSessionConnection";
import type {CodexAcpClient} from "./CodexAcpClient";
import type {RateLimitSnapshot, SkillsListEntry} from "./app-server/v2";
import type {TurnCompletedNotification} from "./app-server/v2";
import type {SessionState} from "./CodexAcpServer";
import type {RateLimitsMap} from "./RateLimitsMap";
import type {TokenCount} from "./TokenCount";
Expand Down Expand Up @@ -41,7 +42,7 @@ export class CodexCommands {
}
}

async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<CommandHandlingResult | false> {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is intention behind this return type? And why we added type type CommandHandlingResult = true | TurnCompletedNotification;?

const command = this.parseCommand(prompt);
if (command) {
return this.handleCommand(command, sessionState);
Expand Down Expand Up @@ -91,6 +92,11 @@ export class CodexCommands {
description: "Display session configuration and token usage.",
input: null
},
{
name: "compact",
description: "Summarize conversation to prevent hitting the context limit.",
input: null
},
{
name: "logout",
description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.",
Expand Down Expand Up @@ -119,10 +125,12 @@ export class CodexCommands {
};
}

async handleCommand(command: ParsedCommand, sessionState: SessionState): Promise<boolean> {
async handleCommand(command: ParsedCommand, sessionState: SessionState): Promise<CommandHandlingResult> {
const sessionId = sessionState.sessionId;

switch (command.name) {
case "compact":
return await this.runWithProcessCheck(() => this.codexAcpClient.compactSession(sessionId));
case "status": {
const session = new ACPSessionConnection(this.connection, sessionId);
const message = this.buildStatusMessage(sessionState);
Expand Down Expand Up @@ -355,3 +363,4 @@ export class CodexCommands {
}

type ParsedCommand = { name: string; input: string | null };
type CommandHandlingResult = true | TurnCompletedNotification;
16 changes: 14 additions & 2 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,14 @@ export class CodexEventHandler {
return await createMcpToolCallUpdate(event.item);
case "dynamicToolCall":
return await createDynamicToolCallUpdate(event.item);
case "contextCompaction":
return {
sessionUpdate: "tool_call",
toolCallId: event.item.id,
kind: "other",
title: "Compacting context",
status: "in_progress",
};
case "collabAgentToolCall":
case "userMessage":
case "hookPrompt":
Expand All @@ -237,7 +245,6 @@ export class CodexEventHandler {
case "imageGeneration":
case "enteredReviewMode":
case "exitedReviewMode":
case "contextCompaction":
case "plan":
return null;
}
Expand Down Expand Up @@ -272,6 +279,12 @@ export class CodexEventHandler {
text: summary
}
}
case "contextCompaction":
return {
sessionUpdate: "tool_call_update",
toolCallId: event.item.id,
status: "completed",
};
case "collabAgentToolCall":
case "userMessage":
case "hookPrompt":
Expand All @@ -281,7 +294,6 @@ export class CodexEventHandler {
case "imageGeneration":
case "enteredReviewMode":
case "exitedReviewMode":
case "contextCompaction":
case "plan":
return null;
}
Expand Down
Loading
Loading