feat(steering): mesaje mid-tur + /stop pe turul în zbor

Un al doilea mesaj trimis cât Claude încă lucra aștepta până se termina
turul 1 — corecția „stai, nu în master" ajungea după ce greșeala era gata.
Verificat în producție înainte de commit: mesajul 2 stătea 25s blocat în
lock, apoi pornea ca tur separat.

Acum canalele de chat pot ține un proces `claude` viu per canal, cu stdin
deschis, și al doilea mesaj intră în ACELAȘI tur.

- `src/claude_runner.py` — ClaudeProcess (steering, respawn cu --resume,
  drenare stderr, respawn la comutarea OpenRouter) + RunnerRegistry
  (max_live, reaper pe inactivitate, stop_all la shutdown)
- `src/stream_json.py` — parser stream-json partajat cu `_run_claude`;
  pur, nu aruncă niciodată pe is_error (PlanningSession retrimite pe
  error_max_turns și depinde de asta)
- `src/sentinels.py` — un singur loc pentru __AUDIO__/__STEERED__, în loc
  de 4 verificări copiate; repară și bug-ul preexistent prin care
  WhatsApp posta literal `__AUDIO__:/cale`
- dispecer în `send_message`: lock.acquire(blocking=False) — eșecul de a
  lua lock-ul ESTE „rulează un tur", ceea ce elimină flagul inflight din
  decizie și cursa TOCTOU odată cu el
- `/stop` oprește turul, nu sesiunea — active.json rămâne valid
- rate limit prin proces persistent vine ca result.is_error, nu ca exit
  code; convertit înapoi în același RuntimeError, altfel fallback-ul
  local nu s-ar mai declanșa niciodată, în tăcere

Steering-ul nu face niciodată cross-adapter (un mesaj text nu intră
într-un tur voice: împart același channel_id). Mesajele steered dintr-un
tur care pică sunt re-livrate, nu pierdute.

Testat live cu CLI-ul real: corecție la secunda 10 dintr-un tur de 24s,
un singur result, num_turns=2. Notă: mesajele steered sunt împachetate în
[EXTERNAL CONTENT], deci o corecție formulată ca override agresiv poate
fi refuzată ca prompt injection — pentru oprire folosește /stop.

Suită: 1199 passed, 12 failed (toate pre-existente pe HEAD curat).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SiJGsZVSEGjRHZEJiXaxCC
This commit is contained in:
2026-09-02 11:05:58 +00:00
parent 2d8ee9b581
commit 747afbaf9d
23 changed files with 4183 additions and 95 deletions

