From 9a74246a2cdcf4c83d227ecc96f76478f0ac89e7 Mon Sep 17 00:00:00 2001 From: Dmitry Timofeev Date: Tue, 10 Mar 2026 17:25:04 +0100 Subject: [PATCH] LLM-25262 Special handling of file deletion when processing patches Codex can delete the file after the patch has been created. To avoid accessing non-existing files, file update changes with the kind 'delete' are processed as follows. - If the file still exists, its old content is read directly from the file. The new content is always set to an empty string (it's consistent with the IntelliJ Idea ACP client handling of file deletion). - If the file can't be read, the old content is restored from the patch itself. --- src/CodexToolCallMapper.ts | 63 ++++++++++++++++- .../data/file-change-delete-raw-content.json | 2 +- .../CodexACPAgent/file-change-events.test.ts | 68 ++++++++++++++++++- 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 92db4027..4f4cb26e 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -1,5 +1,5 @@ import type { ToolCallContent } from "@agentclientprotocol/sdk"; -import { applyPatch } from "diff"; +import { applyPatch, parsePatch } from "diff"; import { readFile } from "node:fs/promises"; import path from "node:path"; import type { UpdateSessionEvent } from "./ACPSessionConnection"; @@ -86,6 +86,7 @@ export async function createMcpToolCallUpdate( ): Promise { return createExecuteToolCallUpdate(item, `mcp.${item.server}.${item.tool}`); } + export async function createDynamicToolCallUpdate( item: ThreadItem & { type: "dynamicToolCall" } ): Promise { @@ -235,7 +236,28 @@ async function createPatchContent(change: FileUpdateChange): Promise + isUnifiedDiff(change.diff) ? patchToDeletedContent(change.diff) : change.diff + ); + + return { + type: "diff", + oldText: oldContent, + newText: "", + path: change.path, + _meta: { + kind: "delete", + } + } + } + + const oldContent = change.kind.type === "add" ? "" : await readFile(change.path, { encoding: "utf8" }).catch(() => null); + if (oldContent === null) { + return null; + } + const newContent = applyPatch(oldContent, change.diff); if (newContent === false) { return null; @@ -254,3 +276,40 @@ async function createPatchContent(change: FileUpdateChange): Promise { +const { mockFiles, mockFileContent, removeMockFile, clearMockFiles } = vi.hoisted(() => { const files = new Map(); return { mockFiles: files, mockFileContent: (path: string, content: string) => files.set(path, content), + removeMockFile: (path: string) => files.delete(path), clearMockFiles: () => files.clear(), }; }); @@ -205,4 +206,69 @@ describe('CodexEventHandler - file change events', () => { 'data/file-change-delete-raw-content.json' ); }); + + it('should handle file deletion when old file is already missing', async () => { + removeMockFile('/test/project/OldFile.kt'); + + const deleteFileNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-3', + changes: [ + { + path: '/test/project/OldFile.kt', + kind: { type: 'delete' }, + diff: `--- /test/project/OldFile.kt ++++ /dev/null +@@ -1,3 +0,0 @@ +-package test.project +- +-class OldFile {}`, + }, + ], + status: 'completed', + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [deleteFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-delete-file.json' + ); + }); + + it('should handle file deletion with raw content when old file is already missing', async () => { + removeMockFile('/test/project/RawDeleteFile.kt'); + + const deletedFileNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-delete-raw', + changes: [ + { + path: '/test/project/RawDeleteFile.kt', + kind: { type: 'delete' }, + diff: 'fun main() {\n println("Hello, World!")\n}\n', + }, + ], + status: 'completed', + }, + }, + }; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, [deletedFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-delete-raw-content.json' + ); + }); });