claire/tests/test_rclaude_wrapper.py
Natalie eb382ff1c6 feat(@projects/@claire): improve session targeting for direct sends
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-22 00:30:39 -07:00

165 lines
6.7 KiB
Python

from __future__ import annotations
import subprocess
import pytest
from claire.rclaude import Rclaude, RclaudeError
def _fake_runner(stdout: str = "", stderr: str = "", returncode: int = 0):
def runner(*args, **kwargs):
return subprocess.CompletedProcess(
args=args[0] if args else [], returncode=returncode, stdout=stdout, stderr=stderr
)
return runner
def test_rclaude_raises_when_binary_missing(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: None)
rcl = Rclaude(binary="rclaude")
with pytest.raises(RclaudeError, match="not found on PATH"):
rcl.version()
def test_rclaude_raises_on_nonzero_exit(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
rcl = Rclaude(
binary="rclaude",
runner=_fake_runner(stderr="boom", returncode=1),
)
with pytest.raises(RclaudeError, match="exited 1"):
rcl.version()
def test_rclaude_list_tmux_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = (
"local\ttmux\tclaude-natalie-foo-1715964800\t1 windows (created ...)\n"
"apricot\ttmux\tclaude-natalie-bar-1715964900\t2 windows (created ...)\n"
)
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_tmux()
assert len(rows) == 2
assert rows[0].host == "local"
assert rows[0].session_name == "claude-natalie-foo-1715964800"
assert rows[0].detail.startswith("1 windows")
assert rows[1].host == "apricot"
def test_rclaude_list_tmux_parses_resumed_uuid(monkeypatch: pytest.MonkeyPatch) -> None:
"""The 5th TSV column (resumed_uuid) is parsed when present."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = (
# resumed session — has the 5th column
"local\ttmux\tclaude-natalie-foo-1715964800\t1 windows\t"
"08381960-ecca-49eb-9d6e-9f0c505e8f9e\n"
# fresh-spawned session — 5th column present but empty
"apricot\ttmux\tclaude-natalie-bar-1715964900\t2 windows\t\n"
)
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_tmux()
assert len(rows) == 2
assert rows[0].resumed_uuid == "08381960-ecca-49eb-9d6e-9f0c505e8f9e"
assert rows[1].resumed_uuid is None
def test_rclaude_list_tmux_tolerates_missing_5th_column(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Output from an older rclaude (only 4 columns) maps resumed_uuid to None."""
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = "local\ttmux\tclaude-natalie-foo-1715964800\t1 windows (created ...)\n"
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_tmux()
assert len(rows) == 1
assert rows[0].session_name == "claude-natalie-foo-1715964800"
assert rows[0].resumed_uuid is None
def test_rclaude_list_sessions_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
# `rclaude list sessions --tsv` interleaves tmux rows with session rows;
# the wrapper should skip the tmux ones.
output = (
"local\ttmux\tclaude-foo\t1 windows\n"
"local\tsession\t94fc4f45-0160-4fb8-9c23-475a0c63c983\thello world\t/Users/x/Code\t1779098754\n"
"apricot\tsession\tbd306333-eb2d-4b65-8a50-2179b2697756\tworking on rclaude\t/home/u/proj\t1779098693\n"
)
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.list_sessions()
assert len(rows) == 2
assert rows[0].host == "local"
assert str(rows[0].uuid) == "94fc4f45-0160-4fb8-9c23-475a0c63c983"
assert rows[0].cwd == "/Users/x/Code"
assert rows[0].mtime_epoch == 1779098754
assert rows[0].snippet == "hello world"
assert rows[1].host == "apricot"
assert rows[1].cwd == "/home/u/proj"
def test_rclaude_triage_parses_tsv(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
output = (
"local\ttriage\t94fc4f45-0160-4fb8-9c23-475a0c63c983\t1\tin_progress"
"\tMulti-agent setup\tResume #31\t/Users/x/Code\t1779098754\n"
"apricot\ttriage\t41daf63a-245b-4194-b9ee-b704f833400f\t2\tblocked"
"\tWrong branch\tUpdate config\t/home/u/proj\t1779098796\n"
)
rcl = Rclaude(runner=_fake_runner(stdout=output))
rows = rcl.triage()
assert len(rows) == 2
assert rows[0].host == "local"
assert str(rows[0].uuid) == "94fc4f45-0160-4fb8-9c23-475a0c63c983"
assert rows[0].priority == 1
assert rows[0].status == "in_progress"
assert rows[0].summary == "Multi-agent setup"
assert rows[0].next_action == "Resume #31"
assert rows[0].cwd == "/Users/x/Code"
assert rows[0].mtime_epoch == 1779098754
assert rows[1].host == "apricot"
assert rows[1].priority == 2
def test_rclaude_triage_passes_flags(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
captured_args: list[list[str]] = []
def runner(args, **kwargs):
captured_args.append(list(args))
return subprocess.CompletedProcess(args=args, returncode=0, stdout="", stderr="")
rcl = Rclaude(runner=runner)
rcl.triage(refresh=True, limit=5)
assert captured_args == [["rclaude", "triage", "--tsv", "--refresh", "--limit", "5"]]
def test_spawn_strips_resume_env_vars(monkeypatch: pytest.MonkeyPatch) -> None:
"""`spawn` must never inherit RCLAUDE_RESUME_ID from the environment.
Guards the 2026-05-20 regression: `claire web` launched from inside a
`rclaude resume`-spawned shell carried RCLAUDE_RESUME_ID in os.environ,
so rclaude resumed an unrelated stale session instead of spawning a
fresh orchestrator — and discovery then timed out.
"""
monkeypatch.setattr("shutil.which", lambda _: "/usr/local/bin/rclaude")
monkeypatch.setenv("RCLAUDE_RESUME_ID", "bd306333-eb2d-4b65-8a50-2179b2697756")
monkeypatch.setenv("RCLAUDE_RESUME", "1")
captured_env: list[dict[str, str] | None] = []
def runner(args, **kwargs):
captured_env.append(kwargs.get("env"))
return subprocess.CompletedProcess(
args=args, returncode=0, stdout="claude-natalie-proj-1779326655\n", stderr=""
)
rcl = Rclaude(runner=runner)
name = rcl.spawn(host="apricot", cwd="/var/home/lilith/Code/@projects/@claire")
assert name == "claude-natalie-proj-1779326655"
env = captured_env[0]
assert env is not None
# Resume directives stripped; spawn's own RCLAUDE_DETACHED kept.
assert "RCLAUDE_RESUME_ID" not in env
assert "RCLAUDE_RESUME" not in env
assert env["RCLAUDE_DETACHED"] == "1"