claire/tests/test_orchestrator_turn.py
autocommit 6d212b7dbe refactor(testing-test): ♻️ Update test imports to use claire instead of clare in package references
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-20 19:54:05 -07:00

168 lines
5.8 KiB
Python

"""End-to-end test of the orchestrator turn flow.
Mocks `_send_via_rclaude` so no real rclaude binary is invoked. The fake
send runs in a worker thread, picks the turn_id out of the message, and
calls `turns.deliver_reply` to simulate Claude calling `submit_chat_reply`.
"""
from __future__ import annotations
import re
import threading
import time
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from claire.orchestrator import turns
@pytest.fixture
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TestClient:
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
from claire.web.app import create_app
return TestClient(create_app())
_TURN_RE = re.compile(r"\[turn:([0-9a-fA-F-]{8,})\]")
def _configure_orchestrator(tmp_path: Path, session_uuid: str, timeout_s: int = 5) -> None:
"""Write a claire.toml with the orchestrator section pinned."""
cfg_path = tmp_path / "config" / "claire" / "claire.toml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
# load_or_init populates the file on first import; rewrite with our values.
from claire.config import ClaireConfig, OrchestratorConfig, _serialize
import uuid as _u
cfg = ClaireConfig(
machine_id=str(_u.uuid4()),
sync_secret="dGVzdC1zZWNyZXQ=",
orchestrator=OrchestratorConfig(
session_uuid=session_uuid, host="local", reply_timeout_s=timeout_s,
),
)
cfg_path.write_text(_serialize(cfg), encoding="utf-8")
def test_orchestrator_chat_unconfigured_returns_helpful_error(
client: TestClient,
) -> None:
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "hello"},
)
assert r.status_code == 201, r.text
payload = r.json()
body = payload["replies"][0]["body"].lower()
assert "orchestrator session not configured" in body
assert "claire orchestrator init" in body
def test_orchestrator_slash_command_still_works_without_session(
client: TestClient,
) -> None:
"""Slash commands bypass Claude — should work even when orchestrator
isn't configured."""
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "/status"},
)
assert r.status_code == 201
payload = r.json()
assert "project(s)" in payload["replies"][0]["body"]
def test_orchestrator_round_trip_with_fake_claude(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Configure an orchestrator session and stand up a fake Claude that
submits a reply via the turns registry."""
fake_session = "11111111-2222-3333-4444-555555555555"
_configure_orchestrator(tmp_path, fake_session, timeout_s=3)
def fake_send(*, text: str, match: str) -> None:
# Parse the turn_id out of the prefix and fire submit_chat_reply
# from a worker thread to mimic Claude's async behavior.
m = _TURN_RE.search(text)
assert m, f"missing [turn:..] prefix in: {text!r}"
tid = m.group(1)
# Sanity: we addressed by tmux-name slug (UUID isn't in the tmux name).
assert "claire-orchestrator" in match
def deliver() -> None:
time.sleep(0.05) # simulate Claude thinking
turns.deliver_reply(tid, body="hello from orchestrator")
threading.Thread(target=deliver, daemon=True).start()
monkeypatch.setattr(
"claire.web.chat.handler._send_via_rclaude", fake_send,
)
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "what's up?"},
)
assert r.status_code == 201, r.text
payload = r.json()
assert payload["replies"][0]["body"] == "hello from orchestrator"
assert payload["replies"][0]["meta"]["kind"] == "orchestrator_reply"
def test_orchestrator_timeout_when_claude_never_replies(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_session = "99999999-aaaa-bbbb-cccc-dddddddddddd"
_configure_orchestrator(tmp_path, fake_session, timeout_s=1)
def fake_send_silent(*, text: str, match: str) -> None:
# Don't deliver anything — let it time out.
pass
monkeypatch.setattr(
"claire.web.chat.handler._send_via_rclaude", fake_send_silent,
)
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "anyone home?"},
)
assert r.status_code == 201
reply = r.json()["replies"][0]
assert reply["meta"]["kind"] == "timeout"
assert "didn't respond within 1s" in reply["body"]
# Retry hint + session link are both surfaced.
assert "Resend the same message" in reply["body"]
assert fake_session in reply["body"]
# Original body preserved in meta so a future retry button can re-use it.
assert reply["meta"]["original_body"] == "anyone home?"
assert reply["meta"]["session_uuid"] == fake_session
def test_orchestrator_send_error_surfaces_cleanly(
client: TestClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_session = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
_configure_orchestrator(tmp_path, fake_session, timeout_s=10)
from claire.web.chat.handler import OrchestratorError
def fake_send_fail(*, text: str, match: str) -> None:
raise OrchestratorError("rclaude not on PATH")
monkeypatch.setattr(
"claire.web.chat.handler._send_via_rclaude", fake_send_fail,
)
r = client.post(
"/api/v1/chat",
json={"scope": "orchestrator", "scope_ref": None, "body": "hello"},
)
assert r.status_code == 201
reply = r.json()["replies"][0]
assert "Couldn't reach orchestrator" in reply["body"]
assert reply["meta"]["kind"] == "error"
# And the slot didn't leak.
assert turns.pending_count() == 0