124
tests/fake_claude.py Executable file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""Fake `claude` CLI binary for tests/test_claude_runner.py.
Runs as a REAL subprocess (spawned by `subprocess.Popen`, exactly like the
real CLI) so steering genuinely exercises stdin/stdout pipe timing that a
mock `Popen` can't reproduce. Speaks the subset of stream-json that
`src/claude_runner.py` depends on:
- one `system`/`init` event with a `session_id`, on the first turn only
- one `assistant` event per response, with a text block
- one `result` event per turn
Controlled entirely via environment variables (this process has no access
to the test's Python objects):
FAKE_CLAUDE_SCENARIO normal | steer | timeout | rate_limit |
big_stderr | crash_immediately (default: normal)
FAKE_CLAUDE_SESSION_ID session_id to report (default: fake-session-1)
The `system`/`init` event also carries the process's own `argv` — tests use
this to assert `--resume <sid>` was actually passed on respawn, without
needing a separate side channel.
"""
import json
import os
import sys
import time
def _emit(obj: dict) -> None:
print(json.dumps(obj), flush=True)
def main() -> None:
scenario = os.environ.get("FAKE_CLAUDE_SCENARIO", "normal")
session_id = os.environ.get("FAKE_CLAUDE_SESSION_ID", "fake-session-1")
if scenario == "crash_immediately":
sys.exit(1)
if scenario == "big_stderr":
# T3: without a stderr-draining thread on the caller's side, this
# fills the OS pipe buffer (~64KB) and blocks forever, before this
# process ever gets to read a turn off stdin.
for _ in range(4000):
print("x" * 40, file=sys.stderr, flush=True)
first_turn = True
for raw_line in sys.stdin:
raw_line = raw_line.strip()
if not raw_line:
continue
try:
msg = json.loads(raw_line)
except json.JSONDecodeError:
continue
text = msg.get("message", {}).get("content", "")
if first_turn:
_emit({
"type": "system", "subtype": "init",
"session_id": session_id, "argv": sys.argv,
})
first_turn = False
if scenario == "timeout":
time.sleep(600) # never responds — caller's own watchdog must kill us
return
if scenario == "rate_limit":
_emit({
"type": "result", "subtype": "error", "session_id": session_id,
"result": "You've hit your session limit · resets 10:50am (UTC)",
"is_error": True,
})
continue
if scenario == "generic_error":
# is_error=True but NOT a rate limit — C4: must be returned,
# never raised, so a future PlanningSession-style caller can
# still retry on `subtype` instead of catching an exception.
_emit({
"type": "result", "subtype": "error_max_turns", "session_id": session_id,
"result": "hit max turns for this task", "is_error": True,
})
continue
if scenario == "steer":
# Simulate a turn in progress that then reads a SECOND stdin
# line (the steer) before finishing — exactly what a real
# steering exchange looks like (spike: one `result`, num_turns=2).
_emit({"type": "assistant", "message": {"content": [
{"type": "text", "text": f"got:{text}"},
]}})
second_raw = sys.stdin.readline().strip()
if second_raw:
try:
second_msg = json.loads(second_raw)
second_text = second_msg.get("message", {}).get("content", "")
except json.JSONDecodeError:
second_text = ""
_emit({"type": "assistant", "message": {"content": [
{"type": "text", "text": f"steered:{second_text}"},
]}})
_emit({
"type": "result", "subtype": "success", "session_id": session_id,
"result": "done", "is_error": False, "num_turns": 2,
"usage": {"input_tokens": 1, "output_tokens": 1},
})
continue
# normal
_emit({"type": "assistant", "message": {"content": [
{"type": "text", "text": f"echo:{text}"},
]}})
_emit({
"type": "result", "subtype": "success", "session_id": session_id,
"result": f"echo:{text}", "is_error": False, "num_turns": 1,
"usage": {"input_tokens": 1, "output_tokens": 1},
})
if __name__ == "__main__":
main()

351
tests/test_claude_runner.py Normal file
View File

@@ -0,0 +1,351 @@
"""Tests for src/claude_runner.py (steering — persistent Claude processes).
Fully offline: `tests/fake_claude.py` is a real subprocess (no mocked
`subprocess.Popen`) speaking a minimal stream-json dialect, so steering
timing over real OS pipes is genuinely exercised rather than assumed.
"""
import threading
import time
from pathlib import Path
import pytest
from src import claude_runner, claude_session
from src.claude_runner import SteerStatus
FAKE_CLAUDE = Path(__file__).parent / "fake_claude.py"
def _wait_until(predicate, timeout=5.0, interval=0.02):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(interval)
raise AssertionError("condition not met within timeout")
@pytest.fixture(autouse=True)
def _reset_registry():
"""H2: the registry is module-level global state (like
claude_session._session_locks) — clear it around every test so fake
processes from one test never leak into the next."""
claude_runner.reset_registry_for_tests()
yield
claude_runner.reset_registry_for_tests()
@pytest.fixture
def make_proc(monkeypatch):
"""Factory for a ClaudeProcess wired to tests/fake_claude.py, with
automatic cleanup of every process it creates."""
created: list[claude_runner.ClaudeProcess] = []
monkeypatch.setattr(claude_runner, "CLAUDE_BIN", str(FAKE_CLAUDE))
def _factory(scenario: str, channel_id: str = "test-channel",
session_id_env: str = "fake-session-1", model: str = "sonnet"):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", scenario)
monkeypatch.setenv("FAKE_CLAUDE_SESSION_ID", session_id_env)
proc = claude_runner.ClaudeProcess(channel_id, model=model)
created.append(proc)
return proc
yield _factory
for p in created:
try:
p.stop()
except Exception:
pass
# ---------------------------------------------------------------------------
# T1 — normal turn shape + rate limit + timeout (Etapa 1 contract)
# ---------------------------------------------------------------------------
def test_run_turn_matches_run_claude_dict_shape(make_proc):
proc = make_proc("normal")
result = proc.run_turn("hello")
expected_keys = {
"result", "session_id", "usage", "total_cost_usd", "cost_usd",
"duration_ms", "num_turns", "intermediate_count", "subtype", "is_error",
}
assert set(result.keys()) == expected_keys
assert result["is_error"] is False
assert result["session_id"] == "fake-session-1"
def test_run_turn_wraps_external_content(make_proc):
"""T1 (-k external_content selects this test and the steer one below)."""
proc = make_proc("normal")
result = proc.run_turn("plain text from Marius")
assert "[EXTERNAL CONTENT]" in result["result"]
assert "[END EXTERNAL CONTENT]" in result["result"]
assert "plain text from Marius" in result["result"]
def test_timeout_raises_exact_message(make_proc):
proc = make_proc("timeout")
with pytest.raises(TimeoutError, match=r"Claude CLI timed out after 1s"):
proc.run_turn("hi", timeout=1)
def test_rate_limit_raises_runtime_error_router_can_detect(make_proc):
"""T2/C4/T6: the exact failure mode that must keep `_local_fallback_reply`
alive — router.py/scheduler.py both gate on `is_rate_limit_error`."""
proc = make_proc("rate_limit")
with pytest.raises(RuntimeError) as excinfo:
proc.run_turn("hi")
assert claude_session.is_rate_limit_error(str(excinfo.value))
def test_non_rate_limit_error_is_returned_not_raised(make_proc):
"""C4: the parser/wrapper must stay non-throwing for any `is_error` that
isn't a rate limit (PlanningSession-style callers retry on subtype)."""
proc = make_proc("generic_error")
result = proc.run_turn("hi") # must NOT raise
assert result["is_error"] is True
assert result["subtype"] == "error_max_turns"
# ---------------------------------------------------------------------------
# Steering — mid-turn message lands on the same stdin
# ---------------------------------------------------------------------------
def test_steering_mid_turn_reflects_second_message(make_proc):
proc = make_proc("steer")
texts: list[str] = []
holder: dict = {}
def _go():
holder["result"] = proc.run_turn("first message", on_text=texts.append)
t = threading.Thread(target=_go)
t.start()
# Wait for the fake's first assistant block — proves it's now blocked
# on its own stdin.readline(), genuinely waiting for the steer.
_wait_until(lambda: len(texts) >= 1)
assert proc.inflight is True
outcome = proc.steer("second message")
t.join(timeout=5)
assert outcome.status == SteerStatus.STEERED
assert outcome.turn is None
result = holder["result"]
assert "steered:" in result["result"]
assert result["num_turns"] == 2
assert proc.inflight is False
def test_steer_wraps_external_content(make_proc):
"""T1 (-k external_content)."""
proc = make_proc("steer")
texts: list[str] = []
holder: dict = {}
def _go():
holder["result"] = proc.run_turn("first", on_text=texts.append)
t = threading.Thread(target=_go)
t.start()
_wait_until(lambda: len(texts) >= 1)
proc.steer("stai, schimbare de plan")
t.join(timeout=5)
assert "[EXTERNAL CONTENT]" in holder["result"]["result"]
assert "stai, schimbare de plan" in holder["result"]["result"]
def test_steer_pending_texts_available_on_turn_failure(make_proc):
"""C3: if the turn a steer landed in fails, the steered text must be
exposed for re-dispatch, never silently dropped."""
proc = make_proc("timeout") # sleeps forever after init — never sends a result
def _go():
try:
proc.run_turn("first", timeout=1)
except TimeoutError:
pass
t = threading.Thread(target=_go)
t.start()
# session_id arrives (via the init event) right after spawn, well
# before the fake's 600s sleep would ever resolve — a reliable
# "the turn is genuinely in flight" signal for this scenario.
_wait_until(lambda: proc.session_id == "fake-session-1")
assert proc.inflight is True
outcome = proc.steer("please don't get lost")
assert outcome.status == SteerStatus.STEERED
t.join(timeout=5) # watchdog kills the process after 1s -> TimeoutError
pending = proc.pop_pending_steers()
assert pending == ["please don't get lost"]
# popped once -> empty on a second call
assert proc.pop_pending_steers() == []
# ---------------------------------------------------------------------------
# C1 — RAN_AS_TURN: steer() finds no in-flight turn
# ---------------------------------------------------------------------------
def test_steer_without_inflight_turn_becomes_ran_as_turn(make_proc):
proc = make_proc("normal")
first = proc.run_turn("warm up")
assert first["is_error"] is False
assert proc.inflight is False
outcome = proc.steer("out of band message")
assert outcome.status == SteerStatus.RAN_AS_TURN
assert outcome.turn is not None
assert "out of band message" in outcome.turn["result"]
# D4: the caller must treat `turn` as the response and never re-dispatch
# by calling run_turn() again for the same text — nothing to assert
# mechanically here beyond the API shape making that the only sane path.
# ---------------------------------------------------------------------------
# T7 — BrokenPipeError on steer falls back to a normal turn
# ---------------------------------------------------------------------------
def test_steer_broken_pipe_falls_back_to_turn(make_proc, monkeypatch):
proc = make_proc("normal")
proc.run_turn("warm up") # spawns the process
proc.proc.kill()
proc.proc.wait()
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal")
outcome = proc.steer("please deliver me")
assert outcome.status == SteerStatus.PROCESS_DEAD
assert outcome.turn is not None
assert "please deliver me" in outcome.turn["result"]
# ---------------------------------------------------------------------------
# M2 — timeout kills the process; next turn respawns with --resume
# ---------------------------------------------------------------------------
def test_respawn_with_resume_after_timeout_kill(make_proc, monkeypatch):
proc = make_proc("timeout")
with pytest.raises(TimeoutError):
proc.run_turn("hi", timeout=1)
# session_id came from the `system`/`init` event, captured before the
# turn ever got its (never-arriving) result line.
assert proc.session_id == "fake-session-1"
assert not proc.alive()
dead_pid = proc.proc.pid
cmd = proc._build_cmd()
assert "--resume" in cmd
assert cmd[cmd.index("--resume") + 1] == "fake-session-1"
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal")
result = proc.run_turn("again", timeout=5)
assert result["is_error"] is False
assert proc.proc.pid != dead_pid # actually respawned, not reused
# ---------------------------------------------------------------------------
# T3 — stderr pipe must never deadlock a turn
# ---------------------------------------------------------------------------
def test_big_stderr_does_not_deadlock(make_proc):
proc = make_proc("big_stderr")
result = proc.run_turn("hello", timeout=15)
assert result["is_error"] is False
assert len(proc._stderr_buf) > 0
assert len(proc._stderr_buf) <= 50 # deque(maxlen=50)
# ---------------------------------------------------------------------------
# RunnerRegistry — max_live degradation, reaper resilience
# ---------------------------------------------------------------------------
def test_max_live_degrades_second_channel_to_one_shot():
registry = claude_runner.RunnerRegistry(max_live=1, idle_minutes=20)
proc_a = registry.get("channel-a")
assert proc_a is not None
proc_b = registry.get("channel-b")
assert proc_b is None # T10: degrade, don't evict (X6) and don't block
# the same channel is never punished by its own occupied slot:
assert registry.get("channel-a") is proc_a
registry.stop_all()
def test_reaper_never_stops_inflight_process():
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
proc = registry.get("channel-a")
proc.inflight = True
proc.last_active = time.monotonic() - 3600
registry._reap_once()
assert registry.live_count() == 1
registry.stop_all()
def test_reaper_stops_idle_process():
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
proc = registry.get("channel-a")
proc.inflight = False
proc.last_active = time.monotonic() - 3600
registry._reap_once()
assert registry.live_count() == 0
def test_reaper_survives_stop_exception(monkeypatch):
"""T11: a dead reaper is a silent RAM leak — one process's stop()
raising must not kill the reap loop or the process."""
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=1)
proc = registry.get("channel-a")
proc.inflight = False
proc.last_active = time.monotonic() - 3600
def _boom():
raise RuntimeError("stop() exploded")
monkeypatch.setattr(proc, "stop", _boom)
registry._reap_once() # must not raise
assert registry.live_count() == 0 # bookkeeping removed regardless
def test_stop_all_clears_registry(make_proc):
proc = make_proc("normal")
registry = claude_runner.RunnerRegistry(max_live=2, idle_minutes=20)
registry._procs["test-channel"] = proc
proc.run_turn("hello") # actually spawns
assert proc.alive()
registry.stop_all()
assert registry.live_count() == 0
assert not proc.alive()
def test_get_registry_singleton_and_reset():
r1 = claude_runner.get_registry(max_live=3, idle_minutes=5)
r2 = claude_runner.get_registry(max_live=99) # ignored once created
assert r1 is r2
assert r1.max_live == 3
claude_runner.reset_registry_for_tests()
r3 = claude_runner.get_registry(max_live=7)
assert r3 is not r1
assert r3.max_live == 7
def test_max_live_non_positive_logs_and_always_degrades(caplog):
with caplog.at_level("WARNING"):
registry = claude_runner.RunnerRegistry(max_live=0, idle_minutes=20)
assert any("max_live" in rec.message for rec in caplog.records)
assert registry.get("any-channel") is None

