"""Tests for ArtifactWatcherMiddleware: write_file / edit_file detection.""" from __future__ import annotations from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest from my_deepagent.middleware.artifact_watcher import ArtifactWatcherMiddleware def _make_request(tool_name: str, args: dict[str, Any]) -> MagicMock: """Create a minimal ToolCallRequest-like mock.""" request = MagicMock() request.tool_call = {"name": tool_name, "args": args, "id": "test-id"} return request @pytest.mark.asyncio async def test_write_file_matching_path_triggers_callback(tmp_path: Path) -> None: """write_file targeting expected_path fires the callback and sets notified event.""" expected = tmp_path / "artifact.json" received: list[tuple[str, str]] = [] async def _cb(path: str, content: str) -> None: received.append((path, content)) watcher = ArtifactWatcherMiddleware(expected, _cb) handler = AsyncMock(return_value=MagicMock()) request = _make_request("write_file", {"file_path": str(expected), "content": '{"ok": true}'}) await watcher.awrap_tool_call(request, handler) assert watcher.notified.is_set() assert len(received) == 1 assert received[0][0] == str(expected) assert received[0][1] == '{"ok": true}' assert watcher.content == '{"ok": true}' @pytest.mark.asyncio async def test_edit_file_matching_path_triggers_callback(tmp_path: Path) -> None: """edit_file targeting expected_path also fires the callback.""" expected = tmp_path / "spec.json" received: list[str] = [] async def _cb(path: str, _content: str) -> None: received.append(path) watcher = ArtifactWatcherMiddleware(expected, _cb) handler = AsyncMock(return_value=MagicMock()) request = _make_request("edit_file", {"file_path": str(expected), "new_string": "hello"}) await watcher.awrap_tool_call(request, handler) assert watcher.notified.is_set() assert len(received) == 1 @pytest.mark.asyncio async def test_write_file_different_path_does_not_trigger(tmp_path: Path) -> None: """write_file targeting a different path does NOT fire the callback.""" expected = tmp_path / "artifact.json" other = tmp_path / "other.json" received: list[str] = [] async def _cb(path: str, _content: str) -> None: received.append(path) watcher = ArtifactWatcherMiddleware(expected, _cb) handler = AsyncMock(return_value=MagicMock()) request = _make_request("write_file", {"file_path": str(other), "content": "data"}) await watcher.awrap_tool_call(request, handler) assert not watcher.notified.is_set() assert len(received) == 0 @pytest.mark.asyncio async def test_read_file_never_triggers_callback(tmp_path: Path) -> None: """read_file does NOT fire the callback even if the path matches.""" expected = tmp_path / "artifact.json" received: list[str] = [] async def _cb(path: str, _content: str) -> None: received.append(path) watcher = ArtifactWatcherMiddleware(expected, _cb) handler = AsyncMock(return_value=MagicMock()) request = _make_request("read_file", {"file_path": str(expected)}) await watcher.awrap_tool_call(request, handler) assert not watcher.notified.is_set() assert len(received) == 0 @pytest.mark.asyncio async def test_relative_path_normalised_to_expected(tmp_path: Path) -> None: """A relative path in the tool args is resolved relative to expected_path.parent.""" expected = tmp_path / "artifacts" / "spec.json" expected.parent.mkdir(parents=True, exist_ok=True) received: list[str] = [] async def _cb(path: str, _content: str) -> None: received.append(path) watcher = ArtifactWatcherMiddleware(expected, _cb) handler = AsyncMock(return_value=MagicMock()) # Relative to expected.parent → artifacts/spec.json resolves to expected request = _make_request("write_file", {"file_path": "spec.json", "content": "{}"}) await watcher.awrap_tool_call(request, handler) assert watcher.notified.is_set() assert len(received) == 1 @pytest.mark.asyncio async def test_callback_exception_does_not_break_result(tmp_path: Path) -> None: """An exception raised inside the callback is swallowed; the tool result is still returned.""" expected = tmp_path / "artifact.json" sentinel = MagicMock() async def _bad_cb(_path: str, _content: str) -> None: raise RuntimeError("oops") watcher = ArtifactWatcherMiddleware(expected, _bad_cb) handler = AsyncMock(return_value=sentinel) request = _make_request("write_file", {"file_path": str(expected), "content": "{}"}) result = await watcher.awrap_tool_call(request, handler) # Callback exception was swallowed; the tool result is still returned assert result is sentinel # notified is still set even if callback raises assert watcher.notified.is_set()