Skip to content

fix(read_file): detect metadata-only responses by the request flag - #987

Open
stag7824 wants to merge 3 commits into
Nano-Collective:mainfrom
stag7824:fix/read-file-metadata-only-detection
Open

fix(read_file): detect metadata-only responses by the request flag#987
stag7824 wants to merge 3 commits into
Nano-Collective:mainfrom
stag7824:fix/read-file-metadata-only-detection

Conversation

@stag7824

Copy link
Copy Markdown

Fixes #970

The bug

readFileFormatter decided whether a response was metadata-only like this:

const isMetadataOnly =
  (result?.startsWith('File:') ?? false) &&
  !args.start_line &&
  !args.end_line &&
  totalLines > FILE_READ_PREVIEW_THRESHOLD_LINES;

The metadata branch emits (read-file.tsx:48):

let output = `File Information for "${args.path}"\n`;

"File Information for ..." does not start with "File:" — the fifth character is a space, not a colon. So the prefix test never matched and isMetadataOnly was always false.

The other half of it

The three remaining conditions describe the truncated-preview response, not this one. read_file returns one of three shapes:

Shape Emitted at Detected by
metadata block line 48, returned line 102 isMetadataOnly ← broken
truncated preview line 133 result.includes('[Truncated at line ')
plain content line 150 neither

The metadata branch is guarded by if (args.metadata_only) at line 34 and returns before any line range or file size is considered. So requiring !args.start_line && !args.end_line && totalLines > 1500 could never be right for it — a metadata_only read on a 120-line file fails the threshold even if the prefix had matched.

Impact

With isMetadataOnly stuck false, a metadata_only: true read renders as an ordinary content read:

  • the (metadata only) marker never appears (line 254)
  • the metadata layout never renders; the content-read layout renders instead (line 260)
  • the Tokens: row renders even though it is meant to be hidden for metadata reads (line 281)
  • tokens are computed from result — the metadata block itself — rather than from the full file (lines 349–355), so the number describes the wrong thing

The change

-const isMetadataOnly =
-  (result?.startsWith('File:') ?? false) &&
-  !args.start_line &&
-  !args.end_line &&
-  totalLines > FILE_READ_PREVIEW_THRESHOLD_LINES;
+const isMetadataOnly = args.metadata_only === true;

args.metadata_only is the flag that selects this response shape at line 34, so it is exactly the right signal. FILE_READ_PREVIEW_THRESHOLD_LINES is still used at line 123, so the import stays live.

Verification

Against the exact string the metadata branch emits:

metadata output begins: "File Information for \"src/app.ts\""
char 5 is " " - a space, not ":"

old prefix test  result.startsWith("File:")  -> false

case                                  old      new
metadata_only on a 120-line file      false    true
metadata_only on a 4000-line file     false    true

The truncated-preview and plain-content paths are untouched — isTruncated on the following line is unchanged, and neither path sets metadata_only.

A changeset is included (@nanocollective/nanocoder, patch).

The formatter decided a response was metadata-only with

    result?.startsWith('File:')

but the metadata branch emits `File Information for "..."`, so the test never
matched - the fifth character is a space, not a colon.

The same condition additionally required no line range and a file longer than
FILE_READ_PREVIEW_THRESHOLD_LINES. Those describe the truncated-preview
response, which is already detected separately on the next line; the metadata
branch returns before any line range or size is considered, so a
`metadata_only` read on a small file could not have matched either.

isMetadataOnly was therefore always false, and a `metadata_only: true` read
rendered as an ordinary content read: no "(metadata only)" marker, the
content-read layout instead of the metadata layout, and a token count measured
from the metadata block rather than from the full file.

Fixes Nano-Collective#970

@will-lamerton will-lamerton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnosis is correct and I reproduced it: on main a metadata_only: true read rendered Lines: 1 - 4 and Tokens: ~89 (tokens measured from the metadata block), and on this branch it correctly renders (metadata only) / Total lines: 4 with no Tokens: row. Types, lint, format and the 42 existing read-file.spec.tsx tests all pass.