View File

@@ -25,12 +25,14 @@ from unittest.mock import patch
import pytest
from src import claude_session
from src import claude_runner, claude_session
from src.claude_runner import SteerResult, SteerStatus
from src.claude_session import (
_get_session_lock,
_session_locks,
send_message,
)
from src.sentinels import is_steered
# ---------------------------------------------------------------------------
@@ -40,10 +42,19 @@ from src.claude_session import (
@pytest.fixture(autouse=True)
def _clear_session_locks():
"""Each test starts with a fresh lock map so we don't share state."""
"""Each test starts with a fresh lock map so we don't share state.
H2: also resets the steering registry singleton and adapter-ownership
map — both module-level globals — so a fake `ClaudeProcess` installed
by one test (steering-ON variants below) never leaks into the next.
"""
_session_locks.clear()
claude_session._channel_adapter.clear()
claude_runner.reset_registry_for_tests()
yield
_session_locks.clear()
claude_session._channel_adapter.clear()
claude_runner.reset_registry_for_tests()
@pytest.fixture
@@ -68,7 +79,7 @@ def _slow_run_claude(sleep_seconds: float, in_critical: threading.Event,
"""
state = {"active": 0, "lock": threading.Lock()}
def fake(cmd, timeout, on_text=None, cwd=None):
def fake(cmd, timeout, on_text=None, cwd=None, channel_id=None):
with state["lock"]:
state["active"] += 1
if state["active"] > 1:
@@ -268,7 +279,7 @@ class TestAcquisitionBehavior:
call_count = {"n": 0}
def flaky(cmd, timeout, on_text=None, cwd=None):
def flaky(cmd, timeout, on_text=None, cwd=None, channel_id=None):
call_count["n"] += 1
if call_count["n"] == 1:
raise RuntimeError("simulated subprocess crash")
@@ -305,3 +316,98 @@ class TestAcquisitionBehavior:
)
t.join(timeout=1.0)
assert result_box == ["Hello from Claude!"]
# ---------------------------------------------------------------------------
# H2 — twin of TestAcquisitionBehavior with steering ON: a contending
# caller must STEER, not block-then-run. Pins that the flag actually
# changes this contract (the flag-off tests above must stay unchanged).
# ---------------------------------------------------------------------------
class _FakeSteeringProc:
"""Minimal `ClaudeProcess` stand-in — just enough of the contract
`_dispatch_steering` relies on (`inflight`, `run_turn`, `steer`,
`pop_pending_steers`) to prove the dispatch decision, without a real
subprocess (that's covered by tests/test_claude_runner.py)."""
def __init__(self):
self.inflight = False
self.session_id = "fake-sid"
self.steer_calls: list[str] = []
self._release = threading.Event()
def run_turn(self, text, on_text=None, timeout=300):
self._release.clear()
self.inflight = True
released = self._release.wait(timeout=5.0)
self.inflight = False
assert released, "test never released the fake turn"
return {
"result": f"done:{text}", "session_id": self.session_id, "usage": {},
"total_cost_usd": 0, "cost_usd": 0, "duration_ms": 1, "num_turns": 1,
"intermediate_count": 0, "subtype": "success", "is_error": False,
}
def steer(self, text):
self.steer_calls.append(text)
return SteerResult(SteerStatus.STEERED)
def pop_pending_steers(self):
return []
def release(self):
self._release.set()
class _FakeSteeringRegistry:
def __init__(self, proc, channel_id):
self._procs = {channel_id: proc}
def get(self, channel_id, model=None, session_id=None, cwd=None):
return self._procs.get(channel_id)
def stop(self, channel_id):
return False
class TestAcquisitionBehaviorSteering:
"""Twin of `TestAcquisitionBehavior.test_contested_acquire_blocks_then_proceeds`
with `steering.enabled` on: contention must STEER instead of blocking."""
def test_contested_acquire_steers_when_steering_enabled(
self, temp_sessions, monkeypatch
):
proc = _FakeSteeringProc()
registry = _FakeSteeringRegistry(proc, "ch-contend-steer")
monkeypatch.setattr(claude_runner, "_registry", registry)
monkeypatch.setattr(claude_runner, "get_registry", lambda **kw: registry)
monkeypatch.setattr(claude_session, "_steering_config", lambda channel_id=None: (True, 2, 20))
outcome: dict[str, str] = {}
def run(label: str):
outcome[label] = send_message(
"ch-contend-steer", label, adapter_name="discord",
)
t1 = threading.Thread(target=run, args=("first",))
t1.start()
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline and not proc.inflight:
time.sleep(0.01)
assert proc.inflight, "first call never entered the turn"
t2 = threading.Thread(target=run, args=("second",))
t2.start()
t2.join(timeout=5.0)
assert is_steered(outcome["second"]), (
"With steering on, a contended caller must STEER, not block."
)
assert proc.steer_calls == ["second"]
proc.release()
t1.join(timeout=5.0)
assert outcome["first"] == "done:first"

View File

@@ -87,6 +87,54 @@ class TestStatus:
assert "WA Bridge: ONLINE" in out
# ---------------------------------------------------------------------------
# eco status — steering line (T4/A3)
# ---------------------------------------------------------------------------
class TestSteeringStatusLine:
def _mock_service(self, active="inactive"):
return {"ActiveState": active, "MainPID": "0", "ActiveEnterTimestamp": ""}
def test_steering_off_no_procs(self, iso, capsys):
"""pgrep's own no-match case: exit code 1, stdout '0' — a
legitimate zero, not a counting failure."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
mock_pgrep = MagicMock(returncode=1, stdout="0\n")
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", return_value=mock_pgrep):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: off · 0 procese vii" in out
def test_steering_counts_live_procs(self, iso, capsys):
"""A real match: pgrep -fc finds 2 live steering processes."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = True
mock_pgrep = MagicMock(returncode=0, stdout="2\n")
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", return_value=mock_pgrep):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: on · 2 procese vii" in out
def test_survives_pgrep_failure(self, iso, capsys):
"""D1/D3 guard: a failed process-table scan must not break the
rest of `eco status`, and must not silently read as a wrong zero."""
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
with patch("cli._get_service_status", return_value=self._mock_service()), \
patch("src.config.Config", return_value=mock_cfg), \
patch("subprocess.run", side_effect=OSError("pgrep not found")):
cli.cmd_status(_args())
out = capsys.readouterr().out
assert "steering: off · necunoscut procese vii" in out
assert "Sessions:" in out
# ---------------------------------------------------------------------------
# cmd_doctor
# ---------------------------------------------------------------------------
@@ -194,6 +242,22 @@ class TestDoctor:
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "Claude CLI functional" in out
def test_steering_off_by_default(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
mock_cfg = MagicMock()
mock_cfg.get.return_value = False
with patch("src.config.Config", return_value=mock_cfg):
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "[PASS] Steering (optional, currently off)" in out
def test_steering_checks_stream_json_when_enabled(self, iso, capsys):
iso["config_file"].write_text('{"bot":{}}')
mock_cfg = MagicMock()
mock_cfg.get.return_value = True
with patch("src.config.Config", return_value=mock_cfg):
out, code = self._run_doctor(iso, capsys, setup_full=True)
assert "Claude CLI supports --input-format stream-json" in out
# ---------------------------------------------------------------------------
# cmd_restart

View File

@@ -1,15 +1,18 @@
"""Tests for the local LLM fallback: history, tools, net status, web search."""
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src import fallback_history, net_status, web_search
from src import claude_runner, claude_session, fallback_history, net_status, router, web_search
from src import local_fallback_tools as lft
from src.router import (_forced_tool, _is_creative_request, _is_text_task,
_parse_tool_args, _run_fallback_tools)
FAKE_CLAUDE = Path(__file__).parent / "fake_claude.py"
@pytest.fixture(autouse=True)
def clean_history():
@@ -623,3 +626,217 @@ class TestFallbackNeverDropsTurn:
assert "răspuns direct" in reply
conv.assert_called_once()
# ---------------------------------------------------------------------------
# T6 — persistent-process rate limit must still reach the fallback
# ---------------------------------------------------------------------------
#
# The one-shot `claude -p` dies with a nonzero exit code at the limit; a
# persistent steering process (src/claude_runner.py) reports the same limit
# as `result.is_error` in the stream instead — it never exits. `ClaudeProcess`
# converts that back into the exact `RuntimeError` the one-shot path raises
# (covered in tests/test_claude_runner.py). What isn't covered anywhere else
# is the far end of the wire: that error genuinely reaching router.py's
# handler and firing `_local_fallback_reply` for real. Untested, this is how
# the single most-worked-on subsystem in the repo goes dead in silence
# (tasks/steering-plan.md, blockers C3/C4, task T6 — "the 2am Friday test").
def _local_fallback_cfg():
"""`_get_config()` double: only `local_fallback` is stubbed — every
other key falls through to its caller-supplied default, same as the
real `Config().get(key, default)`. A blanket `return_value` (as used
for the router-only tests above) would also hijack route_message's own
`_get_config().get("bot.default_model", "sonnet")` lookup and feed a
dict into `--model`."""
cfg = MagicMock()
cfg.get.side_effect = lambda key, default=None: (
{"enabled": True, "url": "http://x"} if key == "local_fallback" else default
)
return cfg
@pytest.fixture(autouse=True)
def _reset_steering_registry():
"""H2: the steering registry is module-level global state — never let
a live (fake) process from one test leak into the next."""
claude_runner.reset_registry_for_tests()
yield
claude_runner.reset_registry_for_tests()
@pytest.fixture
def temp_sessions(tmp_path, monkeypatch):
"""Isolated sessions/active.json so these tests never touch the real one."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
sf.write_text("{}")
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
return sf
@pytest.fixture
def steering_on(monkeypatch, temp_sessions):
"""Steering enabled, wired to the real tests/fake_claude.py subprocess —
a genuine persistent process, not a mock, so the rate-limit path fires
the way it actually does in production."""
monkeypatch.setattr(claude_runner, "CLAUDE_BIN", str(FAKE_CLAUDE))
monkeypatch.setattr(claude_session, "_steering_config", lambda *a, **kw: (True, 2, 20))
class TestPersistentRateLimitReachesFallback:
"""T6 (`pytest -k persistent`): a rate limit arriving as `result.is_error`
from a PERSISTENT process must still invoke the local model — asserting
the RESCUE happened, not merely that some exception was raised. A test
that only checks "an exception was raised" is exactly the bug this task
exists to catch: that's also true the day the fallback silently stops
firing."""
def test_persistent_rate_limit_invokes_real_fallback(self, steering_on, monkeypatch):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "rate_limit")
with patch("src.router._get_config", return_value=_local_fallback_cfg()), \
patch("src.router.set_channel_context"), \
patch("src.router._call_local_llm", return_value={"content": "raspuns local"}), \
patch("src.router._local_fallback_reply",
wraps=router._local_fallback_reply) as fallback_spy:
result, is_cmd = router.route_message("ch-t6-persist", "user-1", "salut")
fallback_spy.assert_called_once_with("salut", channel_id="ch-t6-persist")
assert "raspuns local" in result
assert is_cmd is False
class _RateLimitAfterSteerProc:
"""Minimal ClaudeProcess double: the turn raises the rate-limit
RuntimeError while leaving one already-steered text behind in
`_pending_steers` — the shape of a real ClaudeProcess whose turn a
`steer()` landed in, then died (C3). `ClaudeProcess`'s own
thread-timing for how a steer lands mid-turn is exercised for real in
tests/test_claude_runner.py; this double exists only to drive
router.py's redelivery path deterministically."""
def __init__(self, channel_id, model=None, session_id=None, cwd=None):
self.channel_id = channel_id
self.session_id = session_id or "fake-sid"
self.inflight = False
self._pending_steers = ["steered while you were away"]
def run_turn(self, text, on_text=None, timeout=300):
self.inflight = True
raise RuntimeError(
"Claude CLI error (exit 1): You've hit your session limit · resets 10am (UTC)"
)
def pop_pending_steers(self):
pending, self._pending_steers = self._pending_steers, []
return pending
class _SingleProcRegistry:
"""RunnerRegistry double that always hands back the one proc it was
built with."""
def __init__(self, proc):
self._procs = {proc.channel_id: proc}
def get(self, channel_id, model=None, session_id=None, cwd=None):
return self._procs.get(channel_id)
def stop(self, channel_id):
return self._procs.pop(channel_id, None) is not None
def _echo_llm(url, messages, tools=None, temperature=0.0):
"""`_call_local_llm` double whose reply names the user text it saw, so
the original turn's reply and the steered turn's reply stay
distinguishable however many passes `_local_fallback_reply` makes."""
return {"content": f"echo:{messages[-1]['content']}"}
class TestSteeredTextDeliveredOnRateLimit:
"""C3: the plan is explicit that T6 must test DELIVERY, not just
detection. A message steered into a turn that then dies on a rate
limit must still produce its own answer to the user — its request
thread already returned `__STEERED__` and is gone, so `on_text` is the
only channel left."""
def test_steered_reply_delivered_via_on_text(self, monkeypatch, temp_sessions):
proc = _RateLimitAfterSteerProc("ch-t6-c3")
registry = _SingleProcRegistry(proc)
# Both names: `_dispatch_steering` calls `get_registry()`, but
# `claude_session.pop_pending_steers` (C3's redelivery hook) peeks
# the module-level `_registry` directly rather than calling
# `get_registry()` again (that would spin one up as a side effect
# for a channel that never used steering).
monkeypatch.setattr(claude_runner, "get_registry", lambda **kw: registry)
monkeypatch.setattr(claude_runner, "_registry", registry)
monkeypatch.setattr(claude_session, "_steering_config", lambda *a, **kw: (True, 2, 20))
streamed = []
with patch("src.router._get_config", return_value=_local_fallback_cfg()), \
patch("src.router.set_channel_context"), \
patch("src.router._call_local_llm", side_effect=_echo_llm):
result, is_cmd = router.route_message(
"ch-t6-c3", "user-1", "mesaj original", on_text=streamed.append,
)
# The original message's own answer still comes back as the
# function's return value.
assert "echo:mesaj original" in result
assert is_cmd is False
# The steered message never had a request thread of its own left to
# return to — its answer must have gone out through on_text.
assert len(streamed) == 1
assert "echo:steered while you were away" in streamed[0]
class TestNonRateLimitErrorStaysQuiet:
"""C4 regression guard: verified in tasks/steering-plan.md that
`PlanningSession` retries on `error_max_turns`, which depends on the
runner RETURNING (never raising) a non-rate-limit `is_error`. If a
future change 'simplifies' that into a raise, this test catches it by
failing on the wrong side: the fallback would fire when it must not."""
def test_generic_is_error_does_not_raise_or_call_fallback(self, steering_on, monkeypatch):
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "generic_error")
with patch("src.router._local_fallback_reply") as fallback:
result, is_cmd = router.route_message("ch-t6-c4", "user-1", "salut")
fallback.assert_not_called()
assert "hit max turns" in result
assert is_cmd is False
class TestSteeringOffRollback:
"""The flag is the rollback mechanism (steering-plan.md Etapa 2) — it
has to actually roll the rate-limit -> fallback path back to today's
behavior, not merely skip steering-specific code paths."""
def test_steering_off_still_reaches_fallback_via_one_shot_path(
self, monkeypatch, temp_sessions,
):
monkeypatch.setattr(claude_session, "_steering_config", lambda *a, **kw: (False, 0, 20))
monkeypatch.setattr(
claude_session, "_run_claude",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError(
"Claude CLI error (exit 1): You've hit your session limit · resets 10am (UTC)"
)),
)
with patch("src.router._get_config", return_value=_local_fallback_cfg()), \
patch("src.router.set_channel_context"), \
patch("src.router._call_local_llm", return_value={"content": "raspuns local"}), \
patch("src.router._local_fallback_reply",
wraps=router._local_fallback_reply) as fallback_spy:
result, is_cmd = router.route_message("ch-t6-off", "user-1", "salut")
fallback_spy.assert_called_once_with("salut", channel_id="ch-t6-off")
assert "raspuns local" in result
assert is_cmd is False
# Steering played no role at all — the registry was never even created.
assert claude_runner._registry is None

View File

@@ -193,7 +193,7 @@ class TestRegularMessage:
response, is_cmd = route_message("ch-1", "user-1", "hello")
assert response == "Hello from Claude!"
assert is_cmd is False
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None, voice_mode=False, adapter_name=None)
@patch("src.router.send_message")
def test_model_override(self, mock_send):
@@ -201,7 +201,7 @@ class TestRegularMessage:
response, is_cmd = route_message("ch-1", "user-1", "hello", model="opus")
assert response == "Response"
assert is_cmd is False
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False, adapter_name=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -274,7 +274,7 @@ class TestRegularMessage:
cb = lambda t: None
route_message("ch-1", "user-1", "hello", on_text=cb)
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=cb, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=cb, voice_mode=False, adapter_name=None)
# --- _get_channel_config ---
@@ -316,7 +316,7 @@ class TestModelResolution:
mock_chan_cfg.return_value = {"id": "ch-1", "default_model": "haiku"}
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="haiku", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="haiku", on_text=None, voice_mode=False, adapter_name=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -330,7 +330,7 @@ class TestModelResolution:
mock_get_config.return_value = mock_cfg
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False, adapter_name=None)
@patch("src.router._get_channel_config")
@patch("src.router._get_config")
@@ -344,7 +344,7 @@ class TestModelResolution:
mock_get_config.return_value = mock_cfg
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="sonnet", on_text=None, voice_mode=False, adapter_name=None)
@patch("src.router.get_active_session")
@patch("src.router.send_message")
@@ -354,7 +354,7 @@ class TestModelResolution:
mock_get_session.return_value = {"model": "opus", "session_id": "abc"}
route_message("ch-1", "user-1", "hello")
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False)
mock_send.assert_called_once_with("ch-1", "hello", model="opus", on_text=None, voice_mode=False, adapter_name=None)
# --- Voice/text unify regression guards ---

