Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,19 +153,33 @@ export class CodexEventHandler {
}

private async createPatchContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can not remove files using this event?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will handle it later

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<UpdateSessionEvent> {
const commandAction = item.commandActions.length === 1 ? item.commandActions[0] : undefined;
if (commandAction) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
23 changes: 23 additions & 0 deletions src/__tests__/CodexACPAgent/data/file-change-add-new-file.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
23 changes: 23 additions & 0 deletions src/__tests__/CodexACPAgent/data/file-change-add-raw-content.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
23 changes: 23 additions & 0 deletions src/__tests__/CodexACPAgent/data/file-change-delete-file.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
]
}
204 changes: 204 additions & 0 deletions src/__tests__/CodexACPAgent/file-change-events.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
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'
);
});
});