Skip to content

Commit 3e5f2fb

Browse files
committed
Enrich file change payloads with paths and diff fallbacks
1 parent ea754af commit 3e5f2fb

11 files changed

Lines changed: 385 additions & 61 deletions

src/CodexApprovalHandler.ts

Lines changed: 24 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type {
66
CommandExecutionRequestApprovalResponse,
77
FileChangeRequestApprovalParams,
88
FileChangeRequestApprovalResponse,
9-
FileUpdateChange,
109
McpServerElicitationRequestParams,
1110
McpServerElicitationRequestResponse
1211
} from "./app-server/v2";
@@ -15,7 +14,12 @@ import type {ToolCallContent} from "@agentclientprotocol/sdk/dist/schema/types.g
1514
import {logger} from "./Logger";
1615
import {stripShellPrefix} from "./CodexEventHandler";
1716
import type {ApprovalContextStore} from "./CodexApprovalContext";
18-
import {createFileChangeContents} from "./CodexToolCallMapper";
17+
import {
18+
createFileChangeContents,
19+
createFileChangeLocations,
20+
createRawFileChangeInput,
21+
parseUnifiedDiffChanges,
22+
} from "./CodexToolCallMapper";
1923

2024
const APPROVAL_OPTIONS: acp.PermissionOption[] = [
2125
{ optionId: "allow_once", name: "Allow Once", kind: "allow_once" },
@@ -119,47 +123,39 @@ export class CodexApprovalHandler implements ApprovalHandler {
119123
}
120124
}
121125

