Skip to content

Commit 7b6100f

Browse files
committed
Add session close support and cleanup handling
Advertise ACP close capability and implement `unstable_closeSession` to interrupt active turns, unsubscribe threads, remove per-session listeners, and forget session state.
1 parent 6b9ed8d commit 7b6100f

5 files changed

Lines changed: 183 additions & 1 deletion

File tree

src/CodexAcpClient.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,28 @@ export class CodexAcpClient {
370370
this.codexClient.onElicitationRequest(sessionId, elicitationHandler);
371371
}
372372

373+
unsubscribeFromSessionEvents(sessionId: string): void {
374+
this.codexClient.removeServerNotification(sessionId);
375+
this.codexClient.removeApprovalRequest(sessionId);
376+
this.codexClient.removeElicitationRequest(sessionId);
377+
}
378+
379+
async closeSession(sessionId: string, currentTurnId: string | null): Promise<void> {
380+
if (currentTurnId) {
381+
try {
382+
await this.codexClient.turnInterrupt({
383+
threadId: sessionId,
384+
turnId: currentTurnId,
385+
});
386+
} catch (err) {
387+
logger.error(`Failed to interrupt turn while closing session ${sessionId}`, err);
388+
}
389+
}
390+
391+
await this.codexClient.threadUnsubscribe({threadId: sessionId});
392+
this.unsubscribeFromSessionEvents(sessionId);
393+
}
394+
373395
async sendPrompt(
374396
request: acp.PromptRequest,
375397
agentMode: AgentMode,

src/CodexAcpServer.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export class CodexAcpServer implements acp.Agent {
101101
image: true
102102
},
103103
sessionCapabilities: {
104+
close: {},
104105
resume: { },
105106
list: { }
106107
},
@@ -238,6 +239,19 @@ export class CodexAcpServer implements acp.Agent {
238239
return await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params));
239240
}
240241

