Skip to content
Draft
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: 1 addition & 19 deletions src/CodexApprovalHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
FileChangeRequestApprovalParams,
FileChangeRequestApprovalResponse
} from "./app-server/v2";
import type {ToolCallContent} from "@agentclientprotocol/sdk/dist/schema/types.gen";
import {logger} from "./Logger";
import {stripShellPrefix} from "./CodexEventHandler";

Expand Down Expand Up @@ -61,45 +60,28 @@ export class CodexApprovalHandler implements ApprovalHandler {
sessionId: string,
params: CommandExecutionRequestApprovalParams
): acp.RequestPermissionRequest {
const reasonContent = this.createContentFromReason(params.reason ?? null);
return {
sessionId,
toolCall: {
toolCallId: params.itemId,
kind: "execute",
status: "pending",
content: reasonContent ? [reasonContent] : null,
rawInput: params.command ? { command: stripShellPrefix(params.command), cwd: params.cwd } : null,
...(params.command ? { rawInput: { command: stripShellPrefix(params.command), cwd: params.cwd } } : {}),
},
options: APPROVAL_OPTIONS,
};
}

private createContentFromReason(reason: string | null): ToolCallContent | null {
if (reason === null || reason === "") {
return null;
}
return {
type: "content",
content: {
type: "text",
text: reason
}
}
}

