Skip to content

Commit 0beb4d7

Browse files
committed
feat: Add review and compact slash commands
1 parent 7e18de9 commit 0beb4d7

9 files changed

Lines changed: 575 additions & 23 deletions

src/CodexAcpClient.ts

Lines changed: 17 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,
@@ -270,6 +271,22 @@ export class CodexAcpClient {
270271
}
271272
}
272273

274+
async runReview(
275+
sessionId: string,
276+
target: ReviewTarget,
277+
onTurnStarted?: (turnId: string) => void,
278+
): Promise<TurnCompletedNotification> {
279+
return await this.codexClient.runReview({
280+
threadId: sessionId,
281+
target,
282+
delivery: "inline",
283+
}, onTurnStarted);
284+
}
285+
286+
async runCompact(sessionId: string): Promise<void> {
287+
await this.codexClient.runCompact({threadId: sessionId});
288+
}
289+
273290
async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
274291
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
275292
}

src/CodexAcpServer.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1223,8 +1223,34 @@ 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+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
1230+
if (commandResult.turnCompleted?.turn.status === "interrupted") {
1231+
if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) {
1232+
await this.connection.sessionUpdate({
1233+
sessionId: params.sessionId,
1234+
update: {
1235+
sessionUpdate: "agent_message_chunk",
1236+
content: {
1237+
type: "text",
1238+
text: "*Conversation interrupted*"
1239+
}
1240+
}
1241+
});
1242+
}
1243+
return {
1244+
stopReason: "cancelled",
1245+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
1246+
_meta: this.buildQuotaMeta(sessionState),
1247+
};
1248+
}
1249+
const error = eventHandler.getFailure()
1250+
if (error) {
1251+
// noinspection ExceptionCaughtLocallyJS
1252+
throw error;
1253+
}
12281254
return {
12291255
stopReason: "end_turn",
12301256
usage: this.buildPromptUsage(sessionState.lastTokenUsage),

src/CodexAppServerClient.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ import type {
2121
McpServerStatusUpdatedNotification,
2222
ModelListParams,
2323
ModelListResponse,
24+
ReviewStartParams,
25+
ReviewStartResponse,
2426
SkillsListParams,
2527
SkillsListResponse,
28+
ThreadCompactStartParams,
29+
ThreadCompactStartResponse,
2630
ThreadLoadedListParams,
2731
ThreadLoadedListResponse,
2832
ThreadListParams,
@@ -44,6 +48,7 @@ import type {
4448
CommandExecutionRequestApprovalResponse,
4549
FileChangeRequestApprovalParams,
4650
FileChangeRequestApprovalResponse,
51+
ItemCompletedNotification,
4752
} from "./app-server/v2";
4853

4954
export interface ApprovalHandler {
@@ -96,6 +101,7 @@ export class CodexAppServerClient {
96101
private readonly mcpServerStartupStates = new Map<string, McpServerStartupSnapshot>();
97102
private readonly mcpServerStartupResolvers: Array<McpServerStartupResolver> = [];
98103
private readonly pendingTurnCompletionResolvers = new Map<string, Map<string, (event: TurnCompletedNotification) => void>>();
104+
private readonly pendingCompactionCompletionResolvers = new Map<string, Set<(event: CompactionCompletedNotification) => void>>();
99105
private readonly turnCompletionCaptures = new Map<string, Set<(event: TurnCompletedNotification) => void>>();
100106
private readonly staleTurnIds = new Map<string, Set<string>>();
101107

@@ -115,6 +121,9 @@ export class CodexAppServerClient {
115121
if (isTurnCompletedNotification(serverNotification)) {
116122
this.recordTurnCompleted(serverNotification.params);
117123
}
124+
if (isCompactionCompletedNotification(serverNotification)) {
125+
this.recordCompactionCompleted(serverNotification);
126+
}
118127
const routing = extractTurnRouting(serverNotification);
119128
const staleTurnNotification = this.isStaleTurn(routing.threadId, routing.turnId);
120129
if (staleTurnNotification) {
@@ -210,10 +219,40 @@ export class CodexAppServerClient {
210219
}
211220
}
212221

222+
async runReview(params: ReviewStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
223+
const capturedCompletions: Array<TurnCompletedNotification> = [];
224+
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
225+
capturedCompletions.push(event);
226+
});
227+
228+
try {
229+
const reviewStarted = await this.reviewStart(params);
230+
onTurnStarted?.(reviewStarted.turn.id);
231+
const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id);
232+
releaseCapture();
233+
if (earlyCompletion) {
234+
return earlyCompletion;
235+
}
236+
return await this.awaitTurnCompleted(reviewStarted.reviewThreadId, reviewStarted.turn.id);
237+
} finally {
238+
releaseCapture();
239+
}
240+
}
241+
242+
async runCompact(params: ThreadCompactStartParams): Promise<CompactionCompletedNotification> {
243+
const compactionCompleted = this.awaitCompactionCompleted(params.threadId);
244+
await this.threadCompactStart(params);
245+
return await compactionCompleted;
246+
}
247+
213248
async turnInterrupt(params: TurnInterruptParams): Promise<TurnInterruptResponse> {
214249
return await this.sendRequest({ method: "turn/interrupt", params: params });
215250
}
216251

252+
async reviewStart(params: ReviewStartParams): Promise<ReviewStartResponse> {
253+
return await this.sendRequest({ method: "review/start", params: params });
254+
}
255+
217256
markTurnStale(threadId: string, turnId: string): void {
218257
const threadStaleTurns = this.staleTurnIds.get(threadId) ?? new Set<string>();
219258
threadStaleTurns.add(turnId);
@@ -244,6 +283,10 @@ export class CodexAppServerClient {
244283
return await this.sendRequest({ method: "thread/unsubscribe", params: params });
245284
}
246285

286+
async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
287+
return await this.sendRequest({ method: "thread/compact/start", params: params });
288+
}
289+
247290
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
248291
return await this.sendRequest({ method: "mcpServerStatus/list", params });
249292
}
@@ -296,6 +339,14 @@ export class CodexAppServerClient {
296339
});
297340
}
298341

