8cb0531959
* fix(lint): organize imports in queen_orchestrator.create_queen Ruff I001 blocks CI on every PR against main. The deferred imports inside create_queen were not in alphabetical order between the queen package and the framework package; ruff auto-fix moves framework.config below the framework.agents.queen.nodes block. No behavior change. * fix(ci): install Playwright Chromium before Test Tools job The new chart_tools smoke tests added infeabf327require a Chromium build for ECharts/Mermaid rendering, but the test-tools workflow only ran `uv sync` and went straight to pytest. Three tests (test_render_echarts_bar_chart, test_render_echarts_accepts_string_spec, test_render_mermaid_flowchart) crash on every PR with: BrowserType.launch: Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium_headless_shell-1208/... Split the install/run into separate steps and add `playwright install chromium` before pytest. Use `--with-deps` on Linux to pull system libraries; Windows runners only need the browser binary. * fix(tests): adapt test_file_state_cache to new file_ops API The file_ops rewrite infeabf327dropped the standalone hashline_edit tool (the file_system_toolkits/hashline_edit/ directory was removed) and switched edit_file to a mode-first signature (mode, path, old_string, new_string, ...). The test fixture still tried to look up "hashline_edit" via the MCP tool manager and crashed with KeyError before any test could run, and the edit_file calls were positional in the old order so they hit "unknown mode 'e.py'" once the fixture was fixed. Drop the stale hashline_edit lookup and pass mode="replace" explicitly to every edit_file call. All 11 tests pass locally. * fix(tests): skip terminal_tools tests on Windows (POSIX-only) The new terminal_tools package added infeabf327imports the Unix-only `resource` module in tools/src/terminal_tools/common/limits.py to set RLIMIT_CPU / RLIMIT_AS / RLIMIT_FSIZE on subprocesses. Five of the six terminal_tools test files therefore crash on windows-latest with `ModuleNotFoundError: No module named 'resource'` once their fixtures trigger the import chain. test_terminal_tools_pty.py already has the right module-level skip (PTY is POSIX-only). Apply the same `pytestmark = skipif(win32)` to the other five so the whole suite skips cleanly on Windows. The terminal-tools package is bash-only by design (zsh refused at the shell-resolver level), so a Windows port is out of scope.
194 lines
6.9 KiB
Python
194 lines
6.9 KiB
Python
"""Tests for aden_tools.file_state_cache and its integration with file_ops.
|
|
|
|
These tests cover the stale-edit guard added for Gap 4:
|
|
- read_file records a per-file hash snapshot
|
|
- edit_file / write_file refuse to run when the on-disk file has
|
|
diverged from the last recorded read
|
|
- write_file is allowed without a prior read when the target doesn't
|
|
exist yet (brand-new file, nothing to clobber)
|
|
- re-recording after a successful write keeps chained edits working
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastmcp import FastMCP
|
|
|
|
from aden_tools import file_state_cache
|
|
from aden_tools.file_ops import register_file_tools
|
|
|
|
|
|
def _find_tool(mcp: FastMCP, name: str):
|
|
"""Pull a tool function out of an MCP registration for direct testing."""
|
|
# fastmcp stores tools in a ToolManager. We reach through it to grab
|
|
# the underlying callable so tests can invoke tools directly without
|
|
# a full MCP round-trip.
|
|
manager = getattr(mcp, "_tool_manager", None) or getattr(mcp, "tool_manager", None)
|
|
assert manager is not None, "could not locate fastmcp tool manager"
|
|
tools = getattr(manager, "_tools", None) or getattr(manager, "tools", None)
|
|
assert tools is not None, "could not locate fastmcp tools dict"
|
|
tool = tools[name]
|
|
return getattr(tool, "fn", None) or getattr(tool, "func", None) or tool
|
|
|
|
|
|
@pytest.fixture
|
|
def sandbox(tmp_path: Path):
|
|
"""A sandbox directory the tools are allowed to read/write within."""
|
|
file_state_cache.reset_all()
|
|
return tmp_path
|
|
|
|
|
|
@pytest.fixture
|
|
def tools(sandbox: Path):
|
|
"""Register file_ops onto a fresh FastMCP and return the tool callables."""
|
|
mcp = FastMCP("test-server")
|
|
register_file_tools(mcp, home=str(sandbox))
|
|
|
|
return {
|
|
"read_file": _find_tool(mcp, "read_file"),
|
|
"write_file": _find_tool(mcp, "write_file"),
|
|
"edit_file": _find_tool(mcp, "edit_file"),
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cache primitives
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_check_fresh_returns_unread_when_never_recorded(sandbox: Path):
|
|
target = sandbox / "nope.txt"
|
|
target.write_text("hi")
|
|
result = file_state_cache.check_fresh(None, str(target))
|
|
assert result.status is file_state_cache.Freshness.UNREAD
|
|
|
|
|
|
def test_record_then_check_returns_fresh(sandbox: Path):
|
|
target = sandbox / "a.txt"
|
|
target.write_text("one")
|
|
file_state_cache.record_read(None, str(target), content_bytes=b"one")
|
|
result = file_state_cache.check_fresh(None, str(target))
|
|
assert result.status is file_state_cache.Freshness.FRESH
|
|
|
|
|
|
def test_external_write_makes_check_return_stale(sandbox: Path):
|
|
target = sandbox / "b.txt"
|
|
target.write_text("original")
|
|
file_state_cache.record_read(None, str(target), content_bytes=b"original")
|
|
|
|
# Simulate an external editor save with different content. Sleep
|
|
# briefly to ensure mtime moves (some filesystems have 1s resolution
|
|
# but most Linux fs have ns; this is belt-and-braces).
|
|
time.sleep(0.01)
|
|
target.write_text("hijacked by the user")
|
|
os.utime(str(target), None) # bump mtime in case the write was too fast
|
|
|
|
result = file_state_cache.check_fresh(None, str(target))
|
|
assert result.status is file_state_cache.Freshness.STALE
|
|
assert "changed on disk" in result.detail or "differs" in result.detail
|
|
|
|
|
|
def test_identical_content_rewrite_stays_fresh(sandbox: Path):
|
|
"""Editors that rewrite a file without changing its bytes shouldn't
|
|
be reported as stale even though mtime moved."""
|
|
target = sandbox / "c.txt"
|
|
target.write_text("same")
|
|
file_state_cache.record_read(None, str(target), content_bytes=b"same")
|
|
|
|
time.sleep(0.01)
|
|
target.write_text("same") # different mtime, same content
|
|
os.utime(str(target), None)
|
|
|
|
result = file_state_cache.check_fresh(None, str(target))
|
|
assert result.status is file_state_cache.Freshness.FRESH
|
|
|
|
|
|
def test_agent_scopes_are_isolated(sandbox: Path):
|
|
target = sandbox / "d.txt"
|
|
target.write_text("xyz")
|
|
file_state_cache.record_read("agent-A", str(target), content_bytes=b"xyz")
|
|
|
|
# Another agent hasn't read this file yet.
|
|
result = file_state_cache.check_fresh("agent-B", str(target))
|
|
assert result.status is file_state_cache.Freshness.UNREAD
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# file_ops integration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_edit_file_refuses_without_prior_read(sandbox: Path, tools):
|
|
target = sandbox / "e.py"
|
|
target.write_text("print('hello')\n")
|
|
# Clear the cache first so there's definitely no recorded read.
|
|
file_state_cache.reset_all()
|
|
|
|
result = tools["edit_file"]("replace", "e.py", "hello", "world")
|
|
assert "Refusing to edit" in result
|
|
assert "read_file" in result
|
|
|
|
|
|
def test_edit_file_proceeds_after_read(sandbox: Path, tools):
|
|
target = sandbox / "f.py"
|
|
target.write_text("print('hello')\n")
|
|
file_state_cache.reset_all()
|
|
|
|
tools["read_file"]("f.py")
|
|
result = tools["edit_file"]("replace", "f.py", "hello", "world")
|
|
assert "Replaced" in result
|
|
assert target.read_text() == "print('world')\n"
|
|
|
|
|
|
def test_edit_file_refuses_when_file_changed_between_read_and_edit(sandbox: Path, tools):
|
|
target = sandbox / "g.py"
|
|
target.write_text("print('hello')\n")
|
|
file_state_cache.reset_all()
|
|
|
|
tools["read_file"]("g.py")
|
|
|
|
# Simulate the user editing the file outside the agent.
|
|
time.sleep(0.01)
|
|
target.write_text("print('bye')\n")
|
|
os.utime(str(target), None)
|
|
|
|
result = tools["edit_file"]("replace", "g.py", "hello", "world")
|
|
assert "Refusing to edit" in result
|
|
assert "Re-read" in result
|
|
|
|
|
|
def test_write_file_allowed_for_new_file_without_prior_read(sandbox: Path, tools):
|
|
file_state_cache.reset_all()
|
|
result = tools["write_file"]("brand_new.txt", "first contents\n")
|
|
assert "Created" in result
|
|
assert (sandbox / "brand_new.txt").read_text() == "first contents\n"
|
|
|
|
|
|
def test_write_file_refuses_overwrite_without_prior_read(sandbox: Path, tools):
|
|
target = sandbox / "existing.txt"
|
|
target.write_text("do not clobber\n")
|
|
file_state_cache.reset_all()
|
|
|
|
result = tools["write_file"]("existing.txt", "clobbered\n")
|
|
assert "Refusing to overwrite" in result
|
|
assert target.read_text() == "do not clobber\n" # unchanged
|
|
|
|
|
|
def test_chained_edits_in_same_turn_do_not_self_invalidate(sandbox: Path, tools):
|
|
target = sandbox / "chained.py"
|
|
target.write_text("print('a')\nprint('b')\n")
|
|
file_state_cache.reset_all()
|
|
|
|
tools["read_file"]("chained.py")
|
|
r1 = tools["edit_file"]("replace", "chained.py", "a", "A")
|
|
assert "Replaced" in r1
|
|
# Immediate second edit must NOT trip the stale guard because
|
|
# edit_file re-records the post-write state.
|
|
r2 = tools["edit_file"]("replace", "chained.py", "b", "B")
|
|
assert "Replaced" in r2
|
|
assert target.read_text() == "print('A')\nprint('B')\n"
|