feat: relay daemon skeleton — queue, dispatch, conversation, ntfy (#1)

First-PR scope from #1. Single-process Python daemon that relays
between Claude Code instances and chat-Claude (Anthropic API).

Components:

* relay.config — .env + config.yaml loader. Auto-generates ntfy
  topic on first run and persists it back to .env.
* relay.state — atomic file I/O via tempfile + rename, advisory
  flock at state/.lock to enforce single-instance.
* relay.conversation — append-only history with summarization.
  Triggers a summarize call when total chars exceed
  HISTORY_CHAR_CAP (default 400k); replaces history with the
  summary plus the most recent 10 turns.
* relay.anthropic_client — SDK wrapper. Marks the system prompt
  cacheable (5-min ephemeral cache); concatenates text blocks;
  estimates per-call cost from the Anthropic price table with
  cache-write/read accounted for.
* relay.queue — JSON envelope intake; oldest-by-mtime;
  malformed envelopes moved to queue/.rejected/.
* relay.dispatch — one-input-at-a-time per session
  (dispatch/<session_id>/input.txt). Won't overwrite a pending
  dispatch; queues internally and waits for CC to delete.
* relay.ntfy — best-effort POST to https://ntfy.sh/<topic>;
  failures logged but never block the main loop.
* relay.daemon — main loop. Polls jc_input.txt (priority) then
  queue/. Detects [NEEDS-JC] in the first 200 chars of any
  response and pauses dispatch until JC writes jc_input.txt.
  JC override supports @session-N: prefix for direct dispatch
  without an API call.
* relay.__main__ — CLI: relay run / relay status / relay topic.

Tests: 57 unit tests pass (config, state, conversation, queue,
dispatch, anthropic_client, ntfy, full daemon loop with a fake
client). One real-API smoke test marked real_api, opt-in via
pytest -m real_api; skips cleanly on credit-balance errors.

Out of scope for this PR (deferred to follow-ups): Flask status
endpoint, multi-session config in production, exponential
backoff, systemd unit, cost-tracking aggregation.

Closes #1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ac
2026-05-02 15:24:47 +00:00
parent 50d957e14e
commit 540b4f5b01
24 changed files with 2205 additions and 8 deletions

0
tests/__init__.py Normal file
View File

View File

@@ -0,0 +1,122 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from relay.anthropic_client import AnthropicClient
def _fake_message(
text: str, *, in_tokens: int = 100, out_tokens: int = 50, cache_w: int = 0, cache_r: int = 0
):
"""Build a stand-in for anthropic.types.Message with .content + .usage."""
return SimpleNamespace(
content=[SimpleNamespace(type="text", text=text)],
usage=SimpleNamespace(
input_tokens=in_tokens,
output_tokens=out_tokens,
cache_creation_input_tokens=cache_w,
cache_read_input_tokens=cache_r,
),
)
def _client_with_mock(model: str = "claude-opus-4-7") -> tuple[AnthropicClient, MagicMock]:
client = AnthropicClient(api_key="sk-fake", model=model)
mock_create = MagicMock(return_value=_fake_message("response text"))
client._sdk = SimpleNamespace(messages=SimpleNamespace(create=mock_create))
return client, mock_create
def test_send_returns_assistant_text_and_usage() -> None:
client, _ = _client_with_mock()
result = client.send(system_prompt="sys", messages=[{"role": "user", "content": "u1"}])
assert result.text == "response text"
assert result.input_tokens == 100
assert result.output_tokens == 50
def test_send_passes_system_prompt_with_cache_control() -> None:
client, mock_create = _client_with_mock()
client.send(system_prompt="SYSTEM PROMPT", messages=[{"role": "user", "content": "u"}])
call = mock_create.call_args
system_arg = call.kwargs["system"]
assert system_arg[0]["text"] == "SYSTEM PROMPT"
assert system_arg[0]["cache_control"] == {"type": "ephemeral"}
def test_cost_estimation_opus() -> None:
client, _ = _client_with_mock()
# Override response with specific token counts
client._sdk.messages.create = MagicMock(
return_value=_fake_message("x", in_tokens=1_000_000, out_tokens=0)
)
result = client.send(system_prompt="s", messages=[{"role": "user", "content": "u"}])
# Opus: $15/M input → $15
assert abs(result.estimated_cost_usd - 15.0) < 0.01
def test_cost_estimation_includes_cache_savings() -> None:
client, _ = _client_with_mock()
client._sdk.messages.create = MagicMock(
return_value=_fake_message(
"x",
in_tokens=0,
out_tokens=0,
cache_w=1_000_000, # 1M cache write at $18.75/M
cache_r=1_000_000, # 1M cache read at $1.50/M
)
)
result = client.send(system_prompt="s", messages=[{"role": "user", "content": "u"}])
# cache_w 1M @ $18.75 + cache_r 1M @ $1.50 = $20.25
assert abs(result.estimated_cost_usd - 20.25) < 0.01
def test_cost_estimation_unknown_model_falls_back_to_opus() -> None:
client = AnthropicClient(api_key="sk-fake", model="claude-future-9000")
client._sdk = SimpleNamespace(
messages=SimpleNamespace(
create=MagicMock(return_value=_fake_message("x", in_tokens=1_000_000, out_tokens=0))
)
)
result = client.send(system_prompt="s", messages=[{"role": "user", "content": "u"}])
# Falls back to Opus pricing
assert abs(result.estimated_cost_usd - 15.0) < 0.01
def test_send_concatenates_multiple_text_blocks() -> None:
client, _ = _client_with_mock()
multi = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="hello "),
SimpleNamespace(type="text", text="world"),
],
usage=SimpleNamespace(
input_tokens=0,
output_tokens=0,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
),
)
client._sdk.messages.create = MagicMock(return_value=multi)
result = client.send(system_prompt="s", messages=[{"role": "user", "content": "u"}])
assert result.text == "hello world"
def test_send_ignores_non_text_blocks() -> None:
client, _ = _client_with_mock()
mixed = SimpleNamespace(
content=[
SimpleNamespace(type="text", text="text part"),
SimpleNamespace(type="tool_use"), # no .text — would crash if not filtered
],
usage=SimpleNamespace(
input_tokens=0,
output_tokens=0,
cache_creation_input_tokens=0,
cache_read_input_tokens=0,
),
)
client._sdk.messages.create = MagicMock(return_value=mixed)
result = client.send(system_prompt="s", messages=[{"role": "user", "content": "u"}])
assert result.text == "text part"