101
tests/test_sentinels.py Normal file
View File

@@ -0,0 +1,101 @@
"""src.sentinels is the single place that recognises router sentinels
(__AUDIO__:<path> and __STEERED__) so adapters don't each hand-roll the
prefix check. Offline, no subprocess/network involved.
"""
from __future__ import annotations
import asyncio
import pytest
from src.sentinels import AUDIO_PREFIX, STEERED, audio_path, is_steered
def test_audio_path_extracts_payload():
assert audio_path(f"{AUDIO_PREFIX}/tmp/echo-audio.wav") == "/tmp/echo-audio.wav"
def test_audio_path_none_for_plain_text():
assert audio_path("salut, ce mai faci?") is None
def test_audio_path_none_for_empty_and_none():
assert audio_path("") is None
assert audio_path(None) is None
def test_audio_path_none_for_steered():
assert audio_path(STEERED) is None
def test_is_steered_true_only_for_exact_sentinel():
assert is_steered(STEERED) is True
assert is_steered("__STEERED__ ") is False
assert is_steered("some text") is False
def test_is_steered_false_for_empty_and_none():
assert is_steered("") is False
assert is_steered(None) is False
def test_is_steered_false_for_audio_sentinel():
assert is_steered(f"{AUDIO_PREFIX}/tmp/x.wav") is False
# --- Voice pipeline: a steered response must not be mirrored into the
# text channel (blocker C2 in tasks/steering-plan.md). Reuses the fakes
# from tests/test_pipeline_mirror.py.
from unittest.mock import AsyncMock, MagicMock
from src.voice.pipeline import VoiceSession
def _make_text_channel(send_mock: AsyncMock) -> MagicMock:
tc = MagicMock(name="text_channel")
tc.send = send_mock
return tc
def _make_session(*, reply_text: str, text_channel) -> VoiceSession:
bot = MagicMock(name="bot")
bot.get_channel = MagicMock(return_value=text_channel)
bot.get_user = MagicMock(return_value=None)
ttsq = MagicMock(name="ttsq")
ttsq.push_text = MagicMock()
ttsq.clear = MagicMock()
route_mock = MagicMock(name="route_message", return_value=(reply_text, False))
return VoiceSession(
text_channel_id=1001,
voice_channel_id=2002,
guild_id=42,
voice_client=MagicMock(name="voice_client"),
bot=bot,
ttsq=ttsq,
whitelist=set(),
record_enabled=False,
mirror_enabled=True,
transcripts_jsonl_path=None,
loop=asyncio.get_event_loop_policy().new_event_loop(),
router_route_message=route_mock,
)
def _reply_chunks(send_mock: AsyncMock) -> list[str]:
return [
call.args[0]
for call in send_mock.call_args_list
if not call.args[0].startswith("\U0001f3a4")
]
@pytest.mark.asyncio
async def test_voice_path_does_not_mirror_steered_sentinel():
send_mock = AsyncMock(name="text_send")
text_channel = _make_text_channel(send_mock)
session = _make_session(reply_text=STEERED, text_channel=text_channel)
await session.on_segment_done(speaker_id=123, text="salut", no_speech_prob=0.1)
assert _reply_chunks(send_mock) == []

