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:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
122
tests/test_anthropic_client.py
Normal file
122
tests/test_anthropic_client.py
Normal 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
101
tests/test_config.py
Normal 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)
|
||||
67
tests/test_conversation.py
Normal file
67
tests/test_conversation.py
Normal 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
229
tests/test_daemon_loop.py
Normal 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
48
tests/test_dispatch.py
Normal 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
39
tests/test_ntfy.py
Normal 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
90
tests/test_queue.py
Normal 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
57
tests/test_real_api.py
Normal 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
102
tests/test_state.py
Normal 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
|
||||
Reference in New Issue
Block a user