claire/tests/test_orchestrator_tools.py
Natalie 4b768acba2 feat(@projects/@claire): add tmux_name validation in session dispatch tests
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-22 00:38:16 -07:00

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 == []