From f0aea839d56d5c0e2b27c73378f60c529815ac49 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 9 Jun 2026 12:34:46 +0200 Subject: [PATCH] Handle file change patch updates --- src/CodexEventHandler.ts | 4 +- src/CodexToolCallMapper.ts | 34 +++++++++-- .../data/file-change-patch-updated.json | 61 +++++++++++++++++++ .../CodexACPAgent/file-change-events.test.ts | 61 ++++++++++++++++++- 4 files changed, 152 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/file-change-patch-updated.json diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 70c31314..faa1d2a2 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -27,6 +27,7 @@ import {toTokenCount} from "./TokenCount"; import { createCommandExecutionUpdate, createDynamicToolCallUpdate, + createFileChangePatchUpdate, createFileChangeUpdate, createMcpRawInput, createMcpRawOutput, @@ -138,6 +139,8 @@ export class CodexEventHandler { return this.handleFuzzyFileSearchSessionUpdated(notification.params); case "fuzzyFileSearch/sessionCompleted": return this.handleFuzzyFileSearchSessionCompleted(notification.params); + case "item/fileChange/patchUpdated": + return await createFileChangePatchUpdate(notification.params); // ignored events case "command/exec/outputDelta": case "item/autoApprovalReview/started": @@ -150,7 +153,6 @@ export class CodexEventHandler { case "turn/diff/updated": case "item/commandExecution/terminalInteraction": case "item/fileChange/outputDelta": - case "item/fileChange/patchUpdated": case "account/updated": case "fs/changed": case "mcpServer/startupStatus/updated": diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index e1be3fdf..62375eed 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -40,12 +40,7 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { export async function createFileChangeUpdate( item: ThreadItem & { type: "fileChange" } ): Promise { - const patches: ToolCallContent[] = []; - for (const change of item.changes) { - const content = await createPatchContent(change); - if (content) patches.push(content); - // ignore unparseable diffs - } + const patches = await createFileChangeContent(item.changes); return { sessionUpdate: "tool_call", toolCallId: item.id, @@ -56,6 +51,33 @@ export async function createFileChangeUpdate( }; } +export async function createFileChangePatchUpdate( + event: { itemId: string, changes: Array } +): Promise { + if (event.changes.length === 0) { + return null; + } + const patches = await createFileChangeContent(event.changes); + return { + sessionUpdate: "tool_call_update", + toolCallId: event.itemId, + title: "Editing files", + kind: "edit", + status: "in_progress", + content: patches, + }; +} + +async function createFileChangeContent(changes: Array): Promise { + const patches: ToolCallContent[] = []; + for (const change of changes) { + const content = await createPatchContent(change); + if (content) patches.push(content); + // ignore unparseable diffs + } + return patches; +} + export async function createCommandExecutionUpdate( item: ThreadItem & { type: "commandExecution" } ): Promise { diff --git a/src/__tests__/CodexACPAgent/data/file-change-patch-updated.json b/src/__tests__/CodexACPAgent/data/file-change-patch-updated.json new file mode 100644 index 00000000..c506add9 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-patch-updated.json @@ -0,0 +1,61 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-patch-live", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "type": "diff", + "oldText": null, + "newText": "class FileA\n", + "path": "/test/project/FileA.kt", + "_meta": { + "kind": "add" + } + } + ] + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "file-change-patch-live", + "title": "Editing files", + "kind": "edit", + "status": "in_progress", + "content": [ + { + "type": "diff", + "oldText": null, + "newText": "class FileA {\n fun hello() = \"hi\"\n}\n", + "path": "/test/project/FileA.kt", + "_meta": { + "kind": "add" + } + }, + { + "type": "diff", + "oldText": null, + "newText": "class FileB\n", + "path": "/test/project/FileB.kt", + "_meta": { + "kind": "add" + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/file-change-events.test.ts b/src/__tests__/CodexACPAgent/file-change-events.test.ts index 795d71e3..e884bff1 100644 --- a/src/__tests__/CodexACPAgent/file-change-events.test.ts +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { SessionState } from '../../CodexAcpServer'; import type { ServerNotification } from '../../app-server'; -import { createFileChangeUpdate } from '../../CodexToolCallMapper'; +import { createFileChangePatchUpdate, createFileChangeUpdate } from '../../CodexToolCallMapper'; import type { ThreadItem } from '../../app-server/v2'; import { createCodexMockTestFixture, createTestSessionState, setupPromptAndSendNotifications, type CodexMockTestFixture } from '../acp-test-utils'; import {AgentMode} from "../../AgentMode"; @@ -115,6 +115,65 @@ describe('CodexEventHandler - file change events', () => { ); }); + it('should handle file change patch updates', async () => { + const fileChangeStarted: ServerNotification = { + method: 'item/started', + params: { + threadId: sessionId, + turnId: 'turn-1', + startedAtMs: 0, + item: { + type: 'fileChange', + id: 'file-change-patch-live', + changes: [ + { + path: '/test/project/FileA.kt', + kind: { type: 'add' }, + diff: 'class FileA\n', + }, + ], + status: 'inProgress', + }, + }, + }; + const fileChangePatchUpdated: ServerNotification = { + method: 'item/fileChange/patchUpdated', + params: { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'file-change-patch-live', + changes: [ + { + path: '/test/project/FileA.kt', + kind: { type: 'add' }, + diff: 'class FileA {\n fun hello() = "hi"\n}\n', + }, + { + path: '/test/project/FileB.kt', + kind: { type: 'add' }, + diff: 'class FileB\n', + }, + ], + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [ + fileChangeStarted, + fileChangePatchUpdated, + ]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-patch-updated.json' + ); + }); + + it('should ignore empty file change patch updates', async () => { + await expect(createFileChangePatchUpdate({ + itemId: 'file-change-empty-patch', + changes: [], + })).resolves.toBeNull(); + }); + it('should handle new file creation with raw content', async () => { // Codex sends raw file content (not unified diff) for new files const newFileNotification: ServerNotification = {