2026-06-04 03:02:17 +00:00
import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { describe , expect , test } from "bun:test"
import { Effect , Layer } from "effect"
2026-06-29 15:35:17 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
2026-06-04 03:02:17 +00:00
import { FileMutation } from "@opencode-ai/core/file-mutation"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { Location } from "@opencode-ai/core/location"
import { LocationMutation } from "@opencode-ai/core/location-mutation"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
2026-06-05 03:12:17 +00:00
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
2026-06-29 15:35:17 +00:00
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
2026-06-04 03:02:17 +00:00
import { EditTool } from "@opencode-ai/core/tool/edit"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
2026-06-07 00:49:12 +00:00
import { toolIdentity , executeTool , settleTool , toolDefinitions } from "./lib/tool"
2026-06-04 03:02:17 +00:00
const sessionID = SessionV2 . ID . make ( "ses_edit_tool_test" )
const assertions : PermissionV2.AssertInput [ ] = [ ]
const writes : string [ ] = [ ]
let reads = 0
let denyAction : string | undefined
let afterRead = ( _target : string , _content : Uint8Array ) : Effect . Effect < void > = > Effect . void
const permission = Layer . succeed (
PermissionV2 . Service ,
PermissionV2 . Service . of ( {
assert : ( input ) = >
Effect . sync ( ( ) = > assertions . push ( input ) ) . pipe (
Effect . andThen (
2026-07-04 21:17:11 +00:00
input . action === denyAction ? Effect . fail ( new PermissionV2 . BlockedError ( { rules : [ ] } ) ) : Effect . void ,
2026-06-04 03:02:17 +00:00
) ,
) ,
ask : ( ) = > Effect . die ( "unused" ) ,
reply : ( ) = > Effect . die ( "unused" ) ,
get : ( ) = > Effect . die ( "unused" ) ,
forSession : ( ) = > Effect . die ( "unused" ) ,
list : ( ) = > Effect . die ( "unused" ) ,
} ) ,
)
const reset = ( ) = > {
assertions . length = 0
writes . length = 0
reads = 0
denyAction = undefined
afterRead = ( ) = > Effect . void
}
const filesystem = Layer . effect (
FSUtil . Service ,
Effect . gen ( function * ( ) {
const fs = yield * FSUtil . Service
return FSUtil . Service . of ( {
. . . fs ,
readFile : ( target ) = >
2026-06-04 03:03:39 +00:00
fs
. readFile ( target )
. pipe (
Effect . tap ( ( content ) = >
Effect . sync ( ( ) = > reads ++ ) . pipe ( Effect . andThen ( Effect . suspend ( ( ) = > afterRead ( target , content ) ) ) ) ,
) ,
2026-06-04 03:02:17 +00:00
) ,
writeWithDirs : ( target , content , mode ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeWithDirs ( target , content , mode ) ) ) ,
2026-06-06 03:08:23 +00:00
writeFile : ( target , content , options ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeFile ( target , content , options ) ) ) ,
writeFileString : ( target , content , options ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeFileString ( target , content , options ) ) ) ,
2026-06-04 03:02:17 +00:00
} )
} ) ,
2026-06-29 15:35:17 +00:00
) . pipe ( Layer . provide ( LayerNode . compile ( FSUtil . node ) ) )
2026-06-04 03:02:17 +00:00
const withTool = < A , E , R > ( directory : string , body : ( registry : ToolRegistry.Interface ) = > Effect . Effect < A , E , R > ) = > {
const activeLocation = Layer . succeed (
Location . Service ,
Location . Service . of ( location ( { directory : AbsolutePath.make ( directory ) } ) ) ,
)
return Effect . gen ( function * ( ) {
return yield * body ( yield * ToolRegistry . Service )
2026-06-29 15:35:17 +00:00
} ) . pipe (
Effect . provide (
2026-06-29 15:37:20 +00:00
AppNodeBuilder . build (
LayerNode . group ( [
ToolRegistry . node ,
ToolRegistry . toolsNode ,
LocationMutation . node ,
FileMutation . node ,
EditTool . node ,
] ) ,
[
[ FSUtil . node , filesystem ] ,
[ Location . node , activeLocation ] ,
[ PermissionV2 . node , permission ] ,
[ ToolOutputStore . node , ToolOutputStore . nodeWithoutConfig ] ,
] ,
) ,
2026-06-29 15:35:17 +00:00
) ,
)
2026-06-04 03:02:17 +00:00
}
2026-06-07 01:55:34 +00:00
const call = ( input : typeof EditTool . Input . Type , id = "call-edit" ) = > ( {
2026-06-04 03:02:17 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-04 03:02:17 +00:00
call : { type : "tool-call" as const , id , name : "edit" , input } ,
} )
const it = testEffect ( Layer . empty )
describe ( "EditTool" , ( ) = > {
it . live ( "registers and replaces relative exact text through FileMutation once" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "hello.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "before\nrest\n" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
2026-06-07 00:49:12 +00:00
expect ( ( yield * toolDefinitions ( registry ) ) . map ( ( tool ) = > tool . name ) ) . toEqual ( [ "edit" ] )
expect ( yield * toolDefinitions ( registry , [ { action : "edit" , resource : "*" , effect : "deny" } ] ) ) . toEqual (
[ ] ,
)
const settled = yield * settleTool (
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "hello.txt" , oldString : "before" , newString : "after" } ) ,
)
expect ( settled . result ) . toEqual ( {
type : "text" ,
value : "Edited file successfully: hello.txt\nReplacements: 1\n```diff\n-before\n+after\n```" ,
} )
expect ( settled . output ? . structured ) . toEqual ( {
replacements : 1 ,
2026-06-27 04:05:29 +00:00
files : [
{
file : "hello.txt" ,
status : "modified" ,
additions : 1 ,
deletions : 1 ,
patch : expect.stringContaining ( "-before\n+after" ) ,
} ,
] ,
2026-06-04 03:02:17 +00:00
} )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after\nrest\n" )
2026-06-07 00:49:12 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "edit" , resources : [ "hello.txt" ] , save : [ "*" ] } ] )
2026-06-04 03:02:17 +00:00
expect ( writes ) . toEqual ( [ yield * Effect . promise ( ( ) = > fs . realpath ( target ) ) ] )
} ) ,
) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "accepts an absolute file path inside the active Location" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "absolute.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "before" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : target , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
expect ( result . type ) . toBe ( "text" )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after" )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "approves an explicit external absolute path before edit" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = > {
reset ( )
const target = path . join ( outside . path , "external.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "before" ) ) . pipe (
Effect . andThen (
withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : target , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
expect ( result . type ) . toBe ( "text" )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" , "edit" ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after" )
expect ( writes ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "does not write when external_directory or edit approval is denied" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > Promise . all ( [ tmpdir ( ) , tmpdir ( ) ] ) ) ,
( [ active , outside ] ) = >
Effect . gen ( function * ( ) {
const external = path . join ( outside . path , "denied.txt" )
yield * Effect . promise ( ( ) = > fs . writeFile ( external , "before" ) )
reset ( )
denyAction = "external_directory"
expect (
yield * withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : external , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) . toEqual ( {
type : "error" ,
value : ` Unable to edit ${ external } ` ,
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" ] )
expect ( reads ) . toBe ( 0 )
expect ( writes ) . toEqual ( [ ] )
reset ( )
denyAction = "edit"
expect (
yield * withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : external , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) . toEqual ( {
type : "error" ,
value : ` Unable to edit ${ external } ` ,
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" , "edit" ] )
expect ( reads ) . toBe ( 0 )
expect ( writes ) . toEqual ( [ ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( external , "utf8" ) ) ) . toBe ( "before" )
} ) ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
it . live ( "denied edit reads no target content and does not disclose whether oldString matches" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
denyAction = "edit"
const target = path . join ( tmp . path , "secret.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "secret content" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
2026-06-07 00:49:12 +00:00
const matching = yield * executeTool (
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "secret.txt" , oldString : "secret content" , newString : "replacement" } ) ,
)
2026-06-07 00:49:12 +00:00
const missing = yield * executeTool (
registry ,
2026-06-04 03:02:17 +00:00
call ( { path : "secret.txt" , oldString : "not present" , newString : "replacement" } ) ,
)
expect ( matching ) . toEqual ( { type : "error" , value : "Unable to edit secret.txt" } )
expect ( missing ) . toEqual ( matching )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" , "edit" ] )
expect ( reads ) . toBe ( 0 )
expect ( writes ) . toEqual ( [ ] )
} ) ,
) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "rejects no-op, empty, missing, and ambiguous exact replacements" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "matches.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "same same" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "same" , newString : "same" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
type : "error" ,
value : "No changes to apply: oldString and newString are identical." ,
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
type : "error" ,
value : "oldString must not be empty. Use write to create or overwrite a file." ,
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "missing" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
type : "error" ,
value :
"Could not find oldString in the file. It must match exactly, including whitespace and indentation." ,
} )
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , call ( { path : "matches.txt" , oldString : "same" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
type : "error" ,
value :
"Found multiple exact matches for oldString. Provide more surrounding context or set replaceAll to true." ,
} )
expect ( writes ) . toEqual ( [ ] )
} ) ,
) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "replaces every exact occurrence when replaceAll is true" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "all.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "same same same" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
settleTool ( registry , call ( { path : "all.txt" , oldString : "same" , newString : "after" , replaceAll : true } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( settled ) = >
Effect . gen ( function * ( ) {
expect ( settled . output ? . structured ) . toMatchObject ( { replacements : 3 } )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "after after after" )
expect ( writes ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "preserves BOM and CRLF line endings" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "windows.txt" )
return Effect . promise ( ( ) = > fs . writeFile ( target , "\uFEFFbefore\r\nrest\r\n" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : "windows.txt" , oldString : "before\nrest" , newString : "after\nrest" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( ) = > Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) ,
Effect . tap ( ( content ) = > Effect . sync ( ( ) = > expect ( content ) . toBe ( "\uFEFFafter\r\nrest\r\n" ) ) ) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "rejects an in-place content change after matching but before conditional commit" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const target = path . join ( tmp . path , "concurrent.txt" )
afterRead = ( ) = > ( reads === 1 ? Effect . promise ( ( ) = > fs . writeFile ( target , "newer\n" ) ) : Effect . void )
return Effect . promise ( ( ) = > fs . writeFile ( target , "before\n" ) ) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : "concurrent.txt" , oldString : "before" , newString : "after" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) ,
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
2026-06-04 03:03:39 +00:00
expect ( result ) . toEqual ( {
type : "error" ,
value : "File changed after permission approval. Read it again before editing." ,
} )
2026-06-04 03:02:17 +00:00
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "newer\n" )
expect ( writes ) . toEqual ( [ ] )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
} )
test ( "keeps the locked edit schema, semantics docstring, and deferred TODOs visible" , async ( ) = > {
const source = ( await fs . readFile ( new URL ( "../src/tool/edit.ts" , import . meta . url ) , "utf8" ) ) . replaceAll ( "\r\n" , "\n" )
const definition = await Effect . runPromise (
2026-06-07 00:49:12 +00:00
withTool ( path . dirname ( fileURLToPath ( import . meta . url ) ) , ( registry ) = > toolDefinitions ( registry ) ) ,
2026-06-04 03:02:17 +00:00
)
const schema = definition [ 0 ] ? . inputSchema as { readonly properties? : Record < string , unknown > }
expect ( Object . keys ( schema . properties ? ? { } ) . sort ( ) ) . toEqual ( [ "newString" , "oldString" , "path" , "replaceAll" ] )
expect ( source ) . toContain (
2026-06-22 04:15:34 +00:00
"absolute external paths retain mutation capability through a separate\n * external_directory approval before edit approval." ,
2026-06-04 03:02:17 +00:00
)
for ( const todo of [
"Port V1 fuzzy correction strategies only after exact-edit behavior is established: line-trimmed matching, block-anchor fallback, indentation correction, and similarity-threshold review." ,
"Add formatter integration after V2 formatter runtime exists." ,
"Publish watcher/file-edit events after V2 watcher integration exists." ,
"Add snapshots / undo after design exists." ,
"Add LSP notification and diagnostics after V2 LSP runtime exists." ,
] ) {
expect ( source ) . toContain ( ` TODO: ${ todo } ` )
}
} )