Skip to content
Merged
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
10 changes: 8 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1890,13 +1890,15 @@ export class CodexAcpServer {
return pendingTurnStart;
};
const disposePromptRequestCancellation = this.observePromptRequestCancellation(signal, sessionState, activePrompt);
let eventHandler: CodexEventHandler | null = null;

try {
const eventHandler = new CodexEventHandler(
const promptEventHandler = new CodexEventHandler(
this.connection,
sessionState,
clientSupportsPlanUpdates(this.clientCapabilities),
);
eventHandler = promptEventHandler;
const approvalHandler = new CodexApprovalHandler(this.connection, sessionState, activePrompt.signal);
const elicitationHandler = new CodexElicitationHandler(
this.connection,
Expand All @@ -1907,7 +1909,7 @@ export class CodexAcpServer {
await this.codexAcpClient.subscribeToSessionEvents(params.sessionId,
async (event) => {
await elicitationHandler.handleNotification(event);
return eventHandler.handleNotification(event);
return promptEventHandler.handleNotification(event);
},
approvalHandler,
elicitationHandler);
Expand Down Expand Up @@ -2041,6 +2043,7 @@ export class CodexAcpServer {
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);

if (turnCompleted.turn.status === "interrupted") {
await eventHandler.flushPendingPlanUpdates();
await this.notifyConversationInterrupted(params.sessionId);
return this.cancelledPromptResponse(sessionState);
}
Expand All @@ -2051,6 +2054,7 @@ export class CodexAcpServer {
throw error;
}

await eventHandler.flushPendingPlanUpdates();
const completedPlan = eventHandler.takeCompletedPlan();
if (
completedPlan !== null
Expand Down Expand Up @@ -2116,6 +2120,7 @@ export class CodexAcpServer {

await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
if (turnCompleted.turn.status === "interrupted") {
await eventHandler.flushPendingPlanUpdates();
await this.notifyConversationInterrupted(params.sessionId);
return this.cancelledPromptResponse(sessionState);
}
Expand All @@ -2142,6 +2147,7 @@ export class CodexAcpServer {
throw err;
} finally {
logger.log("Prompt completed", {sessionId: params.sessionId});
await eventHandler?.dispose();
disposePromptRequestCancellation();
sessionState.currentTurnId = null;
const registeredPendingTurnStart = this.pendingTurnStarts.get(params.sessionId);
Expand Down
106 changes: 91 additions & 15 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
createAgentTextThoughtChunk,
} from "./ContentChunks";
import {sameThreadGoalSnapshot, toThreadGoalSnapshot} from "./ThreadGoalSnapshot";
import {logger} from "./Logger";

export { stripShellPrefix };

Expand All @@ -75,7 +76,8 @@ export type CompletedPlan = {

export class CodexEventHandler {

private readonly connection: AcpClientConnection;
private static readonly PLAN_UPDATE_INTERVAL_MS = 150;

private readonly sessionState: SessionState;
private readonly supportsPlanUpdates: boolean;
private failure: RequestError | null = null;
Expand All @@ -85,6 +87,12 @@ export class CodexEventHandler {
private readonly activeImageGenerationItems = new Set<string>();
private readonly emittedImageViewItems = new Set<string>();
private readonly planDeltaTextByItemId = new Map<string, string>();
private readonly pendingPlanItemIds = new Set<string>();
private readonly lastEmittedPlanTextByItemId = new Map<string, string>();
private readonly session: ACPSessionConnection;
private planUpdateTimer: ReturnType<typeof setTimeout> | null = null;
private planUpdateChain: Promise<void> = Promise.resolve();
private disposed = false;
private readonly seenReasoningDeltaItemIds = new Set<string>();
private readonly terminalCommandIds = new Set<string>();
private readonly terminalCommandOutputIds = new Set<string>();
Expand All @@ -96,9 +104,9 @@ export class CodexEventHandler {
sessionState: SessionState,
supportsPlanUpdates = false,
) {
this.connection = connection;
this.sessionState = sessionState;
this.supportsPlanUpdates = supportsPlanUpdates;
this.session = new ACPSessionConnection(connection, sessionState.sessionId);
}

getFailure(): RequestError | null {
Expand All @@ -112,13 +120,37 @@ export class CodexEventHandler {
}

async handleNotification(notification: ServerNotification) {
const session = new ACPSessionConnection(this.connection, this.sessionState.sessionId);
const updateEvent = await this.createUpdateEvent(notification);
if (updateEvent) {
await session.update(updateEvent);
await this.session.update(updateEvent);
}
}

async flushPendingPlanUpdates(): Promise<void> {
this.cancelPlanUpdateTimer();
do {
const itemIds = [...this.pendingPlanItemIds];
this.pendingPlanItemIds.clear();
await Promise.all(itemIds.map(itemId => {
const text = this.planDeltaTextByItemId.get(itemId) ?? "";
return text.length > 0
? this.enqueuePlanSnapshot(itemId, text)
: Promise.resolve();
}));
await this.planUpdateChain;
} while (this.pendingPlanItemIds.size > 0);
}

async dispose(): Promise<void> {
if (this.disposed) return;
await this.flushPendingPlanUpdates();
this.disposed = true;
this.cancelPlanUpdateTimer();
this.pendingPlanItemIds.clear();
this.planDeltaTextByItemId.clear();
this.lastEmittedPlanTextByItemId.clear();
}

private async createUpdateEvent(notification: ServerNotification): Promise<UpdateSessionEvent | null> {
/*
TODO split UpdateSessionEvent to improve completion
Expand All @@ -144,6 +176,8 @@ export class CodexEventHandler {
this.sessionState.currentTurnId = notification.params.turn.id;
return null;
case "turn/completed":
await this.flushPendingPlanUpdates();
this.clearPlanTurnState();
this.sessionState.currentTurnId = null;
return null;
case "thread/tokenUsage/updated":
Expand Down Expand Up @@ -314,16 +348,18 @@ export class CodexEventHandler {
return this.createAgentThoughtEvent(event.delta, event.itemId);
}

private createPlanDeltaEvent(event: PlanDeltaNotification): UpdateSessionEvent | null {
private createPlanDeltaEvent(event: PlanDeltaNotification): null {
if (event.delta.length === 0) {
return null;
}
const text = this.planDeltaTextByItemId.get(event.itemId) ?? "";
const updatedText = text + event.delta;
this.planDeltaTextByItemId.set(event.itemId, updatedText);
return this.supportsPlanUpdates
? this.createPlanUpdateEvent(updatedText, event.itemId)
: null;
if (this.supportsPlanUpdates) {
this.pendingPlanItemIds.add(event.itemId);
this.schedulePlanUpdate();
}
return null;
}

private createReasoningSectionBreakEvent(event: ReasoningSummaryPartAddedNotification): UpdateSessionEvent {
Expand Down Expand Up @@ -424,8 +460,7 @@ export class CodexEventHandler {
return null;
case "plan": {
const deltaText = this.planDeltaTextByItemId.get(event.item.id) ?? "";
this.planDeltaTextByItemId.delete(event.item.id);
return this.createCompletedPlanEvent(event.item, deltaText);
return await this.createCompletedPlanEvent(event.item, deltaText);
}
case "exitedReviewMode":
return this.createExitedReviewModeEvent(event.item);
Expand Down Expand Up @@ -460,18 +495,59 @@ export class CodexEventHandler {
return this.createAgentThoughtEvent(text, item.id);
}

private createCompletedPlanEvent(
private async createCompletedPlanEvent(
item: ThreadItem & { type: "plan" },
deltaText: string,
): UpdateSessionEvent | null {
): Promise<UpdateSessionEvent | null> {
const text = item.text.length > 0 ? item.text : deltaText;
this.pendingPlanItemIds.delete(item.id);
if (this.pendingPlanItemIds.size === 0) {
this.cancelPlanUpdateTimer();
}
this.planDeltaTextByItemId.delete(item.id);
if (text.length === 0) {
return null;
}
this.completedPlan = {itemId: item.id, text};
return this.supportsPlanUpdates
? this.createPlanUpdateEvent(text, item.id)
: this.createPlanTextEvent(text, item.id);
if (this.supportsPlanUpdates) {
await this.enqueuePlanSnapshot(item.id, text);
return null;
}
return this.createPlanTextEvent(text, item.id);
}

private schedulePlanUpdate(): void {
if (this.disposed || this.planUpdateTimer !== null) return;
this.planUpdateTimer = setTimeout(() => {
this.planUpdateTimer = null;
void this.flushPendingPlanUpdates().catch(error => {
logger.error("Failed to flush throttled plan updates", error);
});
}, CodexEventHandler.PLAN_UPDATE_INTERVAL_MS);
}

private cancelPlanUpdateTimer(): void {
if (this.planUpdateTimer === null) return;
clearTimeout(this.planUpdateTimer);
this.planUpdateTimer = null;
}

private enqueuePlanSnapshot(itemId: string, text: string): Promise<void> {
const send = async () => {
if (this.lastEmittedPlanTextByItemId.get(itemId) === text) return;
await this.session.update(this.createPlanUpdateEvent(text, itemId));
this.lastEmittedPlanTextByItemId.set(itemId, text);
};
const result = this.planUpdateChain.then(send);
this.planUpdateChain = result.catch(() => {});
return result;
}

private clearPlanTurnState(): void {
this.cancelPlanUpdateTimer();
this.pendingPlanItemIds.clear();
this.planDeltaTextByItemId.clear();
this.lastEmittedPlanTextByItemId.clear();
}

private createPlanUpdateEvent(text: string, planId: string): UpdateSessionEvent {
Expand Down
Loading