private buildFileChangePermissionRequest(
sessionId: string,
params: FileChangeRequestApprovalParams
): acp.RequestPermissionRequest {
const reasonContent = this.createContentFromReason(params.reason ?? null);
return {
sessionId,
toolCall: {
toolCallId: params.itemId,
kind: "edit",
status: "pending",
content: reasonContent ? [reasonContent] : null,
},
options: APPROVAL_OPTIONS,
};
Expand Down
6 changes: 5 additions & 1 deletion src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {toTokenCount} from "./TokenCount";
import {
createCommandExecutionUpdate,
createDynamicToolCallUpdate,
createFileChangeCompletionUpdate,
createFileChangePatchUpdate,
createFileChangeUpdate,
createMcpRawInput,
createMcpRawOutput,
Expand Down Expand Up @@ -105,13 +107,14 @@ export class CodexEventHandler {
case "turn/diff/updated":
case "item/commandExecution/terminalInteraction":
case "item/fileChange/outputDelta":
case "item/fileChange/patchUpdated":
case "account/updated":
case "fs/changed":
case "mcpServer/startupStatus/updated":
case "serverRequest/resolved":
case "model/verification":
return null;
case "item/fileChange/patchUpdated":
return await createFileChangePatchUpdate(notification.params);
case "item/mcpToolCall/progress":
return this.createMcpToolProgressEvent(notification.params);
case "account/rateLimits/updated":
Expand Down Expand Up @@ -245,6 +248,7 @@ export class CodexEventHandler {
private async completeItemEvent(event: ItemCompletedNotification): Promise<UpdateSessionEvent | null> {
switch (event.item.type) {
case "fileChange":
return await createFileChangeCompletionUpdate(event.item);
case "dynamicToolCall":
return {
sessionUpdate: "tool_call_update",
Expand Down
130 changes: 110 additions & 20 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ToolCallContent } from "@agentclientprotocol/sdk";
import { applyPatch, parsePatch } from "diff";
import type { ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk";
import { applyPatch, parsePatch, reversePatch } from "diff";
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { UpdateSessionEvent } from "./ACPSessionConnection";
Expand All @@ -12,6 +12,7 @@ import type {
CommandAction,
CommandExecutionStatus,
DynamicToolCallStatus,
FileChangePatchUpdatedNotification,
FileUpdateChange,
McpToolCallError,
McpToolCallResult,
Expand All @@ -23,6 +24,7 @@ import type { JsonValue } from "./app-server/serde_json/JsonValue";

type CodexItemStatus = CommandExecutionStatus | PatchApplyStatus | McpToolCallStatus | DynamicToolCallStatus;
type AcpToolCallStatus = "pending" | "in_progress" | "completed" | "failed";
type FileChangeFields = Pick<Extract<UpdateSessionEvent, { sessionUpdate: "tool_call" }>, "content" | "locations" | "rawOutput">;

function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
switch (status) {
Expand All @@ -39,19 +41,35 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
export async function createFileChangeUpdate(
item: ThreadItem & { type: "fileChange" }
): Promise<UpdateSessionEvent> {
const patches: ToolCallContent[] = [];
for (const change of item.changes) {
const content = await createPatchContent(change);
if (content) patches.push(content);
// ignore unparseable diffs
}
return {
sessionUpdate: "tool_call",
toolCallId: item.id,
title: "Editing files",
kind: "edit",
status: toAcpStatus(item.status),
content: patches,
...await createFileChangeFields(item.changes),
};
}

export async function createFileChangePatchUpdate(
event: FileChangePatchUpdatedNotification
): Promise<UpdateSessionEvent> {
return {
sessionUpdate: "tool_call_update",
toolCallId: event.itemId,
status: "in_progress",
...await createFileChangeFields(event.changes),
};
}

export async function createFileChangeCompletionUpdate(
item: ThreadItem & { type: "fileChange" }
): Promise<UpdateSessionEvent> {
return {
sessionUpdate: "tool_call_update",
toolCallId: item.id,
status: toAcpStatus(item.status),
...await createFileChangeFields(item.changes),
};
}

Expand Down Expand Up @@ -287,26 +305,98 @@ async function createPatchContent(change: FileUpdateChange): Promise<ToolCallCon
}
}

const oldContent = change.kind.type === "add" ? "" : await readFile(change.path, { encoding: "utf8" }).catch(() => null);
if (oldContent === null) {
const currentContent = change.kind.type === "add" ? "" : await readFile(change.path, { encoding: "utf8" }).catch(() => null);
if (currentContent === null) {
return null;
}

const newContent = applyPatch(oldContent, change.diff);
if (newContent === false) {
const newContent = applyPatch(currentContent, change.diff);
if (newContent !== false) {
return {
type: "diff",
oldText: change.kind.type === "add" ? null : currentContent,
newText: newContent,
path: change.path,
_meta: {
kind: change.kind.type,
},
};
}

const oldContent = isUnifiedDiff(change.diff)
? reverseApplyPatch(currentContent, change.diff)
: null;

if (oldContent !== null) {
return {
type: "diff",
oldText: oldContent,
newText: currentContent,
path: change.path,
_meta: {
kind: change.kind.type,
},
};
}

return null;
}

async function createFileChangeFields(changes: FileUpdateChange[]): Promise<FileChangeFields> {
const content: ToolCallContent[] = [];
for (const change of changes) {
const patch = await createPatchContent(change);
if (patch) content.push(patch);
// ignore unparseable diffs
}

const locations = createFileChangeLocations(changes);
const rawOutput = createFileChangeRawOutput(changes);

return {
...(content.length > 0 ? { content } : {}),
...(locations.length > 0 ? { locations } : {}),
...(rawOutput ? { rawOutput } : {}),
};
}

function createFileChangeLocations(changes: FileUpdateChange[]): ToolCallLocation[] {
const paths = new Set<string>();
for (const change of changes) {
paths.add(change.path);
if (change.kind.type === "update" && change.kind.move_path) {
paths.add(change.kind.move_path);
}
}

return Array.from(paths).map((path) => ({ path }));
}

function createFileChangeRawOutput(changes: FileUpdateChange[]): { diff: string, changes: FileUpdateChange[] } | null {
if (changes.length === 0) {
return null;
}

return {
type: "diff",
oldText: change.kind.type === "add" ? null : oldContent,
newText: newContent,
path: change.path,
_meta: {
kind: change.kind.type,
},
diff: changes.map((change) => change.diff).join("\n"),
changes,
};
}

function reverseApplyPatch(content: string, unifiedDiff: string): string | null {
try {
const [patch] = parsePatch(unifiedDiff);
if (!patch) {
return null;
}

const oldContent = applyPatch(content, reversePatch(patch));
return oldContent === false ? null : oldContent;
} catch {
return null;
}
}

function isUnifiedDiff(content: string): boolean {
return content.startsWith("--- ") || content.includes("\n--- ");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,7 @@
"toolCall": {
"toolCallId": "item-snapshot",
"kind": "execute",
"status": "pending",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Running npm install"
}
}
],
"rawInput": null
"status": "pending"
},
"options": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@
"toolCallId": "item-with-command",
"kind": "execute",
"status": "pending",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Installing dependencies"
}
}
],
"rawInput": {
"command": "npm install",
"cwd": "/home/user/project"
Expand Down
11 changes: 1 addition & 10 deletions src/__tests__/CodexACPAgent/data/approval-file-change.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,7 @@
"toolCall": {
"toolCallId": "file-change-snapshot",
"kind": "edit",
"status": "pending",
"content": [
{
"type": "content",
"content": {
"type": "text",
"text": "Modifying config file"
}
}
]
"status": "pending"
},
"options": [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,34 @@
"kind": "add"
}
}
]
],
"locations": [
{
"path": "/test/project/FileA.kt"
},
{
"path": "/test/project/FileB.kt"
}
],
"rawOutput": {
"diff": "--- /dev/null\n+++ /test/project/FileA.kt\n@@ -0,0 +1 @@\n+class FileA\n--- /dev/null\n+++ /test/project/FileB.kt\n@@ -0,0 +1 @@\n+class FileB",
"changes": [
{
"path": "/test/project/FileA.kt",
"kind": {
"type": "add"
},
"diff": "--- /dev/null\n+++ /test/project/FileA.kt\n@@ -0,0 +1 @@\n+class FileA"
},
{
"path": "/test/project/FileB.kt",
"kind": {
"type": "add"
},
"diff": "--- /dev/null\n+++ /test/project/FileB.kt\n@@ -0,0 +1 @@\n+class FileB"
}
]
}
}
}
]
Expand Down
19 changes: 18 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,24 @@
"kind": "add"
}
}
]
],
"locations": [
{
"path": "/test/project/NewFile.kt"
}
],
"rawOutput": {
"diff": "--- /dev/null\n+++ /test/project/NewFile.kt\n@@ -0,0 +1,5 @@\n+package test.project\n+\n+class NewFile {\n+ fun hello() = \"Hello\"\n+}",
"changes": [
{
"path": "/test/project/NewFile.kt",
"kind": {
"type": "add"
},
"diff": "--- /dev/null\n+++ /test/project/NewFile.kt\n@@ -0,0 +1,5 @@\n+package test.project\n+\n+class NewFile {\n+ fun hello() = \"Hello\"\n+}"
}
]
}
}
}
]
Expand Down
Loading
Loading