Skip to content

Commit edaface

Browse files
feat: add review command
1 parent fa6b0a8 commit edaface

14 files changed

Lines changed: 532 additions & 31 deletions

src/CodexAcpClient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import type {
2727
ListMcpServerStatusParams,
2828
ListMcpServerStatusResponse,
2929
Model,
30+
ReviewTarget,
3031
SkillsListParams,
3132
SkillsListResponse,
3233
Thread,
@@ -397,6 +398,14 @@ export class CodexAcpClient {
397398
});
398399
}
399400

401+
async runReview(threadId: string, target: ReviewTarget): Promise<TurnCompletedNotification> {
402+
return await this.codexClient.runReview({
403+
threadId,
404+
target,
405+
delivery: "inline",
406+
});
407+
}
408+
400409
async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
401410
return this.codexClient.listSkills(params ?? {});
402411
}

src/CodexAcpServer.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -554,11 +554,14 @@ export class CodexAcpServer implements acp.Agent {
554554
item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" },
555555
entered: boolean
556556
): UpdateSessionEvent {
557+
const text = entered
558+
? "Code review started: current changes"
559+
: `${item.review}\n\nCode review finished`;
557560
return {
558561
sessionUpdate: "agent_message_chunk",
559562
content: {
560563
type: "text",
561-
text: `${entered ? "Entered" : "Exited"} review mode: ${item.review}`,
564+
text: `${text}\n\n`,
562565
},
563566
};
564567
}
@@ -742,8 +745,19 @@ export class CodexAcpServer implements acp.Agent {
742745
approvalHandler,
743746
elicitationHandler);
744747

745-
if (await this.availableCommands.tryHandle(params.prompt, sessionState)) {
748+
const commandResult = await this.availableCommands.tryHandle(params.prompt, sessionState);
749+
if (commandResult.handled) {
746750
logger.log("Prompt handled by a command");
751+
if (commandResult.turnCompleted?.turn.status === "interrupted") {
752+
return await this.createInterruptedPromptResponse(params.sessionId, sessionState);
753+
}
754+
if (commandResult.turnCompleted) {
755+
const error = eventHandler.getFailure()
756+
if (error) {
757+
// noinspection ExceptionCaughtLocallyJS
758+
throw error;
759+
}
760+
}
747761
return {
748762
stopReason: "end_turn",
749763
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
@@ -772,21 +786,7 @@ export class CodexAcpServer implements acp.Agent {
772786

773787
// Check if turn was interrupted (cancelled)
774788
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-
};
789+
return await this.createInterruptedPromptResponse(params.sessionId, sessionState);
790790
}
791791

792792
const error = eventHandler.getFailure()
@@ -809,6 +809,27 @@ export class CodexAcpServer implements acp.Agent {
809809
}
810810
}
811811

812+
private async createInterruptedPromptResponse(
813+
sessionId: string,
814+
sessionState: SessionState
815+
): Promise<acp.PromptResponse> {
816+
await this.connection.sessionUpdate({
817+
sessionId,
818+
update: {
819+
sessionUpdate: "agent_message_chunk",
820+
content: {
821+
type: "text",
822+
text: "*Conversation interrupted*"
823+
}
824+
}
825+
});
826+
return {
827+
stopReason: "cancelled",
828+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
829+
_meta: this.buildQuotaMeta(sessionState),
830+
};
831+
}
832+
812833
private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
813834
const lastTokenUsage = sessionState.lastTokenUsage;
814835

src/CodexAppServerClient.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import type {
2222
McpServerStatusUpdatedNotification,
2323
ModelListParams,
2424
ModelListResponse,
25+
ReviewStartParams,
26+
ReviewStartResponse,
2527
SkillsListParams,
2628
SkillsListResponse,
2729
ThreadLoadedListParams,
@@ -160,6 +162,10 @@ export class CodexAppServerClient {
160162
return await this.sendRequest({ method: "turn/start", params: params });
161163
}
162164

165+
async reviewStart(params: ReviewStartParams): Promise<ReviewStartResponse> {
166+
return await this.sendRequest({ method: "review/start", params: params });
167+
}
168+
163169
async runTurn(params: TurnStartParams): Promise<TurnCompletedNotification> {
164170
const capturedCompletions: Array<TurnCompletedNotification> = [];
165171
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
@@ -181,6 +187,31 @@ export class CodexAppServerClient {
181187
}
182188
}
183189

190+
async runReview(params: ReviewStartParams): Promise<TurnCompletedNotification> {
191+
if (params.delivery === "detached") {
192+
throw new Error("runReview only supports inline review delivery");
193+
}
194+
const capturedCompletions: Array<TurnCompletedNotification> = [];
195+
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
196+
capturedCompletions.push(event);
197+
});
198+
199+
try {
200+
const reviewStarted = await this.reviewStart(params);
201+
const earlyCompletion = capturedCompletions.find(event =>
202+
event.threadId === reviewStarted.reviewThreadId
203+
&& event.turn.id === reviewStarted.turn.id
204+
);
205+
releaseCapture();
206+
if (earlyCompletion) {
207+
return earlyCompletion;
208+
}
209+
return await this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id);
210+
} finally {
211+
releaseCapture();
212+
}
213+
}
214+
184215
async turnInterrupt(params: TurnInterruptParams): Promise<TurnInterruptResponse> {
185216
return await this.sendRequest({ method: "turn/interrupt", params: params });
186217
}

