Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 46 additions & 13 deletions packages/core/src/tool/plugin/read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,20 +143,53 @@ export const Plugin = {
return join(dirname(absolute), matches[0].name)
})

const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const base = basename(input).toLowerCase()
const suggestions = yield* fs.readDirectory(dirname(absolute)).pipe(
Effect.map((entries) =>
entries
.filter((entry) => {
const candidate = entry.toLowerCase()
return candidate.includes(base) || base.includes(candidate)
})
.map((entry) => join(dirname(input), entry))
.slice(0, 3),
),
Effect.orElseSucceed(() => [] as string[]),
// Lists through the session's environment, which may be a remote workspace rather than this host.
const listing = (path: string) =>
reader.list(AbsolutePath.make(path)).pipe(
Effect.map((entries) => ({ type: "found" as const, names: entries.map((entry) => entry.name) })),
Effect.catchTag("Environment.NotFound", () => Effect.succeed({ type: "missing" as const })),
Effect.orElseSucceed(() => ({ type: "unknown" as const })),
)

const similar = (names: readonly string[], name: string) => {
const base = name.toLowerCase()
return names
.filter((entry) => {
const candidate = entry.toLowerCase()
return candidate.includes(base) || base.includes(candidate)
})
.slice(0, 3)
}

// A missing folder usually means a removed worktree, a moved directory, or a relative path
// resolved from the wrong place, so name the first missing folder and its nearest existing parent.
const missingFolder = Effect.fn("ReadTool.missingFolder")(function* (input: string, folder: string) {
let child = folder
let parent = dirname(folder)
while (parent !== child) {
const found = yield* listing(parent)
if (found.type === "unknown") break
if (found.type === "found") {
const suggestions = similar(found.names, basename(child)).map((entry) => join(parent, entry))
const message = [
`File not found: ${input}`,
`The folder ${child} does not exist. The nearest folder that does is ${parent}.`,
...(suggestions.length === 0 ? [] : [`Did you mean one of these?\n${suggestions.join("\n")}`]),
].join("\n\n")
return yield* new ToolFailure({ message })
}
child = parent
parent = dirname(parent)
}
return yield* new ToolFailure({ message: `File not found: ${input}` })
})

const missing = Effect.fn("ReadTool.missing")(function* (input: string, absolute: string) {
const folder = dirname(absolute)
const found = yield* listing(folder)
if (found.type === "missing") return yield* missingFolder(input, folder)
const suggestions =
found.type === "found" ? similar(found.names, basename(input)).map((entry) => join(dirname(input), entry)) : []
const message =
suggestions.length === 0
? `File not found: ${input}`
Expand Down
78 changes: 62 additions & 16 deletions packages/core/test/tool-read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const readCalls: {
}[] = []
const listCalls: AbsolutePath[] = []
let readDefect: unknown
let directoryEntries: string[] = []
let missingDirectories: string[] = []
let directoryEntryDetails: Environment.DirEntry[] = []
let readResult: ReadToolFileSystem.FileContent | ReadToolFileSystem.TextPage | ReadToolFileSystem.ListPage = {
type: "file",
Expand All @@ -65,6 +65,7 @@ const reader = Layer.succeed(
ReadToolFileSystem.Service.of({
list: (input) => {
listCalls.push(input)
if (missingDirectories.includes(input)) return Effect.fail(new Environment.NotFound({ path: input }))
return Effect.succeed(directoryEntryDetails)
},
read: (input, resource, page = {}) => {
Expand Down Expand Up @@ -98,17 +99,6 @@ const permission = permissionLayer({
})
const config = Config.testLayer()
const imageLayer = AppNodeBuilder.build(Image.node)
const testFileSystem = Layer.effect(
FSUtil.Service,
FSUtil.Service.use((fs) =>
Effect.succeed(
FSUtil.Service.of({
...fs,
readDirectory: () => Effect.succeed(directoryEntries),
}),
),
),
).pipe(Layer.provide(LayerNode.compile(FSUtil.node)))
const locationLayer = Layer.succeed(
Location.Service,
Location.Service.of(location({ directory: AbsolutePath.make(process.cwd()) })),
Expand All @@ -123,7 +113,6 @@ const readLayer = (imageLayer: Layer.Layer<Image.Service>) =>
Permission.node.replace(permission),
Config.node.replace(config),
Image.node.replace(imageLayer),
FSUtil.node.replace(testFileSystem),
Location.node.replace(locationLayer),
Global.node.replace(Global.layerWith({ data: Global.Path.data })),
]),
Expand All @@ -143,7 +132,7 @@ describe("ReadTool", () => {
allow = true
deniedResource = undefined
readDefect = undefined
directoryEntries = []
missingDirectories = []
directoryEntryDetails = []
readResult = {
type: "file",
Expand Down Expand Up @@ -714,13 +703,13 @@ describe("ReadTool", () => {
it.effect("returns missing paths as model-visible tool failures", () =>
Effect.gen(function* () {
readFailure = new Environment.NotFound({ path: missingAbsolutePath })
directoryEntries = [
directoryEntryDetails = [
"__missing_read_target__.txt.bak",
"copy___missing_read_target__.txt",
"old___missing_read_target__.txt",
"other___missing_read_target__.txt",
"unrelated.txt",
]
].map((name) => ({ name, type: "file" as const }))
const registry = yield* Tool.Service

expect(
Expand All @@ -746,6 +735,63 @@ describe("ReadTool", () => {
}),
)

it.effect("names the first missing folder and its nearest existing parent", () =>
Effect.gen(function* () {
const requested = "__missing_read_folder__/src/file.ts"
readFailure = new Environment.NotFound({ path: path.join(process.cwd(), requested) })
missingDirectories = [
path.join(process.cwd(), "__missing_read_folder__", "src"),
path.join(process.cwd(), "__missing_read_folder__"),
]
directoryEntryDetails = [
{ name: "__missing_read_folder__-old", type: "directory" },
{ name: "unrelated", type: "file" },
]
const registry = yield* Tool.Service

expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-folder", name: "read", input: { path: requested } },
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: [
`File not found: ${requested}`,
`The folder ${path.join(process.cwd(), "__missing_read_folder__")} does not exist. The nearest folder that does is ${process.cwd()}.`,
`Did you mean one of these?\n${path.join(process.cwd(), "__missing_read_folder__-old")}`,
].join("\n\n"),
},
})
}),
)

it.effect("trusts the environment, not this host, about which folders exist", () =>
Effect.gen(function* () {
const requested = path.join(process.cwd(), "__only_in_workspace__", "file.ts")
readFailure = new Environment.NotFound({ path: requested })
directoryEntryDetails = [{ name: "file.tsx", type: "file" }]
const registry = yield* Tool.Service

expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-workspace-folder", name: "read", input: { path: requested } },
}),
).toEqual({
status: "error",
error: {
type: "tool.execution",
message: `File not found: ${requested}\n\nDid you mean one of these?\n${path.join(process.cwd(), "__only_in_workspace__", "file.tsx")}`,
},
})
}),
)

it.effect("recovers a unique file whose non-breaking spaces differ", () =>
Effect.gen(function* () {
const requested = "Screenshot 2026-08-27 3.15 PM.png"
Expand Down
Loading