283 lines
11 KiB
Python
283 lines
11 KiB
Python
"""Unit tests for the orchestrator tool layer.
|
|
|
|
Tools are plain functions: tested without an MCP client. The MCP transport
|
|
shell is tested separately in test_orchestrator_mcp.py.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid as _uuid
|
|
|
|
import pytest
|
|
|
|
from claire import events as ev
|
|
from claire.domain import TaskStatus
|
|
from claire.orchestrator import tools
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Slash mirror
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_create_project_round_trip(conn, gen) -> None:
|
|
out = tools.create_project(conn, gen, name="alpha", goal="ship")
|
|
assert out["name"] == "alpha"
|
|
assert out["goal"] == "ship"
|
|
|
|
|
|
def test_create_project_duplicate_raises_toolerror(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
with pytest.raises(tools.ToolError, match="conflict"):
|
|
tools.create_project(conn, gen, name="alpha")
|
|
|
|
|
|
def test_add_task_validates_priority(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
with pytest.raises(tools.ToolError, match="priority"):
|
|
tools.add_task(conn, gen, project="alpha", title="t", priority=99)
|
|
|
|
|
|
def test_list_tasks_filters_status(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
tools.add_task(conn, gen, project="alpha", title="t1")
|
|
out = tools.list_tasks(conn, project="alpha", status="todo")
|
|
assert len(out["tasks"]) == 1
|
|
|
|
|
|
def test_list_tasks_unknown_status_rejected(conn) -> None:
|
|
with pytest.raises(tools.ToolError, match="status"):
|
|
tools.list_tasks(conn, status="bogus")
|
|
|
|
|
|
def test_create_assignment_resolves_prefixes(conn, gen) -> None:
|
|
sid = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=sid, host="local"))
|
|
tools.create_project(conn, gen, name="alpha")
|
|
task = tools.add_task(conn, gen, project="alpha", title="t")
|
|
out = tools.create_assignment(
|
|
conn, gen, task_ref=task["id"][:8], session_ref=str(sid)[:8],
|
|
)
|
|
assert out["task_id"] == task["id"]
|
|
assert out["session_uuid"] == str(sid)
|
|
|
|
|
|
def test_status_counts(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
tools.add_task(conn, gen, project="alpha", title="t1")
|
|
out = tools.status(conn)
|
|
assert out["projects"] == 1
|
|
assert out["open_tasks"] == 1
|
|
assert out["sessions"] == 0
|
|
|
|
|
|
def test_help_lists_all_tools(conn) -> None:
|
|
out = tools.help_text()
|
|
names = {t["name"] for t in out["tools"]}
|
|
expected = {
|
|
"create_project", "add_task", "list_tasks", "create_assignment",
|
|
"broadcast", "pull", "status", "list_recent_events",
|
|
"search_chat_messages", "get_session", "summarize_project",
|
|
"suggest_assignments", "send_to_session", "submit_chat_reply",
|
|
}
|
|
assert expected <= names
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Read tools
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_list_recent_events_orchestrator(conn, gen) -> None:
|
|
# ProjectCreated fans out to orchestrator chat (system message).
|
|
tools.create_project(conn, gen, name="alpha")
|
|
out = tools.list_recent_events(conn, scope="orchestrator")
|
|
assert any("alpha" in m["body"] for m in out["messages"])
|
|
|
|
|
|
def test_list_recent_events_validates_scope(conn) -> None:
|
|
with pytest.raises(tools.ToolError, match="scope"):
|
|
tools.list_recent_events(conn, scope="garbage")
|
|
|
|
|
|
def test_list_recent_events_limit_bounds(conn) -> None:
|
|
with pytest.raises(tools.ToolError, match="limit"):
|
|
tools.list_recent_events(conn, limit=10_000)
|
|
|
|
|
|
def test_search_chat_messages_finds_substring(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
out = tools.search_chat_messages(conn, query="alpha")
|
|
assert out["hits"]
|
|
assert all("alpha" in h["body"].lower() for h in out["hits"])
|
|
|
|
|
|
def test_search_chat_messages_empty_query_rejected(conn) -> None:
|
|
with pytest.raises(tools.ToolError, match="empty"):
|
|
tools.search_chat_messages(conn, query=" ")
|
|
|
|
|
|
def test_get_session_returns_assigned_project(conn, gen) -> None:
|
|
sid = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=sid, host="local"))
|
|
tools.create_project(conn, gen, name="alpha")
|
|
task = tools.add_task(conn, gen, project="alpha", title="t")
|
|
tools.create_assignment(conn, gen, task_ref=task["id"], session_ref=str(sid))
|
|
out = tools.get_session(conn, uuid=str(sid)[:8])
|
|
assert out["host"] == "local"
|
|
assert out["assigned_project"] == "alpha"
|
|
|
|
|
|
def test_get_session_missing_raises(conn) -> None:
|
|
bogus = "deadbeef"
|
|
with pytest.raises(tools.ToolError):
|
|
tools.get_session(conn, uuid=bogus)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Planning tools (M5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_summarize_project_returns_counts(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha", goal="ship")
|
|
tools.add_task(conn, gen, project="alpha", title="t1")
|
|
tools.add_task(conn, gen, project="alpha", title="t2", priority=0)
|
|
out = tools.summarize_project(conn, name="alpha")
|
|
assert out["project"]["name"] == "alpha"
|
|
assert out["task_counts"]["todo"] == 2
|
|
assert out["priority_counts"]["P0"] == 1
|
|
assert out["priority_counts"]["P2"] == 1
|
|
# Ranked by priority — P0 first.
|
|
assert out["open_titles"][0] == "t2"
|
|
assert out["blocked_titles"] == []
|
|
assert out["assignments"] == []
|
|
|
|
|
|
def test_summarize_project_includes_assignments_and_unassigned(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
t1 = tools.add_task(conn, gen, project="alpha", title="assigned", priority=1)
|
|
tools.add_task(conn, gen, project="alpha", title="unpicked", priority=0)
|
|
sid = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=sid, host="apricot"))
|
|
tools.create_assignment(conn, gen, task_ref=t1["id"], session_ref=str(sid))
|
|
out = tools.summarize_project(conn, name="alpha")
|
|
assert len(out["assignments"]) == 1
|
|
assert out["assignments"][0]["task_title"] == "assigned"
|
|
assert out["assignments"][0]["session_host"] == "apricot"
|
|
# `unpicked` is unassigned and higher priority → appears first.
|
|
assert out["unassigned"][0]["title"] == "unpicked"
|
|
|
|
|
|
def test_summarize_project_includes_blocked_titles(conn, gen) -> None:
|
|
from claire.domain import TaskStatus as TS
|
|
tools.create_project(conn, gen, name="alpha")
|
|
t1 = tools.add_task(conn, gen, project="alpha", title="stuck")
|
|
ev.append(conn, gen, ev.TaskUpdated(task_id=_uuid.UUID(t1["id"]), status=TS.BLOCKED))
|
|
out = tools.summarize_project(conn, name="alpha")
|
|
assert "stuck" in out["blocked_titles"]
|
|
|
|
|
|
def test_summarize_project_includes_recent_activity(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
tools.add_task(conn, gen, project="alpha", title="t1")
|
|
out = tools.summarize_project(conn, name="alpha")
|
|
# System messages from project + task creation fan-outs.
|
|
assert out["recent_activity"]
|
|
assert any("t1" in r["body"] for r in out["recent_activity"])
|
|
|
|
|
|
def test_summarize_project_missing_raises(conn) -> None:
|
|
with pytest.raises(tools.ToolError, match="no such project"):
|
|
tools.summarize_project(conn, name="nope")
|
|
|
|
|
|
def test_suggest_assignments_pairs_greedily_by_priority(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
tools.add_task(conn, gen, project="alpha", title="urgent", priority=0)
|
|
tools.add_task(conn, gen, project="alpha", title="lower", priority=3)
|
|
s1 = _uuid.uuid4()
|
|
s2 = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=s1, host="apricot"))
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=s2, host="plum"))
|
|
out = tools.suggest_assignments(conn)
|
|
assert len(out["pairings"]) == 2
|
|
# Highest-priority task goes first in the pairings list.
|
|
assert out["pairings"][0]["task_title"] == "urgent"
|
|
|
|
|
|
def test_suggest_assignments_reports_leftovers(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
tools.add_task(conn, gen, project="alpha", title="solo", priority=1)
|
|
# No sessions registered.
|
|
out = tools.suggest_assignments(conn)
|
|
assert out["pairings"] == []
|
|
assert any(t["title"] == "solo" for t in out["remaining_tasks"])
|
|
|
|
|
|
def test_suggest_assignments_skips_already_assigned(conn, gen) -> None:
|
|
tools.create_project(conn, gen, name="alpha")
|
|
t = tools.add_task(conn, gen, project="alpha", title="t1")
|
|
sid = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=sid, host="local"))
|
|
tools.create_assignment(conn, gen, task_ref=t["id"], session_ref=str(sid))
|
|
out = tools.suggest_assignments(conn)
|
|
assert out["pairings"] == []
|
|
assert out["remaining_tasks"] == []
|
|
assert out["remaining_sessions"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# send_to_session — must target by the resolved tmux_name, not the UUID
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _SendCapturingRclaude:
|
|
"""Records every `send` call's `match` argument."""
|
|
|
|
def __init__(self) -> None:
|
|
self.send_calls: list[dict] = []
|
|
|
|
def send(self, *, text: str, match: str, yes: bool = False, dry_run: bool = False):
|
|
self.send_calls.append({"text": text, "match": match, "yes": yes})
|
|
return None
|
|
|
|
|
|
def test_send_to_session_targets_resolved_tmux_name(
|
|
conn, gen, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""When the session has a known tmux_name, `--match` gets the tmux name
|
|
(not the UUID — `rclaude --match` matches tmux names, never UUIDs).
|
|
"""
|
|
sid = _uuid.uuid4()
|
|
tmux_name = "claude-natalie-Users-natalie-Code-1779419135"
|
|
ev.append(conn, gen, ev.SessionObserved(
|
|
session_uuid=sid, host="local", tmux_name=tmux_name,
|
|
))
|
|
fake = _SendCapturingRclaude()
|
|
monkeypatch.setattr("claire.rclaude.Rclaude", lambda: fake)
|
|
|
|
out = tools.send_to_session(conn, gen, session_ref=str(sid), text="hello")
|
|
assert out["delivered"] is True
|
|
assert out["tmux_name"] == tmux_name
|
|
assert len(fake.send_calls) == 1
|
|
# The load-bearing assertion: the match is the tmux name, NOT a UUID prefix.
|
|
assert fake.send_calls[0]["match"] == tmux_name
|
|
assert fake.send_calls[0]["match"] != str(sid)[:8]
|
|
|
|
|
|
def test_send_to_session_fails_loudly_when_tmux_name_unknown(
|
|
conn, gen, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A session with no known tmux_name must raise a clear error, not
|
|
silently fire a send that matches nothing.
|
|
"""
|
|
sid = _uuid.uuid4()
|
|
ev.append(conn, gen, ev.SessionObserved(session_uuid=sid, host="local"))
|
|
fake = _SendCapturingRclaude()
|
|
monkeypatch.setattr("claire.rclaude.Rclaude", lambda: fake)
|
|
|
|
with pytest.raises(tools.ToolError, match="no known tmux_name"):
|
|
tools.send_to_session(conn, gen, session_ref=str(sid), text="hi")
|
|
# No send was attempted.
|
|
assert fake.send_calls == []
|