1- import type { ContentBlock , ToolCallContent } from "@agentclientprotocol/sdk" ;
2- import { applyPatch , parsePatch , reversePatch } from "diff" ;
3- import { readFile } from "node:fs/promises" ;
1+ import type { ContentBlock , ToolCallContent , ToolCallLocation } from "@agentclientprotocol/sdk" ;
2+ import { parsePatch , type StructuredPatchHunk } from "diff" ;
43import path from "node:path" ;
54import type { UpdateSessionEvent } from "./ACPSessionConnection" ;
65import { stripShellPrefix } from "./CommandUtils" ;
@@ -13,6 +12,7 @@ import type {
1312 CommandAction ,
1413 CommandExecutionStatus ,
1514 DynamicToolCallStatus ,
15+ FileChangePatchUpdatedNotification ,
1616 FileUpdateChange ,
1717 GuardianApprovalReview ,
1818 GuardianApprovalReviewAction ,
@@ -41,6 +41,7 @@ type GuardianApprovalReviewNotification =
4141type WebSearchItem = ThreadItem & { type : "webSearch" } ;
4242type CollabAgentToolCallItem = ThreadItem & { type : "collabAgentToolCall" } ;
4343type CommandExecutionItem = ThreadItem & { type : "commandExecution" } ;
44+ type FileChangeItem = ThreadItem & { type : "fileChange" } ;
4445type ContextCompactionItem = ThreadItem & { type : "contextCompaction" } ;
4546type AcpToolCallEvent = Extract < UpdateSessionEvent , { sessionUpdate : "tool_call" } > ;
4647
@@ -59,21 +60,41 @@ function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus {
5960}
6061
6162export async function createFileChangeUpdate (
62- item : ThreadItem & { type : "fileChange" }
63+ item : FileChangeItem
6364) : Promise < UpdateSessionEvent > {
64- const patches : ToolCallContent [ ] = [ ] ;
65- for ( const change of item . changes ) {
66- const content = await createPatchContent ( change ) ;
67- if ( content ) patches . push ( content ) ;
68- // ignore unparseable diffs
69- }
65+ const rawOutput = createFileChangeRawOutput ( item ) ;
7066 return {
7167 sessionUpdate : "tool_call" ,
7268 toolCallId : item . id ,
7369 title : "Editing files" ,
7470 kind : "edit" ,
7571 status : toAcpStatus ( item . status ) ,
76- content : patches ,
72+ content : await createFileChangeContent ( item . changes ) ,
73+ locations : createFileChangeLocations ( item . changes ) ,
74+ rawInput : createFileChangeRawInput ( item . changes ) ,
75+ ...( rawOutput === undefined ? { } : { rawOutput } ) ,
76+ } ;
77+ }
78+
79+ export async function createFileChangePatchUpdate (
80+ notification : FileChangePatchUpdatedNotification ,
81+ ) : Promise < UpdateSessionEvent > {
82+ return {
83+ sessionUpdate : "tool_call_update" ,
84+ toolCallId : notification . itemId ,
85+ status : "in_progress" ,
86+ content : await createFileChangeContent ( notification . changes ) ,
87+ locations : createFileChangeLocations ( notification . changes ) ,
88+ rawInput : createFileChangeRawInput ( notification . changes ) ,
89+ } ;
90+ }
91+
92+ export function createFileChangeCompleteUpdate ( item : FileChangeItem ) : UpdateSessionEvent {
93+ return {
94+ sessionUpdate : "tool_call_update" ,
95+ toolCallId : item . id ,
96+ status : toAcpStatus ( item . status ) ,
97+ rawOutput : createFileChangeRawOutput ( item ) ,
7798 } ;
7899}
79100
@@ -734,23 +755,31 @@ function createContent(content: ContentBlock): ToolCallContent {
734755 } ;
735756}
736757
737- async function createPatchContent ( change : FileUpdateChange ) : Promise < ToolCallContent | null > {
758+ async function createFileChangeContent ( changes : FileUpdateChange [ ] ) : Promise < ToolCallContent [ ] > {
759+ const content : ToolCallContent [ ] = [ ] ;
760+ for ( const change of changes ) {
761+ content . push ( ...await createPatchContent ( change ) ) ;
762+ }
763+ return content ;
764+ }
765+
766+ async function createPatchContent ( change : FileUpdateChange ) : Promise < ToolCallContent [ ] > {
738767 try {
739768 switch ( change . kind . type ) {
740769 case "add" :
741- return await createAddFileContent ( change ) ;
770+ return [ createAddFileContent ( change ) ] ;
742771 case "delete" :
743- return await createDeleteFileContent ( change ) ;
772+ return [ createDeleteFileContent ( change ) ] ;
744773 case "update" :
745- return await createUpdateFileContent ( change ) ;
774+ return createUpdateFileContent ( change ) ;
746775 }
747776 } catch ( error ) {
748777 logger . log ( `Error processing file update change: ${ error } ` ) ;
749- return null ;
778+ return [ ] ;
750779 }
751780}
752781
753- async function createAddFileContent ( change : FileUpdateChange ) : Promise < ToolCallContent | null > {
782+ function createAddFileContent ( change : FileUpdateChange ) : ToolCallContent {
754783 return {
755784 type : "diff" ,
756785 oldText : null ,
@@ -762,63 +791,46 @@ async function createAddFileContent(change: FileUpdateChange): Promise<ToolCallC
762791 } ;
763792}
764793
765- async function createUpdateFileContent ( change : FileUpdateChange ) : Promise < ToolCallContent | null > {
766- if ( change . kind . type !== "update" ) return null ;
767-
768- const unifiedDiff = recoverCorruptedDiff ( change . diff ) ;
769- const movePath = change . kind . move_path ;
770-
771- const oldContent = await readFileContent ( change . path ) ;
772- if ( oldContent !== null ) {
773- const patchedContent = applyPatch ( oldContent , unifiedDiff ) ;
774- if ( patchedContent === false ) {
775- // If Codex runs in full access mode, the file might already be patched.
776- // we can verify this by checking if the reverted patch applies.
777- const revertedPatch = revertPatch ( unifiedDiff ) ;
778- if ( revertedPatch ) {
779- const revertedContent = applyPatch ( oldContent , revertedPatch ) ;
780- if ( revertedContent !== false ) {
781- return createUpdateDiffContent ( change . path , revertedContent , oldContent ) ;
782- }
783- }
784- return null ;
785- }
786- return createUpdateDiffContent ( movePath ?? change . path , oldContent , patchedContent ) ;
787- }
788-
789- if ( ! movePath ) return null ;
790- const newContent = await readFileContent ( movePath ) ;
791- if ( newContent === null ) return null ;
792-
793- const revertedPatch = revertPatch ( unifiedDiff ) ;
794- if ( ! revertedPatch ) return null ;
795-
796- const revertedContent = applyPatch ( newContent , revertedPatch ) ;
797- if ( revertedContent === false ) return null ;
798-
799- return createUpdateDiffContent ( movePath , revertedContent , newContent ) ;
800- }
801-
802- function revertPatch ( unifiedDiff : string ) {
803- const [ patch ] = parsePatch ( unifiedDiff ) ;
804- if ( ! patch ) return null ;
794+ function createUpdateFileContent ( change : FileUpdateChange ) : ToolCallContent [ ] {
795+ if ( change . kind . type !== "update" ) return [ ] ;
805796
806- return reversePatch ( patch ) ;
797+ const patches = parsePatch ( recoverCorruptedDiff ( change . diff ) ) ;
798+ const targetPath = change . kind . move_path ?? change . path ;
799+ return patches . flatMap ( ( patch ) => patch . hunks . map ( ( hunk ) => createUpdateDiffContent ( targetPath , hunk ) ) ) ;
807800}
808801
809- function createUpdateDiffContent ( path : string , oldText : string , newText : string ) : ToolCallContent {
802+ function createUpdateDiffContent ( path : string , hunk : StructuredPatchHunk ) : ToolCallContent {
810803 return {
811804 type : "diff" ,
812- oldText,
813- newText,
805+ oldText : createHunkText ( hunk , "old" ) ,
806+ newText : createHunkText ( hunk , "new" ) ,
814807 path,
815808 _meta : {
816809 kind : "update" ,
810+ old_start : hunk . oldStart ,
811+ new_start : hunk . newStart ,
817812 } ,
818813 } ;
819814}
820815
821- async function createDeleteFileContent ( change : FileUpdateChange ) : Promise < ToolCallContent > {
816+ function createHunkText ( hunk : StructuredPatchHunk , side : "old" | "new" ) : string {
817+ return hunk . lines . flatMap ( ( line ) : string [ ] => {
818+ switch ( line [ 0 ] ) {
819+ case " " :
820+ return [ line . slice ( 1 ) ] ;
821+ case "-" :
822+ return side === "old" ? [ line . slice ( 1 ) ] : [ ] ;
823+ case "+" :
824+ return side === "new" ? [ line . slice ( 1 ) ] : [ ] ;
825+ case "\\" :
826+ return [ ] ;
827+ default :
828+ return [ ] ;
829+ }
830+ } ) . join ( "\n" ) ;
831+ }
832+
833+ function createDeleteFileContent ( change : FileUpdateChange ) : ToolCallContent {
822834 return {
823835 type : "diff" ,
824836 oldText : change . diff , // app-server always returns file content instead of diff
@@ -830,8 +842,56 @@ async function createDeleteFileContent(change: FileUpdateChange): Promise<ToolCa
830842 }
831843}
832844
833- async function readFileContent ( filePath : string ) : Promise < string | null > {
834- return await readFile ( filePath , { encoding : "utf8" } ) . catch ( ( ) => null ) ;
845+ function createFileChangeLocations ( changes : FileUpdateChange [ ] ) : ToolCallLocation [ ] {
846+ const locations = new Map < string , ToolCallLocation > ( ) ;
847+ const addLocation = ( filePath : string , line ?: number ) => {
848+ const current = locations . get ( filePath ) ;
849+ if ( current ?. line !== undefined || ( current && line === undefined ) ) {
850+ return ;
851+ }
852+ locations . set ( filePath , line === undefined ? { path : filePath } : { path : filePath , line } ) ;
853+ } ;
854+
855+ for ( const change of changes ) {
856+ switch ( change . kind . type ) {
857+ case "add" :
858+ case "delete" :
859+ addLocation ( change . path ) ;
860+ break ;
861+ case "update" : {
862+ const firstHunk = firstUpdateHunk ( change ) ;
863+ if ( change . kind . move_path && change . kind . move_path !== change . path ) {
864+ addLocation ( change . path , firstHunk ?. oldStart ) ;
865+ }
866+ addLocation ( change . kind . move_path ?? change . path , firstHunk ?. newStart ) ;
867+ break ;
868+ }
869+ }
870+ }
871+
872+ return [ ...locations . values ( ) ] ;
873+ }
874+
875+ function firstUpdateHunk ( change : FileUpdateChange ) : StructuredPatchHunk | undefined {
876+ try {
877+ return parsePatch ( recoverCorruptedDiff ( change . diff ) ) [ 0 ] ?. hunks [ 0 ] ;
878+ } catch {
879+ return undefined ;
880+ }
881+ }
882+
883+ function createFileChangeRawInput ( changes : FileUpdateChange [ ] ) {
884+ return { changes } ;
885+ }
886+
887+ function createFileChangeRawOutput ( item : FileChangeItem ) : Record < string , unknown > | undefined {
888+ if ( item . status === "inProgress" ) {
889+ return undefined ;
890+ }
891+ return {
892+ status : item . status ,
893+ success : item . status === "completed" ,
894+ } ;
835895}
836896
837897/**
0 commit comments