2026-07-08 01:16:00 +00:00
export * as PatchTool from "./patch"
2026-06-04 03:02:17 +00:00
2026-07-07 23:56:47 +00:00
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
2026-06-09 18:28:45 +00:00
import { ToolFailure } from "@opencode-ai/llm"
2026-06-26 18:20:47 +00:00
import { FileDiff } from "@opencode-ai/schema/file-diff"
import { createTwoFilesPatch , diffLines } from "diff"
2026-07-03 13:03:53 +00:00
import { Effect , Schema } from "effect"
2026-06-04 03:02:17 +00:00
import { FileMutation } from "../file-mutation"
import { FSUtil } from "../fs-util"
import { LocationMutation } from "../location-mutation"
import { Patch } from "../patch"
2026-06-07 00:49:12 +00:00
import { PermissionV2 } from "../permission"
import { Tool } from "./tool"
2026-06-04 03:02:17 +00:00
2026-07-07 20:27:03 +00:00
export const name = "patch"
2026-06-04 03:02:17 +00:00
2026-06-07 01:55:34 +00:00
export const Input = Schema . Struct ( {
2026-06-04 03:03:39 +00:00
patchText : Schema.String.annotate ( {
description : "The full patch text describing add, update, and delete operations" ,
} ) ,
2026-06-04 03:02:17 +00:00
} )
export const Applied = Schema . Struct ( {
type : Schema . Literals ( [ "add" , "update" , "delete" ] ) ,
resource : Schema.String ,
target : Schema.String ,
} )
2026-06-26 18:20:47 +00:00
export const Output = Schema . Struct ( {
applied : Schema.Array ( Applied ) ,
files : Schema.Array ( FileDiff . Info ) ,
} )
2026-06-07 01:55:34 +00:00
export type Output = typeof Output . Type
2026-06-04 03:02:17 +00:00
2026-06-07 01:55:34 +00:00
export const toModelOutput = ( output : Output ) = >
2026-06-04 03:03:39 +00:00
[
"Applied patch sequentially:" ,
. . . output . applied . map (
( item ) = > ` ${ item . type === "add" ? "A" : item . type === "delete" ? "D" : "M" } ${ item . resource } ` ,
) ,
] . join ( "\n" )
2026-06-04 03:02:17 +00:00
type Prepared =
2026-06-26 18:20:47 +00:00
| ( Extract < Patch.Hunk , { readonly type : " add " | " delete " } > & {
readonly target : LocationMutation.Target
readonly before : string
readonly after : string
} )
2026-06-06 03:08:23 +00:00
| ( Extract < Patch.Hunk , { readonly type : " update " } > & {
readonly target : LocationMutation.Target
2026-06-04 03:03:39 +00:00
readonly source : Uint8Array
readonly content : string
2026-06-26 18:20:47 +00:00
readonly before : string
readonly after : string
2026-06-06 03:08:23 +00:00
} )
2026-06-04 03:02:17 +00:00
2026-07-03 13:03:53 +00:00
export const Plugin = {
2026-07-08 01:16:00 +00:00
id : "opencode.tool.patch" ,
effect : Effect.fn ( "PatchTool.Plugin" ) ( function * ( ctx : PluginContext ) {
2026-06-04 03:02:17 +00:00
const mutation = yield * LocationMutation . Service
const files = yield * FileMutation . Service
const fs = yield * FSUtil . Service
2026-06-07 00:49:12 +00:00
const permission = yield * PermissionV2 . Service
2026-06-04 03:02:17 +00:00
2026-07-03 13:03:53 +00:00
yield * ctx . tool
2026-07-06 17:36:19 +00:00
. transform ( ( draft ) = >
draft . add (
name ,
Tool . withPermission (
Tool . make ( {
description :
"Apply one patch containing add, update, and delete file operations. All targets are resolved and approved before target contents are read. Operations apply sequentially; if a later operation fails, earlier operations remain applied and the failure reports them explicitly. Moves and atomic rollback are not supported yet." ,
input : Input ,
output : Output ,
toModelOutput : ( { output } ) = > [ { type : "text" , text : toModelOutput ( output ) } ] ,
execute : ( input , context ) = > {
const applied : Array < typeof Applied.Type > = [ ]
2026-07-06 22:14:06 +00:00
const fail = ( path : string , error? : unknown ) = > {
2026-07-06 17:36:19 +00:00
const prefix =
applied . length === 0
? ` Unable to apply patch at ${ path } `
: ` Patch partially applied before failing at ${ path } . Applied: ${ applied . map ( ( item ) = > item . resource ) . join ( ", " ) } `
2026-07-06 22:14:06 +00:00
return new ToolFailure ( { message : prefix , error } )
2026-06-07 00:49:12 +00:00
}
2026-07-06 17:36:19 +00:00
return Effect . gen ( function * ( ) {
const source = {
type : "tool" as const ,
messageID : context.assistantMessageID ,
callID : context.toolCallID ,
}
if ( ! input . patchText . trim ( ) ) return yield * new ToolFailure ( { message : "patchText is required" } )
const hunks = yield * Effect . try ( {
try : ( ) = > Patch . parse ( input . patchText ) ,
2026-07-07 20:27:03 +00:00
catch : ( cause ) = > new ToolFailure ( { message : ` patch verification failed: ${ String ( cause ) } ` } ) ,
2026-07-06 17:36:19 +00:00
} )
if ( hunks . length === 0 ) return yield * new ToolFailure ( { message : "patch rejected: empty patch" } )
const move = hunks . find ( ( hunk ) = > hunk . type === "update" && hunk . movePath !== undefined )
2026-07-07 20:27:03 +00:00
if ( move ) return yield * new ToolFailure ( { message : "patch moves are not supported yet" } )
2026-06-04 03:02:17 +00:00
2026-07-06 17:36:19 +00:00
const targets : Array < { readonly hunk : Patch.Hunk ; readonly target : LocationMutation.Target } > = [ ]
for ( const hunk of hunks )
targets . push ( { hunk , target : yield * mutation . resolve ( { path : hunk.path , kind : "file" } ) } )
const externalDirectories = new Map < string , LocationMutation.ExternalDirectoryAuthorization > ( )
for ( const { target } of targets ) {
const external = target . externalDirectory
if ( external ) externalDirectories . set ( external . resource , external )
}
for ( const external of externalDirectories . values ( ) ) {
yield * permission . assert ( {
. . . LocationMutation . externalDirectoryPermission ( external ) ,
sessionID : context.sessionID ,
agent : context.agent ,
source ,
} )
}
2026-06-07 00:49:12 +00:00
yield * permission . assert ( {
2026-07-06 17:36:19 +00:00
action : "edit" ,
resources : [ . . . new Set ( targets . map ( ( { target } ) = > target . resource ) ) ] ,
save : [ "*" ] ,
2026-06-07 00:49:12 +00:00
sessionID : context.sessionID ,
agent : context.agent ,
source ,
} )
2026-06-04 03:02:17 +00:00
2026-07-06 17:36:19 +00:00
const prepared : Prepared [ ] = [ ]
for ( const { hunk , target } of targets ) {
yield * Effect . gen ( function * ( ) {
if ( hunk . type === "add" ) {
prepared . push ( {
. . . hunk ,
target ,
before : "" ,
after :
hunk . contents . endsWith ( "\n" ) || hunk . contents === "" ? hunk . contents : ` ${ hunk . contents } \ n ` ,
} )
return
}
if ( ( yield * fs . stat ( target . canonical ) ) . type !== "File" ) yield * fail ( hunk . path )
const source = yield * fs . readFile ( target . canonical )
const original = new TextDecoder ( "utf-8" , { ignoreBOM : true } ) . decode ( source )
const before = original . replace ( /^\uFEFF/ , "" )
if ( hunk . type === "delete" ) {
prepared . push ( { . . . hunk , target , before , after : "" } )
return
}
const update = Patch . derive ( hunk . path , hunk . chunks , original )
2026-06-26 18:20:47 +00:00
prepared . push ( {
. . . hunk ,
target ,
2026-07-06 17:36:19 +00:00
source ,
content : Patch.joinBom ( update . content , update . bom ) ,
before ,
after : update.content ,
2026-06-26 18:20:47 +00:00
} )
2026-07-06 22:14:06 +00:00
} ) . pipe ( Effect . mapError ( ( error ) = > fail ( hunk . path , error ) ) )
2026-07-06 17:36:19 +00:00
}
2026-06-07 00:49:12 +00:00
2026-07-06 17:36:19 +00:00
const patchFiles = prepared . map ( patchFile )
yield * Effect . forEach (
prepared ,
( change ) = >
Effect . gen ( function * ( ) {
if ( change . type === "add" ) {
const result = yield * files . create ( {
target : change.target ,
content :
change . contents . endsWith ( "\n" ) || change . contents === ""
? change . contents
: ` ${ change . contents } \ n ` ,
} )
applied . push ( { type : change . type , resource : result.resource , target : result.target } )
return
}
if ( change . type === "delete" ) {
const result = yield * files . remove ( { target : change.target } )
applied . push ( { type : change . type , resource : result.resource , target : result.target } )
return
}
const result = yield * files . writeIfUnchanged ( {
2026-06-07 00:49:12 +00:00
target : change.target ,
2026-07-06 17:36:19 +00:00
expected : change.source ,
content : change.content ,
2026-06-07 00:49:12 +00:00
} )
applied . push ( { type : change . type , resource : result.resource , target : result.target } )
2026-07-06 22:14:06 +00:00
} ) . pipe ( Effect . mapError ( ( error ) = > fail ( change . path , error ) ) ) ,
2026-07-06 17:36:19 +00:00
{ discard : true } ,
)
return { applied , files : patchFiles }
2026-07-06 22:14:06 +00:00
} ) . pipe ( Effect . mapError ( ( error ) = > ( error instanceof ToolFailure ? error : fail ( "patch" , error ) ) ) )
2026-07-06 17:36:19 +00:00
} ,
} ) ,
"edit" ,
) ,
2026-06-07 00:49:12 +00:00
) ,
2026-07-06 17:36:19 +00:00
)
2026-06-07 00:49:12 +00:00
. pipe ( Effect . orDie )
2026-06-04 03:02:17 +00:00
} ) ,
2026-07-03 13:03:53 +00:00
}
2026-07-01 21:12:00 +00:00
2026-06-26 18:20:47 +00:00
function patchFile ( change : Prepared ) : typeof FileDiff . Info . Type {
const counts = diffLines ( change . before , change . after ) . reduce (
( result , item ) = > ( {
additions : result.additions + ( item . added ? ( item . count ? ? 0 ) : 0 ) ,
deletions : result.deletions + ( item . removed ? ( item . count ? ? 0 ) : 0 ) ,
} ) ,
{ additions : 0 , deletions : 0 } ,
)
return {
file : change.target.resource ,
patch : createTwoFilesPatch ( change . target . resource , change . target . resource , change . before , change . after ) ,
status : change.type === "add" ? "added" : change . type === "delete" ? "deleted" : "modified" ,
. . . counts ,
}
}