Skip to content

Commit d8a1d0e

Browse files
feat: add review command
1 parent 1d76251 commit d8a1d0e

14 files changed

Lines changed: 511 additions & 36 deletions

src/CodexAcpClient.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
GetAccountResponse,
2929
ListMcpServerStatusResponse,
3030
Model,
31+
ReviewTarget,
3132
SkillsListParams,
3233
SkillsListResponse,
3334
Thread,
@@ -474,6 +475,14 @@ export class CodexAcpClient {
474475
this.codexClient.markTurnStale(params.threadId, params.turnId);
475476
}
476477

478+
async runReview(threadId: string, target: ReviewTarget): Promise<TurnCompletedNotification> {
479+
return await this.codexClient.runReview({
480+
threadId,
481+
target,
482+
delivery: "inline",
483+
});
484+
}
485+
477486
async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {
478487
return this.codexClient.listSkills(params ?? {});
479488
}

src/CodexAcpServer.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1223,8 +1223,20 @@ export class CodexAcpServer implements acp.Agent {
12231223
approvalHandler,
12241224
elicitationHandler);
12251225

1226-
if (await this.availableCommands.tryHandleCommand(params.prompt, sessionState)) {
1226+
const commandResult = await this.availableCommands.tryHandleCommand(params.prompt, sessionState);
1227+
if (commandResult.handled) {
12271228
logger.log("Prompt handled by a command");
1229+
if (commandResult.turnCompleted) {
1230+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
1231+
if (commandResult.turnCompleted.turn.status === "interrupted") {
1232+
return await this.createInterruptedPromptResponse(params.sessionId, sessionState);
1233+
}
1234+
const error = eventHandler.getFailure()
1235+
if (error) {
1236+
// noinspection ExceptionCaughtLocallyJS
1237+
throw error;
1238+
}
1239+
}
12281240
return {
12291241
stopReason: "end_turn",
12301242
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
@@ -1302,23 +1314,7 @@ export class CodexAcpServer implements acp.Agent {
13021314

13031315
// Check if turn was interrupted (cancelled)
13041316
if (turnCompleted.turn.status === "interrupted") {
1305-
if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) {
1306-
await this.connection.sessionUpdate({
1307-
sessionId: params.sessionId,
1308-
update: {
1309-
sessionUpdate: "agent_message_chunk",
1310-
content: {
1311-
type: "text",
1312-
text: "*Conversation interrupted*"
1313-
}
1314-
}
1315-
});
1316-
}
1317-
return {
1318-
stopReason: "cancelled",
1319-
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
1320-
_meta: this.buildQuotaMeta(sessionState),
1321-
};
1317+
return await this.createInterruptedPromptResponse(params.sessionId, sessionState);
13221318
}
13231319

13241320
const error = eventHandler.getFailure()
@@ -1346,6 +1342,29 @@ export class CodexAcpServer implements acp.Agent {
13461342
}
13471343
}
13481344

1345+
private async createInterruptedPromptResponse(
1346+
sessionId: string,
1347+
sessionState: SessionState
1348+
): Promise<acp.PromptResponse> {
1349+
if (!this.sessionIsClosing(sessionId) && this.sessions.has(sessionId)) {
1350+
await this.connection.sessionUpdate({
1351+
sessionId,
1352+
update: {
1353+
sessionUpdate: "agent_message_chunk",
1354+
content: {
1355+
type: "text",
1356+
text: "*Conversation interrupted*"
1357+
}
1358+
}
1359+
});
1360+
}
1361+
return {
1362+
stopReason: "cancelled",
1363+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
1364+
_meta: this.buildQuotaMeta(sessionState),
1365+
};
1366+
}
1367+
13491368
private buildQuotaMeta(sessionState: SessionState): { quota: QuotaMeta } {
13501369
const lastTokenUsage = sessionState.lastTokenUsage;
13511370

src/CodexAppServerClient.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import type {
2121
McpServerStatusUpdatedNotification,
2222
ModelListParams,
2323
ModelListResponse,
24+
ReviewStartParams,
25+
ReviewStartResponse,
2426
SkillsExtraRootsSetParams,
2527
SkillsListParams,
2628
SkillsListResponse,
@@ -189,6 +191,10 @@ export class CodexAppServerClient {
189191
return await this.sendRequest({ method: "turn/start", params: params });
190192
}
191193

194+
async reviewStart(params: ReviewStartParams): Promise<ReviewStartResponse> {
195+
return await this.sendRequest({ method: "review/start", params: params });
196+
}
197+
192198
async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
193199
const capturedCompletions: Array<TurnCompletedNotification> = [];
194200
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
@@ -211,6 +217,31 @@ export class CodexAppServerClient {
211217
}
212218
}
213219

220+
async runReview(params: ReviewStartParams): Promise<TurnCompletedNotification> {
221+
if (params.delivery === "detached") {
222+
throw new Error("runReview only supports inline review delivery");
223+
}
224+
const capturedCompletions: Array<TurnCompletedNotification> = [];
225+
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
226+
capturedCompletions.push(event);
227+
});
228+
229+
try {
230+
const reviewStarted = await this.reviewStart(params);
231+
const earlyCompletion = capturedCompletions.find(event =>
232+
event.threadId === reviewStarted.reviewThreadId
233+
&& event.turn.id === reviewStarted.turn.id
234+
);
235+
releaseCapture();
236+
if (earlyCompletion) {
237+
return earlyCompletion;
238+
}
239+
return await this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id);
240+
} finally {
241+
releaseCapture();
242+
}
243+
}
244+
214245
async turnInterrupt(params: TurnInterruptParams): Promise<TurnInterruptResponse> {
215246
return await this.sendRequest({ method: "turn/interrupt", params: params });
216247
}

