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' + ); + }); });