Skip to content

Commit 019b3dc

Browse files
committed
feat: Add review and compact slash commands
1 parent f99ad4c commit 019b3dc

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,
@@ -274,6 +275,22 @@ export class CodexAcpClient {
274275
await this.codexClient.threadArchive({threadId: sessionId});
275276
}
276277

278+
async runReview(
279+
sessionId: string,
280+
target: ReviewTarget,
281+
onTurnStarted?: (turnId: string) => void,
282+
): Promise<TurnCompletedNotification> {
283+
return await this.codexClient.runReview({
284+
threadId: sessionId,
285+
target,
286+
delivery: "inline",
287+
}, onTurnStarted);
288+
}
289+
290+
async runCompact(sessionId: string): Promise<void> {
291+
await this.codexClient.runCompact({threadId: sessionId});
292+
}
293+
277294
async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
278295
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
279296
}

src/CodexAcpServer.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1259,8 +1259,34 @@ export class CodexAcpServer implements acp.Agent {
12591259
approvalHandler,
12601260
elicitationHandler);
12611261

1262-
if (await this.availableCommands.tryHandleCommand(params.prompt, sessionState)) {
1262+
const commandResult = await this.availableCommands.tryHandleCommand(params.prompt, sessionState);
1263+
if (commandResult.handled) {
12631264
logger.log("Prompt handled by a command");
1265+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
1266+
if (commandResult.turnCompleted?.turn.status === "interrupted") {
1267+
if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) {
1268+
await this.connection.sessionUpdate({
1269+
sessionId: params.sessionId,
1270+
update: {
1271+
sessionUpdate: "agent_message_chunk",
1272+
content: {
1273+
type: "text",
1274+
text: "*Conversation interrupted*"
1275+
}
1276+
}
1277+
});
1278+
}
1279+
return {
1280+
stopReason: "cancelled",
1281+
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
1282+
_meta: this.buildQuotaMeta(sessionState),
1283+
};
1284+
}
1285+
const error = eventHandler.getFailure()
1286+
if (error) {
1287+
// noinspection ExceptionCaughtLocallyJS
1288+
throw error;
1289+
}
12641290
return {
12651291
stopReason: "end_turn",
12661292
usage: this.buildPromptUsage(sessionState.lastTokenUsage),

src/CodexAppServerClient.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,15 @@ import type {
2121
McpServerStatusUpdatedNotification,
2222
ModelListParams,
2323
ModelListResponse,
24+
ReviewStartParams,
25+
ReviewStartResponse,
2426
SkillsExtraRootsSetParams,
2527
SkillsListParams,
2628
SkillsListResponse,
2729
ThreadArchiveParams,
2830
ThreadArchiveResponse,
31+
ThreadCompactStartParams,
32+
ThreadCompactStartResponse,
2933
ThreadLoadedListParams,
3034
ThreadLoadedListResponse,
3135
ThreadListParams,
@@ -47,6 +51,7 @@ import type {
4751
CommandExecutionRequestApprovalResponse,
4852
FileChangeRequestApprovalParams,
4953
FileChangeRequestApprovalResponse,
54+
ItemCompletedNotification,
5055
} from "./app-server/v2";
5156

5257
export interface ApprovalHandler {
@@ -99,6 +104,7 @@ export class CodexAppServerClient {
99104
private readonly mcpServerStartupStates = new Map<string, McpServerStartupSnapshot>();
100105
private readonly mcpServerStartupResolvers: Array<McpServerStartupResolver> = [];
101106
private readonly pendingTurnCompletionResolvers = new Map<string, Map<string, (event: TurnCompletedNotification) => void>>();
107+
private readonly pendingCompactionCompletionResolvers = new Map<string, Set<(event: CompactionCompletedNotification) => void>>();
102108
private readonly turnCompletionCaptures = new Map<string, Set<(event: TurnCompletedNotification) => void>>();
103109
private readonly staleTurnIds = new Map<string, Set<string>>();
104110

@@ -118,6 +124,9 @@ export class CodexAppServerClient {
118124
if (isTurnCompletedNotification(serverNotification)) {
119125
this.recordTurnCompleted(serverNotification.params);
120126
}
127+
if (isCompactionCompletedNotification(serverNotification)) {
128+
this.recordCompactionCompleted(serverNotification);
129+
}
121130
const routing = extractTurnRouting(serverNotification);
122131
const staleTurnNotification = this.isStaleTurn(routing.threadId, routing.turnId);
123132
if (staleTurnNotification) {
@@ -213,10 +222,40 @@ export class CodexAppServerClient {
213222
}
214223
}
215224

225+
async runReview(params: ReviewStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
226+
const capturedCompletions: Array<TurnCompletedNotification> = [];
227+
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
228+
capturedCompletions.push(event);
229+
});
230+
231+
try {
232+
const reviewStarted = await this.reviewStart(params);
233+
onTurnStarted?.(reviewStarted.turn.id);
234+
const earlyCompletion = capturedCompletions.find(event => event.turn.id === reviewStarted.turn.id);
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+
245+
async runCompact(params: ThreadCompactStartParams): Promise<CompactionCompletedNotification> {
246+
const compactionCompleted = this.awaitCompactionCompleted(params.threadId);
247+
await this.threadCompactStart(params);
248+
return await compactionCompleted;
249+
}
250+
216251
async turnInterrupt(params: TurnInterruptParams): Promise<TurnInterruptResponse> {
217252
return await this.sendRequest({ method: "turn/interrupt", params: params });
218253
}
219254

255+
async reviewStart(params: ReviewStartParams): Promise<ReviewStartResponse> {
256+
return await this.sendRequest({ method: "review/start", params: params });
257+
}
258+
220259
markTurnStale(threadId: string, turnId: string): void {
221260
const threadStaleTurns = this.staleTurnIds.get(threadId) ?? new Set<string>();
222261
threadStaleTurns.add(turnId);
@@ -251,6 +290,10 @@ export class CodexAppServerClient {
251290
return await this.sendRequest({ method: "thread/unsubscribe", params: params });
252291
}
253292

293+
async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
294+
return await this.sendRequest({ method: "thread/compact/start", params: params });
295+
}
296+
254297
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
255298
return await this.sendRequest({ method: "mcpServerStatus/list", params });
256299
}
@@ -303,6 +346,14 @@ export class CodexAppServerClient {
303346
});
304347
}
305348

349+
async awaitCompactionCompleted(threadId: string): Promise<CompactionCompletedNotification> {
350+
return await new Promise((resolve) => {
351+
const resolvers = this.pendingCompactionCompletionResolvers.get(threadId) ?? new Set();
352+
resolvers.add(resolve);
353+
this.pendingCompactionCompletionResolvers.set(threadId, resolvers);
354+
});
355+
}
356+
306357
resolveTurnInterrupted(threadId: string, turnId: string): void {
307358
this.recordTurnCompleted({
308359
threadId,
@@ -380,6 +431,21 @@ export class CodexAppServerClient {
380431
}
381432
}
382433

434+
private recordCompactionCompleted(event: CompactionCompletedNotification): void {
435+
const threadId = extractThreadId(event);
436+
if (threadId === null) {
437+
return;
438+
}
439+
const resolvers = this.pendingCompactionCompletionResolvers.get(threadId);
440+
if (!resolvers) {
441+
return;
442+
}
443+
this.pendingCompactionCompletionResolvers.delete(threadId);
444+
for (const resolve of resolvers) {
445+
resolve(event);
446+
}
447+
}
448+
383449
private isStaleTurn(threadId: string | null, turnId: string | null): boolean {
384450
if (threadId === null || turnId === null) {
385451
return false;
@@ -493,6 +559,10 @@ export type CodexConnectionEvent =
493559
| ({ eventType: "response" } & unknown)
494560
| ({ eventType: "notification" } & ServerNotification);
495561

562+
export type CompactionCompletedNotification =
563+
| { method: "thread/compacted", params: Extract<ServerNotification, { method: "thread/compacted" }>["params"] }
564+
| { method: "item/completed", params: ItemCompletedNotification & { item: Extract<ItemCompletedNotification["item"], { type: "contextCompaction" }> } };
565+
496566
type CodexRequest = DistributiveOmit<ClientRequest, "id">
497567

498568
type DistributiveOmit<T, K extends keyof any> = T extends any
@@ -525,6 +595,13 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica
525595
return notification.method === "turn/completed";
526596
}
527597

598+
function isCompactionCompletedNotification(notification: ServerNotification): notification is CompactionCompletedNotification {
599+
if (notification.method === "thread/compacted") {
600+
return true;
601+
}
602+
return notification.method === "item/completed" && notification.params.item.type === "contextCompaction";
603+
}
604+
528605
function extractThreadId(notification: ServerNotification): string | null {
529606
const params = notification.params as { threadId?: unknown } | undefined;
530607
if (params && typeof params.threadId === "string") {

0 commit comments

Comments
 (0)