Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ import {
createContextCompactionCompleteUpdate,
createContextCompactionStartUpdate,
createDynamicToolCallUpdate,
createFileChangeCompleteUpdate,
createFileChangePatchUpdate,
createFileChangeUpdate,
createGuardianApprovalReviewToolCall,
createGuardianApprovalReviewToolCallUpdate,
Expand Down Expand Up @@ -185,6 +187,8 @@ export class CodexEventHandler {
return this.createThreadGoalClearedEvent(notification.params);
case "item/commandExecution/terminalInteraction":
return this.createTerminalInteractionEvent(notification.params);
case "item/fileChange/patchUpdated":
return await createFileChangePatchUpdate(notification.params);
// ignored events
case "thread/deleted":
case "command/exec/outputDelta":
Expand All @@ -193,7 +197,6 @@ export class CodexEventHandler {
case "turn/diff/updated":
case "turn/moderationMetadata":
case "item/fileChange/outputDelta":
case "item/fileChange/patchUpdated":
case "account/updated":
case "fs/changed":
case "mcpServer/startupStatus/updated":
Expand Down Expand Up @@ -360,6 +363,7 @@ export class CodexEventHandler {
private async completeItemEvent(event: ItemCompletedNotification): Promise<UpdateSessionEvent | null> {
switch (event.item.type) {
case "fileChange":
return createFileChangeCompleteUpdate(event.item);
case "dynamicToolCall":
return {
sessionUpdate: "tool_call_update",
Expand Down
188 changes: 124 additions & 64 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { ContentBlock, ToolCallContent } from "@agentclientprotocol/sdk";
import { applyPatch, parsePatch, reversePatch } from "diff";
import { readFile } from "node:fs/promises";
import type { ContentBlock, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
import { parsePatch, type StructuredPatchHunk } from "diff";
import path from "node:path";
import type { UpdateSessionEvent } from "./ACPSessionConnection";
import { stripShellPrefix } from "./CommandUtils";
Expand All @@ -13,6 +12,7 @@ import type {
CommandAction,
CommandExecutionStatus,
DynamicToolCallStatus,
FileChangePatchUpdatedNotification,
FileUpdateChange,
GuardianApprovalReview,
GuardianApprovalReviewAction,
Expand Down Expand Up @@ -41,6 +41,7 @@ type GuardianApprovalReviewNotification =
type WebSearchItem = ThreadItem & { type: "webSearch" };
type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" };
type CommandExecutionItem = ThreadItem & { type: "commandExecution" };
type FileChangeItem = ThreadItem & { type: "fileChange" };
type ContextCompactionItem = ThreadItem & { type: "contextCompaction" };
type AcpToolCallEvent = Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>;

Expand All @@ -59,21 +60,41 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
}

export async function createFileChangeUpdate(
item: ThreadItem & { type: "fileChange" }
item: FileChangeItem
): Promise<UpdateSessionEvent> {
const patches: ToolCallContent[] = [];
for (const change of item.changes) {
const content = await createPatchContent(change);
if (content) patches.push(content);
// ignore unparseable diffs
}
const rawOutput = createFileChangeRawOutput(item);
return {
sessionUpdate: "tool_call",
toolCallId: item.id,
title: "Editing files",
kind: "edit",
status: toAcpStatus(item.status),
content: patches,
content: await createFileChangeContent(item.changes),
locations: createFileChangeLocations(item.changes),
rawInput: createFileChangeRawInput(item.changes),
...(rawOutput === undefined ? {} : { rawOutput }),
};
}

export async function createFileChangePatchUpdate(
notification: FileChangePatchUpdatedNotification,
): Promise<UpdateSessionEvent> {
return {
sessionUpdate: "tool_call_update",
toolCallId: notification.itemId,
status: "in_progress",
content: await createFileChangeContent(notification.changes),
locations: createFileChangeLocations(notification.changes),
rawInput: createFileChangeRawInput(notification.changes),
};
}

export function createFileChangeCompleteUpdate(item: FileChangeItem): UpdateSessionEvent {
return {
sessionUpdate: "tool_call_update",
toolCallId: item.id,
status: toAcpStatus(item.status),
rawOutput: createFileChangeRawOutput(item),
};
}

Expand Down Expand Up @@ -734,23 +755,31 @@ function createContent(content: ContentBlock): ToolCallContent {
};
}

async function createPatchContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
async function createFileChangeContent(changes: FileUpdateChange[]): Promise<ToolCallContent[]> {
const content: ToolCallContent[] = [];
for (const change of changes) {
content.push(...await createPatchContent(change));
}
return content;
}

async function createPatchContent(change: FileUpdateChange): Promise<ToolCallContent[]> {
try {
switch (change.kind.type) {
case "add":
return await createAddFileContent(change);
return [createAddFileContent(change)];
case "delete":
return await createDeleteFileContent(change);
return [createDeleteFileContent(change)];
case "update":
return await createUpdateFileContent(change);
return createUpdateFileContent(change);
}
} catch (error) {
logger.log(`Error processing file update change: ${error}`);
return null;
return [];
}
}

async function createAddFileContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
function createAddFileContent(change: FileUpdateChange): ToolCallContent {
return {
type: "diff",
oldText: null,
Expand All @@ -762,63 +791,46 @@ async function createAddFileContent(change: FileUpdateChange): Promise<ToolCallC
};
}

async function createUpdateFileContent(change: FileUpdateChange): Promise<ToolCallContent | null> {
if (change.kind.type !== "update") return null;

const unifiedDiff = recoverCorruptedDiff(change.diff);
const movePath = change.kind.move_path;

const oldContent = await readFileContent(change.path);
if (oldContent !== null) {
const patchedContent = applyPatch(oldContent, unifiedDiff);
if (patchedContent === false) {
// If Codex runs in full access mode, the file might already be patched.
// we can verify this by checking if the reverted patch applies.
const revertedPatch = revertPatch(unifiedDiff);
if (revertedPatch) {
const revertedContent = applyPatch(oldContent, revertedPatch);
if (revertedContent !== false) {
return createUpdateDiffContent(change.path, revertedContent, oldContent);
}
}
return null;
}
return createUpdateDiffContent(movePath ?? change.path, oldContent, patchedContent);
}

if (!movePath) return null;
const newContent = await readFileContent(movePath);
if (newContent === null) return null;

const revertedPatch = revertPatch(unifiedDiff);
if (!revertedPatch) return null;

const revertedContent = applyPatch(newContent, revertedPatch);
if (revertedContent === false) return null;

return createUpdateDiffContent(movePath, revertedContent, newContent);
}

function revertPatch(unifiedDiff: string) {
const [patch] = parsePatch(unifiedDiff);
if (!patch) return null;
function createUpdateFileContent(change: FileUpdateChange): ToolCallContent[] {
if (change.kind.type !== "update") return [];

return reversePatch(patch);
const patches = parsePatch(recoverCorruptedDiff(change.diff));
const targetPath = change.kind.move_path ?? change.path;
return patches.flatMap((patch) => patch.hunks.map((hunk) => createUpdateDiffContent(targetPath, hunk)));
}

function createUpdateDiffContent(path: string, oldText: string, newText: string): ToolCallContent {
function createUpdateDiffContent(path: string, hunk: StructuredPatchHunk): ToolCallContent {
return {
type: "diff",
oldText,
newText,
oldText: createHunkText(hunk, "old"),
newText: createHunkText(hunk, "new"),
path,
_meta: {
kind: "update",
old_start: hunk.oldStart,
new_start: hunk.newStart,
},
};
}

async function createDeleteFileContent(change: FileUpdateChange): Promise<ToolCallContent> {
function createHunkText(hunk: StructuredPatchHunk, side: "old" | "new"): string {
return hunk.lines.flatMap((line): string[] => {
switch (line[0]) {
case " ":
return [line.slice(1)];
case "-":
return side === "old" ? [line.slice(1)] : [];
case "+":
return side === "new" ? [line.slice(1)] : [];
case "\\":
return [];
default:
return [];
}
}).join("\n");
}

function createDeleteFileContent(change: FileUpdateChange): ToolCallContent {
return {
type: "diff",
oldText: change.diff, // app-server always returns file content instead of diff
Expand All @@ -830,8 +842,56 @@ async function createDeleteFileContent(change: FileUpdateChange): Promise<ToolCa
}
}

async function readFileContent(filePath: string): Promise<string | null> {
return await readFile(filePath, { encoding: "utf8" }).catch(() => null);
function createFileChangeLocations(changes: FileUpdateChange[]): ToolCallLocation[] {
const locations = new Map<string, ToolCallLocation>();
const addLocation = (filePath: string, line?: number) => {
const current = locations.get(filePath);
if (current?.line !== undefined || (current && line === undefined)) {
return;
}
locations.set(filePath, line === undefined ? { path: filePath } : { path: filePath, line });
};

for (const change of changes) {
switch (change.kind.type) {
case "add":
case "delete":
addLocation(change.path);
break;
case "update": {
const firstHunk = firstUpdateHunk(change);
if (change.kind.move_path && change.kind.move_path !== change.path) {
addLocation(change.path, firstHunk?.oldStart);
}
addLocation(change.kind.move_path ?? change.path, firstHunk?.newStart);
break;
}
}
}

return [...locations.values()];
}

function firstUpdateHunk(change: FileUpdateChange): StructuredPatchHunk | undefined {
try {
return parsePatch(recoverCorruptedDiff(change.diff))[0]?.hunks[0];
} catch {
return undefined;
}
}

function createFileChangeRawInput(changes: FileUpdateChange[]) {
return { changes };
}

function createFileChangeRawOutput(item: FileChangeItem): Record<string, unknown> | undefined {
if (item.status === "inProgress") {
return undefined;
}
return {
status: item.status,
success: item.status === "completed",
};
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,37 @@
"kind": "add"
}
}
]
],
"locations": [
{
"path": "/test/project/FileA.kt"
},
{
"path": "/test/project/FileB.kt"
}
],
"rawInput": {
"changes": [
{
"path": "/test/project/FileA.kt",
"kind": {
"type": "add"
},
"diff": "class FileA\n"
},
{
"path": "/test/project/FileB.kt",
"kind": {
"type": "add"
},
"diff": "class FileB\n"
}
]
},
"rawOutput": {
"status": "completed",
"success": true
}
}
}
]
Expand Down
22 changes: 21 additions & 1 deletion src/__tests__/CodexACPAgent/data/file-change-add-new-file.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,27 @@
"kind": "add"
}
}
]
],
"locations": [
{
"path": "/test/project/NewFile.kt"
}
],
"rawInput": {
"changes": [
{
"path": "/test/project/NewFile.kt",
"kind": {
"type": "add"
},
"diff": "package test.project\n\nclass NewFile {\n fun hello() = \"Hello\"\n}\n"
}
]
},
"rawOutput": {
"status": "completed",
"success": true
}
}
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,27 @@
"kind": "add"
}
}
]
],
"locations": [
{
"path": "/test/project/RawFile.kt"
}
],
"rawInput": {
"changes": [
{
"path": "/test/project/RawFile.kt",
"kind": {
"type": "add"
},
"diff": "fun main() {\n println(\"Hello, World!\")\n}\n"
}
]
},
"rawOutput": {
"status": "completed",
"success": true
}
}
}
]
Expand Down
Loading