diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 19119005..8c1bef8b 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -153,19 +153,33 @@ export class CodexEventHandler { } private async createPatchContent(change: FileUpdateChange): Promise { - const oldContent = await readFile(change.path, { encoding: "utf8" }); + if (change.kind.type === "add" && !this.isUnifiedDiff(change.diff)) { + // For new files, diff may contain raw file content instead of a patch + return { + type: "diff", + oldText: null, + newText: change.diff, + path: change.path, + } + } + + const oldContent = change.kind.type === "add" ? "" : await readFile(change.path, { encoding: "utf8" }); const newContent = applyPatch(oldContent, change.diff); - if (!newContent) { + if (newContent === false) { return null } return { type: "diff", - oldText: oldContent, + oldText: change.kind.type === "add" ? null : oldContent, newText: newContent, path: change.path, } } + private isUnifiedDiff(content: string): boolean { + return content.startsWith('--- ') || content.includes('\n--- '); + } + private async createCommandEvent(item: ThreadItem & { "type": "commandExecution" }): Promise { const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined; if (commandAction) { diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json new file mode 100644 index 00000000..0c5c705e --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-add-multiple-files.json @@ -0,0 +1,29 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-2", + "title": "Editing files", + "kind": "edit", + "status": "completed", + "content": [ + { + "type": "diff", + "oldText": null, + "newText": "class FileA\n", + "path": "/test/project/FileA.kt" + }, + { + "type": "diff", + "oldText": null, + "newText": "class FileB\n", + "path": "/test/project/FileB.kt" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json new file mode 100644 index 00000000..87cfa460 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-add-new-file.json @@ -0,0 +1,23 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-1", + "title": "Editing files", + "kind": "edit", + "status": "completed", + "content": [ + { + "type": "diff", + "oldText": null, + "newText": "package test.project\n\nclass NewFile {\n fun hello() = \"Hello\"\n}\n", + "path": "/test/project/NewFile.kt" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json new file mode 100644 index 00000000..87f69065 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json @@ -0,0 +1,23 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-raw", + "title": "Editing files", + "kind": "edit", + "status": "completed", + "content": [ + { + "type": "diff", + "oldText": null, + "newText": "fun main() {\n println(\"Hello, World!\")\n}\n", + "path": "/test/project/RawFile.kt" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/file-change-delete-file.json b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json new file mode 100644 index 00000000..7a9fdc38 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/file-change-delete-file.json @@ -0,0 +1,23 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "file-change-3", + "title": "Editing files", + "kind": "edit", + "status": "completed", + "content": [ + { + "type": "diff", + "oldText": "package test.project\n\nclass OldFile {}", + "newText": "", + "path": "/test/project/OldFile.kt" + } + ] + } + } + ] +} \ 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 new file mode 100644 index 00000000..b0521c90 --- /dev/null +++ b/src/__tests__/CodexACPAgent/file-change-events.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { SessionState } from '../../CodexAcpServer'; +import type { ServerNotification } from '../../app-server'; +import { createCodexMockTestFixture, type CodexMockTestFixture } from '../acp-test-utils'; + +const { mockFiles, mockFileContent, clearMockFiles } = vi.hoisted(() => { + const files = new Map(); + return { + mockFiles: files, + mockFileContent: (path: string, content: string) => files.set(path, content), + clearMockFiles: () => files.clear(), + }; +}); + +vi.mock('node:fs/promises', () => ({ + readFile: (path: string) => { + const content = mockFiles.get(path); + if (content !== undefined) { + return Promise.resolve(content); + } + return Promise.reject(new Error(`ENOENT: no such file or directory, open '${path}'`)); + }, +})); + +describe('CodexEventHandler - file change events', () => { + let mockFixture: CodexMockTestFixture; + const sessionId = 'test-session-id'; + + beforeEach(() => { + mockFixture = createCodexMockTestFixture(); + clearMockFiles(); + mockFileContent('/test/project/OldFile.kt', 'package test.project\n\nclass OldFile {}'); + }); + + const sessionState: SessionState = { + pendingPrompt: null, + sessionMetadata: { + sessionId, + currentModelId: 'model-id', + models: [], + }, + }; + + async function setupAndSendNotifications(notifications: ServerNotification[]) { + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + + mockFixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue(undefined); + mockFixture.getCodexAppServerClient().awaitTurnCompleted = vi.fn().mockResolvedValue(undefined); + + vi.spyOn(codexAcpAgent, 'getSessionState').mockReturnValue(sessionState); + + await codexAcpAgent.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'test prompt' }], + }); + + mockFixture.clearAcpConnectionDump(); + + for (const notification of notifications) { + mockFixture.sendServerNotification(notification); + } + + await vi.waitFor(() => { + const dump = mockFixture.getAcpConnectionDump([]); + expect(dump.length).toBeGreaterThan(0); + }); + } + + it('should handle new file creation', async () => { + const newFileNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-1', + changes: [ + { + path: '/test/project/NewFile.kt', + kind: { type: 'add' }, + diff: `--- /dev/null ++++ /test/project/NewFile.kt +@@ -0,0 +1,5 @@ ++package test.project ++ ++class NewFile { ++ fun hello() = "Hello" ++}`, + }, + ], + status: 'completed', + }, + }, + }; + + await setupAndSendNotifications([newFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-add-new-file.json' + ); + }); + + it('should handle multiple new files in single change', async () => { + const multiFileNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-2', + changes: [ + { + path: '/test/project/FileA.kt', + kind: { type: 'add' }, + diff: `--- /dev/null ++++ /test/project/FileA.kt +@@ -0,0 +1 @@ ++class FileA`, + }, + { + path: '/test/project/FileB.kt', + kind: { type: 'add' }, + diff: `--- /dev/null ++++ /test/project/FileB.kt +@@ -0,0 +1 @@ ++class FileB`, + }, + ], + status: 'completed', + }, + }, + }; + + await setupAndSendNotifications([multiFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-add-multiple-files.json' + ); + }); + + it('should handle new file creation with raw content', async () => { + // Codex sends raw file content (not unified diff) for new files + const newFileNotification: ServerNotification = { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: 'fileChange', + id: 'file-change-raw', + changes: [ + { + path: '/test/project/RawFile.kt', + kind: { type: 'add' }, + diff: 'fun main() {\n println("Hello, World!")\n}\n', + }, + ], + status: 'completed', + }, + }, + }; + + await setupAndSendNotifications([newFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-add-raw-content.json' + ); + }); + + it('should handle file deletion', async () => { + 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 setupAndSendNotifications([deleteFileNotification]); + + await expect(mockFixture.getAcpConnectionDump(['id'])).toMatchFileSnapshot( + 'data/file-change-delete-file.json' + ); + }); +});