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:
124
tests/fake_claude.py
Executable file
124
tests/fake_claude.py
Executable 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
351
tests/test_claude_runner.py
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
101
tests/test_sentinels.py
Normal 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) == []
|
||||
433
tests/test_steering_dispatch.py
Normal file
433
tests/test_steering_dispatch.py
Normal 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)
|
||||
58
tests/test_stop_command.py
Normal file
58
tests/test_stop_command.py
Normal 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()
|
||||
Reference in New Issue
Block a user