View File

@@ -0,0 +1,433 @@
"""Tests for the steering dispatcher (H1/C2/C3/T9/T12) in
``src/claude_session.py`` and its ``__STEERED__`` handling in
``src/router.py``.
`ClaudeProcess`/`RunnerRegistry` internals (spawn, stdin/stdout framing,
`steer()`'s own race handling) are already covered by
``tests/test_claude_runner.py`` against the real ``tests/fake_claude.py``
subprocess. This file is about the DISPATCHER's decisions — which only need
a process double that behaves the right way at the right time, synchronised
via ``threading.Event`` rather than real subprocess timing (the plan
explicitly warns sleep-based subprocess races are the flaky way to test
this).
"""
import threading
import time
from unittest.mock import patch
import pytest
from src import claude_runner, claude_session, router
from src.claude_runner import SteerResult, SteerStatus
from src.claude_session import send_message
from src.sentinels import is_steered
def _wait_until(predicate, timeout=5.0, interval=0.01):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if predicate():
return
time.sleep(interval)
raise AssertionError("condition not met within timeout")
# ---------------------------------------------------------------------------
# Scriptable ClaudeProcess/RunnerRegistry doubles
# ---------------------------------------------------------------------------
class FakeProc:
"""Stand-in for `ClaudeProcess`. `run_turn` blocks on a per-call Event
so a test can deterministically control exactly when a turn "finishes"."""
def __init__(self, channel_id="ch", session_id="fake-sid"):
self.channel_id = channel_id
self.session_id = session_id
self.inflight = False
self._pending_steers: list[str] = []
self.steer_calls: list[str] = []
self.run_turn_calls: list[str] = []
self.stopped = False
self.turn_error: Exception | None = None
self._release = threading.Event()
def run_turn(self, text, on_text=None, timeout=300):
self._release.clear()
self.inflight = True
self.run_turn_calls.append(text)
released = self._release.wait(timeout=5.0)
self.inflight = False
assert released, "test never released the fake turn"
if self.turn_error is not None:
err, self.turn_error = self.turn_error, None
raise err
return {
"result": f"done:{text}", "session_id": self.session_id, "usage": {},
"total_cost_usd": 0, "cost_usd": 0, "duration_ms": 1, "num_turns": 1,
"intermediate_count": 0, "subtype": "success", "is_error": False,
}
def steer(self, text):
self.steer_calls.append(text)
assert self.inflight, "dispatcher should only steer an inflight turn"
self._pending_steers.append(text)
return SteerResult(SteerStatus.STEERED)
def pop_pending_steers(self):
pending, self._pending_steers = self._pending_steers, []
return pending
def release(self):
self._release.set()
class FakeRegistry:
"""Stand-in for `RunnerRegistry` — one proc per channel, created
on demand, degrading to None past `capacity` (T10)."""
def __init__(self, capacity=2):
self.capacity = capacity
self._procs: dict[str, FakeProc] = {}
def get(self, channel_id, model=None, session_id=None, cwd=None):
proc = self._procs.get(channel_id)
if proc is not None:
return proc
if len(self._procs) >= self.capacity:
return None
proc = FakeProc(channel_id, session_id=session_id or "fake-sid")
self._procs[channel_id] = proc
return proc
def stop(self, channel_id):
proc = self._procs.pop(channel_id, None)
if proc is None:
return False
proc.stopped = True
return True
def live_count(self):
return len(self._procs)
@pytest.fixture(autouse=True)
def _clean_state():
"""Dispatcher state (`_session_locks`, `_channel_adapter`) and the
steering registry singleton are module-level globals — clear them
around every test so nothing leaks between tests."""
claude_session._session_locks.clear()
claude_session._channel_adapter.clear()
claude_runner.reset_registry_for_tests()
yield
claude_session._session_locks.clear()
claude_session._channel_adapter.clear()
claude_runner.reset_registry_for_tests()
@pytest.fixture
def temp_sessions(tmp_path, monkeypatch):
"""Isolated active.json per test — keeps real session state untouched."""
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir()
sf = sessions_dir / "active.json"
sf.write_text("{}")
monkeypatch.setattr(claude_session, "SESSIONS_DIR", sessions_dir)
monkeypatch.setattr(claude_session, "_SESSIONS_FILE", sf)
return sf
@pytest.fixture
def fake_registry(monkeypatch, temp_sessions):
"""Steering ON, backed by a FakeRegistry instead of real subprocesses."""
registry = FakeRegistry()
monkeypatch.setattr(claude_runner, "_registry", registry)
monkeypatch.setattr(claude_runner, "get_registry", lambda **kw: registry)
monkeypatch.setattr(claude_session, "_steering_config", lambda channel_id=None: (True, 2, 20))
return registry
class _FakeConfig:
"""Stand-in for `src.config.Config` used by `_steering_config`. Takes
the full raw dict (`steering`, `channels`, etc.) like the real thing."""
def __init__(self, data: dict):
self._data = data
def __call__(self, *a, **kw):
return self
def get(self, key, default=None):
return self._data.get(key, default)
# ---------------------------------------------------------------------------
# `_steering_config` — flag off, kill switch, clamps (X7/X8)
# ---------------------------------------------------------------------------
class TestSteeringConfig:
def test_disabled_by_default_config(self, monkeypatch):
monkeypatch.setattr("src.config.Config", _FakeConfig({"steering": {"enabled": False}}))
enabled, _, _ = claude_session._steering_config()
assert enabled is False
def test_kill_switch_wins(self, monkeypatch):
monkeypatch.setenv("ECHO_STEERING", "off")
monkeypatch.setattr(
"src.config.Config",
_FakeConfig({"steering": {"enabled": True, "max_live": 2, "idle_minutes": 20}}),
)
enabled, _, _ = claude_session._steering_config()
assert enabled is False
def test_max_live_zero_disables(self, monkeypatch):
monkeypatch.setattr(
"src.config.Config",
_FakeConfig({"steering": {"enabled": True, "max_live": 0, "idle_minutes": 20}}),
)
enabled, _, _ = claude_session._steering_config()
assert enabled is False
def test_idle_minutes_clamped_to_one(self, monkeypatch):
monkeypatch.setattr(
"src.config.Config",
_FakeConfig({"steering": {"enabled": True, "max_live": 2, "idle_minutes": 0}}),
)
_, _, idle_minutes = claude_session._steering_config()
assert idle_minutes == 1
# ---------------------------------------------------------------------------
# Etapa 6 (X16) — per-channel `channels.<alias>.steering` override
# ---------------------------------------------------------------------------
class TestPerChannelSteeringOverride:
def test_channel_on_overrides_global_off(self, monkeypatch):
monkeypatch.setattr("src.config.Config", _FakeConfig({
"steering": {"enabled": False, "max_live": 2, "idle_minutes": 20},
"channels": {"echo-core": {"id": "chan-1", "steering": True}},
}))
enabled, _, _ = claude_session._steering_config("chan-1")
assert enabled is True
def test_channel_off_overrides_global_on(self, monkeypatch):
monkeypatch.setattr("src.config.Config", _FakeConfig({
"steering": {"enabled": True, "max_live": 2, "idle_minutes": 20},
"channels": {"echo-core": {"id": "chan-1", "steering": False}},
}))
enabled, _, _ = claude_session._steering_config("chan-1")
assert enabled is False
def test_channel_unset_falls_through_to_global(self, monkeypatch):
monkeypatch.setattr("src.config.Config", _FakeConfig({
"steering": {"enabled": True, "max_live": 2, "idle_minutes": 20},
"channels": {"echo-core": {"id": "chan-1"}}, # no "steering" key
}))
enabled, _, _ = claude_session._steering_config("chan-1")
assert enabled is True
enabled_other, _, _ = claude_session._steering_config("chan-unrelated")
assert enabled_other is True # not present at all -> global default too
# ---------------------------------------------------------------------------
# Flag off -> unchanged blocking path
# ---------------------------------------------------------------------------
def test_steering_off_uses_unchanged_blocking_path(monkeypatch, temp_sessions):
monkeypatch.setattr(claude_session, "_steering_config", lambda channel_id=None: (False, 0, 20))
calls = []
def fake_run_claude(cmd, timeout, on_text=None, cwd=None, channel_id=None):
calls.append(cmd)
return {
"result": "hi", "session_id": "sid-1", "usage": {}, "total_cost_usd": 0,
"cost_usd": 0, "duration_ms": 1, "num_turns": 1, "intermediate_count": 0,
"subtype": "success", "is_error": False,
}
with patch.object(claude_session, "_run_claude", side_effect=fake_run_claude):
result = send_message("ch-off", "hello")
assert result == "hi"
assert len(calls) == 1
# The dispatcher never touched the registry — disabled path is a no-op
# with respect to steering, not just "didn't steer".
assert claude_runner._registry is None
# ---------------------------------------------------------------------------
# H1 — contention steers, no separate `inflight` check/TOCTOU
# ---------------------------------------------------------------------------
def test_contention_steers(fake_registry):
outcome = {}
def first():
outcome["first"] = send_message("ch-1", "first", adapter_name="discord")
t1 = threading.Thread(target=first)
t1.start()
_wait_until(lambda: fake_registry._procs.get("ch-1") is not None
and fake_registry._procs["ch-1"].inflight)
second = send_message("ch-1", "second", adapter_name="discord")
assert is_steered(second)
proc = fake_registry._procs["ch-1"]
assert proc.steer_calls == ["second"]
proc.release()
t1.join(timeout=5)
assert outcome["first"] == "done:first"
# ---------------------------------------------------------------------------
# C2/M1 — adapter mismatch never steers, and never spawns a second writer
# ---------------------------------------------------------------------------
def test_adapter_mismatch_waits_instead_of_steering(fake_registry):
voice_outcome = {}
def voice_turn():
voice_outcome["r"] = send_message("ch-2", "voice text", adapter_name="discord-voice")
t1 = threading.Thread(target=voice_turn)
t1.start()
_wait_until(lambda: fake_registry._procs.get("ch-2") is not None
and fake_registry._procs["ch-2"].inflight)
text_outcome = {}
def text_turn():
text_outcome["r"] = send_message("ch-2", "text message", adapter_name="discord")
t2 = threading.Thread(target=text_turn)
t2.start()
proc = fake_registry._procs["ch-2"]
time.sleep(0.1) # give t2 a chance to reach the mismatch branch
assert proc.steer_calls == [], "a mismatched adapter must never be steered (C2)"
proc.release() # finish the voice turn -> releases the lock
t1.join(timeout=5)
assert voice_outcome["r"] == "done:voice text"
_wait_until(lambda: proc.inflight) # t2 now got the lock and started its own turn
proc.release()
t2.join(timeout=5)
assert text_outcome["r"] == "done:text message"
assert not is_steered(text_outcome["r"])
assert proc.run_turn_calls == ["voice text", "text message"]
assert fake_registry.live_count() == 1, "must never spawn a second writer on the session (M1)"
# ---------------------------------------------------------------------------
# T10 — registry at capacity degrades to one-shot for a NEW channel
# ---------------------------------------------------------------------------
def test_capacity_degrades_new_channel_to_one_shot(fake_registry, temp_sessions):
fake_registry.capacity = 1
fake_registry._procs["ch-existing"] = FakeProc("ch-existing")
calls = []
def fake_run_claude(cmd, timeout, on_text=None, cwd=None, channel_id=None):
calls.append(cmd)
return {
"result": "one-shot reply", "session_id": "sid-2", "usage": {},
"total_cost_usd": 0, "cost_usd": 0, "duration_ms": 1, "num_turns": 1,
"intermediate_count": 0, "subtype": "success", "is_error": False,
}
with patch.object(claude_session, "_run_claude", side_effect=fake_run_claude):
result = send_message("ch-new", "hello", adapter_name="discord")
assert result == "one-shot reply"
assert len(calls) == 1
assert "ch-new" not in fake_registry._procs
# ---------------------------------------------------------------------------
# T9 — /clear and /model stop the live process
# ---------------------------------------------------------------------------
def test_clear_session_stops_live_process(fake_registry):
proc = fake_registry.get("ch-3")
fake_registry._procs["ch-3"] = proc
claude_session._save_sessions({"ch-3": {"session_id": "sid", "model": "sonnet"}})
assert claude_session.clear_session("ch-3") is True
assert proc.stopped is True
assert "ch-3" not in fake_registry._procs
def test_set_session_model_stops_live_process(fake_registry):
proc = fake_registry.get("ch-4")
fake_registry._procs["ch-4"] = proc
claude_session._save_sessions({"ch-4": {"session_id": "sid", "model": "sonnet"}})
assert claude_session.set_session_model("ch-4", "opus") is True
assert proc.stopped is True
assert "ch-4" not in fake_registry._procs
# ---------------------------------------------------------------------------
# C3 — a steered message must never be lost when its turn then fails
# ---------------------------------------------------------------------------
def test_pending_steers_redispatched_on_failed_turn(monkeypatch):
"""router.route_message's exception handler must pop pending steers
(claude_session.pop_pending_steers) and redeliver each one via
on_text — their own request threads already returned __STEERED__ and
are gone, so on_text is the only way left to answer them."""
pending = ["steered one", "steered two"]
monkeypatch.setattr(router, "_pop_pending_steers", lambda channel_id: list(pending))
monkeypatch.setattr(
router, "send_message",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError(
"Claude CLI error (exit 1): You've hit your session limit · resets 10am (UTC)"
)),
)
fallback_calls = []
def fake_fallback(text, channel_id=None, manual=False):
fallback_calls.append(text)
return f"fallback:{text}"
monkeypatch.setattr(router, "_local_fallback_reply", fake_fallback)
streamed = []
result, is_cmd = router.route_message(
"ch-c3", "user-1", "original message", on_text=streamed.append,
)
assert result == "fallback:original message"
assert is_cmd is False
assert fallback_calls == ["original message", "steered one", "steered two"]
assert streamed == ["fallback:steered one", "fallback:steered two"]
def test_pending_steers_logged_without_on_text(monkeypatch, caplog):
"""No on_text means no way to redeliver — must be logged (T12), not
silently dropped."""
monkeypatch.setattr(router, "_pop_pending_steers", lambda channel_id: ["lost message"])
monkeypatch.setattr(
router, "send_message",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")),
)
with caplog.at_level("WARNING"):
result, is_cmd = router.route_message("ch-c3b", "user-1", "hello")
assert result == "Error: boom"
assert any("steered message lost" in rec.message for rec in caplog.records)

