2026-06-06 03:08:55 +00:00
import { beforeEach , describe , expect } from "bun:test"
2026-06-22 04:15:34 +00:00
import path from "path"
2026-07-27 17:38:08 +00:00
import { Effect , Exit , Layer , PlatformError , Stream } from "effect"
2026-06-05 23:48:34 +00:00
import { Config } from "@opencode-ai/core/config"
import { ConfigAttachments } from "@opencode-ai/core/config/attachments"
2026-07-01 21:12:00 +00:00
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
2026-07-21 14:34:29 +00:00
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
2026-07-27 19:52:50 +00:00
import { FileSystem } from "@opencode-ai/core/filesystem"
2026-07-21 14:34:29 +00:00
import { FSUtil } from "@opencode-ai/util/fs-util"
2026-06-09 18:28:45 +00:00
import { Location } from "@opencode-ai/core/location"
2026-06-07 01:24:54 +00:00
import { Image } from "@opencode-ai/core/image"
2026-07-27 00:08:55 +00:00
import { Permission } from "@opencode-ai/core/permission"
import { Session } from "@opencode-ai/core/session"
2026-07-27 19:52:50 +00:00
import { AbsolutePath , RelativePath } from "@opencode-ai/core/schema"
2026-07-21 14:34:29 +00:00
import { Global } from "@opencode-ai/util/global"
2026-06-26 17:55:04 +00:00
import { LocationMutation } from "@opencode-ai/core/location-mutation"
2026-06-09 18:28:45 +00:00
import { location } from "./fixture/location"
2026-07-27 00:08:55 +00:00
import { Tool } from "@opencode-ai/core/tool"
import { ReadTool } from "@opencode-ai/core/tool/plugin/read"
2026-06-09 18:28:45 +00:00
import { ReadToolFileSystem } from "@opencode-ai/core/tool/read-filesystem"
2026-07-21 14:34:29 +00:00
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
2026-07-03 13:03:53 +00:00
import { SessionInstructions } from "@opencode-ai/core/session/instructions"
2026-06-04 03:02:17 +00:00
import { testEffect } from "./lib/effect"
2026-07-23 21:13:31 +00:00
import { toolIdentity , executeTool , registerToolPlugin , toolDefinitions } from "./lib/tool"
2026-07-03 13:03:53 +00:00
const readToolNode = makeLocationNode ( {
name : "test/read-tool-plugin" ,
layer : Layer.effectDiscard ( registerToolPlugin ( ReadTool . Plugin ) ) ,
deps : [
2026-07-27 00:08:55 +00:00
Tool . node ,
2026-07-03 13:03:53 +00:00
ReadToolFileSystem . node ,
LocationMutation . node ,
Image . node ,
2026-07-27 00:08:55 +00:00
Permission . node ,
2026-07-03 13:03:53 +00:00
SessionInstructions . node ,
FSUtil . node ,
Location . node ,
] ,
} )
2026-06-04 03:02:17 +00:00
2026-07-27 00:08:55 +00:00
const assertions : Permission.AssertInput [ ] = [ ]
2026-06-21 17:50:14 +00:00
const missingPath = "__missing_read_target__.txt"
2026-06-22 04:15:34 +00:00
const missingAbsolutePath = path . join ( process . cwd ( ) , missingPath )
2026-07-27 19:52:50 +00:00
const notFound = ( target : string ) = >
PlatformError . systemError ( {
_tag : "NotFound" ,
module : "FileSystem" ,
method : "stat" ,
pathOrDescriptor : target ,
} )
2026-06-06 03:08:55 +00:00
const readCalls : {
2026-06-09 18:28:45 +00:00
input : AbsolutePath
page : ReadToolFileSystem.PageInput
2026-06-06 03:08:55 +00:00
} [ ] = [ ]
2026-06-09 18:28:45 +00:00
const listCalls : ReadToolFileSystem.PageInput [ ] = [ ]
2026-07-27 19:52:50 +00:00
let listResult = new ReadToolFileSystem . ListPage ( { type : "list-page" , entries : [ ] , truncated : false } )
2026-06-06 03:08:55 +00:00
let resolvedType : "file" | "directory" = "file"
2026-06-04 03:02:17 +00:00
let resolveFailure : unknown
2026-07-27 19:52:50 +00:00
let inspectFailure : ReadToolFileSystem.InspectError | undefined
let directoryEntries : string [ ] = [ ]
2026-07-27 14:09:44 +00:00
let readResult : ReadToolFileSystem.FileContent | ReadToolFileSystem . TextPage = {
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///README.md" ,
name : "README.md" ,
2026-06-05 23:48:34 +00:00
content : "hello" ,
2026-06-09 18:28:45 +00:00
encoding : "utf8" ,
2026-06-05 23:48:34 +00:00
mime : "text/plain" ,
2026-06-09 18:28:45 +00:00
}
2026-06-21 20:12:42 +00:00
let readFailure : ReadToolFileSystem.ReadError | undefined
2026-06-09 18:28:45 +00:00
const reader = Layer . succeed (
ReadToolFileSystem . Service ,
ReadToolFileSystem . Service . of ( {
2026-07-27 19:52:50 +00:00
inspect : ( ) = >
resolveFailure !== undefined
? Effect . die ( resolveFailure )
: inspectFailure !== undefined
? Effect . fail ( inspectFailure )
: Effect . succeed ( resolvedType ) ,
2026-06-09 18:28:45 +00:00
read : ( input , _resource , page = { } ) = > {
2026-06-06 03:08:55 +00:00
readCalls . push ( { input , page } )
2026-06-21 20:12:42 +00:00
if ( readFailure !== undefined ) return Effect . fail ( readFailure )
2026-06-06 03:08:55 +00:00
return Effect . succeed ( readResult )
2026-06-05 23:48:34 +00:00
} ,
2026-06-09 18:28:45 +00:00
list : ( _path , input = { } ) = >
2026-06-04 03:02:17 +00:00
Effect . sync ( ( ) = > {
2026-06-06 03:08:55 +00:00
listCalls . push ( input )
2026-07-27 19:52:50 +00:00
return listResult
2026-06-04 03:02:17 +00:00
} ) ,
} ) ,
)
let allow = true
const permission = Layer . succeed (
2026-07-27 00:08:55 +00:00
Permission . Service ,
Permission . Service . of ( {
2026-06-04 03:02:17 +00:00
assert : ( input ) = >
Effect . sync ( ( ) = > {
assertions . push ( input )
2026-07-06 22:14:06 +00:00
} ) . pipe (
Effect . andThen (
allow
? Effect . void
: Effect . fail (
2026-07-27 00:08:55 +00:00
new Permission . BlockedError ( {
2026-07-06 22:14:06 +00:00
rules : [ ] ,
permission : input.action ,
resources : input.resources ,
} ) ,
) ,
) ,
) ,
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" ) ,
} ) ,
)
2026-07-27 18:32:38 +00:00
const config = Config . testLayer ( )
2026-07-01 21:12:00 +00:00
const imageLayer = AppNodeBuilder . build ( Image . node , [ [ Config . node , config ] ] )
2026-06-09 18:28:45 +00:00
const testFileSystem = Layer . effect (
FSUtil . Service ,
2026-06-21 17:50:14 +00:00
FSUtil . Service . use ( ( fs ) = >
Effect . succeed (
FSUtil . Service . of ( {
. . . fs ,
2026-07-27 19:52:50 +00:00
readDirectory : ( ) = > Effect . succeed ( directoryEntries ) ,
2026-06-21 17:50:14 +00:00
realPath : ( path ) = >
path === missingAbsolutePath
? Effect . fail (
PlatformError . systemError ( {
_tag : "NotFound" ,
module : "FileSystem" ,
method : "realPath" ,
pathOrDescriptor : path ,
} ) ,
)
: Effect . succeed ( path ) ,
} ) ,
) ,
) ,
2026-07-01 21:12:00 +00:00
) . pipe ( Layer . provide ( LayerNode . compile ( FSUtil . node ) ) )
const locationLayer = Layer . succeed (
Location . Service ,
Location . Service . of ( location ( { directory : AbsolutePath.make ( process . cwd ( ) ) } ) ) ,
2026-06-09 18:28:45 +00:00
)
2026-06-26 17:55:04 +00:00
const mutation = Layer . succeed (
LocationMutation . Service ,
LocationMutation . Service . of ( {
resolve : ( input ) = > {
const canonical = path . resolve ( process . cwd ( ) , input . path )
const external = path . isAbsolute ( input . path ) && ! FSUtil . contains ( process . cwd ( ) , canonical )
const resource = external ? canonical . replaceAll ( "\\" , "/" ) : path . relative ( process . cwd ( ) , canonical ) || "."
const directory = path . dirname ( canonical )
const externalResource = path . join ( directory , "*" ) . replaceAll ( "\\" , "/" )
return Effect . succeed ( {
canonical ,
resource ,
externalDirectory : external
? {
action : "external_directory" as const ,
directory ,
resource : externalResource ,
save : externalResource ,
}
: undefined ,
} )
} ,
} ) ,
)
2026-06-07 01:24:54 +00:00
const unavailableImage = Layer . succeed (
Image . Service ,
Image . Service . of ( { normalize : ( ) = > Effect . fail ( new Image . ResizerUnavailableError ( ) ) } ) ,
)
2026-07-01 21:12:00 +00:00
const readLayer = ( imageLayer : Layer.Layer < Image.Service > ) = >
2026-07-27 18:32:38 +00:00
Layer . mergeAll (
AppNodeBuilder . build ( LayerNode . group ( [ Tool . node , readToolNode ] ) , [
[ ReadToolFileSystem . node , reader ] ,
[ Permission . node , permission ] ,
[ Config . node , config ] ,
[ Image . node , imageLayer ] ,
[ LocationMutation . node , mutation ] ,
[ FSUtil . node , testFileSystem ] ,
[ Location . node , locationLayer ] ,
[ Global . node , Global . layerWith ( { data : Global.Path.data } ) ] ,
] ) ,
// Merge by reference so Config.Test resolves to the memoized instance.
config ,
)
2026-07-01 21:12:00 +00:00
const it = testEffect ( readLayer ( imageLayer ) )
const itWithoutResizer = testEffect ( readLayer ( unavailableImage ) )
2026-07-27 00:08:55 +00:00
const sessionID = Session . ID . make ( "ses_read_tool_test" )
2026-06-04 03:02:17 +00:00
describe ( "ReadTool" , ( ) = > {
2026-06-06 03:08:55 +00:00
beforeEach ( ( ) = > {
assertions . length = 0
readCalls . length = 0
listCalls . length = 0
allow = true
resolvedType = "file"
resolveFailure = undefined
2026-07-27 19:52:50 +00:00
inspectFailure = undefined
directoryEntries = [ ]
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///README.md" ,
name : "README.md" ,
content : "hello" ,
encoding : "utf8" ,
mime : "text/plain" ,
}
2026-06-06 03:08:55 +00:00
readFailure = undefined
2026-07-27 19:52:50 +00:00
listResult = new ReadToolFileSystem . ListPage ( { type : "list-page" , entries : [ ] , truncated : false } )
2026-06-06 03:08:55 +00:00
} )
2026-06-04 03:02:17 +00:00
it . effect ( "registers, authorizes, and reads through the location filesystem" , ( ) = >
Effect . gen ( function * ( ) {
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
2026-07-25 18:01:05 +00:00
expect ( ( yield * toolDefinitions ( registry ) ) . map ( ( tool ) = > tool . name ) ) . toEqual ( [ "read" , "execute" ] )
expect (
( yield * toolDefinitions ( registry , [ { action : "read" , resource : "*" , effect : "deny" } ] ) ) . map (
( tool ) = > tool . name ,
) ,
) . toEqual ( [ "execute" ] )
2026-07-23 21:13:31 +00:00
const execution = yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-read" , name : "read" , input : { path : "README.md" } } ,
} )
expect ( execution . status ) . toBe ( "completed" )
if ( execution . status !== "completed" ) return
expect ( execution . output ) . toEqual ( {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-07-23 21:13:31 +00:00
uri : "file:///README.md" ,
name : "README.md" ,
content : "hello" ,
encoding : "utf8" ,
mime : "text/plain" ,
2026-06-09 18:28:45 +00:00
} )
2026-07-27 19:52:50 +00:00
expect ( execution . content ) . toEqual ( [ { type : "text" , text : "Read file README.md, lines 1-1\n1: hello" } ] )
2026-06-04 03:02:17 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "read" , resources : [ "README.md" ] , save : [ "*" ] } ] )
2026-06-22 04:15:34 +00:00
expect ( readCalls ) . toEqual ( [
{
input : AbsolutePath.make ( path . join ( process . cwd ( ) , "README.md" ) ) ,
page : { offset : undefined , limit : undefined } ,
} ,
] )
2026-06-04 03:02:17 +00:00
} ) ,
)
2026-06-26 17:55:04 +00:00
it . effect ( "asks for external_directory approval before reading an external absolute path" , ( ) = >
Effect . gen ( function * ( ) {
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-26 17:55:04 +00:00
const external = path . join ( path . parse ( process . cwd ( ) ) . root , "external-read" , "notes.txt" )
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-external-read" , name : "read" , input : { path : external } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toMatchObject ( { status : "completed" } )
2026-06-26 17:55:04 +00:00
expect ( assertions ) . toMatchObject ( [
{
sessionID ,
action : "external_directory" ,
resources : [ path . join ( path . dirname ( external ) , "*" ) . replaceAll ( "\\" , "/" ) ] ,
} ,
{ sessionID , action : "read" , resources : [ external . replaceAll ( "\\" , "/" ) ] , save : [ "*" ] } ,
] )
2026-06-26 17:56:55 +00:00
expect ( readCalls ) . toEqual ( [ { input : AbsolutePath.make ( external ) , page : { offset : undefined , limit : undefined } } ] )
2026-06-26 17:55:04 +00:00
} ) ,
)
2026-06-05 23:48:34 +00:00
it . effect ( "returns a small PNG as native media instead of durable base64 text" , ( ) = >
Effect . gen ( function * ( ) {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///pixel.png" ,
name : "pixel.png" ,
2026-06-05 23:48:34 +00:00
content : png ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
2026-07-23 21:13:31 +00:00
const execution = yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-image" , name : "read" , input : { path : "pixel.png" } } ,
2026-06-05 23:48:34 +00:00
} )
2026-07-23 21:13:31 +00:00
expect ( execution . status ) . toBe ( "completed" )
if ( execution . status !== "completed" ) return
expect ( execution . content ) . toEqual ( [
{ type : "text" , text : "Image read successfully" } ,
{ type : "file" , uri : ` data:image/png;base64, ${ png } ` , mime : "image/png" , name : "pixel.png" } ,
] )
2026-06-22 04:15:34 +00:00
expect ( readCalls ) . toEqual ( [
{
input : AbsolutePath.make ( path . join ( process . cwd ( ) , "pixel.png" ) ) ,
page : { offset : undefined , limit : undefined } ,
} ,
] )
2026-06-05 23:48:34 +00:00
2026-07-23 21:13:31 +00:00
const settled = yield * executeTool ( registry , {
2026-06-05 23:48:34 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-05 23:48:34 +00:00
call : { type : "tool-call" , id : "call-image-settle" , name : "read" , input : { path : "pixel.png" } } ,
} )
2026-07-23 21:13:31 +00:00
expect ( settled . status ) . toBe ( "completed" )
if ( settled . status !== "completed" ) return
// Image base64 is carried by the content file item only; read produces no
// metadata, so the original bytes are never persisted twice.
expect ( settled . metadata ) . toBeUndefined ( )
expect ( settled . content ) . toMatchObject ( [
2026-06-05 23:48:34 +00:00
{ type : "text" , text : "Image read successfully" } ,
2026-06-09 18:28:45 +00:00
{ type : "file" , mime : "image/png" , uri : ` data:image/png;base64, ${ png } ` } ,
2026-06-05 23:48:34 +00:00
] )
} ) ,
)
2026-06-07 00:49:12 +00:00
it . effect ( "preserves a PNG above the generic text limit as native media" , ( ) = >
Effect . gen ( function * ( ) {
const photon = yield * Effect . promise ( ( ) = > import ( "@silvia-odwyer/photon-node" ) )
const pixels = Uint8Array . from ( { length : 256 * 256 * 4 } , ( _ , index ) = > ( index * 73 + ( index >> 3 ) ) % 256 )
const source = new photon . PhotonImage ( pixels , 256 , 256 )
const png = Buffer . from ( source . get_bytes ( ) ) . toString ( "base64" )
source . free ( )
expect ( Buffer . byteLength ( png ) ) . toBeGreaterThan ( 50 * 1024 )
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///large.png" ,
name : "large.png" ,
2026-06-07 00:49:12 +00:00
content : png ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-07 00:49:12 +00:00
2026-07-23 21:13:31 +00:00
const settled = yield * executeTool ( registry , {
2026-06-07 00:49:12 +00:00
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-large-image" , name : "read" , input : { path : "large.png" } } ,
} )
2026-07-23 21:13:31 +00:00
expect ( settled . status ) . toBe ( "completed" )
if ( settled . status !== "completed" ) return
expect ( settled . output ) . toMatchObject ( {
2026-06-09 18:28:45 +00:00
uri : "file:///large.png" ,
name : "large.png" ,
mime : "image/png" ,
encoding : "base64" ,
} )
2026-07-23 21:13:31 +00:00
expect ( settled . content ) . toEqual ( [
{ type : "text" , text : "Image read successfully" } ,
{ type : "file" , uri : ` data:image/png;base64, ${ png } ` , mime : "image/png" , name : "large.png" } ,
] )
2026-06-07 00:49:12 +00:00
} ) ,
)
2026-06-07 01:24:54 +00:00
itWithoutResizer . effect ( "returns the original image when the resizer is unavailable" , ( ) = >
Effect . gen ( function * ( ) {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///pixel.png" ,
name : "pixel.png" ,
2026-06-07 01:24:54 +00:00
content : png ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-07 01:24:54 +00:00
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-image-fallback" , name : "read" , input : { path : "pixel.png" } } ,
} ) ,
) . toMatchObject ( {
2026-07-23 21:13:31 +00:00
status : "completed" ,
content : [ { type : "text" } , { type : "file" , uri : ` data:image/png;base64, ${ png } ` , mime : "image/png" } ] ,
2026-06-07 01:24:54 +00:00
} )
} ) ,
)
2026-07-23 21:13:31 +00:00
it . effect ( "drops undecodable image data from the outcome" , ( ) = >
2026-06-05 23:48:34 +00:00
Effect . gen ( function * ( ) {
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///truncated.png" ,
name : "truncated.png" ,
2026-06-06 03:08:55 +00:00
content : "iVBORw0KGgo=" ,
2026-06-05 23:48:34 +00:00
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , {
2026-06-05 23:48:34 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-05 23:48:34 +00:00
call : { type : "tool-call" , id : "call-truncated-image" , name : "read" , input : { path : "truncated.png" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toMatchObject ( {
status : "completed" ,
content : [
2026-07-16 16:16:19 +00:00
{ type : "text" , text : "Image read successfully" } ,
{ type : "text" , text : "[1 image omitted: could not be decoded.]" } ,
] ,
} )
2026-06-05 23:48:34 +00:00
} ) ,
)
2026-07-23 21:13:31 +00:00
it . effect ( "drops oversized images from the outcome when resizing is disabled" , ( ) = >
2026-06-05 23:48:34 +00:00
Effect . gen ( function * ( ) {
const photon = yield * Effect . promise ( ( ) = > import ( "@silvia-odwyer/photon-node" ) )
const source = new photon . PhotonImage ( new Uint8Array ( Array . from ( { length : 16 * 4 } , ( ) = > 255 ) ) , 16 , 1 )
const base64 = Buffer . from ( source . get_bytes ( ) ) . toString ( "base64" )
source . free ( )
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///wide.png" ,
name : "wide.png" ,
2026-06-05 23:48:34 +00:00
content : base64 ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 18:32:38 +00:00
const configTest = yield * Config . Test
yield * configTest . setEntries ( [
2026-06-05 23:48:34 +00:00
new Config . Document ( {
type : "document" ,
info : new Config . Info ( {
attachments : new ConfigAttachments . Info ( {
image : new ConfigAttachments . Image ( { auto_resize : false , max_width : 4 } ) ,
} ) ,
} ) ,
} ) ,
2026-07-27 18:32:38 +00:00
] )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
2026-07-16 16:16:19 +00:00
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-wide-image" , name : "read" , input : { path : "wide.png" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toMatchObject ( {
status : "completed" ,
content : [
2026-07-16 16:16:19 +00:00
{ type : "text" , text : "Image read successfully" } ,
{ type : "text" , text : "[1 image omitted: could not be resized below the image size limit.]" } ,
] ,
} )
2026-06-05 23:48:34 +00:00
} ) ,
)
it . effect ( "resizes images to configured dimensions before returning media" , ( ) = >
Effect . gen ( function * ( ) {
const photon = yield * Effect . promise ( ( ) = > import ( "@silvia-odwyer/photon-node" ) )
const source = new photon . PhotonImage ( new Uint8Array ( Array . from ( { length : 16 * 4 } , ( ) = > 255 ) ) , 16 , 1 )
const base64 = Buffer . from ( source . get_bytes ( ) ) . toString ( "base64" )
source . free ( )
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///wide.png" ,
name : "wide.png" ,
2026-06-05 23:48:34 +00:00
content : base64 ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 18:32:38 +00:00
const configTest = yield * Config . Test
yield * configTest . setEntries ( [
2026-06-05 23:48:34 +00:00
new Config . Document ( {
type : "document" ,
info : new Config . Info ( {
attachments : new ConfigAttachments . Info ( { image : new ConfigAttachments . Image ( { max_width : 4 } ) } ) ,
} ) ,
} ) ,
2026-07-27 18:32:38 +00:00
] )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-07 00:49:12 +00:00
const result = yield * executeTool ( registry , {
2026-06-05 23:48:34 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-05 23:48:34 +00:00
call : { type : "tool-call" , id : "call-resize-image" , name : "read" , input : { path : "wide.png" } } ,
} )
2026-07-23 21:13:31 +00:00
expect ( result . status ) . toBe ( "completed" )
if ( result . status !== "completed" ) return
2026-07-27 00:08:55 +00:00
const media = result . content ? . [ 1 ]
2026-06-09 18:28:45 +00:00
expect ( media ? . type ) . toBe ( "file" )
if ( media ? . type !== "file" ) return
const resized = photon . PhotonImage . new_from_byteslice ( Buffer . from ( media . uri . split ( "," ) [ 1 ] ? ? "" , "base64" ) )
2026-06-05 23:48:34 +00:00
expect ( resized . get_width ( ) ) . toBeLessThanOrEqual ( 4 )
expect ( resized . get_height ( ) ) . toBeLessThanOrEqual ( 2 _000 )
resized . free ( )
} ) ,
)
2026-07-16 16:16:19 +00:00
it . effect ( "drops images that cannot fit max base64 bytes after resize attempts" , ( ) = >
2026-06-05 23:48:34 +00:00
Effect . gen ( function * ( ) {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///pixel.png" ,
name : "pixel.png" ,
2026-06-05 23:48:34 +00:00
content : png ,
encoding : "base64" ,
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 18:32:38 +00:00
const configTest = yield * Config . Test
yield * configTest . setEntries ( [
2026-06-05 23:48:34 +00:00
new Config . Document ( {
type : "document" ,
info : new Config . Info ( {
attachments : new ConfigAttachments . Info ( {
image : new ConfigAttachments . Image ( { max_base64_bytes : 1 } ) ,
} ) ,
} ) ,
} ) ,
2026-07-27 18:32:38 +00:00
] )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
2026-07-16 16:16:19 +00:00
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-max-bytes" , name : "read" , input : { path : "pixel.png" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toMatchObject ( {
status : "completed" ,
content : [
2026-07-16 16:16:19 +00:00
{ type : "text" , text : "Image read successfully" } ,
{ type : "text" , text : "[1 image omitted: could not be resized below the image size limit.]" } ,
] ,
} )
2026-06-05 23:48:34 +00:00
} ) ,
)
2026-06-06 03:08:55 +00:00
it . effect ( "returns supported image contents despite a misleading binary extension" , ( ) = >
2026-06-05 23:48:34 +00:00
Effect . gen ( function * ( ) {
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///pixel.bin" ,
name : "pixel.bin" ,
2026-06-05 23:48:34 +00:00
content : png ,
encoding : "base64" ,
2026-06-06 03:08:55 +00:00
mime : "image/png" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , {
2026-06-05 23:48:34 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-05 23:48:34 +00:00
call : { type : "tool-call" , id : "call-disguised-image" , name : "read" , input : { path : "pixel.bin" } } ,
} ) ,
) . toMatchObject ( {
2026-07-23 21:13:31 +00:00
status : "completed" ,
content : [ { type : "text" } , { type : "file" , mime : "image/png" , name : "pixel.bin" } ] ,
2026-06-05 23:48:34 +00:00
} )
} ) ,
)
2026-07-27 14:58:37 +00:00
it . effect ( "returns PDFs as native media" , ( ) = >
Effect . gen ( function * ( ) {
const pdf = "JVBERi0xLjcK"
readResult = {
type : "file" ,
uri : "file:///document.pdf" ,
name : "document.pdf" ,
content : pdf ,
encoding : "base64" ,
mime : "application/pdf" ,
}
const registry = yield * Tool . Service
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-pdf" , name : "read" , input : { path : "document.pdf" } } ,
} ) ,
) . toMatchObject ( {
status : "completed" ,
content : [
{ type : "text" , text : "PDF read successfully" } ,
{ type : "file" , uri : ` data:application/pdf;base64, ${ pdf } ` , mime : "application/pdf" , name : "document.pdf" } ,
] ,
} )
} ) ,
)
2026-06-21 20:12:42 +00:00
it . effect ( "returns expected filesystem failures to the model" , ( ) = >
Effect . gen ( function * ( ) {
readFailure = new ReadToolFileSystem . BinaryFileError ( { resource : "archive.dat" } )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-21 20:12:42 +00:00
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : {
type : "tool-call" ,
id : "call-binary" ,
name : "read" ,
input : { path : "archive.dat" , offset : 2 , limit : 1 } ,
} ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toEqual ( { status : "error" , error : { type : "unknown" , message : "Cannot read binary file: archive.dat" } } )
2026-06-21 20:12:42 +00:00
expect ( readCalls ) . toEqual ( [
2026-06-22 04:15:34 +00:00
{ input : AbsolutePath.make ( path . join ( process . cwd ( ) , "archive.dat" ) ) , page : { offset : 2 , limit : 1 } } ,
2026-06-21 20:12:42 +00:00
] )
} ) ,
)
2026-07-27 14:58:37 +00:00
it . effect ( "preserves actionable read failure messages" , ( ) = >
Effect . gen ( function * ( ) {
const registry = yield * Tool . Service
for ( const [ error , message ] of [
[
new ReadToolFileSystem . MalformedUtf8Error ( { resource : "invalid.txt" } ) ,
"File is not valid UTF-8: invalid.txt" ,
] ,
[ new ReadToolFileSystem . OffsetOutOfRangeError ( { offset : 10 } ) , "Offset 10 is out of range" ] ,
[
new ReadToolFileSystem . PathKindError ( { resource : "socket" , expected : "a file" } ) ,
"Path is not a file: socket" ,
] ,
] as const ) {
readFailure = error
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : ` call- ${ error . _tag } ` , name : "read" , input : { path : "target" } } ,
} ) ,
) . toEqual ( { status : "error" , error : { type : "unknown" , message } } )
}
} ) ,
)
2026-06-07 00:49:12 +00:00
it . effect ( "preserves unexpected filesystem defects" , ( ) = >
2026-06-05 23:48:34 +00:00
Effect . gen ( function * ( ) {
2026-06-21 20:12:42 +00:00
resolveFailure = new Error ( "unexpected" )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
expect (
2026-06-07 00:49:12 +00:00
Exit . isFailure (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
2026-06-21 20:12:42 +00:00
call : { type : "tool-call" , id : "call-defect" , name : "read" , input : { path : "README.md" } } ,
2026-06-07 00:49:12 +00:00
} ) . pipe ( Effect . exit ) ,
) ,
) . toBe ( true )
2026-06-05 23:48:34 +00:00
} ) ,
)
2026-06-04 03:02:17 +00:00
it . effect ( "does not read when permission is denied" , ( ) = >
Effect . gen ( function * ( ) {
allow = false
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , {
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" , id : "call-read" , name : "read" , input : { path : "README.md" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toEqual ( { status : "error" , error : { type : "permission.rejected" , message : "Permission denied: read" } } )
2026-06-06 03:08:55 +00:00
expect ( readCalls ) . toEqual ( [ ] )
2026-06-04 03:02:17 +00:00
} ) ,
)
2026-06-21 17:50:14 +00:00
it . effect ( "returns missing paths as model-visible tool failures" , ( ) = >
Effect . gen ( function * ( ) {
2026-07-27 19:52:50 +00:00
inspectFailure = notFound ( missingAbsolutePath )
directoryEntries = [
"__missing_read_target__.txt.bak" ,
"copy___missing_read_target__.txt" ,
"old___missing_read_target__.txt" ,
"other___missing_read_target__.txt" ,
"unrelated.txt" ,
]
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-21 17:50:14 +00:00
expect (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-missing-path" , name : "read" , input : { path : missingPath } } ,
} ) ,
2026-07-27 19:52:50 +00:00
) . toEqual ( {
status : "error" ,
error : {
type : "tool.execution" ,
message : ` File not found: ${ missingPath } \ n \ nDid you mean one of these? \ n__missing_read_target__.txt.bak \ ncopy___missing_read_target__.txt \ nold___missing_read_target__.txt ` ,
} ,
} )
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "read" , resources : [ missingPath ] , save : [ "*" ] } ] )
2026-06-21 17:50:14 +00:00
expect ( readCalls ) . toEqual ( [ ] )
} ) ,
)
2026-06-04 03:02:17 +00:00
it . effect ( "lists a bounded directory page through read" , ( ) = >
Effect . gen ( function * ( ) {
2026-06-06 03:08:55 +00:00
resolvedType = "directory"
2026-07-27 19:52:50 +00:00
listResult = new ReadToolFileSystem . ListPage ( {
type : "list-page" ,
entries : [
FileSystem . Entry . make ( { path : RelativePath.make ( "components/" ) , type : "directory" } ) ,
FileSystem . Entry . make ( { path : RelativePath.make ( "index.ts" ) , type : "file" } ) ,
] ,
truncated : true ,
next : 4 ,
} )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
2026-07-27 19:52:50 +00:00
const result = yield * executeTool ( registry , {
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" ,
id : "call-read-directory" ,
name : "read" ,
input : { path : "src" , offset : 2 , limit : 10 } ,
} ,
2026-07-27 19:52:50 +00:00
} )
expect ( result ) . toMatchObject ( { status : "completed" , output : { entries : listResult.entries , truncated : true , next : 4 } } )
if ( result . status !== "completed" ) return
expect ( result . content ) . toEqual ( [
{
type : "text" ,
text : "Read directory src, entries 2-3\ncomponents/\nindex.ts\n[Output truncated. Continue reading with offset: 4]" ,
} ,
] )
2026-06-04 03:02:17 +00:00
expect ( assertions ) . toMatchObject ( [ { sessionID , action : "read" , resources : [ "src" ] , save : [ "*" ] } ] )
2026-06-09 18:28:45 +00:00
expect ( listCalls ) . toEqual ( [ { offset : 2 , limit : 10 } ] )
2026-06-04 03:02:17 +00:00
} ) ,
)
it . effect ( "does not list a directory when permission is denied" , ( ) = >
Effect . gen ( function * ( ) {
allow = false
2026-06-06 03:08:55 +00:00
resolvedType = "directory"
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , {
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" , id : "call-read-directory-denied" , name : "read" , input : { path : "src" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toEqual ( { status : "error" , error : { type : "permission.rejected" , message : "Permission denied: read" } } )
2026-06-06 03:08:55 +00:00
expect ( listCalls ) . toEqual ( [ ] )
2026-06-04 03:02:17 +00:00
} ) ,
)
2026-06-07 00:49:12 +00:00
it . effect ( "preserves unexpected resolution defects" , ( ) = >
2026-06-04 03:02:17 +00:00
Effect . gen ( function * ( ) {
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
resolveFailure = new Error ( "missing" )
expect (
2026-06-07 00:49:12 +00:00
Exit . isFailure (
yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : { type : "tool-call" , id : "call-missing" , name : "read" , input : { path : "missing.txt" } } ,
} ) . pipe ( Effect . exit ) ,
) ,
) . toBe ( true )
2026-06-04 03:02:17 +00:00
2026-06-06 03:08:55 +00:00
expect ( readCalls ) . toEqual ( [ ] )
2026-06-04 03:02:17 +00:00
} ) ,
)
2026-06-06 03:08:55 +00:00
it . effect ( "forwards pagination and returns bounded text pages with continuation" , ( ) = >
2026-06-04 03:02:17 +00:00
Effect . gen ( function * ( ) {
2026-06-09 18:28:45 +00:00
readResult = new ReadToolFileSystem . TextPage ( {
2026-06-06 03:08:55 +00:00
type : "text-page" ,
content : "hello" ,
mime : "text/plain" ,
offset : 2 ,
truncated : true ,
next : 3 ,
} )
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-04 03:02:17 +00:00
2026-07-27 19:52:50 +00:00
const result = yield * executeTool ( registry , {
sessionID ,
. . . toolIdentity ,
call : {
type : "tool-call" ,
id : "call-large" ,
name : "read" ,
input : { path : "large.txt" , offset : 2 , limit : 1 } ,
} ,
} )
expect ( result ) . toMatchObject ( {
2026-07-23 21:13:31 +00:00
status : "completed" ,
output : { type : "text-page" , content : "hello" , mime : "text/plain" , offset : 2 , truncated : true , next : 3 } ,
2026-06-04 03:02:17 +00:00
} )
2026-07-27 19:52:50 +00:00
if ( result . status !== "completed" ) return
expect ( result . content ) . toEqual ( [
{
type : "text" ,
text : "Read file large.txt, lines 2-2\n2: hello\n[Output truncated. Continue reading with offset: 3]" ,
} ,
] )
2026-06-09 18:28:45 +00:00
expect ( readCalls ) . toEqual ( [
2026-06-22 04:15:34 +00:00
{ input : AbsolutePath.make ( path . join ( process . cwd ( ) , "large.txt" ) ) , page : { offset : 2 , limit : 1 } } ,
2026-06-09 18:28:45 +00:00
] )
2026-06-05 23:48:34 +00:00
} ) ,
)
it . effect ( "rejects unsupported binary discovered by a direct read" , ( ) = >
Effect . gen ( function * ( ) {
2026-06-09 18:28:45 +00:00
readResult = {
2026-07-27 14:09:44 +00:00
type : "file" ,
2026-06-09 18:28:45 +00:00
uri : "file:///late-binary" ,
name : "late-binary" ,
2026-06-05 23:48:34 +00:00
content : "AAECAw==" ,
encoding : "base64" ,
mime : "application/octet-stream" ,
2026-06-09 18:28:45 +00:00
}
2026-07-27 00:08:55 +00:00
const registry = yield * Tool . Service
2026-06-05 23:48:34 +00:00
expect (
2026-06-07 00:49:12 +00:00
yield * executeTool ( registry , {
2026-06-05 23:48:34 +00:00
sessionID ,
2026-06-07 00:49:12 +00:00
. . . toolIdentity ,
2026-06-05 23:48:34 +00:00
call : { type : "tool-call" , id : "call-direct-binary" , name : "read" , input : { path : "late-binary" } } ,
} ) ,
2026-07-23 21:13:31 +00:00
) . toEqual ( { status : "error" , error : { type : "unknown" , message : "Cannot read binary file: late-binary" } } )
2026-06-05 23:48:34 +00:00
} ) ,
)
2026-06-04 03:02:17 +00:00
} )