101
tests/test_config.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
def _write_env(tmp_path: Path, body: str) -> Path:
env = tmp_path / ".env"
env.write_text(body)
return env
def test_load_settings_reads_env_and_creates_runtime_dirs(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_env(
tmp_path,
"ANTHROPIC_API_KEY=sk-ant-test\nANTHROPIC_MODEL=claude-haiku-4-5-20251001\nNTFY_TOPIC=topictopic12\n",
)
monkeypatch.setattr("relay.config.REPO_ROOT", tmp_path)
monkeypatch.setattr("relay.config.ENV_FILE", tmp_path / ".env")
monkeypatch.setattr("relay.config.CONFIG_FILE", tmp_path / "config.yaml")
from relay.config import load_settings # import after monkeypatch
s = load_settings()
assert s.api_key == "sk-ant-test"
assert s.model == "claude-haiku-4-5-20251001"
assert s.ntfy_topic == "topictopic12"
for d in (s.queue_dir, s.dispatch_dir, s.state_dir, s.logs_dir):
assert d.exists()
def test_load_settings_generates_ntfy_topic_when_blank(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_env(tmp_path, "ANTHROPIC_API_KEY=sk-ant-test\nNTFY_TOPIC=\n")
monkeypatch.setattr("relay.config.REPO_ROOT", tmp_path)
monkeypatch.setattr("relay.config.ENV_FILE", tmp_path / ".env")
monkeypatch.setattr("relay.config.CONFIG_FILE", tmp_path / "config.yaml")
from relay.config import load_settings
with patch("relay.config._generate_ntfy_topic", return_value="generatedtopic1"):
s = load_settings()
assert s.ntfy_topic == "generatedtopic1"
# Persisted back to .env
env_after = (tmp_path / ".env").read_text()
assert "NTFY_TOPIC=generatedtopic1" in env_after
def test_load_settings_treats_inline_hash_comment_as_blank(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Defensive: dotenv parses 'KEY= # comment' as 'KEY' = ' # comment'.
The settings loader must recognise that as effectively blank so a
fresh topic is generated.
"""
_write_env(tmp_path, "ANTHROPIC_API_KEY=sk-ant-test\nNTFY_TOPIC= # generated on first run\n")
monkeypatch.setattr("relay.config.REPO_ROOT", tmp_path)
monkeypatch.setattr("relay.config.ENV_FILE", tmp_path / ".env")
monkeypatch.setattr("relay.config.CONFIG_FILE", tmp_path / "config.yaml")
from relay.config import load_settings
with patch("relay.config._generate_ntfy_topic", return_value="newtopic"):
s = load_settings()
assert s.ntfy_topic == "newtopic"
def test_missing_api_key_raises(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
_write_env(tmp_path, "ANTHROPIC_MODEL=claude-opus-4-7\n")
monkeypatch.setattr("relay.config.REPO_ROOT", tmp_path)
monkeypatch.setattr("relay.config.ENV_FILE", tmp_path / ".env")
monkeypatch.setattr("relay.config.CONFIG_FILE", tmp_path / "config.yaml")
# Also ensure env var doesn't leak in
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
from relay.config import load_settings
with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY"):
load_settings()
def test_default_config_yaml_is_seeded_when_missing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
_write_env(tmp_path, "ANTHROPIC_API_KEY=sk-ant-test\nNTFY_TOPIC=t1234\n")
monkeypatch.setattr("relay.config.REPO_ROOT", tmp_path)
monkeypatch.setattr("relay.config.ENV_FILE", tmp_path / ".env")
monkeypatch.setattr("relay.config.CONFIG_FILE", tmp_path / "config.yaml")
from relay.config import load_settings
s = load_settings()
cfg_path = tmp_path / "config.yaml"
assert cfg_path.exists()
assert any(sess.session_id == "session-1" for sess in s.sessions)

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
import json
from pathlib import Path
from relay.conversation import RECENT_TURNS_KEPT, Conversation, render_for_log
def test_append_and_read_back(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
convo.append("user", "hello", session_id="session-1")
convo.append("assistant", "hi back")
assert [t.role for t in convo.turns] == ["user", "assistant"]
persisted = json.loads((tmp_path / "c.json").read_text())
assert persisted[0]["session_id"] == "session-1"
def test_total_chars_sums_content(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
convo.append("user", "abc")
convo.append("assistant", "defg")
assert convo.total_chars() == 7
def test_needs_summarization_threshold(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
convo.append("user", "x" * 100)
assert not convo.needs_summarization(200)
assert convo.needs_summarization(50)
def test_replace_with_summary_keeps_recent_turns(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
for i in range(RECENT_TURNS_KEPT + 5):
convo.append("user", f"u{i}")
convo.append("assistant", f"a{i}")
convo.replace_with_summary("SUMMARY")
assert convo.turns[0].role == "assistant"
assert convo.turns[0].content == "SUMMARY"
assert convo.turns[0].meta == "summary"
# 1 summary + RECENT_TURNS_KEPT verbatim
assert len(convo.turns) == 1 + RECENT_TURNS_KEPT
def test_replace_with_summary_persists(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
for i in range(20):
convo.append("user", f"u{i}")
convo.replace_with_summary("S")
reloaded = Conversation(tmp_path / "c.json")
assert reloaded.turns[0].content == "S"
def test_to_api_messages_strips_metadata(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
convo.append("user", "x", session_id="s1")
convo.append("assistant", "y")
msgs = convo.to_api_messages()
assert msgs == [{"role": "user", "content": "x"}, {"role": "assistant", "content": "y"}]
def test_render_for_log_truncates_long_content(tmp_path: Path) -> None:
convo = Conversation(tmp_path / "c.json")
convo.append("user", "a" * 1000)
rendered = render_for_log(convo.turns[0], max_chars=50)
assert len(rendered["content"]) < 100
assert "more chars" in rendered["content"]

229
tests/test_daemon_loop.py Normal file
View File

@@ -0,0 +1,229 @@
"""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

48
tests/test_dispatch.py Normal file
View File

@@ -0,0 +1,48 @@
from __future__ import annotations
from pathlib import Path
from relay.dispatch import DispatchManager
def test_writes_input_when_session_dir_empty(tmp_path: Path) -> None:
mgr = DispatchManager(tmp_path / "dispatch")
delivered = mgr.queue_or_write("session-1", "hello")
assert delivered is True
assert (tmp_path / "dispatch" / "session-1" / "input.txt").read_text() == "hello"
def test_does_not_overwrite_pending_input(tmp_path: Path) -> None:
mgr = DispatchManager(tmp_path / "dispatch")
mgr.queue_or_write("session-1", "first")
delivered = mgr.queue_or_write("session-1", "second")
assert delivered is False
# First file unchanged
assert (tmp_path / "dispatch" / "session-1" / "input.txt").read_text() == "first"
assert mgr.has_pending("session-1")
def test_flush_all_delivers_after_consumer_deletes(tmp_path: Path) -> None:
mgr = DispatchManager(tmp_path / "dispatch")
mgr.queue_or_write("session-1", "first")
mgr.queue_or_write("session-1", "second")
# Consumer "consumes" first
(tmp_path / "dispatch" / "session-1" / "input.txt").unlink()
delivered = mgr.flush_all()
assert delivered == 1
assert (tmp_path / "dispatch" / "session-1" / "input.txt").read_text() == "second"
assert not mgr.has_pending("session-1")
def test_independent_sessions_do_not_block_each_other(tmp_path: Path) -> None:
mgr = DispatchManager(tmp_path / "dispatch")
mgr.queue_or_write("session-1", "a") # blocks on session-1
delivered = mgr.queue_or_write("session-2", "b") # session-2 unaffected
assert delivered is True
assert (tmp_path / "dispatch" / "session-2" / "input.txt").read_text() == "b"
def test_input_path_resolution(tmp_path: Path) -> None:
mgr = DispatchManager(tmp_path / "dispatch")
p = mgr.input_path("session-1")
assert p == tmp_path / "dispatch" / "session-1" / "input.txt"

39
tests/test_ntfy.py Normal file
View File

@@ -0,0 +1,39 @@
from __future__ import annotations
from unittest.mock import MagicMock, patch
from relay.ntfy import notify, topic_url
def test_topic_url_builds_correctly() -> None:
assert topic_url("abc123") == "https://ntfy.sh/abc123"
def test_notify_returns_false_on_empty_topic() -> None:
assert notify("", "title", "msg") is False
def test_notify_posts_with_headers_and_body() -> None:
with patch("relay.ntfy.requests.post") as post:
post.return_value = MagicMock(status_code=200)
ok = notify("topic", "the title", "body text", priority="high", tags=["warning"])
assert ok is True
args, kwargs = post.call_args
assert args[0] == "https://ntfy.sh/topic"
assert kwargs["data"] == b"body text"
assert kwargs["headers"]["Title"] == "the title"
assert kwargs["headers"]["Priority"] == "high"
assert kwargs["headers"]["Tags"] == "warning"
def test_notify_returns_false_on_non_200() -> None:
with patch("relay.ntfy.requests.post") as post:
post.return_value = MagicMock(status_code=500, text="oops")
assert notify("topic", "t", "m") is False
def test_notify_returns_false_on_network_error() -> None:
import requests
with patch("relay.ntfy.requests.post", side_effect=requests.ConnectionError("down")):
assert notify("topic", "t", "m") is False

90
tests/test_queue.py Normal file
View File

@@ -0,0 +1,90 @@
from __future__ import annotations
import json
import time
from pathlib import Path
from relay.queue import ack, list_pending, stuck_age_seconds, take_oldest
def _drop(queue_dir: Path, name: str, payload: dict) -> Path:
queue_dir.mkdir(parents=True, exist_ok=True)
path = queue_dir / name
path.write_text(json.dumps(payload))
return path
def test_take_oldest_returns_none_on_empty_queue(tmp_path: Path) -> None:
assert take_oldest(tmp_path / "queue") is None
def test_take_oldest_returns_oldest_by_mtime(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
older = _drop(queue_dir, "a.json", {"session_id": "s1", "timestamp": "t1", "content": "first"})
time.sleep(0.01)
newer = _drop(queue_dir, "b.json", {"session_id": "s2", "timestamp": "t2", "content": "second"})
entry = take_oldest(queue_dir)
assert entry is not None
assert entry.path == older
assert entry.session_id == "s1"
assert entry.content == "first"
assert newer.exists()
def test_invalid_json_is_rejected(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
queue_dir.mkdir()
bad = queue_dir / "bad.json"
bad.write_text("not json")
assert take_oldest(queue_dir) is None
assert not bad.exists()
assert (queue_dir / ".rejected" / "bad.json").exists()
def test_envelope_missing_keys_is_rejected(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
_drop(queue_dir, "x.json", {"session_id": "s1"}) # no timestamp / content
assert take_oldest(queue_dir) is None
assert (queue_dir / ".rejected" / "x.json").exists()
def test_envelope_blank_content_is_rejected(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
_drop(queue_dir, "x.json", {"session_id": "s1", "timestamp": "t", "content": " "})
assert take_oldest(queue_dir) is None
def test_ack_deletes_file(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
_drop(queue_dir, "a.json", {"session_id": "s1", "timestamp": "t", "content": "x"})
entry = take_oldest(queue_dir)
assert entry is not None
ack(entry)
assert not entry.path.exists()
def test_list_pending_skips_rejected_dir(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
queue_dir.mkdir()
rejected = queue_dir / ".rejected"
rejected.mkdir()
(rejected / "old.json").write_text("{}")
a = _drop(queue_dir, "a.json", {"session_id": "s", "timestamp": "t", "content": "x"})
pending = list_pending(queue_dir)
assert pending == [a]
def test_stuck_age_returns_zero_on_empty_queue(tmp_path: Path) -> None:
assert stuck_age_seconds(tmp_path / "queue") == 0
def test_stuck_age_reports_oldest_age(tmp_path: Path) -> None:
queue_dir = tmp_path / "queue"
path = _drop(queue_dir, "a.json", {"session_id": "s", "timestamp": "t", "content": "x"})
# Backdate the file
old_mtime = time.time() - 60
import os
os.utime(path, (old_mtime, old_mtime))
age = stuck_age_seconds(queue_dir)
assert age >= 59

57
tests/test_real_api.py Normal file
View File

@@ -0,0 +1,57 @@
"""Live-API smoke test.
Runs only when ANTHROPIC_API_KEY is set in the env (or .env). Sends one
real turn, verifies we get back a non-empty assistant text and usage
counts. Marked ``real_api`` so it is excluded from the default suite —
opt in with ``pytest -m real_api``.
"""
from __future__ import annotations
import os
import pytest
from dotenv import dotenv_values
from relay.anthropic_client import AnthropicClient
from relay.config import REPO_ROOT
def _api_key_available() -> str | None:
env_values = dotenv_values(REPO_ROOT / ".env")
return (
env_values.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_API_KEY") or ""
).strip() or None
@pytest.mark.real_api
@pytest.mark.skipif(_api_key_available() is None, reason="ANTHROPIC_API_KEY not configured")
def test_real_round_trip_against_haiku() -> None:
"""One round-trip against the Haiku model — cheapest available, ~$0.0001 per call.
Skips (rather than fails) on credit-balance errors so the test is
honest about why it didn't run. Real authentication errors (401)
still fail loudly.
"""
import anthropic
api_key = _api_key_available()
assert api_key is not None
client = AnthropicClient(
api_key=api_key, model="claude-haiku-4-5-20251001", max_output_tokens=64
)
try:
result = client.send(
system_prompt="You are a terse echo. Reply with exactly 'pong' and nothing else.",
messages=[{"role": "user", "content": "ping"}],
)
except anthropic.BadRequestError as exc:
msg = str(exc).lower()
if "credit balance" in msg or "billing" in msg:
pytest.skip(f"Anthropic API key has no credit balance: {exc}")
raise
assert result.text.strip().lower() == "pong"
assert result.input_tokens > 0
assert result.output_tokens > 0
assert result.estimated_cost_usd > 0

102
tests/test_state.py Normal file
View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import os
import threading
from pathlib import Path
import pytest
from relay.state import InstanceLock, StateError, read_json, write_atomic, write_json_atomic
def test_write_atomic_round_trips(tmp_path: Path) -> None:
target = tmp_path / "f.json"
write_atomic(target, "hello")
assert target.read_text() == "hello"
def test_write_atomic_does_not_leave_temp_files(tmp_path: Path) -> None:
target = tmp_path / "f.json"
write_atomic(target, "hello")
siblings = [p.name for p in tmp_path.iterdir() if p != target]
assert siblings == []
def test_write_json_atomic_round_trips(tmp_path: Path) -> None:
target = tmp_path / "value.json"
payload = [{"k": "v"}, {"k2": "v2"}]
write_json_atomic(target, payload)
assert json.loads(target.read_text()) == payload
def test_read_json_returns_default_when_missing(tmp_path: Path) -> None:
assert read_json(tmp_path / "nope.json", default=[]) == []
assert read_json(tmp_path / "nope.json", default={"x": 1}) == {"x": 1}
def test_read_json_returns_default_when_empty(tmp_path: Path) -> None:
target = tmp_path / "empty.json"
target.write_text("")
assert read_json(target, default=[]) == []
def test_read_json_raises_on_corruption(tmp_path: Path) -> None:
target = tmp_path / "bad.json"
target.write_text("{not json")
with pytest.raises(StateError):
read_json(target, default=[])
def test_instance_lock_acquires_and_releases(tmp_path: Path) -> None:
lock = InstanceLock(tmp_path / ".lock")
lock.acquire()
try:
# PID written to file
assert (tmp_path / ".lock").read_text().strip() == str(os.getpid())
finally:
lock.release()
def test_instance_lock_rejects_second_acquirer(tmp_path: Path) -> None:
lock1 = InstanceLock(tmp_path / ".lock")
lock1.acquire()
try:
lock2 = InstanceLock(tmp_path / ".lock")
with pytest.raises(StateError):
lock2.acquire()
finally:
lock1.release()
def test_instance_lock_release_is_idempotent(tmp_path: Path) -> None:
lock = InstanceLock(tmp_path / ".lock")
lock.acquire()
lock.release()
lock.release() # second release should not raise
def test_concurrent_acquire_serializes(tmp_path: Path) -> None:
"""Confirm flock semantics: two threads acquiring the same lock can't overlap."""
lock_path = tmp_path / ".lock"
held: list[bool] = []
error_count = [0]
def worker():
lock = InstanceLock(lock_path)
try:
lock.acquire()
held.append(True)
lock.release()
except StateError:
error_count[0] += 1
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
# At least one succeeded; the other either succeeded after the first
# released, or failed to acquire (depending on scheduling).
assert sum(held) + error_count[0] == 2