src/CodexCommands.ts

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@ import type * as acp from "@agentclientprotocol/sdk";
22
import type {AgentSideConnection, AvailableCommand} from "@agentclientprotocol/sdk";
33
import {ACPSessionConnection} from "./ACPSessionConnection";
44
import type {CodexAcpClient} from "./CodexAcpClient";
5-
import type {RateLimitSnapshot, SkillsListEntry} from "./app-server/v2";
5+
import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, TurnCompletedNotification} from "./app-server/v2";
66
import type {SessionState} from "./CodexAcpServer";
77
import type {RateLimitsMap} from "./RateLimitsMap";
88
import type {TokenCount} from "./TokenCount";
99
import {logger} from "./Logger";
1010

11+
type CommandHandlingResult =
12+
| { handled: false }
13+
| { handled: true; turnCompleted?: TurnCompletedNotification };
14+
1115
export class CodexCommands {
1216
private readonly connection: AgentSideConnection;
1317
private readonly codexAcpClient: CodexAcpClient;
@@ -41,12 +45,12 @@ export class CodexCommands {
4145
}
4246
}
4347

44-
async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
48+
async tryHandle(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<CommandHandlingResult> {
4549
const command = this.parseCommand(prompt);
4650
if (command) {
4751
return this.handleCommand(command, sessionState);
4852
}
49-
return false;
53+
return { handled: false };
5054
}
5155

5256
private buildAvailableCommands(skillsEntries: SkillsListEntry[]): AvailableCommand[] {
@@ -81,6 +85,11 @@ export class CodexCommands {
8185
description: "List configured Model Context Protocol (MCP) tools.",
8286
input: null
8387
},
88+
{
89+
name: "review",
90+
description: "Review uncommitted changes.",
91+
input: null
92+
},
8493
{
8594
name: "skills",
8695
description: "List available skills.",
@@ -119,7 +128,7 @@ export class CodexCommands {
119128
};
120129
}
121130

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

125134
switch (command.name) {
@@ -130,7 +139,7 @@ export class CodexCommands {
130139
sessionUpdate: "agent_message_chunk",
131140
content: { type: "text", text: message }
132141
});
133-
return true;
142+
return { handled: true };
134143
}
135144
case "logout": {
136145
await this.runWithProcessCheck(() => this.codexAcpClient.logout());
@@ -139,7 +148,7 @@ export class CodexCommands {
139148
sessionUpdate: "agent_message_chunk",
140149
content: { type: "text", text: "Logged out from Codex account." }
141150
});
142-
return true;
151+
return { handled: true };
143152
}
144153
case "skills": {
145154
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills());
@@ -156,7 +165,7 @@ export class CodexCommands {
156165
sessionUpdate: "agent_message_chunk",
157166
content: { type: "text", text }
158167
});
159-
return true;
168+
return { handled: true };
160169
}
161170
case "mcp": {
162171
const servers = await this.runWithProcessCheck(() => this.codexAcpClient.listMcpServers());
@@ -177,14 +186,33 @@ export class CodexCommands {
177186
sessionUpdate: "agent_message_chunk",
178187
content: { type: "text", text }
179188
});
180-
return true;
189+
return { handled: true };
190+
}
191+
case "review": {
192+
if (command.input !== null) {
193+
await this.sendCommandMessage(sessionId, "/review does not accept arguments yet.");
194+
return { handled: true };
195+
}
196+
const target: ReviewTarget = { type: "uncommittedChanges" };
197+
const turnCompleted = await this.runWithProcessCheck(() =>
198+
this.codexAcpClient.runReview(sessionId, target)
199+
);
200+
return { handled: true, turnCompleted };
181201
}
182202
default:
183203
await this.sendUnknownCommandMessage(command.name, sessionId);
184-
return true;
204+
return { handled: true };
185205
}
186206
}
187207

208+
private async sendCommandMessage(sessionId: string, text: string): Promise<void> {
209+
const session = new ACPSessionConnection(this.connection, sessionId);
210+
await session.update({
211+
sessionUpdate: "agent_message_chunk",
212+
content: { type: "text", text }
213+
});
214+
}
215+
188216
private async sendUnknownCommandMessage(name: string, sessionId: string): Promise<void> {
189217
const lines = this.getBuiltinCommands().map(command => `- /${command.name}: ${command.description}`);
190218
const text = [

src/CodexEventHandler.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,8 @@ export class CodexEventHandler {
227227
return await createMcpToolCallUpdate(event.item);
228228
case "dynamicToolCall":
229229
return await createDynamicToolCallUpdate(event.item);
230+
case "enteredReviewMode":
231+
return this.createReviewModeEvent(event.item, true);
230232
case "collabAgentToolCall":
231233
case "userMessage":
232234
case "hookPrompt":
@@ -235,7 +237,6 @@ export class CodexEventHandler {
235237
case "webSearch":
236238
case "imageView":
237239
case "imageGeneration":
238-
case "enteredReviewMode":
239240
case "exitedReviewMode":
240241
case "contextCompaction":
241242
case "plan":
@@ -280,13 +281,31 @@ export class CodexEventHandler {
280281
case "imageView":
281282
case "imageGeneration":
282283
case "enteredReviewMode":
284+
return null;
283285
case "exitedReviewMode":
286+
return this.createReviewModeEvent(event.item, false);
284287
case "contextCompaction":
285288
case "plan":
286289
return null;
287290
}
288291
}
289292

293+
private createReviewModeEvent(
294+
item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" },
295+
entered: boolean
296+
): UpdateSessionEvent {
297+
const text = entered
298+
? "Code review started: current changes"
299+
: `${item.review}\n\nCode review finished`;
300+
return {
301+
sessionUpdate: "agent_message_chunk",
302+
content: {
303+
type: "text",
304+
text: `${text}\n\n`,
305+
},
306+
};
307+
}
308+
290309
private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent {
291310
return {
292311
sessionUpdate: "tool_call_update",

0 commit comments

Comments
 (0)