feat(discord-bridge): punte Discord -> Claude Code pe LXC 171
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
This commit is contained in:
137
proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py
Normal file
137
proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""T10: chunker si loop de editare per canal."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
import render
|
||||
|
||||
|
||||
def test_text_scurt_ramane_intr_o_bucata():
|
||||
r = render.split_message("salut")
|
||||
assert r.kind == "chunks" and r.parts == ["salut"]
|
||||
|
||||
|
||||
def test_sparge_la_2000_de_caractere():
|
||||
text = "\n".join(f"linia {i} " + "x" * 50 for i in range(60))
|
||||
r = render.split_message(text)
|
||||
assert r.kind == "chunks" and len(r.parts) > 1
|
||||
assert all(len(p) <= render.MAX_MSG for p in r.parts)
|
||||
assert "linia 0" in r.parts[0] and "linia 59" in r.parts[-1]
|
||||
|
||||
|
||||
def test_fence_nu_se_rupe_peste_granita():
|
||||
corp = "\n".join(f"cod linia {i}" for i in range(300))
|
||||
r = render.split_message(f"intro\n```python\n{corp}\n```\ngata")
|
||||
assert len(r.parts) >= 2
|
||||
for p in r.parts:
|
||||
assert p.count("```") % 2 == 0 # fiecare bucata are fence-uri echilibrate
|
||||
assert r.parts[0].endswith("```")
|
||||
assert r.parts[1].startswith("```python")
|
||||
assert all(len(p) <= render.MAX_MSG for p in r.parts)
|
||||
|
||||
|
||||
def test_fence_fara_limbaj():
|
||||
corp = "\n".join(f"linia {i}" for i in range(400))
|
||||
r = render.split_message(f"```\n{corp}\n```")
|
||||
assert r.parts[1].startswith("```\n")
|
||||
|
||||
|
||||
def test_linie_unica_uriasa_se_taie():
|
||||
r = render.split_message("y" * 5000)
|
||||
assert r.kind == "chunks" and all(len(p) <= render.MAX_MSG for p in r.parts)
|
||||
|
||||
|
||||
def test_peste_6000_devine_atasament():
|
||||
r = render.split_message("z" * 6001)
|
||||
assert r.kind == "attachment"
|
||||
assert isinstance(r, render.Attachment)
|
||||
assert r.content == "z" * 6001 and r.filename.endswith(".md")
|
||||
assert len(r.preview) <= render.MAX_MSG
|
||||
|
||||
|
||||
def test_text_gol():
|
||||
assert render.split_message("").parts == [""]
|
||||
|
||||
|
||||
def test_footer():
|
||||
s = render.footer("sonnet", 34500, 0.1547, 1.2)
|
||||
assert "sonnet" in s and "34.5s" in s and "0.1547" in s and "1.2000" in s
|
||||
|
||||
|
||||
# ------------------------------------------------------------- edit loop
|
||||
async def test_loop_coalesceaza_actualizarile():
|
||||
edits = []
|
||||
|
||||
async def edit(target, text):
|
||||
edits.append((target, text))
|
||||
|
||||
loop = render.ChannelEditLoop("c1", edit, min_interval=0.05, max_interval=0.2)
|
||||
loop.start()
|
||||
for i in range(20):
|
||||
loop.queue("msg1", f"text {i}")
|
||||
await asyncio.sleep(0.15)
|
||||
await loop.stop()
|
||||
assert len(edits) < 20 # nu s-a trimis fiecare actualizare
|
||||
assert edits[-1] == ("msg1", "text 19") # ultima versiune ajunge
|
||||
|
||||
|
||||
async def test_flush_trimite_tot_ce_a_ramas():
|
||||
edits = []
|
||||
loop = render.ChannelEditLoop("c1", lambda t, x: _append(edits, t, x),
|
||||
min_interval=10, max_interval=10)
|
||||
loop.queue("m1", "a")
|
||||
loop.queue("m2", "b")
|
||||
await loop.flush()
|
||||
assert sorted(edits) == [("m1", "a"), ("m2", "b")]
|
||||
|
||||
|
||||
async def _append(lst, t, x):
|
||||
lst.append((t, x))
|
||||
|
||||
|
||||
async def test_intervalul_creste_sub_presiune_si_scade_la_liniste():
|
||||
loop = render.ChannelEditLoop("c1", lambda t, x: _append([], t, x),
|
||||
min_interval=0.01, max_interval=0.2)
|
||||
loop.start()
|
||||
for i in range(10):
|
||||
loop.queue(f"m{i}", "x")
|
||||
await asyncio.sleep(0.1)
|
||||
crescut = loop.interval
|
||||
assert crescut > 0.01
|
||||
loop.note_rate_limited()
|
||||
assert loop.interval == 0.2
|
||||
await asyncio.sleep(0.5)
|
||||
await loop.stop()
|
||||
assert loop.interval < 0.2 # linistea coboara intervalul
|
||||
|
||||
|
||||
async def test_editarea_esuata_nu_doboara_loop_ul():
|
||||
async def edit(target, text):
|
||||
raise RuntimeError("429 sau altceva")
|
||||
|
||||
loop = render.ChannelEditLoop("c1", edit, min_interval=0.01, max_interval=0.05)
|
||||
loop.start()
|
||||
loop.queue("m1", "a")
|
||||
await asyncio.sleep(0.1)
|
||||
assert loop.errors >= 1
|
||||
loop.queue("m1", "b")
|
||||
await asyncio.sleep(0.1)
|
||||
assert loop.errors >= 2 # loop-ul inca traieste
|
||||
await loop.stop()
|
||||
|
||||
|
||||
async def test_un_singur_loop_per_canal():
|
||||
edits = []
|
||||
mgr = render.RenderManager(lambda t, x: _append(edits, t, x),
|
||||
min_interval=0.01, max_interval=0.05)
|
||||
l1 = mgr.loop_for("canal-1")
|
||||
assert mgr.loop_for("canal-1") is l1
|
||||
assert mgr.loop_for("canal-2") is not l1
|
||||
mgr.queue("canal-1", "m1", "salut")
|
||||
await asyncio.sleep(0.08)
|
||||
assert ("m1", "salut") in edits
|
||||
await mgr.stop_all()
|
||||
assert mgr.loops == {}
|
||||
Reference in New Issue
Block a user