342+
async awaitCompactionCompleted(threadId: string): Promise<CompactionCompletedNotification> {
343+
return await new Promise((resolve) => {
344+
const resolvers = this.pendingCompactionCompletionResolvers.get(threadId) ?? new Set();
345+
resolvers.add(resolve);
346+
this.pendingCompactionCompletionResolvers.set(threadId, resolvers);
347+
});
348+
}
349+
299350
resolveTurnInterrupted(threadId: string, turnId: string): void {
300351
this.recordTurnCompleted({
301352
threadId,
@@ -368,6 +419,21 @@ export class CodexAppServerClient {
368419
}
369420
}
370421

422+
private recordCompactionCompleted(event: CompactionCompletedNotification): void {
423+
const threadId = extractThreadId(event);
424+
if (threadId === null) {
425+
return;
426+
}
427+
const resolvers = this.pendingCompactionCompletionResolvers.get(threadId);
428+
if (!resolvers) {
429+
return;
430+
}
431+
this.pendingCompactionCompletionResolvers.delete(threadId);
432+
for (const resolve of resolvers) {
433+
resolve(event);
434+
}
435+
}
436+
371437
private isStaleTurn(threadId: string | null, turnId: string | null): boolean {
372438
if (threadId === null || turnId === null) {
373439
return false;
@@ -481,6 +547,10 @@ export type CodexConnectionEvent =
481547
| ({ eventType: "response" } & unknown)
482548
| ({ eventType: "notification" } & ServerNotification);
483549

550+
export type CompactionCompletedNotification =
551+
| { method: "thread/compacted", params: Extract<ServerNotification, { method: "thread/compacted" }>["params"] }
552+
| { method: "item/completed", params: ItemCompletedNotification & { item: Extract<ItemCompletedNotification["item"], { type: "contextCompaction" }> } };
553+
484554
type CodexRequest = DistributiveOmit<ClientRequest, "id">
485555

486556
type DistributiveOmit<T, K extends keyof any> = T extends any
@@ -513,6 +583,13 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica
513583
return notification.method === "turn/completed";
514584
}
515585

586+
function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification {
587+
if (notification.method === "thread/compacted") {
588+
return true;
589+
}
590+
return notification.method === "item/completed" && notification.params.item.type === "contextCompaction";
591+
}
592+
516593
function extractThreadId(notification: ServerNotification): string | null {
517594
const params = notification.params as { threadId?: unknown } | undefined;
518595
if (params && typeof params.threadId === "string") {

0 commit comments

Comments
 (0)