Files
hermes-agent/tests/tools/test_file_tools_cwd_resolution.py
kshitijk4poor 432325933a test: restore unrelated trailing newlines in cwd/tool-search tests
The salvaged PR incidentally stripped a trailing blank line from two
unrelated test files (test_file_tools_cwd_resolution.py,
test_tool_search.py). Restore them to keep the salvage diff scoped to
the observability feature.
2026-06-03 06:36:46 -07:00

198 lines
8.1 KiB
Python

"""Regression tests for file-tool path resolution base correctness.
The bug (observed in a worktree dev session, May 2026): when the resolution
base for a relative path is itself RELATIVE — e.g. ``TERMINAL_CWD="."`` from a
stale config — ``_resolve_path_for_task`` resolved the path against the agent's
PROCESS cwd instead of the intended workspace. In a git-worktree session this
silently routed ``patch``/``write_file`` edits into the *main* checkout: the
write landed, self-verified, and reported success — against the wrong file.
The agent then grepped the worktree, saw nothing, and concluded the patch tool
had silently no-op'd. It hadn't; it wrote to the wrong place.
Core invariant these tests pin:
The resolution base for a relative path MUST always be absolute. A relative
``TERMINAL_CWD`` (``.``, ``./sub``, ``..``) must be anchored deterministically,
never left to resolve against whatever the process cwd happens to be.
"""
import os
from pathlib import Path
import pytest
import tools.file_tools as ft
@pytest.fixture
def _isolated_cwd(tmp_path, monkeypatch):
"""Two checkouts: workspace (intended) + decoy (process cwd)."""
workspace = tmp_path / "workspace"
decoy = tmp_path / "decoy"
workspace.mkdir()
decoy.mkdir()
(workspace / "target.py").write_text("WORKSPACE_ORIGINAL\n")
(decoy / "target.py").write_text("DECOY_ORIGINAL\n")
# Process cwd = decoy, analogous to "main repo" while the terminal is in
# the worktree.
monkeypatch.chdir(decoy)
# No live-terminal-cwd tracking recorded yet (fresh-session condition).
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
return workspace, decoy
def test_relative_terminal_cwd_anchors_to_absolute_not_process_cwd(_isolated_cwd, monkeypatch):
"""TERMINAL_CWD='.' must NOT silently mean 'the agent process cwd'.
A relative base is meaningless as a resolution anchor. The resolver must
make it absolute deterministically. We assert the resolved path is
absolute and stable regardless of where os.getcwd() points.
"""
workspace, decoy = _isolated_cwd
# Poison config: literal relative '.'
monkeypatch.setenv("TERMINAL_CWD", ".")
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute(), f"resolution base leaked a relative path: {resolved}"
# The exact anchor for a bare '.' is the process cwd resolved to absolute —
# that is acceptable as long as it is ABSOLUTE and stable. The bug was that
# a relative base produced surprising results; the fix is that the base is
# always absolutised. (We do not require it to point at the workspace here —
# that's what live-cwd tracking is for; see the next test.)
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
def test_live_tracking_cwd_wins_over_relative_terminal_cwd(_isolated_cwd, monkeypatch):
"""When the terminal reports its absolute cwd, that is authoritative.
This is the real-world fix: the terminal's tracked absolute cwd (the
worktree) must override a stale relative TERMINAL_CWD so edits land where
the agent is actually working.
"""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_terminal_cwd_used_verbatim(_isolated_cwd, monkeypatch):
"""An absolute TERMINAL_CWD is the resolution base (no live tracking)."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", str(workspace))
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved == (workspace / "target.py")
def test_absolute_input_path_ignores_base(_isolated_cwd, monkeypatch):
"""An absolute input path is never re-anchored."""
workspace, decoy = _isolated_cwd
monkeypatch.setenv("TERMINAL_CWD", ".")
abs_target = str(workspace / "target.py")
resolved = ft._resolve_path_for_task(abs_target, task_id="default")
assert resolved == Path(abs_target).resolve()
def test_resolution_base_always_absolute_no_terminal_cwd(_isolated_cwd, monkeypatch):
"""With TERMINAL_CWD unset, the base falls back to an ABSOLUTE process cwd."""
workspace, decoy = _isolated_cwd
monkeypatch.delenv("TERMINAL_CWD", raising=False)
resolved = ft._resolve_path_for_task("target.py", task_id="default")
assert resolved.is_absolute()
assert str(resolved) == str((Path(os.getcwd()) / "target.py").resolve())
# ── B-(ii): workspace-divergence warning ────────────────────────────────────
def test_warning_fires_when_relative_path_escapes_workspace(_isolated_cwd, monkeypatch):
"""Relative path resolving outside the live workspace must warn."""
workspace, decoy = _isolated_cwd
# Live cwd = workspace, but the relative path resolves to decoy (process cwd)
# because TERMINAL_CWD is the poison '.'. Simulate by pointing live tracking
# at workspace while the resolved path is under decoy.
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_decoy = decoy / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_decoy, task_id="default")
assert warn is not None
assert "OUTSIDE the active workspace" in warn
assert str(decoy) in warn
assert str(workspace) in warn
def test_no_warning_when_relative_path_inside_workspace(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
resolved_in_workspace = workspace / "target.py"
warn = ft._path_resolution_warning("target.py", resolved_in_workspace, task_id="default")
assert warn is None
def test_no_warning_for_absolute_input(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
warn = ft._path_resolution_warning(str(decoy / "target.py"), decoy / "target.py", task_id="default")
assert warn is None
def test_no_warning_when_no_live_cwd(_isolated_cwd, monkeypatch):
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": None)
warn = ft._path_resolution_warning("target.py", decoy / "target.py", task_id="default")
assert warn is None
# ── Fix A: write_file / patch report the resolved ABSOLUTE path ──────────────
def test_write_file_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""write_file_tool must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.write_file_tool("newfile.txt", "hello\n", task_id="t1"))
expected = str((workspace / "newfile.txt").resolve())
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert (workspace / "newfile.txt").read_text() == "hello\n"
def test_patch_reports_resolved_absolute_path(_isolated_cwd, monkeypatch):
"""patch_tool (replace mode) must put the absolute on-disk path in files_modified."""
workspace, decoy = _isolated_cwd
monkeypatch.setattr(ft, "_get_live_tracking_cwd", lambda task_id="default": str(workspace))
import json
out = json.loads(ft.patch_tool(
mode="replace", path="target.py",
old_string="WORKSPACE_ORIGINAL", new_string="WORKSPACE_PATCHED",
task_id="t1",
))
expected = str((workspace / "target.py").resolve())
assert not out.get("error"), out
assert out.get("resolved_path") == expected
assert out.get("files_modified") == [expected]
assert "WORKSPACE_PATCHED" in (workspace / "target.py").read_text()
# And the decoy copy is untouched.
assert (decoy / "target.py").read_text() == "DECOY_ORIGINAL\n"