src/CodexCommands.ts

Lines changed: 55 additions & 16 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;
@@ -73,6 +77,11 @@ export class CodexCommands {
7377
description: "List configured Model Context Protocol (MCP) tools.",
7478
input: null
7579
},
80+
{
81+
name: "review",
82+
description: "Review uncommitted changes.",
83+
input: null
84+
},
7685
{
7786
name: "skills",
7887
description: "List available skills.",
@@ -91,7 +100,7 @@ export class CodexCommands {
91100
];
92101
}
93102

94-
private getCommandName(prompt: acp.ContentBlock[]): string | null {
103+
private parseCommand(prompt: acp.ContentBlock[]): ParsedCommand | null {
95104
const firstBlock = prompt[0];
96105
if (!firstBlock || firstBlock.type != "text") return null;
97106

@@ -101,25 +110,33 @@ export class CodexCommands {
101110
const commandText = text.slice(1).trim();
102111
if (commandText.length === 0) return null;
103112

104-
const [name] = commandText.split(/\s+/);
105-
return name?.toLowerCase() ?? null;
113+
const separatorIndex = commandText.search(/\s/);
114+
if (separatorIndex === -1) {
115+
return {name: commandText.toLowerCase(), input: null};
116+
}
117+
const name = commandText.slice(0, separatorIndex).toLowerCase();
118+
const input = commandText.slice(separatorIndex).trim();
119+
return {name, input: input.length > 0 ? input : null};
106120
}
107121

108-
async tryHandleCommand(prompt: acp.ContentBlock[], sessionState: SessionState): Promise<boolean> {
109-
const commandName = this.getCommandName(prompt);
110-
if (commandName === null) return false;
111-
if (commandName.startsWith("$")) return false;
122+
async tryHandleCommand(
123+
prompt: acp.ContentBlock[],
124+
sessionState: SessionState
125+
): Promise<CommandHandlingResult> {
126+
const command = this.parseCommand(prompt);
127+
if (command === null) return {handled: false};
128+
if (command.name.startsWith("$")) return {handled: false};
112129

113130
const sessionId = sessionState.sessionId;
114-
switch (commandName) {
131+
switch (command.name) {
115132
case "status": {
116133
const session = new ACPSessionConnection(this.connection, sessionId);
117134
const message = this.buildStatusMessage(sessionState);
118135
await session.update({
119136
sessionUpdate: "agent_message_chunk",
120137
content: { type: "text", text: message }
121138
});
122-
return true;
139+
return {handled: true};
123140
}
124141
case "logout": {
125142
await this.runWithProcessCheck(() => this.codexAcpClient.logout());
@@ -128,7 +145,7 @@ export class CodexCommands {
128145
sessionUpdate: "agent_message_chunk",
129146
content: { type: "text", text: "Logged out from Codex account." }
130147
});
131-
return true;
148+
return {handled: true};
132149
}
133150
case "skills": {
134151
const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills());
@@ -145,7 +162,7 @@ export class CodexCommands {
145162
sessionUpdate: "agent_message_chunk",
146163
content: { type: "text", text }
147164
});
148-
return true;
165+
return {handled: true};
149166
}
150167
case "mcp": {
151168
const servers = await this.runWithProcessCheck(() => this.codexAcpClient.listMcpServers());
@@ -166,14 +183,33 @@ export class CodexCommands {
166183
sessionUpdate: "agent_message_chunk",
167184
content: { type: "text", text }
168185
});
169-
return true;
186+
return {handled: true};
187+
}
188+
case "review": {
189+
if (command.input !== null) {
190+
await this.sendCommandMessage(sessionId, "/review does not accept arguments yet.");
191+
return {handled: true};
192+
}
193+
const target: ReviewTarget = {type: "uncommittedChanges"};
194+
const turnCompleted = await this.runWithProcessCheck(() =>
195+
this.codexAcpClient.runReview(sessionId, target)
196+
);
197+
return {handled: true, turnCompleted};
170198
}
171199
default:
172-
await this.sendUnknownCommandMessage(commandName, sessionId);
173-
return true;
200+
await this.sendUnknownCommandMessage(command.name, sessionId);
201+
return {handled: true};
174202
}
175203
}
176204

