"""End-to-end loop test with a fake AnthropicClient. Drives one daemon `_tick` at a time so we can inspect state after each event. Tests: - queue entry → API call → dispatch - response containing [NEEDS-JC] → state=needs_jc, no dispatch - jc_input.txt with @session-N: prefix → direct dispatch, no API call - jc_input.txt without prefix → next chat-side turn (API call), clears needs_jc - summarization triggers when history exceeds the cap - malformed queue file is rejected, doesn't break the loop """ from __future__ import annotations import json from dataclasses import replace from pathlib import Path from types import SimpleNamespace from unittest.mock import patch import pytest from relay.anthropic_client import TurnResult from relay.config import SessionConfig, Settings from relay.daemon import Daemon @pytest.fixture def settings(tmp_path: Path) -> Settings: s = Settings( api_key="sk-fake", model="claude-opus-4-7", ntfy_topic="test-topic", status_port=0, history_char_cap=400_000, repo_root=tmp_path, queue_dir=tmp_path / "queue", dispatch_dir=tmp_path / "dispatch", state_dir=tmp_path / "state", logs_dir=tmp_path / "logs", system_prompt="SYS", summarization_prompt="please summarize", sessions=(SessionConfig(session_id="session-1"),), ) for d in (s.queue_dir, s.dispatch_dir, s.state_dir, s.logs_dir): d.mkdir(parents=True, exist_ok=True) return s def _fake_turn_result(text: str, *, cost: float = 0.001) -> TurnResult: return TurnResult( text=text, input_tokens=100, output_tokens=50, cache_creation_input_tokens=0, cache_read_input_tokens=0, estimated_cost_usd=cost, raw=SimpleNamespace(), ) def _drop_queue(settings: Settings, name: str, content: str, session_id: str = "session-1") -> Path: path = settings.queue_dir / name path.write_text( json.dumps( {"session_id": session_id, "timestamp": "2026-05-02T00:00:00Z", "content": content} ) ) return path def _build_daemon(settings: Settings, *, response_text: str) -> Daemon: daemon = Daemon(settings) daemon.client.send = lambda **kwargs: _fake_turn_result(response_text) # type: ignore[assignment] return daemon def test_queue_entry_flows_through_to_dispatch(settings: Settings) -> None: _drop_queue(settings, "a.json", "first message") daemon = _build_daemon(settings, response_text="ok cool reply") with patch("relay.daemon.notify"): daemon._tick() dispatched = settings.dispatch_dir / "session-1" / "input.txt" assert dispatched.read_text() == "ok cool reply" # Queue entry consumed assert list(settings.queue_dir.glob("*.json")) == [] # History recorded assert daemon.conversation.total_chars() > 0 def test_needs_jc_token_pauses_dispatch(settings: Settings) -> None: _drop_queue(settings, "a.json", "i need a decision") daemon = _build_daemon(settings, response_text="[NEEDS-JC] should I pick option A or B?") with patch("relay.daemon.notify") as ntfy: daemon._tick() assert daemon.status.state == "needs_jc" assert daemon.status.last_needs_jc_text is not None # No dispatch happened dispatched = settings.dispatch_dir / "session-1" / "input.txt" assert not dispatched.exists() # ntfy was called with the high priority alert assert any("paused" in c.kwargs.get("title", "").lower() for c in ntfy.call_args_list) def test_needs_jc_pause_blocks_subsequent_queue_entries(settings: Settings) -> None: _drop_queue(settings, "a.json", "first") daemon = _build_daemon(settings, response_text="[NEEDS-JC] need decision") with patch("relay.daemon.notify"): daemon._tick() # New queue entry while paused _drop_queue(settings, "b.json", "second") with patch("relay.daemon.notify"): daemon._tick() # Second queue entry NOT consumed pending = list(settings.queue_dir.glob("*.json")) assert len(pending) == 1 def test_jc_input_prefix_dispatches_directly_without_api_call(settings: Settings) -> None: daemon = _build_daemon(settings, response_text="should not be called") api_calls: list[object] = [] daemon.client.send = lambda **kwargs: (api_calls.append(kwargs), _fake_turn_result("x"))[1] # type: ignore[assignment] (settings.state_dir / "jc_input.txt").write_text("@session-1: do this thing") with patch("relay.daemon.notify"): daemon._tick() dispatched = settings.dispatch_dir / "session-1" / "input.txt" assert dispatched.read_text() == "do this thing" assert api_calls == [] # no API call for direct dispatch assert not (settings.state_dir / "jc_input.txt").exists() def test_jc_input_clears_needs_jc(settings: Settings) -> None: daemon = _build_daemon(settings, response_text="[NEEDS-JC] question") _drop_queue(settings, "a.json", "trigger needs_jc") with patch("relay.daemon.notify"): daemon._tick() assert daemon.status.state == "needs_jc" # JC writes a non-prefix response — treated as next chat turn. daemon.client.send = lambda **kwargs: _fake_turn_result("post-jc reply") # type: ignore[assignment] (settings.state_dir / "jc_input.txt").write_text("here's the answer: do A") with patch("relay.daemon.notify"): daemon._tick() assert daemon.status.state == "running" dispatched = settings.dispatch_dir / "session-1" / "input.txt" assert dispatched.read_text() == "post-jc reply" def test_summarization_triggers_at_cap(settings: Settings) -> None: tiny = replace(settings, history_char_cap=200) daemon = Daemon(tiny) # Pre-load history to exceed the cap daemon.conversation.append("user", "u" * 100) daemon.conversation.append("assistant", "a" * 200) assert daemon.conversation.needs_summarization(200) daemon.client.send = lambda **kwargs: _fake_turn_result("CONDENSED SUMMARY") # type: ignore[assignment] _drop_queue(tiny, "a.json", "next user turn") with patch("relay.daemon.notify"): daemon._tick() # First turn after cap was hit invokes summarization, then sends the user # turn through the same call. The summary turn is now the head. assert daemon.conversation.turns[0].meta == "summary" assert daemon.conversation.turns[0].content == "CONDENSED SUMMARY" def test_malformed_queue_file_does_not_crash_loop(settings: Settings) -> None: bad = settings.queue_dir / "broken.json" bad.write_text("{not json") daemon = _build_daemon(settings, response_text="x") with patch("relay.daemon.notify"): daemon._tick() # Bad file moved to .rejected, no crash assert not bad.exists() assert (settings.queue_dir / ".rejected" / "broken.json").exists() def test_dispatch_queues_when_session_input_pending(settings: Settings) -> None: """Two queue entries for the same session and the first dispatch hasn't been consumed yet.""" daemon = _build_daemon(settings, response_text="reply 1") _drop_queue(settings, "a.json", "msg 1") with patch("relay.daemon.notify"): daemon._tick() assert (settings.dispatch_dir / "session-1" / "input.txt").read_text() == "reply 1" daemon.client.send = lambda **kwargs: _fake_turn_result("reply 2") # type: ignore[assignment] _drop_queue(settings, "b.json", "msg 2") with patch("relay.daemon.notify"): daemon._tick() # First dispatch still present (CC hasn't consumed); second is queued. assert (settings.dispatch_dir / "session-1" / "input.txt").read_text() == "reply 1" assert daemon.dispatch.has_pending("session-1") # CC consumes (settings.dispatch_dir / "session-1" / "input.txt").unlink() with patch("relay.daemon.notify"): daemon._tick() assert (settings.dispatch_dir / "session-1" / "input.txt").read_text() == "reply 2" def test_status_persists_to_disk(settings: Settings) -> None: daemon = _build_daemon(settings, response_text="ok") _drop_queue(settings, "a.json", "hi") with patch("relay.daemon.notify"): daemon._tick() daemon._persist_status() status_path = settings.state_dir / "status.json" payload = json.loads(status_path.read_text()) assert payload["state"] == "running" assert payload["last_dispatch_session"] == "session-1" assert payload["history_turns"] >= 2 # user + assistant