diff --git a/run_agent.py b/run_agent.py index e91376b03..ba214b715 100644 --- a/run_agent.py +++ b/run_agent.py @@ -100,6 +100,7 @@ from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, ) +from utils import atomic_json_write HONCHO_TOOL_NAMES = { "honcho_context", @@ -1398,8 +1399,12 @@ class AIAgent: "messages": cleaned, } - with open(self.session_log_file, "w", encoding="utf-8") as f: - json.dump(entry, f, indent=2, ensure_ascii=False, default=str) + atomic_json_write( + self.session_log_file, + entry, + indent=2, + default=str, + ) except Exception as e: if self.verbose_logging: diff --git a/tests/conftest.py b/tests/conftest.py index 9469ee45f..9c9f9a44e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Shared fixtures for the hermes-agent test suite.""" +import asyncio import os import signal import sys @@ -59,6 +60,39 @@ def mock_config(): def _timeout_handler(signum, frame): raise TimeoutError("Test exceeded 30 second timeout") +@pytest.fixture(autouse=True) +def _ensure_current_event_loop(request): + """Provide a default event loop for sync tests that call get_event_loop(). + + Python 3.11+ no longer guarantees a current loop for plain synchronous tests. + A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...). + Ensure they always have a usable loop without interfering with pytest-asyncio's + own loop management for @pytest.mark.asyncio tests. + """ + if request.node.get_closest_marker("asyncio") is not None: + yield + return + + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + loop = None + + created = loop is None or loop.is_closed() + if created: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + yield + finally: + if created and loop is not None: + try: + loop.close() + finally: + asyncio.set_event_loop(None) + + @pytest.fixture(autouse=True) def _enforce_test_timeout(): """Kill any individual test that takes longer than 30 seconds.""" diff --git a/tests/test_atomic_json_write.py b/tests/test_atomic_json_write.py index 681b7d8a8..cb8b2d6d0 100644 --- a/tests/test_atomic_json_write.py +++ b/tests/test_atomic_json_write.py @@ -97,6 +97,17 @@ class TestAtomicJsonWrite: text = target.read_text() assert ' "a"' in text # 4-space indent + def test_accepts_json_dump_default_hook(self, tmp_path): + class CustomValue: + def __str__(self): + return "custom-value" + + target = tmp_path / "custom_default.json" + atomic_json_write(target, {"value": CustomValue()}, default=str) + + result = json.loads(target.read_text(encoding="utf-8")) + assert result == {"value": "custom-value"} + def test_unicode_content(self, tmp_path): target = tmp_path / "unicode.json" data = {"emoji": "🎉", "japanese": "日本語"} diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 1fcb191e7..15a0d5fba 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1928,6 +1928,24 @@ class TestSafeWriter: assert inner.getvalue() == "test" +class TestSaveSessionLogAtomicWrite: + def test_uses_shared_atomic_json_helper(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + messages = [{"role": "user", "content": "hello"}] + + with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write: + agent._save_session_log(messages) + + mock_atomic_write.assert_called_once() + call_args = mock_atomic_write.call_args + assert call_args.args[0] == agent.session_log_file + payload = call_args.args[1] + assert payload["session_id"] == agent.session_id + assert payload["messages"] == messages + assert call_args.kwargs["indent"] == 2 + assert call_args.kwargs["default"] is str + + # =================================================================== # Anthropic adapter integration fixes # =================================================================== diff --git a/utils.py b/utils.py index 1b99d60fe..762bcb84f 100644 --- a/utils.py +++ b/utils.py @@ -9,17 +9,25 @@ from typing import Any, Union import yaml -def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> None: +def atomic_json_write( + path: Union[str, Path], + data: Any, + *, + indent: int = 2, + **dump_kwargs: Any, +) -> None: """Write JSON data to a file atomically. Uses temp file + fsync + os.replace to ensure the target file is never - left in a partially-written state. If the process crashes mid-write, + left in a partially-written state. If the process crashes mid-write, the previous version of the file remains intact. Args: path: Target file path (will be created or overwritten). data: JSON-serializable data to write. indent: JSON indentation (default 2). + **dump_kwargs: Additional keyword args forwarded to json.dump(), such + as default=str for non-native types. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) @@ -31,7 +39,13 @@ def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, indent=indent, ensure_ascii=False) + json.dump( + data, + f, + indent=indent, + ensure_ascii=False, + **dump_kwargs, + ) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path)