fix(list_dir): file-vs-dir mismatch gives an actionable hint (#45)

The 'Not a directory' branch returned a bare message; models re-issued the
identical list_dir until cancel (session a60c54b0). Name the remedy: the path
is a FILE — file_read it or delete/convert it first. Regression test added.
This commit is contained in:
2026-07-12 12:34:37 +04:00
parent 25ab4faac9
commit d0ab027353
2 changed files with 22 additions and 1 deletions
@@ -111,7 +111,15 @@ class ListDirTool(
ToolResult.Failure(request.invocationId, msg, recoverable = true) ToolResult.Failure(request.invocationId, msg, recoverable = true)
} }
!Files.isDirectory(root) -> !Files.isDirectory(root) ->
ToolResult.Failure(request.invocationId, "Not a directory: $pathString", recoverable = true) // Explicit file-vs-dir mismatch: a bare "Not a directory" left models re-issuing the
// identical list_dir until cancel (session a60c54b0). Name the remedy so the next
// action is unambiguous — file_read it, or delete/convert it first.
ToolResult.Failure(
request.invocationId,
"Not a directory: $pathString is a FILE, not a directory. " +
"Use file_read to read it, or delete/convert it before listing it as a directory.",
recoverable = true,
)
else -> runCatching { walk(root, recursive, request) }.getOrElse { else -> runCatching { walk(root, recursive, request) }.getOrElse {
ToolResult.Failure(request.invocationId, "Failed to list dir: ${it.message}", recoverable = false) ToolResult.Failure(request.invocationId, "Failed to list dir: ${it.message}", recoverable = false)
} }
@@ -58,6 +58,19 @@ class ListDirToolTest {
assertFalse(out.contains("deep"), out) assertFalse(out.contains("deep"), out)
} }
@Test
fun `listing a file gives a recoverable file-vs-dir hint, not a bare error`(): Unit = runBlocking {
// Regression (session a60c54b0): list_dir on a plain file returned a bare "Not a directory"
// and models re-issued the identical call until cancel. The failure must name the remedy.
val root = Files.createTempDirectory("listdir")
Files.writeString(root.resolve("pages"), "i am a file")
val tool = ListDirTool(allowedPaths = setOf(root), workingDir = root)
val result = tool.execute(request(path = "pages")) as ToolResult.Failure
assertTrue(result.recoverable, "file-vs-dir mismatch is recoverable")
assertTrue(result.reason.contains("FILE"), result.reason)
assertTrue(result.reason.contains("file_read"), result.reason)
}
@Test @Test
fun `non-recursive lists only immediate children`(): Unit = runBlocking { fun `non-recursive lists only immediate children`(): Unit = runBlocking {
val root = Files.createTempDirectory("listdir") val root = Files.createTempDirectory("listdir")