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"
import { FileMutation } from "@opencode-ai/core/file-mutation"
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 { 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 { WriteTool } from "@opencode-ai/core/tool/write"
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_write_tool_test" )
const assertions : PermissionV2.AssertInput [ ] = [ ]
const writes : string [ ] = [ ]
let denyAction : string | undefined
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
denyAction = undefined
}
const filesystem = Layer . effect (
FSUtil . Service ,
Effect . gen ( function * ( ) {
const fs = yield * FSUtil . Service
return FSUtil . Service . of ( {
. . . fs ,
writeWithDirs : ( target , content , mode ) = >
Effect . sync ( ( ) = > writes . push ( target ) ) . pipe ( Effect . andThen ( fs . writeWithDirs ( target , content , mode ) ) ) ,
} )
} ) ,
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 ,
WriteTool . 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 WriteTool . Input . Type , id = "call-write" ) = > ( {
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 : "write" , input } ,
} )
const it = testEffect ( Layer . empty )
describe ( "WriteTool" , ( ) = > {
it . live ( "registers and creates a relative file through FileMutation once" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
return withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
2026-06-07 00:49:12 +00:00
expect ( ( yield * toolDefinitions ( registry ) ) . map ( ( tool ) = > tool . name ) ) . toEqual ( [ "write" ] )
const settled = yield * settleTool ( registry , call ( { path : "src/new.txt" , content : "created" } ) )
2026-06-04 03:02:17 +00:00
expect ( settled ) . toEqual ( {
result : { type : "text" , value : "Created file successfully: src/new.txt" } ,
output : {
structured : {
operation : "write" ,
target : path.join ( yield * Effect . promise ( ( ) = > fs . realpath ( tmp . path ) ) , "src" , "new.txt" ) ,
resource : "src/new.txt" ,
existed : false ,
} ,
content : [ { type : "text" , text : "Created file successfully: src/new.txt" } ] ,
} ,
} )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( path . join ( tmp . path , "src" , "new.txt" ) , "utf8" ) ) ) . toBe (
"created" ,
)
2026-06-07 00:49:12 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "edit" , resources : [ "src/new.txt" ] , save : [ "*" ] } ] )
2026-06-04 03:02:17 +00:00
expect ( writes ) . toEqual ( [ path . join ( yield * Effect . promise ( ( ) = > fs . realpath ( tmp . path ) ) , "src" , "new.txt" ) ] )
} ) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "overwrites a relative existing file and reports that it wrote the file" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
return Effect . promise ( ( ) = > fs . writeFile ( path . join ( tmp . path , "existing.txt" ) , "before" ) ) . pipe (
Effect . andThen (
2026-06-07 00:49:12 +00:00
withTool ( tmp . path , ( registry ) = > settleTool ( registry , call ( { path : "existing.txt" , content : "after" } ) ) ) ,
2026-06-04 03:02:17 +00:00
) ,
Effect . andThen ( ( settled ) = >
Effect . gen ( function * ( ) {
expect ( settled . result ) . toEqual ( { type : "text" , value : "Wrote file successfully: existing.txt" } )
expect ( settled . output ? . structured ) . toMatchObject ( { resource : "existing.txt" , existed : true } )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( path . join ( tmp . path , "existing.txt" ) , "utf8" ) ) ) . toBe (
"after" ,
)
expect ( writes ) . toHaveLength ( 1 )
} ) ,
) ,
)
} ,
( tmp ) = > Effect . promise ( ( ) = > tmp [ Symbol . asyncDispose ] ( ) ) ,
) ,
)
it . live ( "preserves exactly one BOM when overwriting existing files" , ( ) = >
Effect . acquireUseRelease (
Effect . promise ( ( ) = > tmpdir ( ) ) ,
( tmp ) = > {
reset ( )
const preserved = path . join ( tmp . path , "preserved.txt" )
const deduplicated = path . join ( tmp . path , "deduplicated.txt" )
return Effect . promise ( ( ) = >
Promise . all ( [ fs . writeFile ( preserved , "\uFEFFbefore" ) , fs . writeFile ( deduplicated , "\uFEFFbefore" ) ] ) ,
) . pipe (
Effect . andThen (
withTool ( tmp . path , ( registry ) = >
Effect . gen ( function * ( ) {
2026-06-07 00:49:12 +00:00
yield * settleTool ( registry , call ( { path : "preserved.txt" , content : "after" } , "call-preserved" ) )
yield * settleTool (
registry ,
call ( { path : "deduplicated.txt" , content : "\uFEFFafter" } , "call-deduplicated" ) ,
)
2026-06-04 03:02:17 +00:00
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( preserved , "utf8" ) ) ) . toBe ( "\uFEFFafter" )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( deduplicated , "utf8" ) ) ) . toBe ( "\uFEFFafter" )
} ) ,
) ,
) ,
)
} ,
( 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" )
2026-06-07 00:49:12 +00:00
return withTool ( tmp . path , ( registry ) = > executeTool ( registry , call ( { path : target , content : "inside" } ) ) ) . pipe (
2026-06-04 03:02:17 +00:00
Effect . andThen ( ( result ) = >
Effect . gen ( function * ( ) {
expect ( result ) . toEqual ( { type : "text" , value : "Created file successfully: absolute.txt" } )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" ] )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "inside" )
} ) ,
) ,
)
} ,
( 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" )
2026-06-07 00:49:12 +00:00
return withTool ( active . path , ( registry ) = >
settleTool ( registry , call ( { path : target , content : "external" } ) ) ,
) . pipe (
2026-06-04 03:02:17 +00:00
Effect . andThen ( ( settled ) = >
Effect . gen ( function * ( ) {
const canonicalTarget = path . join ( yield * Effect . promise ( ( ) = > fs . realpath ( outside . path ) ) , "external.txt" )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" , "edit" ] )
expect ( assertions [ 0 ] ) . toMatchObject ( {
resources : [
path . join ( yield * Effect . promise ( ( ) = > fs . realpath ( outside . path ) ) , "*" ) . replaceAll ( "\\" , "/" ) ,
] ,
} )
expect ( assertions [ 1 ] ) . toMatchObject ( { resources : [ canonicalTarget . replaceAll ( "\\" , "/" ) ] , save : [ "*" ] } )
expect ( settled . output ? . structured ) . toMatchObject ( {
target : canonicalTarget ,
resource : canonicalTarget.replaceAll ( "\\" , "/" ) ,
existed : false ,
} )
expect ( yield * Effect . promise ( ( ) = > fs . readFile ( target , "utf8" ) ) ) . toBe ( "external" )
expect ( writes ) . toEqual ( [ canonicalTarget ] )
} ) ,
) ,
)
} ,
( [ 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" )
reset ( )
denyAction = "external_directory"
expect (
2026-06-07 00:49:12 +00:00
yield * withTool ( active . path , ( registry ) = >
executeTool ( registry , call ( { path : external , content : "blocked" } ) ) ,
) ,
2026-06-04 03:02:17 +00:00
) . toEqual ( {
type : "error" ,
value : ` Unable to write ${ external } ` ,
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "external_directory" ] )
expect ( writes ) . toEqual ( [ ] )
reset ( )
denyAction = "edit"
expect (
yield * withTool ( active . path , ( registry ) = >
2026-06-07 00:49:12 +00:00
executeTool ( registry , call ( { path : "denied.txt" , content : "blocked" } ) ) ,
2026-06-04 03:02:17 +00:00
) ,
) . toEqual ( {
type : "error" ,
value : "Unable to write denied.txt" ,
} )
expect ( assertions . map ( ( input ) = > input . action ) ) . toEqual ( [ "edit" ] )
expect ( writes ) . toEqual ( [ ] )
} ) ,
( [ active , outside ] ) = >
Effect . promise ( ( ) = >
Promise . all ( [ active [ Symbol . asyncDispose ] ( ) , outside [ Symbol . asyncDispose ] ( ) ] ) . then ( ( ) = > undefined ) ,
) ,
) ,
)
} )
test ( "keeps the locked write schema, semantics docstring, and deferred UX TODOs visible" , async ( ) = > {
const source = ( await fs . readFile ( new URL ( "../src/tool/write.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 ( [ "content" , "path" ] )
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 [
"Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior." ,
"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 } ` )
}
} )