168 lines
5.8 KiB
Python
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
|