Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
232 lines
7.8 KiB
Python
232 lines
7.8 KiB
Python
"""Comenzile puntii: !new (+ --fork), !cd, !model, !status, !stop, !cleanup, !help."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import bot
|
|
|
|
try:
|
|
from tests.test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge)
|
|
except ImportError: # pragma: no cover
|
|
from test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge)
|
|
|
|
__all__ = ["allowed", "bridge"]
|
|
|
|
|
|
async def run(bridge, text, ch=None):
|
|
ch = ch or FakeChannel()
|
|
msg = FakeMessage(text, channel=ch)
|
|
label = await bridge.handle_message(msg)
|
|
return label, ch
|
|
|
|
|
|
# ------------------------------------------------------------------- parser
|
|
def test_parse_command():
|
|
cmd = bot.parse_command("!cd /workspace/romfastsql")
|
|
assert cmd.name == "cd" and cmd.rest == "/workspace/romfastsql"
|
|
assert bot.parse_command("!MODEL opus").name == "model"
|
|
assert bot.parse_command("salut") is None
|
|
assert bot.parse_command("!") is None
|
|
|
|
|
|
async def test_comanda_necunoscuta(bridge):
|
|
label, ch = await run(bridge, "!inexistenta")
|
|
assert label == "unknown-command"
|
|
assert "nu exista" in ch.all_text
|
|
|
|
|
|
async def test_help(bridge):
|
|
label, ch = await run(bridge, "!help")
|
|
assert label == "cmd:help"
|
|
for c in ("!new", "!cd", "!model", "!status", "!stop", "!cleanup"):
|
|
assert c in ch.all_text
|
|
|
|
|
|
# ---------------------------------------------------------------------- !new
|
|
async def test_new_sterge_sesiunea(bridge, store):
|
|
ch = FakeChannel()
|
|
await run(bridge, "salut", ch) # creeaza sesiunea
|
|
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
|
label, _ = await run(bridge, "!new", ch)
|
|
assert label == "cmd:new"
|
|
assert store.thread(str(ch.id))["sid"] is None
|
|
assert bridge.runner.procs[str(ch.id)].alive is False
|
|
|
|
|
|
async def test_new_fork_pastreaza_contextul(bridge, store):
|
|
ch = FakeChannel()
|
|
await run(bridge, "salut", ch)
|
|
await run(bridge, "!new --fork", ch)
|
|
assert store.thread(str(ch.id))["sid"] == "sid-fake-0001"
|
|
assert "fork" in ch.all_text
|
|
assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001"
|
|
|
|
|
|
# ----------------------------------------------------------------------- !cd
|
|
async def test_cd_oriunde_in_workspace(bridge, store, tmp_path):
|
|
target = tmp_path / "proiect-nou"
|
|
target.mkdir()
|
|
label, ch = await run(bridge, f"!cd {target}")
|
|
assert label == "cmd:cd"
|
|
assert store.thread(str(ch.id))["cwd"] == str(target)
|
|
assert str(target) in ch.all_text
|
|
|
|
|
|
async def test_cd_refuza_ce_nu_e_director(bridge, store):
|
|
label, ch = await run(bridge, "!cd /nu/exista/nicaieri")
|
|
assert "nu e un director" in ch.all_text
|
|
assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri"
|
|
|
|
|
|
async def test_cd_fara_argument(bridge):
|
|
_, ch = await run(bridge, "!cd")
|
|
assert "Foloseste" in ch.all_text
|
|
|
|
|
|
async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path):
|
|
ch = FakeChannel()
|
|
await run(bridge, "salut", ch)
|
|
target = tmp_path / "alt-proiect"
|
|
target.mkdir()
|
|
await run(bridge, f"!cd {target}", ch)
|
|
assert bridge.runner.procs[str(ch.id)].cwd == str(target)
|
|
assert bridge.runner.procs[str(ch.id)].alive is False # se reia cu --resume
|
|
|
|
|
|
# -------------------------------------------------------------------- !model
|
|
async def test_model_implicit_e_sonnet(bridge, store):
|
|
_, ch = await run(bridge, "salut")
|
|
assert store.thread(str(ch.id))["model"] == "sonnet"
|
|
assert "sonnet" in ch.all_text
|
|
|
|
|
|
async def test_model_opus_per_fir(bridge, store):
|
|
ch = FakeChannel()
|
|
label, _ = await run(bridge, "!model opus", ch)
|
|
assert label == "cmd:model"
|
|
assert store.thread(str(ch.id))["model"] == "opus"
|
|
# firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet
|
|
ch2 = FakeChannel("201", parent_id=CHANNEL)
|
|
await run(bridge, "!status", ch2)
|
|
assert "model : sonnet" in ch2.all_text
|
|
|
|
|
|
async def test_modelul_ales_ajunge_in_subsol(bridge):
|
|
ch = FakeChannel()
|
|
await run(bridge, "!model opus", ch)
|
|
await run(bridge, "salut", ch)
|
|
assert "opus" in ch.sent[-1].content
|
|
|
|
|
|
async def test_model_necunoscut_refuzat(bridge, store):
|
|
ch = FakeChannel()
|
|
await run(bridge, "!model gpt", ch)
|
|
assert "necunoscut" in ch.all_text
|
|
assert store.thread(str(ch.id))["model"] == "sonnet"
|
|
|
|
|
|
async def test_model_fara_argument_arata_curentul(bridge):
|
|
_, ch = await run(bridge, "!model")
|
|
assert "Model curent" in ch.all_text
|
|
|
|
|
|
# ------------------------------------------------------------------- !status
|
|
async def test_status_arata_campurile_cerute(bridge):
|
|
ch = FakeChannel()
|
|
await run(bridge, "salut", ch)
|
|
_, _ = await run(bridge, "!status", ch)
|
|
text = ch.sent[-1].content
|
|
for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor",
|
|
"in coada", "cost fir", "cost azi"):
|
|
assert camp in text
|
|
assert "sid-fake-0001" in text
|
|
assert "$0.0123" in text
|
|
|
|
|
|
async def test_status_arata_stderr(bridge, monkeypatch):
|
|
ch = FakeChannel()
|
|
monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "crash")
|
|
await run(bridge, "salut", ch) # esueaza si lasa stderr in buffer
|
|
proc = bridge.runner.procs[str(ch.id)]
|
|
proc.stderr_buf.append("fake_claude: boom")
|
|
_, _ = await run(bridge, "!status", ch)
|
|
assert "boom" in ch.sent[-1].content
|
|
|
|
|
|
async def test_status_pe_fir_fara_proces(bridge):
|
|
_, ch = await run(bridge, "!status")
|
|
assert "proces : oprit" in ch.all_text
|
|
assert "(noua)" in ch.all_text
|
|
|
|
|
|
# --------------------------------------------------------------------- !stop
|
|
async def test_stop_opreste_procesul(bridge, store):
|
|
ch = FakeChannel()
|
|
await run(bridge, "salut", ch)
|
|
proc = bridge.runner.procs[str(ch.id)]
|
|
assert proc.alive is True # procesul ramane viu intre tururi
|
|
await run(bridge, "!stop", ch)
|
|
assert "oprit" in ch.all_text
|
|
assert proc.alive is False
|
|
|
|
|
|
async def test_stop_fara_proces(bridge):
|
|
_, ch = await run(bridge, "!stop")
|
|
assert "Nu ruleaza nimic" in ch.all_text
|
|
|
|
|
|
async def test_stop_curata_inflight(bridge, store, monkeypatch):
|
|
ch = FakeChannel()
|
|
store.set_inflight(str(ch.id), "t1", USER, "m1")
|
|
await run(bridge, "!stop", ch)
|
|
assert store.is_inflight(str(ch.id)) is False
|
|
|
|
|
|
# ------------------------------------------------------------------ !cleanup
|
|
async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch):
|
|
calls = {}
|
|
|
|
class FakeCleanup:
|
|
@staticmethod
|
|
def find_orphans(state):
|
|
calls["state"] = state
|
|
return [{"pid": 1234, "cmdline": "claude -p", "age_s": 10, "rss_mb": 406.0}]
|
|
|
|
@staticmethod
|
|
def kill_orphans(orphans, dry_run=True):
|
|
calls["dry_run"] = dry_run
|
|
return [{"pid": 1234, "action": "dry-run", "detail": "s-ar trimite SIGTERM"}]
|
|
|
|
@staticmethod
|
|
def format_report(orphans, results=None):
|
|
calls["results"] = results
|
|
return f"{len(orphans)} orfani"
|
|
|
|
monkeypatch.setattr(bot, "cleanup", FakeCleanup)
|
|
_, ch = await run(bridge, "!cleanup")
|
|
assert "1 orfani" in ch.all_text
|
|
assert calls["dry_run"] is True and calls["results"] is None
|
|
assert "threads" in calls["state"]
|
|
|
|
_, ch2 = await run(bridge, "!cleanup --force")
|
|
assert calls["dry_run"] is False and calls["results"] is not None
|
|
|
|
|
|
async def test_cleanup_care_crapa_nu_doboara_botul(bridge, monkeypatch):
|
|
class Boom:
|
|
@staticmethod
|
|
def find_orphans(state):
|
|
raise RuntimeError("proc ilizibil")
|
|
|
|
monkeypatch.setattr(bot, "cleanup", Boom)
|
|
_, ch = await run(bridge, "!cleanup")
|
|
assert "a esuat" in ch.all_text
|
|
|
|
|
|
async def test_cleanup_real_ruleaza_in_dry_run(bridge):
|
|
"""Modulul real al lui Lane C: nu omoara nimic implicit."""
|
|
cleanup = pytest.importorskip("cleanup")
|
|
_, ch = await run(bridge, "!cleanup")
|
|
assert ch.all_text.strip() != ""
|