@@ -365,16 +365,25 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
365365
366366 case 'patch' : {
367367 const fileId = ( args as Record < string , unknown > ) . fileId as string | undefined
368- const edits = ( args as Record < string , unknown > ) . edits as
368+ const edit = ( args as Record < string , unknown > ) . edit as
369+ | {
370+ mode : string
371+ before_anchor ?: string
372+ after_anchor ?: string
373+ start_anchor ?: string
374+ end_anchor ?: string
375+ anchor ?: string
376+ content ?: string
377+ occurrence ?: number
378+ }
379+ | undefined
380+ const legacyEdits = ( args as Record < string , unknown > ) . edits as
369381 | { search : string ; replace : string } [ ]
370382 | undefined
371383
372384 if ( ! fileId ) {
373385 return { success : false , message : 'fileId is required for patch operation' }
374386 }
375- if ( ! edits || ! Array . isArray ( edits ) || edits . length === 0 ) {
376- return { success : false , message : 'edits array is required for patch operation' }
377- }
378387
379388 const fileRecord = await getWorkspaceFile ( workspaceId , fileId )
380389 if ( ! fileRecord ) {
@@ -384,24 +393,122 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
384393 const currentBuffer = await downloadWsFile ( fileRecord )
385394 let content = currentBuffer . toString ( 'utf-8' )
386395
387- for ( const edit of edits ) {
388- const firstIdx = content . indexOf ( edit . search )
389- if ( firstIdx === - 1 ) {
396+ if ( edit && typeof edit . mode === 'string' ) {
397+ const lines = content . split ( '\n' )
398+
399+ const defaultOccurrence = edit . occurrence ?? 1
400+
401+ const findAnchorLine = (
402+ anchor : string ,
403+ occurrence = defaultOccurrence ,
404+ afterIndex = - 1
405+ ) : { index : number ; error ?: string } => {
406+ const trimmed = anchor . trim ( )
407+ let count = 0
408+ for ( let i = afterIndex + 1 ; i < lines . length ; i ++ ) {
409+ if ( lines [ i ] . trim ( ) === trimmed ) {
410+ count ++
411+ if ( count === occurrence ) return { index : i }
412+ }
413+ }
414+ if ( count === 0 ) {
415+ return {
416+ index : - 1 ,
417+ error : `Anchor line not found in "${ fileRecord . name } ": "${ anchor . slice ( 0 , 100 ) } "` ,
418+ }
419+ }
390420 return {
391- success : false ,
392- message : `Patch failed: search string not found in file "${ fileRecord . name } ". Search : "${ edit . search . slice ( 0 , 100 ) } ${ edit . search . length > 100 ? '...' : '' } "` ,
421+ index : - 1 ,
422+ error : `Anchor line occurrence ${ occurrence } not found (only ${ count } match ${ count > 1 ? 'es' : '' } ) in "${ fileRecord . name } ": "${ anchor . slice ( 0 , 100 ) } "` ,
393423 }
394424 }
395- if ( content . indexOf ( edit . search , firstIdx + 1 ) !== - 1 ) {
425+
426+ if ( edit . mode === 'replace_between' ) {
427+ if ( ! edit . before_anchor || ! edit . after_anchor ) {
428+ return {
429+ success : false ,
430+ message : 'replace_between requires before_anchor and after_anchor' ,
431+ }
432+ }
433+ const before = findAnchorLine ( edit . before_anchor )
434+ if ( before . error ) return { success : false , message : `Patch failed: ${ before . error } ` }
435+ const after = findAnchorLine ( edit . after_anchor , defaultOccurrence , before . index )
436+ if ( after . error ) return { success : false , message : `Patch failed: ${ after . error } ` }
437+ if ( after . index <= before . index ) {
438+ return {
439+ success : false ,
440+ message : 'Patch failed: after_anchor must appear after before_anchor in the file' ,
441+ }
442+ }
443+
444+ const newLines = [
445+ ...lines . slice ( 0 , before . index + 1 ) ,
446+ ...( edit . content ?? '' ) . split ( '\n' ) ,
447+ ...lines . slice ( after . index ) ,
448+ ]
449+ content = newLines . join ( '\n' )
450+ } else if ( edit . mode === 'insert_after' ) {
451+ if ( ! edit . anchor ) {
452+ return { success : false , message : 'insert_after requires anchor' }
453+ }
454+ const found = findAnchorLine ( edit . anchor )
455+ if ( found . error ) return { success : false , message : `Patch failed: ${ found . error } ` }
456+
457+ const newLines = [
458+ ...lines . slice ( 0 , found . index + 1 ) ,
459+ ...( edit . content ?? '' ) . split ( '\n' ) ,
460+ ...lines . slice ( found . index + 1 ) ,
461+ ]
462+ content = newLines . join ( '\n' )
463+ } else if ( edit . mode === 'delete_between' ) {
464+ if ( ! edit . start_anchor || ! edit . end_anchor ) {
465+ return {
466+ success : false ,
467+ message : 'delete_between requires start_anchor and end_anchor' ,
468+ }
469+ }
470+ const start = findAnchorLine ( edit . start_anchor )
471+ if ( start . error ) return { success : false , message : `Patch failed: ${ start . error } ` }
472+ const end = findAnchorLine ( edit . end_anchor , defaultOccurrence , start . index )
473+ if ( end . error ) return { success : false , message : `Patch failed: ${ end . error } ` }
474+ if ( end . index <= start . index ) {
475+ return {
476+ success : false ,
477+ message : 'Patch failed: end_anchor must appear after start_anchor in the file' ,
478+ }
479+ }
480+
481+ const newLines = [ ...lines . slice ( 0 , start . index ) , ...lines . slice ( end . index ) ]
482+ content = newLines . join ( '\n' )
483+ } else {
396484 return {
397485 success : false ,
398- message : `Patch failed: search string is ambiguous — found at multiple locations in "${ fileRecord . name } ". Use a longer, unique search string.` ,
486+ message : `Unknown edit mode: "${ edit . mode } ". Use "replace_between", "insert_after", or "delete_between".` ,
487+ }
488+ }
489+ } else if ( legacyEdits && Array . isArray ( legacyEdits ) && legacyEdits . length > 0 ) {
490+ for ( const le of legacyEdits ) {
491+ const firstIdx = content . indexOf ( le . search )
492+ if ( firstIdx === - 1 ) {
493+ return {
494+ success : false ,
495+ message : `Patch failed: search string not found in file "${ fileRecord . name } ". Search: "${ le . search . slice ( 0 , 100 ) } ${ le . search . length > 100 ? '...' : '' } "` ,
496+ }
497+ }
498+ if ( content . indexOf ( le . search , firstIdx + 1 ) !== - 1 ) {
499+ return {
500+ success : false ,
501+ message : `Patch failed: search string is ambiguous — found at multiple locations in "${ fileRecord . name } ". Use a longer, unique search string.` ,
502+ }
399503 }
504+ content =
505+ content . slice ( 0 , firstIdx ) + le . replace + content . slice ( firstIdx + le . search . length )
506+ }
507+ } else {
508+ return {
509+ success : false ,
510+ message : 'patch requires either an edit object (with mode) or a legacy edits array' ,
400511 }
401- content =
402- content . slice ( 0 , firstIdx ) +
403- edit . replace +
404- content . slice ( firstIdx + edit . search . length )
405512 }
406513
407514 const patchLowerName = fileRecord . name ?. toLowerCase ( ) ?? ''
@@ -445,16 +552,17 @@ export const workspaceFileServerTool: BaseServerTool<WorkspaceFileArgs, Workspac
445552 patchSourceMime
446553 )
447554
555+ const editMode = edit ?. mode ?? 'legacy'
448556 logger . info ( 'Workspace file patched via copilot' , {
449557 fileId,
450558 name : fileRecord . name ,
451- editCount : edits . length ,
559+ editMode ,
452560 userId : context . userId ,
453561 } )
454562
455563 return {
456564 success : true ,
457- message : `File "${ fileRecord . name } " patched successfully (${ edits . length } edit${ edits . length > 1 ? 's' : '' } applied)` ,
565+ message : `File "${ fileRecord . name } " patched successfully (${ editMode } edit applied)` ,
458566 data : {
459567 id : fileId ,
460568 name : fileRecord . name ,
0 commit comments