View File

@@ -0,0 +1,58 @@
"""Tests for /stop — Etapa 0 of the steering plan (one-shot architecture).
`stop_turn(channel_id)` kills the in-flight Claude CLI subprocess registered
for a channel, without touching sessions/active.json (the session survives,
only the turn dies).
"""
from unittest.mock import MagicMock
import pytest
from src.claude_session import _live_procs, stop_turn
from src.router import route_message
@pytest.fixture(autouse=True)
def _clear_live_procs():
"""Fresh registry per test — fake processes must not leak between tests."""
_live_procs.clear()
yield
_live_procs.clear()
class TestStopTurn:
def test_idle_channel_returns_false(self):
assert stop_turn("ch-1") is False
def test_terminates_registered_process_and_cleans_up_registry(self):
fake_proc = MagicMock()
fake_proc.poll.return_value = None # still running
_live_procs["ch-1"] = fake_proc
assert stop_turn("ch-1") is True
fake_proc.terminate.assert_called_once()
fake_proc.wait.assert_called_once()
assert "ch-1" not in _live_procs
def test_already_exited_process_returns_false(self):
fake_proc = MagicMock()
fake_proc.poll.return_value = 0 # exited before /stop ran
_live_procs["ch-1"] = fake_proc
assert stop_turn("ch-1") is False
class TestStopCommand:
def test_stop_nothing_running(self):
response, is_cmd = route_message("ch-1", "user-1", "/stop")
assert response == "Nu rulează nimic pe canalul ăsta."
assert is_cmd is True
def test_stop_kills_in_flight_turn(self):
fake_proc = MagicMock()
fake_proc.poll.return_value = None
_live_procs["ch-1"] = fake_proc
response, is_cmd = route_message("ch-1", "user-1", "/stop")
assert response == "⏹ Oprit."
assert is_cmd is True
fake_proc.terminate.assert_called_once()