diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0f873e4b..0096344d 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -270,6 +270,10 @@ export class CodexAcpClient { } } + async compactSession(sessionId: string): Promise { + await this.codexClient.runThreadCompact({threadId: sessionId}); + } + async awaitMcpServerStartup(serverNames: Array, afterVersion: number): Promise { return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion); } diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index d5145346..c9c007cb 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -28,6 +28,8 @@ import type { ThreadLoadedListResponse, ThreadListParams, ThreadListResponse, + ThreadCompactStartParams, + ThreadCompactStartResponse, ThreadReadParams, ThreadReadResponse, ThreadResumeParams, @@ -99,6 +101,7 @@ export class CodexAppServerClient { private readonly pendingTurnCompletionResolvers = new Map void>>(); private readonly turnCompletionCaptures = new Map void>>(); private readonly staleTurnIds = new Map>(); + private readonly pendingCompactResolvers = new Map>(); constructor(connection: MessageConnection) { this.connection = connection; @@ -116,6 +119,7 @@ export class CodexAppServerClient { if (isTurnCompletedNotification(serverNotification)) { this.recordTurnCompleted(serverNotification.params); } + this.recordCompactCompletion(serverNotification); const routing = extractTurnRouting(serverNotification); const staleTurnNotification = this.isStaleTurn(routing.threadId, routing.turnId); if (staleTurnNotification) { @@ -237,6 +241,21 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "thread/loaded/list", params: params }); } + async threadCompactStart(params: ThreadCompactStartParams): Promise { + return await this.sendRequest({ method: "thread/compact/start", params }); + } + + async runThreadCompact(params: ThreadCompactStartParams): Promise { + const completion = this.awaitCompactCompleted(params.threadId); + try { + await this.threadCompactStart(params); + await completion.promise; + } catch (err) { + completion.dispose(); + throw err; + } + } + async threadRead(params: ThreadReadParams): Promise { return await this.sendRequest({ method: "thread/read", params: params }); } @@ -374,6 +393,52 @@ export class CodexAppServerClient { } } + private awaitCompactCompleted(threadId: string): { promise: Promise; dispose: () => void } { + let resolver: CompactCompletionResolver | null = null; + const promise = new Promise((resolve, reject) => { + resolver = { resolve, reject }; + const resolvers = this.pendingCompactResolvers.get(threadId) ?? new Set(); + resolvers.add(resolver); + this.pendingCompactResolvers.set(threadId, resolvers); + }); + + return { + promise, + dispose: () => { + if (resolver === null) { + return; + } + const resolvers = this.pendingCompactResolvers.get(threadId); + resolvers?.delete(resolver); + if (resolvers?.size === 0) { + this.pendingCompactResolvers.delete(threadId); + } + resolver = null; + }, + }; + } + + private recordCompactCompletion(notification: ServerNotification): void { + const compactResult = getCompactCompletionResult(notification); + if (compactResult === null) { + return; + } + + const resolvers = this.pendingCompactResolvers.get(compactResult.threadId); + if (!resolvers) { + return; + } + this.pendingCompactResolvers.delete(compactResult.threadId); + + for (const resolver of resolvers) { + if (compactResult.error) { + resolver.reject(compactResult.error); + } else { + resolver.resolve(); + } + } + } + private isStaleTurn(threadId: string | null, turnId: string | null): boolean { if (threadId === null || turnId === null) { return false; @@ -505,6 +570,16 @@ type McpServerStartupResolver = { resolve: (result: McpStartupResult) => void; }; +type CompactCompletionResolver = { + resolve: () => void; + reject: (error: Error) => void; +}; + +type CompactCompletionResult = { + threadId: string; + error: Error | null; +}; + function isMcpServerStatusUpdatedNotification(notification: ServerNotification): notification is { method: "mcpServer/startupStatus/updated"; params: McpServerStatusUpdatedNotification; @@ -519,6 +594,45 @@ function isTurnCompletedNotification(notification: ServerNotification): notifica return notification.method === "turn/completed"; } +function getCompactCompletionResult(notification: ServerNotification): CompactCompletionResult | null { + switch (notification.method) { + case "thread/compacted": + return { + threadId: notification.params.threadId, + error: null, + }; + case "item/completed": + if (notification.params.item.type !== "contextCompaction") { + return null; + } + return { + threadId: notification.params.threadId, + error: null, + }; + case "turn/completed": + if (!notification.params.turn.items.some(item => item.type === "contextCompaction")) { + return null; + } + if (notification.params.turn.status === "failed") { + return { + threadId: notification.params.threadId, + error: new Error(notification.params.turn.error?.message ?? "Context compaction failed"), + }; + } + return { + threadId: notification.params.threadId, + error: null, + }; + case "error": + return { + threadId: notification.params.threadId, + error: new Error(notification.params.error.message), + }; + default: + return null; + } +} + function extractThreadId(notification: ServerNotification): string | null { const params = notification.params as { threadId?: unknown } | undefined; if (params && typeof params.threadId === "string") { diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index a4ca15e4..929bdc37 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -83,6 +83,11 @@ export class CodexCommands { description: "Display session configuration and token usage.", input: null }, + { + name: "compact", + description: "Summarize the conversation to free tokens.", + input: null + }, { name: "logout", description: "Sign out of Codex. This option is available when you are logged in via ChatGPT.", @@ -130,6 +135,16 @@ export class CodexCommands { }); return true; } + case "compact": { + const session = new ACPSessionConnection(this.connection, sessionId); + await session.update({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Compacting context...\n\n" } + }); + await this.runWithProcessCheck(() => this.codexAcpClient.compactSession(sessionId)); + await this.codexAcpClient.waitForSessionNotifications(sessionId); + return true; + } case "skills": { const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSkills()); const skills = (response?.data ?? []).flatMap(entry => entry.skills); diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 70c31314..0310b282 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -265,9 +265,10 @@ export class CodexEventHandler { case "imageGeneration": case "enteredReviewMode": case "exitedReviewMode": - case "contextCompaction": case "plan": return null; + case "contextCompaction": + return this.createContextCompactionUpdate(event.item); } } @@ -309,12 +310,34 @@ export class CodexEventHandler { case "imageGeneration": case "enteredReviewMode": case "exitedReviewMode": - case "contextCompaction": case "plan": return null; + case "contextCompaction": + return { + sessionUpdate: "tool_call_update", + toolCallId: event.item.id, + status: "completed", + content: [{ + type: "content", + content: { + type: "text", + text: "Context compacted.", + }, + }], + }; } } + private createContextCompactionUpdate(item: ThreadItem & { type: "contextCompaction" }): UpdateSessionEvent { + return { + sessionUpdate: "tool_call", + toolCallId: item.id, + kind: "other", + title: "Compact context", + status: "in_progress", + }; + } + private createCommandOutputDeltaEvent(event: CommandExecutionOutputDeltaNotification): UpdateSessionEvent { return { sessionUpdate: "tool_call_update", diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 355e330a..43d0419e 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -829,6 +829,55 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(fixture.getAcpConnectionDump(["sessionId"])).toMatchFileSnapshot("data/command-logout.json"); }); + it('handles compact command', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const sessionState: SessionState = createTestSessionState(); + + vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(sessionState); + + const promptPromise = codexAcpAgent.prompt({ + sessionId: sessionState.sessionId, + prompt: [{ type: "text", text: "/compact " }], + }); + + await vi.waitFor(() => { + expect(mockFixture.getCodexConnectionEvents([])).toContainEqual({ + eventType: "request", + method: "thread/compact/start", + params: { threadId: sessionState.sessionId }, + }); + }); + let settled = false; + void promptPromise.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + mockFixture.sendServerNotification({ + method: "item/started", + params: { + threadId: sessionState.sessionId, + turnId: "compact-turn-id", + startedAtMs: 0, + item: { type: "contextCompaction", id: "compact-item-id" }, + }, + }); + mockFixture.sendServerNotification({ + method: "item/completed", + params: { + threadId: sessionState.sessionId, + turnId: "compact-turn-id", + completedAtMs: 1, + item: { type: "contextCompaction", id: "compact-item-id" }, + }, + }); + + await expect(promptPromise).resolves.toMatchObject({ stopReason: "end_turn" }); + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/command-compact.json"); + }); + it('handles skills command', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 42999913..fd5e8a1c 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -21,6 +21,11 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "compact", + "description": "Summarize the conversation to free tokens.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index cc64ab60..43a060b8 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -21,6 +21,11 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "compact", + "description": "Summarize the conversation to free tokens.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.", diff --git a/src/__tests__/CodexACPAgent/data/command-compact.json b/src/__tests__/CodexACPAgent/data/command-compact.json new file mode 100644 index 00000000..2357f32e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/command-compact.json @@ -0,0 +1,52 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-id", + "update": { + "sessionUpdate": "agent_message_chunk", + "content": { + "type": "text", + "text": "Compacting context...\n\n" + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "compact-item-id", + "kind": "other", + "title": "Compact context", + "status": "in_progress" + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "compact-item-id", + "status": "completed", + "content": [ + { + "type": "content", + "content": { + "type": "text", + "text": "Context compacted." + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 8e962238..e8eb78a3 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -21,6 +21,11 @@ "description": "Display session configuration and token usage.", "input": null }, + { + "name": "compact", + "description": "Summarize the conversation to free tokens.", + "input": null + }, { "name": "logout", "description": "Sign out of Codex. This option is available when you are logged in via ChatGPT.",