Skip to content

Commit b7d0987

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 30a61f6 commit b7d0987

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
@@ -375,6 +375,28 @@ export class CodexAcpClient {
375375
this.codexClient.onElicitationRequest(sessionId, elicitationHandler);
376376
}
377377

378+
unsubscribeFromSessionEvents(sessionId: string): void {
379+
this.codexClient.removeServerNotification(sessionId);
380+
this.codexClient.removeApprovalRequest(sessionId);
381+
this.codexClient.removeElicitationRequest(sessionId);
382+
}
383+
384+
async closeSession(sessionId: string, currentTurnId: string | null): Promise<void> {
385+
if (currentTurnId) {
386+
try {
387+
await this.codexClient.turnInterrupt({
388+
threadId: sessionId,
389+
turnId: currentTurnId,
390+
});
391+
} catch (err) {
392+
logger.error(`Failed to interrupt turn while closing session ${sessionId}`, err);
393+
}
394+
}
395+
396+
await this.codexClient.threadUnsubscribe({threadId: sessionId});
397+
this.unsubscribeFromSessionEvents(sessionId);
398+
}
399+
378400
async sendPrompt(
379401
request: acp.PromptRequest,
380402
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
@@ -924,4 +924,117 @@ describe('ACP server test', { timeout: 40_000 }, () => {
924924
}
925925
});
926926
});
927+
928+
it('closes an active session by interrupting the turn, unsubscribing the thread, and removing listeners', async () => {
929+
const mockFixture = createCodexMockTestFixture();
930+
const codexAcpClient = mockFixture.getCodexAcpClient();
931+
const codexAppServerClient = mockFixture.getCodexAppServerClient();
932+
933+
await codexAcpClient.subscribeToSessionEvents(
934+
"session-close",
935+
vi.fn(),
936+
{
937+
handleCommandExecution: vi.fn(),
938+
handleFileChange: vi.fn(),
939+
},
940+
{
941+
handleElicitation: vi.fn(),
942+
}
943+
);
944+
945+
// @ts-expect-error verifying test-only access to private maps
946+
expect(codexAppServerClient.notificationHandlers.has("session-close")).toBe(true);
947+
// @ts-expect-error verifying test-only access to private maps
948+
expect(codexAppServerClient.approvalHandlers.has("session-close")).toBe(true);
949+
// @ts-expect-error verifying test-only access to private maps
950+
expect(codexAppServerClient.elicitationHandlers.has("session-close")).toBe(true);
951+
952+
await codexAcpClient.closeSession("session-close", "turn-close");
953+
954+
const closeRequests = mockFixture.getCodexConnectionEvents([])
955+
.filter((event) => event.eventType === "request");
956+
expect(closeRequests).toEqual([
957+
{
958+
eventType: "request",
959+
method: "turn/interrupt",
960+
params: {threadId: "session-close", turnId: "turn-close"},
961+
},
962+
{
963+
eventType: "request",
964+
method: "thread/unsubscribe",
965+
params: {threadId: "session-close"},
966+
},
967+
]);
968+
969+
// @ts-expect-error verifying test-only access to private maps
970+
expect(codexAppServerClient.notificationHandlers.has("session-close")).toBe(false);
971+
// @ts-expect-error verifying test-only access to private maps
972+
expect(codexAppServerClient.approvalHandlers.has("session-close")).toBe(false);
973+
// @ts-expect-error verifying test-only access to private maps
974+
expect(codexAppServerClient.elicitationHandlers.has("session-close")).toBe(false);
975+
});
976+
977+
it('removes session bookkeeping after unstable_closeSession', async () => {
978+
const mockFixture = createCodexMockTestFixture();
979+
const codexAcpAgent = mockFixture.getCodexAcpAgent();
980+
const codexAcpClient = mockFixture.getCodexAcpClient();
981+
const sessionState = createTestSessionState({
982+
sessionId: "session-close",
983+
currentTurnId: "turn-close",
984+
});
985+
const closeSpy = vi.spyOn(codexAcpClient, "closeSession").mockResolvedValue();
986+
987+
// @ts-expect-error seeding private session store for focused close-session test
988+
codexAcpAgent.sessions.set(sessionState.sessionId, sessionState);
989+
// @ts-expect-error seeding private session store for focused close-session test
990+
codexAcpAgent.pendingMcpStartupSessions.set(sessionState.sessionId, {
991+
requestedServers: new Set(["alpha"]),
992+
});
993+
994+
await expect(
995+
codexAcpAgent.unstable_closeSession({sessionId: sessionState.sessionId})
996+
).resolves.toEqual({});
997+
expect(closeSpy).toHaveBeenCalledWith(sessionState.sessionId, sessionState.currentTurnId);
998+
expect(() => codexAcpAgent.getSessionState(sessionState.sessionId)).toThrow(
999+
`Session ${sessionState.sessionId} not found`
1000+
);
1001+
// @ts-expect-error verifying test-only access to private map
1002+
expect(codexAcpAgent.pendingMcpStartupSessions.has(sessionState.sessionId)).toBe(false);
1003+
});
1004+
1005+
it('skips late MCP startup updates after a session is closed', async () => {
1006+
const mockFixture = createCodexMockTestFixture();
1007+
const codexAcpAgent = mockFixture.getCodexAcpAgent();
1008+
const codexAcpClient = mockFixture.getCodexAcpClient();
1009+
const sessionState = createTestSessionState({
1010+
sessionId: "session-close",
1011+
});
1012+
1013+
let resolveStartup!: (event: any) => void;
1014+
const startupPromise = new Promise((resolve) => {
1015+
resolveStartup = resolve;
1016+
});
1017+
vi.spyOn(codexAcpClient, "awaitMcpStartupResult").mockReturnValue(startupPromise as Promise<any>);
1018+
1019+
// @ts-expect-error seeding private session store for focused close-session test
1020+
codexAcpAgent.sessions.set(sessionState.sessionId, sessionState);
1021+
// @ts-expect-error seeding private session store for focused close-session test
1022+
codexAcpAgent.pendingMcpStartupSessions.set(sessionState.sessionId, {
1023+
requestedServers: new Set(["alpha"]),
1024+
});
1025+
1026+
// @ts-expect-error exercising private helper to verify close-session race handling
1027+
const publishPromise = codexAcpAgent.doPublishMcpStartupStatus(sessionState.sessionId, 1);
1028+
// @ts-expect-error exercising private helper to simulate close-session cleanup
1029+
codexAcpAgent.forgetSession(sessionState.sessionId);
1030+
1031+
resolveStartup({
1032+
ready: ["alpha"],
1033+
failed: [],
1034+
cancelled: [],
1035+
});
1036+
await publishPromise;
1037+
1038+
expect(mockFixture.getAcpConnectionEvents([])).toEqual([]);
1039+
});
9271040
});

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)