205+
private async sendCommandMessage(sessionId: string, text: string): Promise<void> {
206+
const session = new ACPSessionConnection(this.connection, sessionId);
207+
await session.update({
208+
sessionUpdate: "agent_message_chunk",
209+
content: {type: "text", text}
210+
});
211+
}
212+
177213
private async sendUnknownCommandMessage(name: string, sessionId: string): Promise<void> {
178214
const lines = this.getBuiltinCommands().map(command => `- /${command.name}: ${command.description}`);
179215
const text = [
@@ -343,4 +379,7 @@ export class CodexCommands {
343379
}
344380
}
345381

346-
type ParsedCommand = { name: string; };
382+
type ParsedCommand = {
383+
name: string;
384+
input: string | null;
385+
};

src/CodexEventHandler.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ export class CodexEventHandler {
255255
return await createMcpToolCallUpdate(event.item);
256256
case "dynamicToolCall":
257257
return await createDynamicToolCallUpdate(event.item);
258+
case "enteredReviewMode":
259+
return this.createReviewModeEvent(event.item, true);
258260
case "collabAgentToolCall":
259261
case "userMessage":
260262
case "hookPrompt":
@@ -263,7 +265,6 @@ export class CodexEventHandler {
263265
case "webSearch":
264266
case "imageView":
265267
case "imageGeneration":
266-
case "enteredReviewMode":
267268
case "exitedReviewMode":
268269
case "contextCompaction":
269270
case "plan":
@@ -308,13 +309,31 @@ export class CodexEventHandler {
308309
case "imageView":
309310
case "imageGeneration":
310311
case "enteredReviewMode":
312+
return null;
311313
case "exitedReviewMode":
314+
return this.createReviewModeEvent(event.item, false);
312315
case "contextCompaction":
313316
case "plan":
314317
return null;
315318
}
316319
}
317320

321+
private createReviewModeEvent(
322+
item: ThreadItem & { type: "enteredReviewMode" | "exitedReviewMode" },
323+
entered: boolean
324+
): UpdateSessionEvent {
325+
const text = entered
326+
? "Code review started: current changes"
327+
: `${item.review}\n\nCode review finished`;
328+
return {
329+
sessionUpdate: "agent_message_chunk",
330+
content: {
331+
type: "text",
332+
text: `${text}\n\n`,
333+
},
334+
};
335+
}
336+
318337
private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent {
319338
return {
320339
sessionUpdate: "tool_call_update",

0 commit comments

Comments
 (0)