Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,10 @@ export class CodexAcpClient {
}
}

async compactSession(sessionId: string): Promise<void> {
await this.codexClient.runThreadCompact({threadId: sessionId});
}

async awaitMcpServerStartup(serverNames: Array<string>, afterVersion: number): Promise<McpStartupResult> {
return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion);
}
Expand Down
114 changes: 114 additions & 0 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import type {
ThreadLoadedListResponse,
ThreadListParams,
ThreadListResponse,
ThreadCompactStartParams,
ThreadCompactStartResponse,
ThreadReadParams,
ThreadReadResponse,
ThreadResumeParams,
Expand Down Expand Up @@ -99,6 +101,7 @@ export class CodexAppServerClient {
private readonly pendingTurnCompletionResolvers = new Map<string, Map<string, (event: TurnCompletedNotification) => void>>();
private readonly turnCompletionCaptures = new Map<string, Set<(event: TurnCompletedNotification) => void>>();
private readonly staleTurnIds = new Map<string, Set<string>>();
private readonly pendingCompactResolvers = new Map<string, Set<CompactCompletionResolver>>();

constructor(connection: MessageConnection) {
this.connection = connection;
Expand All @@ -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) {
Expand Down Expand Up @@ -237,6 +241,21 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "thread/loaded/list", params: params });
}

async threadCompactStart(params: ThreadCompactStartParams): Promise<ThreadCompactStartResponse> {
return await this.sendRequest({ method: "thread/compact/start", params });
}

async runThreadCompact(params: ThreadCompactStartParams): Promise<void> {
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<ThreadReadResponse> {
return await this.sendRequest({ method: "thread/read", params: params });
}
Expand Down Expand Up @@ -374,6 +393,52 @@ export class CodexAppServerClient {
}
}

private awaitCompactCompleted(threadId: string): { promise: Promise<void>; dispose: () => void } {
let resolver: CompactCompletionResolver | null = null;
const promise = new Promise<void>((resolve, reject) => {
resolver = { resolve, reject };
const resolvers = this.pendingCompactResolvers.get(threadId) ?? new Set<CompactCompletionResolver>();
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;
Expand Down Expand Up @@ -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;
Expand All @@ -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") {
Expand Down
15 changes: 15 additions & 0 deletions src/CodexCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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",
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
52 changes: 52 additions & 0 deletions src/__tests__/CodexACPAgent/data/command-compact.json
Original file line number Diff line number Diff line change
@@ -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."
}
}
]
}
}
]
}
5 changes: 5 additions & 0 deletions src/__tests__/CodexACPAgent/data/load-session-history.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down