Skip to content

Commit feb9797

Browse files
authored
Fix stale file read after patch causing empty diff content (#178)
* Fix stale file read after patch causing empty diff content Also ensure events are always handled in order. * queue per session, drain
1 parent bbfb54d commit feb9797

10 files changed

Lines changed: 301 additions & 83 deletions

src/CodexAcpClient.ts

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class CodexAcpClient {
4949
private gatewayConfig: GatewayConfig | null;
5050
private pendingLoginCompleted: Promise<AccountLoginCompletedNotification> | null = null;
5151
private pendingAccountUpdated: Promise<AccountUpdatedNotification> | null = null;
52+
private readonly sessionNotificationQueues = new Map<string, Promise<void>>();
5253

5354

5455
constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
@@ -384,13 +385,56 @@ export class CodexAcpClient {
384385

385386
async subscribeToSessionEvents(
386387
sessionId: string,
387-
eventHandler: (result: ServerNotification) => void,
388+
eventHandler: (result: ServerNotification) => void | Promise<void>,
388389
approvalHandler: ApprovalHandler,
389390
elicitationHandler: ElicitationHandler
390391
) {
391-
this.codexClient.onServerNotification(sessionId, eventHandler);
392-
this.codexClient.onApprovalRequest(sessionId, approvalHandler);
393-
this.codexClient.onElicitationRequest(sessionId, elicitationHandler);
392+
this.codexClient.onServerNotification(sessionId, (event) => {
393+
this.enqueueSessionNotification(sessionId, () => eventHandler(event));
394+
});
395+
this.codexClient.onApprovalRequest(sessionId, {
396+
handleCommandExecution: async (params) => {
397+
await this.waitForSessionNotifications(sessionId);
398+
return await approvalHandler.handleCommandExecution(params);
399+
},
400+
handleFileChange: async (params) => {
401+
await this.waitForSessionNotifications(sessionId);
402+
return await approvalHandler.handleFileChange(params);
403+
},
404+
});
405+
this.codexClient.onElicitationRequest(sessionId, {
406+
handleElicitation: async (params) => {
407+
await this.waitForSessionNotifications(sessionId);
408+
return await elicitationHandler.handleElicitation(params);
409+
},
410+
});
411+
}
412+
413+
async waitForSessionNotifications(sessionId: string): Promise<void> {
414+
while (true) {
415+
const queue = this.sessionNotificationQueues.get(sessionId);
416+
if (!queue) return;
417+
await queue;
418+
}
419+
}
420+
421+
private enqueueSessionNotification(sessionId: string, operation: () => void | Promise<void>): void {
422+
const run = async () => {
423+
try {
424+
await operation();
425+
} catch (error) {
426+
logger.error("Error handling Codex session notification", error);
427+
}
428+
};
429+
430+
const previous = this.sessionNotificationQueues.get(sessionId);
431+
const next = previous ? previous.then(run, run) : run();
432+
this.sessionNotificationQueues.set(sessionId, next);
433+
void next.finally(() => {
434+
if (this.sessionNotificationQueues.get(sessionId) === next) {
435+
this.sessionNotificationQueues.delete(sessionId);
436+
}
437+
});
394438
}
395439

396440
async sendPrompt(

src/CodexAcpServer.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1222,6 +1222,8 @@ export class CodexAcpServer implements acp.Agent {
12221222
};
12231223
}
12241224

1225+
await this.codexAcpClient.waitForSessionNotifications(params.sessionId);
1226+
12251227
// Check if turn was interrupted (cancelled)
12261228
if (turnCompleted.turn.status === "interrupted") {
12271229
if (!this.sessionIsClosing(params.sessionId) && this.sessions.has(params.sessionId)) {

src/CodexToolCallMapper.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,18 @@ async function createUpdateFileContent(change: FileUpdateChange): Promise<ToolCa
294294
const oldContent = await readFileContent(change.path);
295295
if (oldContent !== null) {
296296
const patchedContent = applyPatch(oldContent, unifiedDiff);
297-
if (patchedContent === false) return null;
297+
if (patchedContent === false) {
298+
// If Codex runs in full access mode, the file might already be patched.
299+
// we can verify this by checking if the reverted patch applies.
300+
const revertedPatch = revertPatch(unifiedDiff);
301+
if (revertedPatch) {
302+
const revertedContent = applyPatch(oldContent, revertedPatch);
303+
if (revertedContent !== false) {
304+
return createUpdateDiffContent(change.path, revertedContent, oldContent);
305+
}
306+
}
307+
return null;
308+
}
298309
return createUpdateDiffContent(movePath ?? change.path, oldContent, patchedContent);
299310
}
300311

src/__tests__/CodexACPAgent/CodexAcpClient.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,7 @@ describe('ACP server test', { timeout: 40_000 }, () => {
11721172
}
11731173
}
11741174
});
1175+
await mockFixture.getCodexAcpClient().waitForSessionNotifications(sessionId);
11751176

11761177
expect(sessionState.rateLimits).not.toBeNull();
11771178
expect(sessionState.rateLimits!.size).toBe(2);

src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,23 @@
44
{
55
"sessionId": "test-session-id",
66
"update": {
7-
"sessionUpdate": "tool_call_update",
7+
"sessionUpdate": "tool_call",
88
"toolCallId": "call-id",
9-
"_meta": {
10-
"mcp_output_delta": {
11-
"data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened"
9+
"kind": "execute",
10+
"title": "mcp.ijproxy.read_file",
11+
"status": "in_progress",
12+
"rawInput": {
13+
"server": "ijproxy",
14+
"tool": "read_file",
15+
"arguments": {
16+
"file_path": ".ai/local.md",
17+
"mode": "slice",
18+
"start_line": 1,
19+
"max_lines": 200
1220
}
21+
},
22+
"_meta": {
23+
"is_mcp_tool_call": true
1324
}
1425
}
1526
}
@@ -23,21 +34,9 @@
2334
"update": {
2435
"sessionUpdate": "tool_call_update",
2536
"toolCallId": "call-id",
26-
"status": "failed",
27-
"rawInput": {
28-
"server": "ijproxy",
29-
"tool": "read_file",
30-
"arguments": {
31-
"file_path": ".ai/local.md",
32-
"mode": "slice",
33-
"start_line": 1,
34-
"max_lines": 200
35-
}
36-
},
37-
"rawOutput": {
38-
"result": null,
39-
"error": {
40-
"message": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened"
37+
"_meta": {
38+
"mcp_output_delta": {
39+
"data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened"
4140
}
4241
}
4342
}
@@ -50,11 +49,9 @@
5049
{
5150
"sessionId": "test-session-id",
5251
"update": {
53-
"sessionUpdate": "tool_call",
52+
"sessionUpdate": "tool_call_update",
5453
"toolCallId": "call-id",
55-
"kind": "execute",
56-
"title": "mcp.ijproxy.read_file",
57-
"status": "in_progress",
54+
"status": "failed",
5855
"rawInput": {
5956
"server": "ijproxy",
6057
"tool": "read_file",
@@ -65,8 +62,11 @@
6562
"max_lines": 200
6663
}
6764
},
68-
"_meta": {
69-
"is_mcp_tool_call": true
65+
"rawOutput": {
66+
"result": null,
67+
"error": {
68+
"message": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened"
69+
}
7070
}
7171
}
7272
}

src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,20 @@
44
{
55
"sessionId": "test-session-id",
66
"update": {
7-
"sessionUpdate": "tool_call_update",
7+
"sessionUpdate": "tool_call",
88
"toolCallId": "call-id",
9-
"_meta": {
10-
"mcp_output_delta": {
11-
"data": "Polling for status"
9+
"kind": "execute",
10+
"title": "mcp.server-name.tool-name",
11+
"status": "in_progress",
12+
"rawInput": {
13+
"server": "server-name",
14+
"tool": "tool-name",
15+
"arguments": {
16+
"argument": "example"
1217
}
18+
},
19+
"_meta": {
20+
"is_mcp_tool_call": true
1321
}
1422
}
1523
}
@@ -40,18 +48,9 @@
4048
"update": {
4149
"sessionUpdate": "tool_call_update",
4250
"toolCallId": "call-id",
43-
"status": "failed",
44-
"rawInput": {
45-
"server": "server-name",
46-
"tool": "tool-name",
47-
"arguments": {
48-
"argument": "example"
49-
}
50-
},
51-
"rawOutput": {
52-
"result": null,
53-
"error": {
54-
"message": "Polling for status"
51+
"_meta": {
52+
"mcp_output_delta": {
53+
"data": "Polling for status"
5554
}
5655
}
5756
}
@@ -64,20 +63,21 @@
6463
{
6564
"sessionId": "test-session-id",
6665
"update": {
67-
"sessionUpdate": "tool_call",
66+
"sessionUpdate": "tool_call_update",
6867
"toolCallId": "call-id",
69-
"kind": "execute",
70-
"title": "mcp.server-name.tool-name",
71-
"status": "in_progress",
68+
"status": "failed",
7269
"rawInput": {
7370
"server": "server-name",
7471
"tool": "tool-name",
7572
"arguments": {
7673
"argument": "example"
7774
}
7875
},
79-
"_meta": {
80-
"is_mcp_tool_call": true
76+
"rawOutput": {
77+
"result": null,
78+
"error": {
79+
"message": "Polling for status"
80+
}
8181
}
8282
}
8383
}

src/__tests__/CodexACPAgent/data/terminal-full-flow.json

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,24 @@
44
{
55
"sessionId": "test-session-id",
66
"update": {
7-
"sessionUpdate": "tool_call_update",
7+
"sessionUpdate": "tool_call",
88
"toolCallId": "command-flow",
9+
"kind": "execute",
10+
"title": "echo hello",
11+
"status": "in_progress",
12+
"content": [
13+
{
14+
"type": "terminal",
15+
"terminalId": "command-flow"
16+
}
17+
],
18+
"rawInput": {
19+
"command": "echo hello",
20+
"cwd": "/test/project"
21+
},
922
"_meta": {
10-
"terminal_output_delta": {
11-
"data": "hello\n",
23+
"terminal_info": {
24+
"cwd": "/test/project",
1225
"terminal_id": "command-flow"
1326
}
1427
}
@@ -24,15 +37,9 @@
2437
"update": {
2538
"sessionUpdate": "tool_call_update",
2639
"toolCallId": "command-flow",
27-
"status": "completed",
28-
"rawOutput": {
29-
"formatted_output": "hello\n",
30-
"exit_code": 0
31-
},
3240
"_meta": {
33-
"terminal_exit": {
34-
"exit_code": 0,
35-
"signal": null,
41+
"terminal_output_delta": {
42+
"data": "hello\n",
3643
"terminal_id": "command-flow"
3744
}
3845
}
@@ -46,24 +53,17 @@
4653
{
4754
"sessionId": "test-session-id",
4855
"update": {
49-
"sessionUpdate": "tool_call",
56+
"sessionUpdate": "tool_call_update",
5057
"toolCallId": "command-flow",
51-
"kind": "execute",
52-
"title": "echo hello",
53-
"status": "in_progress",
54-
"content": [
55-
{
56-
"type": "terminal",
57-
"terminalId": "command-flow"
58-
}
59-
],
60-
"rawInput": {
61-
"command": "echo hello",
62-
"cwd": "/test/project"
58+
"status": "completed",
59+
"rawOutput": {
60+
"formatted_output": "hello\n",
61+
"exit_code": 0
6362
},
6463
"_meta": {
65-
"terminal_info": {
66-
"cwd": "/test/project",
64+
"terminal_exit": {
65+
"exit_code": 0,
66+
"signal": null,
6767
"terminal_id": "command-flow"
6868
}
6969
}

src/__tests__/CodexACPAgent/elicitation-events.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ describe('Elicitation Events', () => {
288288

289289
fixture.sendServerNotification(startedNotification);
290290
fixture.sendServerNotification(completedNotification);
291+
await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId);
291292
fixture.clearAcpConnectionDump();
292293

293294
const params: McpServerElicitationRequestParams = {
@@ -340,6 +341,7 @@ describe('Elicitation Events', () => {
340341

341342
fixture.sendServerNotification(startedNotification);
342343
fixture.sendServerNotification(resolvedNotification);
344+
await fixture.getCodexAcpClient().waitForSessionNotifications(sessionId);
343345
fixture.clearAcpConnectionDump();
344346

345347
const params: McpServerElicitationRequestParams = {

0 commit comments

Comments
 (0)