122-
private createTextContents(...texts: Array<string | null | undefined>): Array<ToolCallContent> | null {
123-
const contents = texts
124-
.map(text => this.createTextContent(text ?? null))
125-
.filter((content): content is ToolCallContent => content !== null)
126-
return contents.length > 0 ? contents : null
127-
}
128-
129126
private async buildFileChangePermissionRequest(
130127
sessionId: string,
131128
params: FileChangeRequestApprovalParams
132129
): Promise<acp.RequestPermissionRequest> {
133130
const reasonContent = this.createTextContent(params.reason ?? null);
134131
const fileChange = this.approvalContext.fileChangesByItemId.get(params.itemId);
135-
const diffContent = fileChange ? await createFileChangeContents(fileChange.changes) : [];
132+
const content: ToolCallContent[] = reasonContent ? [reasonContent] : [];
136133
const toolCall: acp.ToolCallUpdate = {
137134
toolCallId: params.itemId,
138135
kind: "edit",
139136
status: "pending",
140137
};
141-
const content = [
142-
...(reasonContent ? [reasonContent] : []),
143-
...diffContent,
144-
];
145-
if (content.length > 0) {
146-
toolCall.content = content;
147-
}
148138
if (fileChange) {
149-
toolCall.locations = dedupePaths(fileChange.changes).map(path => ({ path }));
150-
toolCall.rawInput = {
151-
changes: fileChange.changes.map(change => ({
152-
path: change.path,
153-
kind: change.kind.type,
154-
diff: change.diff,
155-
})),
156-
};
139+
content.push(...await createFileChangeContents(fileChange.changes));
140+
toolCall.locations = createFileChangeLocations(fileChange.changes);
141+
toolCall.rawInput = createRawFileChangeInput(fileChange.changes);
157142
} else {
158143
const turnDiff = this.approvalContext.turnDiffsByTurnId.get(params.turnId);
159144
if (turnDiff) {
160-
toolCall.rawInput = { unifiedDiff: turnDiff };
145+
const parsedChanges = parseUnifiedDiffChanges(turnDiff);
146+
content.push(...await createFileChangeContents(parsedChanges));
147+
const locations = createFileChangeLocations(parsedChanges);
148+
if (locations.length > 0) {
149+
toolCall.locations = locations;
150+
}
151+
toolCall.rawInput = parsedChanges.length > 0
152+
? { unifiedDiff: turnDiff, ...createRawFileChangeInput(parsedChanges) }
153+
: { unifiedDiff: turnDiff };
161154
}
162155
}
156+
if (content.length > 0) {
157+
toolCall.content = content;
158+
}
163159
return {
164160
sessionId,
165161
toolCall,
@@ -179,6 +175,7 @@ export class CodexApprovalHandler implements ApprovalHandler {
179175
const toolDescription = typeof meta?.["tool_description"] === "string"
180176
? meta["tool_description"]
181177
: null;
178+
const toolDescriptionContent = this.createTextContent(toolDescription);
182179
const rawInput = this.tryToJsonValue(meta?.["tool_params"]) ?? null;
183180

184181
return {
@@ -188,7 +185,7 @@ export class CodexApprovalHandler implements ApprovalHandler {
188185
title: params.message !== "" ? params.message : "MCP permission request",
189186
kind: "other",
190187
status: "pending",
191-
content: this.createTextContents(toolDescription),
188+
content: toolDescriptionContent ? [toolDescriptionContent] : null,
192189
rawInput,
193190
},
194191
options: this.buildMcpServerElicitationOptions(persistOptions),
@@ -350,7 +347,3 @@ export class CodexApprovalHandler implements ApprovalHandler {
350347
return { action: "cancel", content: null, _meta: null }
351348
}
352349
}
353-
354-
function dedupePaths(changes: Array<FileUpdateChange>): Array<string> {
355-
return Array.from(new Set(changes.map(change => change.path)));
356-
}

src/CodexToolCallMapper.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,26 @@ export async function createFileChangeUpdate(
4040
item: ThreadItem & { type: "fileChange" }
4141
): Promise<UpdateSessionEvent> {
4242
const patches = await createFileChangeContents(item.changes);
43+
const details = createFileChangeDetails(item.changes);
4344
return {
4445
sessionUpdate: "tool_call",
4546
toolCallId: item.id,
46-
title: "Editing files",
47+
title: details.title,
4748
kind: "edit",
4849
status: toAcpStatus(item.status),
4950
content: patches,
51+
locations: details.locations,
52+
rawInput: details.rawInput,
53+
};
54+
}
55+
56+
export function createFileChangeCompletionUpdate(
57+
item: ThreadItem & { type: "fileChange" }
58+
): UpdateSessionEvent {
59+
return {
60+
sessionUpdate: "tool_call_update",
61+
toolCallId: item.id,
62+
status: toAcpStatus(item.status),
5063
};
5164
}
5265

@@ -60,6 +73,67 @@ export async function createFileChangeContents(changes: Array<FileUpdateChange>)
6073
return patches;
6174
}
6275

76+
export function createFileChangeLocations(changes: Array<FileUpdateChange>): Array<{ path: string }> {
77+
return Array.from(new Set(changes.map(change => change.path))).map(path => ({ path }));
78+
}
79+
80+
export function createRawFileChangeInput(changes: Array<FileUpdateChange>): {
81+
changes: Array<{
82+
path: string;
83+
kind: FileUpdateChange["kind"];
84+
diff: string;
85+
}>;
86+
} {
87+
return {
88+
changes: changes.map(change => ({
89+
path: change.path,
90+
kind: change.kind,
91+
diff: change.diff,
92+
})),
93+
};
94+
}
95+
96+
export function parseUnifiedDiffChanges(unifiedDiff: string): Array<FileUpdateChange> {
97+
try {
98+
return parsePatch(unifiedDiff)
99+
.map(patch => {
100+
const oldFileName = normalizeDiffPath(patch.oldFileName);
101+
const newFileName = normalizeDiffPath(patch.newFileName);
102+
const path = newFileName === "/dev/null" ? oldFileName : newFileName;
103+
if (!path) {
104+
return null;
105+
}
106+
return {
107+
path,
108+
kind: toPatchChangeKind(oldFileName, newFileName),
109+
diff: formatParsedPatch(patch, oldFileName, newFileName),
110+
} satisfies FileUpdateChange;
111+
})
112+
.filter((change): change is FileUpdateChange => change !== null);
113+
} catch {
114+
return [];
115+
}
116+
}
117+
118+
function createFileChangeDetails(changes: Array<FileUpdateChange>): {
119+
title: string;
120+
locations: Array<{ path: string }>;
121+
rawInput: {
122+
changes: Array<{
123+
path: string;
124+
kind: FileUpdateChange["kind"];
125+
diff: string;
126+
}>;
127+
};
128+
} {
129+
const uniquePaths = createFileChangeLocations(changes);
130+
return {
131+
title: uniquePaths.length > 0 ? uniquePaths.map(location => location.path).join(", ") : "File change",
132+
locations: uniquePaths,
133+
rawInput: createRawFileChangeInput(changes),
134+
};
135+
}
136+
63137
export async function createCommandExecutionUpdate(
64138
item: ThreadItem & { type: "commandExecution" }
65139
): Promise<UpdateSessionEvent> {
@@ -313,6 +387,52 @@ function isUnifiedDiff(content: string): boolean {
313387
return content.startsWith("--- ") || content.includes("\n--- ");
314388
}
315389

390+
function normalizeDiffPath(fileName: string | undefined): string | undefined {
391+
if (!fileName || fileName === "/dev/null") {
392+
return fileName;
393+
}
394+
return fileName.replace(/^[ab]\//, "");
395+
}
396+
397+
function toPatchChangeKind(oldFileName: string | undefined, newFileName: string | undefined): FileUpdateChange["kind"] {
398+
if (oldFileName === "/dev/null") {
399+
return { type: "add" };
400+
}
401+
if (newFileName === "/dev/null") {
402+
return { type: "delete" };
403+
}
404+
return {
405+
type: "update",
406+
move_path: oldFileName && newFileName && oldFileName !== newFileName ? oldFileName : null,
407+
};
408+
}
409+
410+
function formatParsedPatch(
411+
patch: ReturnType<typeof parsePatch>[number],
412+
oldFileName: string | undefined,
413+
newFileName: string | undefined,
414+
): string {
415+
const lines = [
416+
`--- ${oldFileName ?? "/dev/null"}`,
417+
`+++ ${newFileName ?? "/dev/null"}`,
418+
];
419+
for (const hunk of patch.hunks) {
420+
lines.push(`@@ -${formatHunkRange(hunk.oldStart, hunk.oldLines)} +${formatHunkRange(hunk.newStart, hunk.newLines)} @@`);
421+
lines.push(...hunk.lines);
422+
}
423+
return lines.join("\n");
424+
}
425+
426+
function formatHunkRange(start: number, lineCount: number): string {
427+
if (lineCount === 0) {
428+
return `${start - 1},0`;
429+
}
430+
if (lineCount === 1) {
431+
return `${start}`;
432+
}
433+
return `${start},${lineCount}`;
434+
}
435+
316436
/**
317437
* Recreates the content of a deleted file from the unified diff.
318438
* @param unifiedDiff The unified diff of the file deletion patch

src/__tests__/CodexACPAgent/approval-events.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('Approval Events', () => {
3737
function setupSessionWithPendingPrompt() {
3838
const codexAcpAgent = fixture.getCodexAcpAgent();
3939

40-
let resolveTurnCompleted: (value: { threadId: string; turn: { id: string; items: never[]; status: string; error: null } }) => void;
40+
let resolveTurnCompleted: (value: { threadId: string; turn: { id: string; items: never[]; status: string; error: null } }) => void = () => {};
4141
const turnCompletedPromise = new Promise<{ threadId: string; turn: { id: string; items: never[]; status: string; error: null } }>((resolve) => {
4242
resolveTurnCompleted = resolve;
4343
});
@@ -61,7 +61,7 @@ describe('Approval Events', () => {
6161

6262
return {
6363
promptPromise,
64-
completeTurn: () => resolveTurnCompleted!({
64+
completeTurn: () => resolveTurnCompleted({
6565
threadId: sessionId,
6666
turn: { id: "turn-id", items: [], status: "completed", error: null }
6767
})
@@ -400,5 +400,51 @@ describe('Approval Events', () => {
400400
completeTurn();
401401
await promptPromise;
402402
});
403+
404+
it('should include diff content from turn diff when file change item is unavailable', async () => {
405+
const { promptPromise, completeTurn } = setupSessionWithPendingPrompt();
406+
fixture.setPermissionResponse({
407+
outcome: { outcome: 'selected', optionId: 'allow_once' }
408+
});
409+
mockFileContent('/test/project/config.json', '{"feature":false}');
410+
411+
const notification: ServerNotification = {
412+
method: 'turn/diff/updated',
413+
params: {
414+
threadId: sessionId,
415+
turnId: 'turn-1',
416+
diff: `diff --git a/test/project/config.json b/test/project/config.json
417+
index 0000000..1111111 100644
418+
--- a/test/project/config.json
419+
+++ b/test/project/config.json
420+
@@ -1 +1 @@
421+
-{"feature":false}
422+
+{"feature":true}`,
423+
},
424+
};
425+
426+
fixture.sendServerNotification(notification);
427+
await Promise.resolve();
428+
429+
const params: FileChangeRequestApprovalParams = {
430+
threadId: sessionId,
431+
turnId: 'turn-1',
432+
itemId: 'file-change-from-turn-diff',
433+
reason: 'Updating config file',
434+
grantRoot: null,
435+
};
436+
437+
await fixture.sendServerRequest(
438+
'item/fileChange/requestApproval',
439+
params
440+
);
441+
442+
await expect(fixture.getAcpConnectionDump(['_meta'])).toMatchFileSnapshot(
443+
'data/approval-file-change-from-turn-diff.json'
444+
);
445+
446+
completeTurn();
447+
await promptPromise;
448+
});
403449
});
404450
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{
2+
"method": "requestPermission",
3+
"args": [
4+
{
5+
"sessionId": "test-session-id",
6+
"toolCall": {
7+
"toolCallId": "file-change-from-turn-diff",
8+
"kind": "edit",
9+
"status": "pending",
10+
"locations": [
11+
{
12+
"path": "test/project/config.json"
13+
}
14+
],
15+
"rawInput": {
16+
"unifiedDiff": "diff --git a/test/project/config.json b/test/project/config.json\nindex 0000000..1111111 100644\n--- a/test/project/config.json\n+++ b/test/project/config.json\n@@ -1 +1 @@\n-{\"feature\":false}\n+{\"feature\":true}",
17+
"changes": [
18+
{
19+
"path": "test/project/config.json",
20+
"kind": {
21+
"type": "update",
22+
"move_path": null
23+
},
24+
"diff": "--- test/project/config.json\n+++ test/project/config.json\n@@ -1 +1 @@\n-{\"feature\":false}\n+{\"feature\":true}"
25+
}
26+
]
27+
},
28+
"content": [
29+
{
30+
"type": "content",
31+
"content": {
32+
"type": "text",
33+
"text": "Updating config file"
34+
}
35+
}
36+
]
37+
},
38+
"options": [
39+
{
40+
"optionId": "allow_once",
41+
"name": "Allow Once",
42+
"kind": "allow_once"
43+
},
44+
{
45+
"optionId": "allow_always",
46+
"name": "Allow for Session",
47+
"kind": "allow_always"
48+
},
49+
{
50+
"optionId": "reject_once",
51+
"name": "Reject",
52+
"kind": "reject_once"
53+
}
54+
]
55+
}
56+
]
57+
}

0 commit comments

Comments
 (0)