"""Tests for the `log` tool_progress mode (salvage of #3459 / #3458). `display.tool_progress: log` keeps the chat silent and appends tool-call lines to ~/.hermes/logs/tool_calls.log via write_tool_log's rotating handler. These tests exercise the mode's building blocks without spinning up a full gateway run: the callback log-branch semantics and the writer coroutine. """ import asyncio import queue from datetime import datetime import pytest def _log_branch(log_queue, progress_queue, event_type, tool_name, preview=None): """Replica of the log-mode branch in gateway/run.py progress_callback.""" if log_queue is not None: if event_type == "tool.started" and tool_name and tool_name != "_thinking": ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") preview_str = f' "{preview}"' if preview else "" log_queue.put(f"{ts} {tool_name}:{preview_str}".rstrip()) if not progress_queue: return "returned" return "fell-through" class TestLogBranchSemantics: def test_tool_started_enqueued(self): q = queue.Queue() assert _log_branch(q, None, "tool.started", "terminal", "ls -la") == "returned" line = q.get_nowait() assert "terminal" in line and "ls -la" in line def test_tool_completed_not_enqueued(self): q = queue.Queue() _log_branch(q, None, "tool.completed", "terminal") assert q.empty() def test_thinking_not_enqueued(self): q = queue.Queue() _log_branch(q, None, "tool.started", "_thinking", "pondering") assert q.empty() def test_no_preview_line_has_no_quotes(self): q = queue.Queue() _log_branch(q, None, "tool.started", "todo") line = q.get_nowait() assert line.endswith("todo:") assert '"' not in line def test_log_none_falls_through(self): assert _log_branch(None, None, "tool.started", "terminal") == "fell-through" @pytest.mark.asyncio async def test_write_tool_log_writes_and_rotates_handler(tmp_path, monkeypatch): """The writer coroutine drains the queue into logs/tool_calls.log.""" import gateway.run as gateway_run monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) log_queue: queue.Queue = queue.Queue() log_queue.put("2026-07-02 10:00:00 terminal: \"echo hi\"") log_queue.put("2026-07-02 10:00:01 read_file: \"foo.py\"") # Minimal inline copy of write_tool_log wiring (the real coroutine is a # closure inside _run_agent); exercise the same handler configuration. import logging from logging.handlers import RotatingFileHandler from agent.redact import RedactingFormatter log_dir = tmp_path / "logs" log_dir.mkdir(parents=True, exist_ok=True) handler = RotatingFileHandler( log_dir / "tool_calls.log", maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8", ) handler.setFormatter(RedactingFormatter("%(message)s")) tool_logger = logging.getLogger(f"hermes.tool_calls.test.{id(log_queue)}") tool_logger.setLevel(logging.INFO) tool_logger.propagate = False tool_logger.addHandler(handler) try: while True: try: tool_logger.info("%s", log_queue.get_nowait()) except queue.Empty: break finally: tool_logger.removeHandler(handler) handler.flush() handler.close() content = (log_dir / "tool_calls.log").read_text(encoding="utf-8") assert "terminal" in content assert "read_file" in content assert content.count("\n") == 2 await asyncio.sleep(0) # keep the asyncio marker honest def test_log_mode_disables_chat_progress(): """tool_progress_enabled must be False in log mode (silent in chat).""" for mode, expected in [("all", True), ("log", False), ("off", False)]: enabled = mode not in {"off", "log"} assert enabled is expected