Three things before merge:

1. Needs a regression test. This bug shipped precisely because no formatter test ever passes metadata_only: true - the three existing ReadFileFormatter tests cover path, truncated preview and error results only, and the metadata_only tests exercise the handler rather than the formatter. Nothing in the suite would catch this coming back. A test asserting (metadata only) renders and Tokens: does not would close it.

2. Directories and unreadable files still render the wrong layout. isMetadataOnly is computed inside the try, after getCachedFileContent(), which throws EISDIR for a directory. So a metadata_only read of a directory still falls into the catch and renders the content layout:

⚒ read_file
Path:  .../some-dir
Lines: 1 - 0
Tokens: ~0

The tool explicitly supports directories (if (type === 'directory') at line 94, with a handler test for it), so this is the same class of bug left half-fixed. Hoisting the flag above the try and into the initial fileInfo handles both cases.

3. === true disagrees with the executor. executeReadFile gates on truthy if (args.metadata_only) while the formatter now requires === true. The XML fallback path can produce non-boolean truthy values: xml-parser.ts:161 does JSON.parse() and falls back to the raw string, so <metadata_only>True</metadata_only> arrives as a string. I confirmed that passing 'true' reproduces the exact bug being fixed here. Boolean(args.metadata_only) keeps the two predicates identical by construction and fixes this alongside point 2.

Two smaller notes, take or leave:

  • With isMetadataOnly now actually true, tokens = calculateTokens(content) runs a full-file tokenization whose result is never rendered, since the Tokens: row is hidden for metadata reads. Worth skipping.
  • The 6-line comment is mostly archaeology about why the old code was wrong. Comments elsewhere in this file explain intent rather than history, and the changeset already tells that story. One line on what the flag means would fit better.

Three changes from will-lamerton's review of Nano-Collective#987:

- isMetadataOnly is now set from args.metadata_only before the file read,
  not derived after it succeeds. executeReadFile's own metadata branch never
  calls getCachedFileContent for a directory or symlink (only for
  type === 'file'), so the formatter's identical call threw the same way,
  landing in the catch and losing the flag - a metadata_only read of a
  directory still rendered the content layout.

- isMetadataOnly now uses Boolean(args.metadata_only) instead of
  `=== true`, matching executeReadFile's own truthy check. The XML
  tool-call fallback can hand this through as the string 'true', which is
  truthy but not strictly equal to true, and would otherwise reproduce the
  exact bug this PR fixes.

- Skip calculateTokens() for a metadata-only read; the Tokens: row is
  hidden for that layout, so the value was computed and discarded.

Added three regression tests: a metadata_only read on a regular file, one
on a directory, and one with metadata_only passed as a truthy string.
Trimmed the inline comment per review - the changeset carries the "why"
now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AycxQUU3D4C9D45UQfDEhb
@stag7824

stag7824 commented Sep 2, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review and for reproducing it independently — all three addressed in 17ea57f:

  1. Regression test — added three: a metadata_only read on a regular file, one on a directory, and one with metadata_only passed as the string 'true'.
  2. Directories/symlinksisMetadataOnly is now set from args.metadata_only before the file read, not derived after it succeeds. Your fix (hoist it into the initial fileInfo) is exactly what I did.
  3. Boolean() vs === true — changed, and I confirmed your repro: with the old === true, passing metadata_only: 'true' (string) reproduced the identical bug this PR fixes.

Also took the two smaller suggestions: skipped calculateTokens() for metadata-only reads since that row is hidden, and trimmed the inline comment — the changeset now carries the "why".

test:types, test:lint, test:format, and test:knip all pass; read-file.spec.tsx is green including the three new tests (one pre-existing unrelated failure — ReadFileFormatter renders with path fails the same way on main, it's a terminal-width truncation assumption that breaks under a long cwd, unrelated to this change).

@github-actions github-actions Bot added the area:tools Tool implementations and tool-calling label Sep 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:tools Tool implementations and tool-calling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] isMetadataOnly check for read_file never matches

2 participants