242+
async unstable_closeSession(params: acp.CloseSessionRequest): Promise<acp.CloseSessionResponse> {
243+
logger.log("Closing session...", {sessionId: params.sessionId});
244+
const sessionState = this.getSessionState(params.sessionId);
245+
246+
await this.runWithProcessCheck(() =>
247+
this.codexAcpClient.closeSession(params.sessionId, sessionState.currentTurnId)
248+
);
249+
this.forgetSession(params.sessionId);
250+
251+
logger.log("Session closed", {sessionId: params.sessionId});
252+
return {};
253+
}
254+
241255
async newSession(
242256
params: acp.NewSessionRequest,
243257
): Promise<acp.NewSessionResponse> {
@@ -641,6 +655,11 @@ export class CodexAcpServer implements acp.Agent {
641655
return sessionState;
642656
}
643657

658+
private forgetSession(sessionId: string): void {
659+
this.sessions.delete(sessionId);
660+
this.pendingMcpStartupSessions.delete(sessionId);
661+
}
662+
644663
private resolveSessionMcpServers(
645664
mcpServers: Array<acp.McpServer>,
646665
recoverFromStartup: boolean,
@@ -678,7 +697,16 @@ export class CodexAcpServer implements acp.Agent {
678697
pendingStartup.afterVersion,
679698
)
680699
);
681-
await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.requestedServers);
700+
const sessionState = this.sessions.get(sessionId);
701+
const pendingStartup = this.pendingMcpStartupSessions.get(sessionId);
702+
if (sessionState && pendingStartup) {
703+
sessionState.sessionMcpServers = mcpStartup.ready.filter(serverName =>
704+
pendingStartup.requestedServers.has(serverName)
705+
);
706+
await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.requestedServers);
707+
} else {
708+
logger.log("Skipping MCP startup status for closed session", {sessionId});
709+
}
682710
} catch (err) {
683711
logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err);
684712
} finally {

src/CodexAppServerClient.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import type {
3434
ThreadResumeResponse,
3535
ThreadStartParams,
3636
ThreadStartResponse,
37+
ThreadUnsubscribeParams,
38+
ThreadUnsubscribeResponse,
3739
TurnCompletedNotification,
3840
TurnInterruptParams,
3941
TurnInterruptResponse,
@@ -143,10 +145,18 @@ export class CodexAppServerClient {
143145
this.approvalHandlers.set(threadId, handler);
144146
}
145147

148+
removeApprovalRequest(threadId: string): void {
149+
this.approvalHandlers.delete(threadId);
150+
}
151+
146152
onElicitationRequest(threadId: string, handler: ElicitationHandler): void {
147153
this.elicitationHandlers.set(threadId, handler);
148154
}
149155

156+
removeElicitationRequest(threadId: string): void {
157+
this.elicitationHandlers.delete(threadId);
158+
}
159+
150160
async initialize(params: InitializeParams): Promise<InitializeResponse> {
151161
return await this.sendRequest({ method: "initialize", params: params });
152162
}
@@ -179,6 +189,10 @@ export class CodexAppServerClient {
179189
return await this.sendRequest({ method: "thread/read", params: params });
180190
}
181191

192+
async threadUnsubscribe(params: ThreadUnsubscribeParams): Promise<ThreadUnsubscribeResponse> {
193+
return await this.sendRequest({ method: "thread/unsubscribe", params });
194+
}
195+
182196
async listMcpServerStatus(params: ListMcpServerStatusParams): Promise<ListMcpServerStatusResponse> {
183197
return await this.sendRequest({ method: "mcpServerStatus/list", params });
184198
}
@@ -248,6 +262,10 @@ export class CodexAppServerClient {
248262
this.notificationHandlers.set(sessionId, callback);
249263
}
250264

265+
removeServerNotification(sessionId: string): void {
266+
this.notificationHandlers.delete(sessionId);
267+
}
268+
251269
private codexEventHandlers: Array<(event: CodexConnectionEvent) => void> = [];
252270
onClientTransportEvent(callback: (event: CodexConnectionEvent) => void){
253271
this.codexEventHandlers.push(callback);

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,4 +915,117 @@ describe('ACP server test', { timeout: 40_000 }, () => {
915915
}
916916
});
917917
});
918+
919+
it('closes an active session by interrupting the turn, unsubscribing the thread, and removing listeners', async () => {
920+
const mockFixture = createCodexMockTestFixture();
921+
const codexAcpClient = mockFixture.getCodexAcpClient();
922+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
923+
924+
await codexAcpClient.subscribeToSessionEvents(
925+
"session-close",
926+
vi.fn(),
927+
{
928+
handleCommandExecution: vi.fn(),
929+
handleFileChange: vi.fn(),
930+
},
931+
{
932+
handleElicitation: vi.fn(),
933+
}
934+
);
935+
936+
// @ts-expect-error verifying test-only access to private maps
937+
expect(codexAppServerClient.notificationHandlers.has("session-close")).toBe(true);
938+
// @ts-expect-error verifying test-only access to private maps
939+
expect(codexAppServerClient.approvalHandlers.has("session-close")).toBe(true);
940+
// @ts-expect-error verifying test-only access to private maps
941+
expect(codexAppServerClient.elicitationHandlers.has("session-close")).toBe(true);
942+
943+
await codexAcpClient.closeSession("session-close", "turn-close");
944+
945+
const closeRequests = mockFixture.getCodexConnectionEvents([])
946+
.filter((event) => event.eventType === "request");
947+
expect(closeRequests).toEqual([
948+
{
949+
eventType: "request",
950+
method: "turn/interrupt",
951+
params: {threadId: "session-close", turnId: "turn-close"},
952+
},
953+
{
954+
eventType: "request",
955+
method: "thread/unsubscribe",
956+
params: {threadId: "session-close"},
957+
},
958+
]);
959+
960+
// @ts-expect-error verifying test-only access to private maps
961+
expect(codexAppServerClient.notificationHandlers.has("session-close")).toBe(false);
962+
// @ts-expect-error verifying test-only access to private maps
963+
expect(codexAppServerClient.approvalHandlers.has("session-close")).toBe(false);
964+
// @ts-expect-error verifying test-only access to private maps
965+
expect(codexAppServerClient.elicitationHandlers.has("session-close")).toBe(false);
966+
});
967+
968+
it('removes session bookkeeping after unstable_closeSession', async () => {
969+
const mockFixture = createCodexMockTestFixture();
970+
const codexAcpAgent = mockFixture.getCodexAcpAgent();
971+
const codexAcpClient = mockFixture.getCodexAcpClient();
972+
const sessionState = createTestSessionState({
973+
sessionId: "session-close",
974+
currentTurnId: "turn-close",
975+
});
976+
const closeSpy = vi.spyOn(codexAcpClient, "closeSession").mockResolvedValue();
977+
978+
// @ts-expect-error seeding private session store for focused close-session test
979+
codexAcpAgent.sessions.set(sessionState.sessionId, sessionState);
980+
// @ts-expect-error seeding private session store for focused close-session test
981+
codexAcpAgent.pendingMcpStartupSessions.set(sessionState.sessionId, {
982+
requestedServers: new Set(["alpha"]),
983+
});
984+
985+
await expect(
986+
codexAcpAgent.unstable_closeSession({sessionId: sessionState.sessionId})
987+
).resolves.toEqual({});
988+
expect(closeSpy).toHaveBeenCalledWith(sessionState.sessionId, sessionState.currentTurnId);
989+
expect(() => codexAcpAgent.getSessionState(sessionState.sessionId)).toThrow(
990+
`Session ${sessionState.sessionId} not found`
991+
);
992+
// @ts-expect-error verifying test-only access to private map
993+
expect(codexAcpAgent.pendingMcpStartupSessions.has(sessionState.sessionId)).toBe(false);
994+
});
995+
996+
it('skips late MCP startup updates after a session is closed', async () => {
997+
const mockFixture = createCodexMockTestFixture();
998+
const codexAcpAgent = mockFixture.getCodexAcpAgent();
999+
const codexAcpClient = mockFixture.getCodexAcpClient();
1000+
const sessionState = createTestSessionState({
1001+
sessionId: "session-close",
1002+
});
1003+
1004+
let resolveStartup!: (event: any) => void;
1005+
const startupPromise = new Promise((resolve) => {
1006+
resolveStartup = resolve;
1007+
});
1008+
vi.spyOn(codexAcpClient, "awaitMcpStartupResult").mockReturnValue(startupPromise as Promise<any>);
1009+
1010+
// @ts-expect-error seeding private session store for focused close-session test
1011+
codexAcpAgent.sessions.set(sessionState.sessionId, sessionState);
1012+
// @ts-expect-error seeding private session store for focused close-session test
1013+
codexAcpAgent.pendingMcpStartupSessions.set(sessionState.sessionId, {
1014+
requestedServers: new Set(["alpha"]),
1015+
});
1016+
1017+
// @ts-expect-error exercising private helper to verify close-session race handling
1018+
const publishPromise = codexAcpAgent.doPublishMcpStartupStatus(sessionState.sessionId, 1);
1019+
// @ts-expect-error exercising private helper to simulate close-session cleanup
1020+
codexAcpAgent.forgetSession(sessionState.sessionId);
1021+
1022+
resolveStartup({
1023+
ready: ["alpha"],
1024+
failed: [],
1025+
cancelled: [],
1026+
});
1027+
await publishPromise;
1028+
1029+
expect(mockFixture.getAcpConnectionEvents([])).toEqual([]);
1030+
});
9181031
});

src/__tests__/CodexACPAgent/initialize.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ describe('CodexACPAgent - initialize', () => {
3737
image: true
3838
},
3939
sessionCapabilities: {
40+
close: {},
4041
resume: {},
4142
list: {},
4243
},

0 commit comments

Comments
 (0)