Files
hermes-agent/tests/tools/test_file_staleness.py
Teknium ef2ae3e48f fix(file_tools): refresh staleness timestamp after writes (#4390)
After a successful write_file or patch, update the stored read
timestamp to match the file's new modification time.  Without this,
consecutive edits by the same task (read → write → write) would
false-warn on the second write because the stored timestamp still
reflected the original read, not the first write.

Also renames the internal tracker key from 'file_mtimes' to
'read_timestamps' for clarity.
2026-04-01 00:50:08 -07:00

242 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""
Tests for file staleness detection in write_file and patch.
When a file is modified externally between the agent's read and write,
the write should include a warning so the agent can re-read and verify.
Run with: python -m pytest tests/tools/test_file_staleness.py -v
"""
import json
import os
import tempfile
import time
import unittest
from unittest.mock import patch, MagicMock
from tools.file_tools import (
read_file_tool,
write_file_tool,
patch_tool,
clear_read_tracker,
_check_file_staleness,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _FakeReadResult:
def __init__(self, content="line1\nline2\n", total_lines=2, file_size=100):
self.content = content
self._total_lines = total_lines
self._file_size = file_size
def to_dict(self):
return {
"content": self.content,
"total_lines": self._total_lines,
"file_size": self._file_size,
}
class _FakeWriteResult:
def __init__(self):
self.bytes_written = 10
def to_dict(self):
return {"bytes_written": self.bytes_written}
class _FakePatchResult:
def __init__(self):
self.success = True
def to_dict(self):
return {"success": True, "diff": "--- a\n+++ b\n@@ ...\n"}
def _make_fake_ops(read_content="hello\n", file_size=6):
fake = MagicMock()
fake.read_file = lambda path, offset=1, limit=500: _FakeReadResult(
content=read_content, total_lines=1, file_size=file_size,
)
fake.write_file = lambda path, content: _FakeWriteResult()
fake.patch_replace = lambda path, old, new, replace_all=False: _FakePatchResult()
return fake
# ---------------------------------------------------------------------------
# Core staleness check
# ---------------------------------------------------------------------------
class TestStalenessCheck(unittest.TestCase):
def setUp(self):
clear_read_tracker()
self._tmpdir = tempfile.mkdtemp()
self._tmpfile = os.path.join(self._tmpdir, "stale_test.txt")
with open(self._tmpfile, "w") as f:
f.write("original content\n")
def tearDown(self):
clear_read_tracker()
try:
os.unlink(self._tmpfile)
os.rmdir(self._tmpdir)
except OSError:
pass
@patch("tools.file_tools._get_file_ops")
def test_no_warning_when_file_unchanged(self, mock_ops):
"""Read then write with no external modification — no warning."""
mock_ops.return_value = _make_fake_ops("original content\n", 18)
read_file_tool(self._tmpfile, task_id="t1")
result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t1"))
self.assertNotIn("_warning", result)
@patch("tools.file_tools._get_file_ops")
def test_warning_when_file_modified_externally(self, mock_ops):
"""Read, then external modify, then write — should warn."""
mock_ops.return_value = _make_fake_ops("original content\n", 18)
read_file_tool(self._tmpfile, task_id="t1")
# Simulate external modification
time.sleep(0.05)
with open(self._tmpfile, "w") as f:
f.write("someone else changed this\n")
result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t1"))
self.assertIn("_warning", result)
self.assertIn("modified since you last read", result["_warning"])
@patch("tools.file_tools._get_file_ops")
def test_no_warning_when_file_never_read(self, mock_ops):
"""Writing a file that was never read — no warning."""
mock_ops.return_value = _make_fake_ops()
result = json.loads(write_file_tool(self._tmpfile, "new content", task_id="t2"))
self.assertNotIn("_warning", result)
@patch("tools.file_tools._get_file_ops")
def test_no_warning_for_new_file(self, mock_ops):
"""Creating a new file — no warning."""
mock_ops.return_value = _make_fake_ops()
new_path = os.path.join(self._tmpdir, "brand_new.txt")
result = json.loads(write_file_tool(new_path, "content", task_id="t3"))
self.assertNotIn("_warning", result)
try:
os.unlink(new_path)
except OSError:
pass
@patch("tools.file_tools._get_file_ops")
def test_different_task_isolated(self, mock_ops):
"""Task A reads, file changes, Task B writes — no warning for B."""
mock_ops.return_value = _make_fake_ops("original content\n", 18)
read_file_tool(self._tmpfile, task_id="task_a")
time.sleep(0.05)
with open(self._tmpfile, "w") as f:
f.write("changed\n")
result = json.loads(write_file_tool(self._tmpfile, "new", task_id="task_b"))
self.assertNotIn("_warning", result)
# ---------------------------------------------------------------------------
# Staleness in patch
# ---------------------------------------------------------------------------
class TestPatchStaleness(unittest.TestCase):
def setUp(self):
clear_read_tracker()
self._tmpdir = tempfile.mkdtemp()
self._tmpfile = os.path.join(self._tmpdir, "patch_test.txt")
with open(self._tmpfile, "w") as f:
f.write("original line\n")
def tearDown(self):
clear_read_tracker()
try:
os.unlink(self._tmpfile)
os.rmdir(self._tmpdir)
except OSError:
pass
@patch("tools.file_tools._get_file_ops")
def test_patch_warns_on_stale_file(self, mock_ops):
"""Patch should warn if the target file changed since last read."""
mock_ops.return_value = _make_fake_ops("original line\n", 15)
read_file_tool(self._tmpfile, task_id="p1")
time.sleep(0.05)
with open(self._tmpfile, "w") as f:
f.write("externally modified\n")
result = json.loads(patch_tool(
mode="replace", path=self._tmpfile,
old_string="original", new_string="patched",
task_id="p1",
))
self.assertIn("_warning", result)
self.assertIn("modified since you last read", result["_warning"])
@patch("tools.file_tools._get_file_ops")
def test_patch_no_warning_when_fresh(self, mock_ops):
"""Patch with no external changes — no warning."""
mock_ops.return_value = _make_fake_ops("original line\n", 15)
read_file_tool(self._tmpfile, task_id="p2")
result = json.loads(patch_tool(
mode="replace", path=self._tmpfile,
old_string="original", new_string="patched",
task_id="p2",
))
self.assertNotIn("_warning", result)
# ---------------------------------------------------------------------------
# Unit test for the helper
# ---------------------------------------------------------------------------
class TestCheckFileStalenessHelper(unittest.TestCase):
def setUp(self):
clear_read_tracker()
def tearDown(self):
clear_read_tracker()
def test_returns_none_for_unknown_task(self):
self.assertIsNone(_check_file_staleness("/tmp/x.py", "nonexistent"))
def test_returns_none_for_unread_file(self):
# Populate tracker with a different file
from tools.file_tools import _read_tracker, _read_tracker_lock
with _read_tracker_lock:
_read_tracker["t1"] = {
"last_key": None, "consecutive": 0,
"read_history": set(), "dedup": {},
"read_timestamps": {"/tmp/other.py": 12345.0},
}
self.assertIsNone(_check_file_staleness("/tmp/x.py", "t1"))
def test_returns_none_when_stat_fails(self):
from tools.file_tools import _read_tracker, _read_tracker_lock
with _read_tracker_lock:
_read_tracker["t1"] = {
"last_key": None, "consecutive": 0,
"read_history": set(), "dedup": {},
"read_timestamps": {"/nonexistent/path": 99999.0},
}
# File doesn't exist → stat fails → returns None (let write handle it)
self.assertIsNone(_check_file_staleness("/nonexistent/path", "t1"))
if __name__ == "__main__":
unittest.main()