diff --git a/evals/readtool/fixtures.py b/evals/readtool/fixtures.py index 133d8f0055..bdb1f6ff0c 100644 --- a/evals/readtool/fixtures.py +++ b/evals/readtool/fixtures.py @@ -26,9 +26,11 @@ NOTES_BULLET_3 = "rotate the API keys quarterly" AGENTS_BUILD_CMD = "npm run build:prod" # The filename the fixture writes (adversarial spelling) vs the spelling a -# prompt/screen would show (clean spelling). NARROW NO-BREAK SPACE before -# "PM", NFD-decomposed accents, RIGHT SINGLE QUOTATION MARK. -NOTES_NAME_CLEAN = "Meeting notes' resume 3.04 PM.txt" +# prompt/screen would show (clean spelling). The two render IDENTICALLY: +# NARROW NO-BREAK SPACE vs space, RIGHT SINGLE QUOTATION MARK vs it typed +# again, NFD vs NFC accents. (Accent-dropping is a VISIBLE difference and +# deliberately not part of this task — that class belongs to did-you-mean.) +NOTES_NAME_CLEAN = "Meeting notes\u2019 r\u00e9sum\u00e9 3.04 PM.txt" NOTES_NAME_HOSTILE = unicodedata.normalize( "NFD", "Meeting\u202fnotes\u2019 re\u0301sume\u0301 3.04\u202fPM.txt" ) diff --git a/tests/tools/test_read_unicode_filename_retry.py b/tests/tools/test_read_unicode_filename_retry.py new file mode 100644 index 0000000000..0fb5817481 --- /dev/null +++ b/tests/tools/test_read_unicode_filename_retry.py @@ -0,0 +1,73 @@ +"""Tests for unicode-equivalent filename retry + near-miss suggestions. + +NFC/NFD, narrow no-break space, and curly quotes render identically in a +terminal — a model that retypes a visually-correct path can never discover +the byte mismatch. The repair is the tool's job (single unambiguous match +only). Visible differences stay with did-you-mean suggestions. +""" + +import json +import unicodedata + +import pytest + +from tools.file_tools import read_file_tool + +HOSTILE = unicodedata.normalize( + "NFD", "Meeting\u202fnotes\u2019 re\u0301sume\u0301 3.04\u202fPM.txt" +) +CLEAN = "Meeting notes\u2019 r\u00e9sum\u00e9 3.04 PM.txt" # NFC + plain spaces + + +@pytest.fixture +def ws(tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + (tmp_path / "notes").mkdir() + (tmp_path / "notes" / HOSTILE).write_text("- rotate the keys\n") + (tmp_path / "AGENTS.md").write_text("npm run build:prod\n") + return tmp_path + + +class TestUnicodeVariantRepair: + def test_nfc_plain_space_spelling_repairs(self, ws): + result = json.loads(read_file_tool(str(ws / "notes" / CLEAN))) + assert "rotate the keys" in result.get("content", "") + assert "unicode-equivalent" in result.get("hint", "") + + def test_exact_spelling_no_note(self, ws): + result = json.loads(read_file_tool(str(ws / "notes" / HOSTILE))) + assert "rotate the keys" in result.get("content", "") + assert "unicode-equivalent" not in (result.get("hint") or "") + + def test_visible_difference_not_repaired(self, ws): + # Straight quote + accent-less = visibly different: suggest, don't repair + result = json.loads( + read_file_tool(str(ws / "notes" / "Meeting notes' resume 3.04 PM.txt")) + ) + assert result.get("error"), "visible diff must stay a not-found" + assert "unicode-equivalent" not in (result.get("hint") or "") + assert result.get("similar_files") + + def test_ambiguous_twins_not_repaired(self, tmp_path, monkeypatch): + monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) + (tmp_path / "caf\u00e9.txt").write_text("nfc\n") + (tmp_path / "cafe\u0301.txt").write_text("nfd\n") + # Both canonicalize to café.txt; a third spelling must not guess. + result = json.loads(read_file_tool(str(tmp_path / "CAFE.txt"))) + assert "unicode-equivalent" not in (result.get("hint") or "") + + def test_plain_missing_file_unchanged(self, ws): + result = json.loads(read_file_tool(str(ws / "missing.txt"))) + assert "not found" in result.get("error", "").lower() + + +class TestNearMissSuggestion: + def test_agent_md_suggests_agents_md(self, ws): + result = json.loads(read_file_tool(str(ws / "AGENT.md"))) + sims = result.get("similar_files") or [] + assert any("AGENTS.md" in s for s in sims), sims + + def test_unrelated_name_no_suggestion_of_agents(self, ws): + result = json.loads(read_file_tool(str(ws / "zzz_qqq.bin"))) + sims = result.get("similar_files") or [] + assert not any("AGENTS.md" in s for s in sims) diff --git a/tools/file_operations.py b/tools/file_operations.py index 41444667d6..87c8a26efc 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -31,6 +31,7 @@ import os import re import difflib import hashlib +import unicodedata from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Optional, List, Dict, Any, ClassVar @@ -1241,7 +1242,23 @@ class ShellFileOperations(FileOperations): stat_result = self._exec(stat_cmd) if stat_result.exit_code != 0: - # File not found - try to suggest similar files + # File not found. Before failing, try unicode-equivalent + # spellings — NFC/NFD, narrow no-break space, curly quotes + # render identically in a terminal, so the model retyping a + # visually-correct path can never discover the byte mismatch + # on its own (retrying is the tool's job, not the model's). + variant = self._unicode_variant_match(path) + if variant is not None: + result = self.read_file(variant, offset=offset, limit=limit) + note = ( + f"Note: '{path}' not found byte-for-byte; resolved to " + f"the unicode-equivalent file '{variant}' (invisible " + "encoding difference: NFC/NFD or special space/quote " + "characters)." + ) + result.hint = f"{note} {result.hint}" if result.hint else note + return result + # No equivalent spelling — suggest similar files return self._suggest_similar_files(path) stat_output = _strip_terminal_fence_leaks(stat_result.stdout) @@ -1323,6 +1340,50 @@ class ShellFileOperations(FileOperations): hint=hint ) + def _unicode_variant_match(self, path: str) -> Optional[str]: + """Find an existing file whose name is unicode-equivalent to ``path``. + + macOS names screenshots with a NARROW NO-BREAK SPACE (U+202F) before + AM/PM, stores names NFD-decomposed, and Finder renames turn ' into + \u2019 — all invisible in rendered text. Compare directory entries + under a normalization that erases exactly those differences and + return the on-disk spelling when exactly one entry matches. + """ + dir_path = os.path.dirname(path) or "." + filename = os.path.basename(path) + if not filename: + return None + + def _canon(name: str) -> str: + # NFC first so composed/decomposed collapse together, then the + # confusable space/quote characters seen in real filenames. + out = unicodedata.normalize("NFC", name) + for src, dst in ( + ("\u202f", " "), # narrow no-break space + ("\u00a0", " "), # no-break space + ("\u2019", "'"), # right single quotation mark + ("\u2018", "'"), # left single quotation mark + ): + out = out.replace(src, dst) + return out + + target = _canon(filename) + ls_cmd = f"ls -1 {self._escape_shell_arg(dir_path)} 2>/dev/null" + ls_result = self._exec(ls_cmd) + if ls_result.exit_code != 0 or not ls_result.stdout.strip(): + return None + candidates = [ + entry + for entry in _strip_terminal_fence_leaks(ls_result.stdout).splitlines() + if entry and entry != filename and _canon(entry) == target + ] + # Exactly one equivalent spelling = unambiguous repair. Zero or + # several = fall through to suggestions; guessing among homoglyph + # collisions would silently read the wrong file. + if len(candidates) == 1: + return os.path.join(dir_path, candidates[0]) if dir_path != "." or "/" in path else candidates[0] + return None + def _suggest_similar_files(self, path: str) -> ReadResult: """Suggest similar files when the requested file is not found.""" dir_path = os.path.dirname(path) or "." @@ -1363,6 +1424,13 @@ class ShellFileOperations(FileOperations): common = set(lower_name) & set(lf) if len(common) >= max(len(lower_name), len(lf)) * 0.4: score = 30 + # Near-miss spelling (AGENT.md -> AGENTS.md): substring + # checks above find nothing, but a high sequence ratio + # catches 1-2 edit typos without a homegrown levenshtein. + if score == 0 and difflib.SequenceMatcher( + None, lower_name, lf + ).ratio() >= 0.8: + score = 50 if score > 0: scored.append((score, os.path.join(dir_path, f)))