fix(search): zero-match probes return the file paths they found, not just counts
The casing/hidden/literal probes already ran the widened search to produce their counts, then threw away the paths and returned a hint-only warning. Strong models pivot in one turn; weak models spiral — the A/B eval measured qwen3-coder-30b going 3.3 -> 9.3 turns on err_case_search, retrying casing variants the probe had already resolved. All three probes (case-insensitive, hidden/gitignored, literal-vs-regex) now include up to 5 matched paths (+N more) in the warning via a shared tally helper. Fixes the class, not the site. Closes #80522
This commit is contained in:
parent
9d4ef04ed0
commit
d273e0f2fa
|
|
@ -27,12 +27,21 @@ class TestZeroMatchProbe:
|
|||
assert r["total_count"] == 0
|
||||
assert "case-insensitive" in r.get("warning", "")
|
||||
|
||||
def test_case_mismatch_hint_names_the_files(self, proj):
|
||||
# The probe already ran the -i search; it must hand over the paths,
|
||||
# not just a count (issue #80522: hint-only sent weak models into
|
||||
# 5-search casing-variant spirals — +6 turns measured on the A/B eval).
|
||||
r = json.loads(search_tool("token_alpha", path=str(proj / "proj"), task_id="t-zm"))
|
||||
w = r.get("warning", "")
|
||||
assert "a.py" in w and "b.py" in w
|
||||
|
||||
def test_regex_metachar_literal_hint(self, proj):
|
||||
d = proj / "proj"
|
||||
(d / "meta.py").write_text("result = lookup[key+1]\n")
|
||||
r = json.loads(search_tool("lookup[key+1]", path=str(d), task_id="t-zm"))
|
||||
assert r["total_count"] == 0
|
||||
assert "literal match" in r.get("warning", "")
|
||||
assert "meta.py" in r.get("warning", "")
|
||||
|
||||
def test_true_zero_match_no_hint(self, proj):
|
||||
r = json.loads(search_tool("zzz_totally_absent_zzz", path=str(proj / "proj"), task_id="t-zm"))
|
||||
|
|
@ -46,6 +55,17 @@ class TestZeroMatchProbe:
|
|||
r = json.loads(search_tool("HIDDEN_ONLY_TOKEN", path=str(d), task_id="t-zm"))
|
||||
assert r["total_count"] == 0
|
||||
assert "hidden or gitignored" in r.get("warning", "")
|
||||
# Same class as the casing probe: the path must be in the hint.
|
||||
assert "conf.cfg" in r.get("warning", "")
|
||||
|
||||
def test_probe_path_list_is_capped(self, proj):
|
||||
d = proj / "proj"
|
||||
for i in range(8):
|
||||
(d / f"cap{i}.txt").write_text("capped_case_token = 1\n")
|
||||
r = json.loads(search_tool("CAPPED_CASE_TOKEN", path=str(d), task_id="t-zm"))
|
||||
w = r.get("warning", "")
|
||||
assert "case-insensitive" in w
|
||||
assert "+3 more" in w # 8 files, 5 shown
|
||||
|
||||
def test_matching_search_unaffected(self, proj):
|
||||
r = json.loads(search_tool("TOKEN_ALPHA", path=str(proj / "proj"), task_id="t-zm"))
|
||||
|
|
|
|||
|
|
@ -2308,6 +2308,23 @@ class ShellFileOperations(FileOperations):
|
|||
"""
|
||||
if not self._has_command('rg'):
|
||||
return None
|
||||
|
||||
def _tally(stdout: str):
|
||||
"""Parse ``path:count`` lines from rg --count-matches."""
|
||||
total = 0
|
||||
per_file = []
|
||||
for line in (stdout or "").strip().splitlines():
|
||||
p, _sep, n = line.rpartition(":")
|
||||
if n.isdigit():
|
||||
total += int(n)
|
||||
per_file.append(p)
|
||||
return total, per_file
|
||||
|
||||
def _paths_note(per_file, cap: int = 5) -> str:
|
||||
shown = ", ".join(per_file[:cap])
|
||||
extra = len(per_file) - cap
|
||||
return shown + (f" (+{extra} more)" if extra > 0 else "")
|
||||
|
||||
glob_expr = f" --glob {self._escape_shell_arg(file_glob)}" if file_glob else ""
|
||||
probe = self._exec(
|
||||
f"rg -i --count-matches{glob_expr} "
|
||||
|
|
@ -2315,17 +2332,12 @@ class ShellFileOperations(FileOperations):
|
|||
f"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
)
|
||||
ci_total = 0
|
||||
ci_files = 0
|
||||
for line in (probe.stdout or "").strip().splitlines():
|
||||
_p, _sep, n = line.rpartition(":")
|
||||
if n.isdigit():
|
||||
ci_total += int(n)
|
||||
ci_files += 1
|
||||
ci_total, ci_paths = _tally(probe.stdout)
|
||||
if ci_total > 0:
|
||||
return (
|
||||
f"0 exact matches, but {ci_total} case-insensitive match(es) "
|
||||
f"in {ci_files} file(s) — the pattern's casing may be wrong."
|
||||
f"in {len(ci_paths)} file(s): {_paths_note(ci_paths)} — "
|
||||
"the pattern's casing may be wrong."
|
||||
)
|
||||
# Hidden/ignored probe: rg skips dotdirs and .gitignore'd files by
|
||||
# default. When the pattern exists only there, say so instead of
|
||||
|
|
@ -2337,18 +2349,12 @@ class ShellFileOperations(FileOperations):
|
|||
f"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
)
|
||||
h_total = 0
|
||||
h_files = 0
|
||||
for line in (hidden.stdout or "").strip().splitlines():
|
||||
_p, _sep, n = line.rpartition(":")
|
||||
if n.isdigit():
|
||||
h_total += int(n)
|
||||
h_files += 1
|
||||
h_total, h_paths = _tally(hidden.stdout)
|
||||
if h_total > 0:
|
||||
return (
|
||||
f"0 matches in visible files, but {h_total} match(es) in "
|
||||
f"{h_files} hidden or gitignored file(s) — these are excluded "
|
||||
"by default. Search the hidden path explicitly to include them."
|
||||
f"{len(h_paths)} hidden or gitignored file(s): "
|
||||
f"{_paths_note(h_paths)} — these are excluded by default."
|
||||
)
|
||||
if re.search(r"[.\[\](){}?*+^$\\|]", pattern):
|
||||
fixed = self._exec(
|
||||
|
|
@ -2357,14 +2363,11 @@ class ShellFileOperations(FileOperations):
|
|||
f"2>/dev/null | head -50",
|
||||
timeout=30,
|
||||
)
|
||||
f_total = sum(
|
||||
int(line.rpartition(":")[2])
|
||||
for line in (fixed.stdout or "").strip().splitlines()
|
||||
if line.rpartition(":")[2].isdigit()
|
||||
)
|
||||
f_total, f_paths = _tally(fixed.stdout)
|
||||
if f_total > 0:
|
||||
return (
|
||||
f"0 regex matches, but {f_total} literal match(es) — the "
|
||||
f"0 regex matches, but {f_total} literal match(es) in "
|
||||
f"{len(f_paths)} file(s): {_paths_note(f_paths)} — the "
|
||||
"pattern contains regex metacharacters that likely need "
|
||||
"escaping (or pass a simpler substring)."
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue