Skip to content

Commit 843654a

Browse files
committed
Track turns by session and route notifications
Store pending turn ids separately from turn completion so session shutdown can interrupt only active turns and wait for them to finish. Also scope app-server notifications to their thread and avoid sending command/session updates after a session has been removed or is closing.
1 parent 7b6100f commit 843654a

7 files changed

Lines changed: 483 additions & 140 deletions

File tree

src/CodexAcpClient.ts

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -377,33 +377,30 @@ export class CodexAcpClient {
377377
}
378378

379379
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-
}
380+
if (currentTurnId && !this.codexClient.hasTurnCompleted(sessionId, currentTurnId)) {
381+
await this.codexClient.turnInterrupt({
382+
threadId: sessionId,
383+
turnId: currentTurnId,
384+
});
385+
await this.codexClient.awaitTurnCompleted(sessionId, currentTurnId);
389386
}
390387

391388
await this.codexClient.threadUnsubscribe({threadId: sessionId});
392389
this.unsubscribeFromSessionEvents(sessionId);
393390
}
394391

395-
async sendPrompt(
392+
async startPrompt(
396393
request: acp.PromptRequest,
397394
agentMode: AgentMode,
398395
modelId: ModelId,
399396
disableSummary: boolean,
400397
cwd: string,
401-
): Promise<TurnCompletedNotification> {
398+
): Promise<string> {
402399
const input = buildPromptItems(request.prompt);
403400
const effort = modelId.effort as ReasoningEffort | null; //TODO remove unsafe conversion
404401

405402
await this.refreshSkills(cwd, request._meta);
406-
await this.codexClient.turnStart({
403+
const response = await this.codexClient.turnStart({
407404
outputSchema: null,
408405
threadId: request.sessionId,
409406
input: input,
@@ -417,9 +414,16 @@ export class CodexAcpClient {
417414
model: modelId.model,
418415
});
419416

420-
// Wait for turn completion
417+
return response.turn.id;
418+
}
419+
420+
async awaitTurnCompleted(sessionId: string, turnId: string): Promise<TurnCompletedNotification> {
421421
// If turnInterrupt() was called, Codex will send turn/completed event with status "interrupted"
422-
return await this.codexClient.awaitTurnCompleted();
422+
return await this.codexClient.awaitTurnCompleted(sessionId, turnId);
423+
}
424+
425+
hasTurnCompleted(sessionId: string, turnId: string): boolean {
426+
return this.codexClient.hasTurnCompleted(sessionId, turnId);
423427
}
424428

425429
async listSkills(params?: SkillsListParams): Promise<SkillsListResponse> {

src/CodexAcpServer.ts

Lines changed: 70 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export interface SessionState {
4444
supportedReasoningEfforts: Array<ReasoningEffortOption>,
4545
supportedInputModalities: Array<InputModality>,
4646
agentMode: AgentMode,
47+
pendingTurnId: Promise<string> | null;
4748
currentTurnId: string | null;
4849
lastTokenUsage: TokenCount | null;
4950
totalTokenUsage: TokenCount | null;
@@ -68,6 +69,7 @@ export class CodexAcpServer implements acp.Agent {
6869

6970
private readonly sessions: Map<string, SessionState>;
7071
private readonly pendingMcpStartupSessions: Map<string, PendingMcpStartupSession>;
72+
private readonly closingSessions: Set<string>;
7173

7274
constructor(
7375
connection: acp.AgentSideConnection,
@@ -77,14 +79,17 @@ export class CodexAcpServer implements acp.Agent {
7779
) {
7880
this.sessions = new Map();
7981
this.pendingMcpStartupSessions = new Map();
82+
this.closingSessions = new Set();
8083
this.connection = connection;
8184
this.codexAcpClient = codexAcpClient;
8285
this.defaultAuthRequest = defaultAuthRequest ?? null;
8386
this.getExitCode = getExitCode ?? (() => null);
8487
this.availableCommands = new CodexCommands(
8588
connection,
8689
codexAcpClient,
87-
(operation) => this.runWithProcessCheck(operation)
90+
(operation) => this.runWithProcessCheck(operation),
91+
(sessionId) => this.sessions.has(sessionId),
92+
(sessionId) => this.closingSessions.has(sessionId),
8893
);
8994
}
9095

@@ -170,6 +175,7 @@ export class CodexAcpServer implements acp.Agent {
170175
supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [],
171176
supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"],
172177
agentMode: AgentMode.getInitialAgentMode(),
178+
pendingTurnId: null,
173179
currentTurnId: null,
174180
lastTokenUsage: null,
175181
totalTokenUsage: null,
@@ -242,11 +248,18 @@ export class CodexAcpServer implements acp.Agent {
242248
async unstable_closeSession(params: acp.CloseSessionRequest): Promise<acp.CloseSessionResponse> {
243249
logger.log("Closing session...", {sessionId: params.sessionId});
244250
const sessionState = this.getSessionState(params.sessionId);
251+
this.closingSessions.add(params.sessionId);
245252

246-
await this.runWithProcessCheck(() =>
247-
this.codexAcpClient.closeSession(params.sessionId, sessionState.currentTurnId)
248-
);
249-
this.forgetSession(params.sessionId);
253+
try {
254+
const activeTurnId = await this.resolveActiveTurnId(sessionState);
255+
await this.runWithProcessCheck(() =>
256+
this.codexAcpClient.closeSession(params.sessionId, activeTurnId)
257+
);
258+
this.forgetSession(params.sessionId);
259+
} catch (err) {
260+
this.closingSessions.delete(params.sessionId);
261+
throw err;
262+
}
250263

251264
logger.log("Session closed", {sessionId: params.sessionId});
252265
return {};
@@ -394,6 +407,7 @@ export class CodexAcpServer implements acp.Agent {
394407
supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [],
395408
supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"],
396409
agentMode: AgentMode.getInitialAgentMode(),
410+
pendingTurnId: null,
397411
currentTurnId: null,
398412
lastTokenUsage: null,
399413
totalTokenUsage: null,
@@ -655,9 +669,26 @@ export class CodexAcpServer implements acp.Agent {
655669
return sessionState;
656670
}
657671

672+
private async resolveActiveTurnId(sessionState: SessionState): Promise<string | null> {
673+
if (sessionState.currentTurnId) {
674+
return sessionState.currentTurnId;
675+
}
676+
if (!sessionState.pendingTurnId) {
677+
return null;
678+
}
679+
680+
try {
681+
return await sessionState.pendingTurnId;
682+
} catch (err) {
683+
logger.error(`Failed to resolve pending turn for session ${sessionState.sessionId}`, err);
684+
return null;
685+
}
686+
}
687+
658688
private forgetSession(sessionId: string): void {
659689
this.sessions.delete(sessionId);
660690
this.pendingMcpStartupSessions.delete(sessionId);
691+
this.closingSessions.delete(sessionId);
661692
}
662693

663694
private resolveSessionMcpServers(
@@ -741,6 +772,10 @@ await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.request
741772
prompt: params.prompt,
742773
});
743774
const sessionState = this.getSessionState(params.sessionId);
775+
if (this.closingSessions.has(params.sessionId)) {
776+
throw RequestError.invalidRequest("Session is closing");
777+
}
778+
sessionState.pendingTurnId = null;
744779
sessionState.currentTurnId = null;
745780
sessionState.lastTokenUsage = null;
746781

@@ -750,6 +785,9 @@ await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.request
750785
const elicitationHandler = new CodexElicitationHandler(this.connection, sessionState);
751786
await this.codexAcpClient.subscribeToSessionEvents(params.sessionId,
752787
(event) => {
788+
if (this.closingSessions.has(params.sessionId)) {
789+
return;
790+
}
753791
elicitationHandler.handleNotification(event);
754792
return eventHandler.handleNotification(event);
755793
},
@@ -781,21 +819,32 @@ await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.request
781819
throw RequestError.invalidRequest("The current model does not support image input");
782820
}
783821
const agentMode = sessionState.agentMode;
822+
const pendingTurnId = this.runWithProcessCheck(
823+
() => this.codexAcpClient.startPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd)
824+
);
825+
sessionState.pendingTurnId = pendingTurnId;
826+
const turnId = await pendingTurnId;
827+
sessionState.pendingTurnId = null;
828+
sessionState.currentTurnId = turnId;
829+
784830
const turnCompleted = await this.runWithProcessCheck(
785-
() => this.codexAcpClient.sendPrompt(params, agentMode, modelId, disableSummary, sessionState.cwd));
831+
() => this.codexAcpClient.awaitTurnCompleted(params.sessionId, turnId)
832+
);
786833

787834
// Check if turn was interrupted (cancelled)
788835
if (turnCompleted.turn.status === "interrupted") {
789-
await this.connection.sessionUpdate({
790-
sessionId: params.sessionId,
791-
update: {
792-
sessionUpdate: "agent_message_chunk",
793-
content: {
794-
type: "text",
795-
text: "*Conversation interrupted*"
836+
if (!this.closingSessions.has(params.sessionId)) {
837+
await this.connection.sessionUpdate({
838+
sessionId: params.sessionId,
839+
update: {
840+
sessionUpdate: "agent_message_chunk",
841+
content: {
842+
type: "text",
843+
text: "*Conversation interrupted*"
844+
}
796845
}
797-
}
798-
});
846+
});
847+
}
799848
return {
800849
stopReason: "cancelled",
801850
usage: this.buildPromptUsage(sessionState.lastTokenUsage),
@@ -819,6 +868,7 @@ await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.request
819868
throw err;
820869
} finally {
821870
logger.log("Prompt completed", {sessionId: params.sessionId});
871+
sessionState.pendingTurnId = null;
822872
sessionState.currentTurnId = null;
823873
}
824874
}
@@ -872,24 +922,25 @@ await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.request
872922
return;
873923
}
874924

875-
if (!sessionState.currentTurnId) {
925+
const currentTurnId = await this.resolveActiveTurnId(sessionState);
926+
if (!currentTurnId) {
876927
logger.log("Cancel request rejected: no current turn", {sessionId: params.sessionId});
877928
return;
878929
}
879930

880931
logger.log("Cancel session requested", {
881932
sessionId: params.sessionId,
882-
currentTurnId: sessionState.currentTurnId
933+
currentTurnId,
883934
});
884935
try {
885936
// After turnInterrupt(), Codex will send turn/completed event, which will naturally complete awaitTurnCompleted()
886937
await this.codexAcpClient.turnInterrupt({
887938
threadId: params.sessionId,
888-
turnId: sessionState.currentTurnId
939+
turnId: currentTurnId
889940
});
890941
logger.log("Cancel - turnInterrupt succeeded", {
891942
sessionId: params.sessionId,
892-
currentTurnId: sessionState.currentTurnId
943+
currentTurnId,
893944
});
894945
} catch (err) {
895946
logger.error(`Cancel - turnInterrupt failed`, err);

0 commit comments

Comments
 (0)