"""Unit tests for Assistent patch extract + in-memory session store (no GPU).""" from __future__ import annotations import time from gpu_rent.debug_assistent import extract_assistent_patch from gpu_rent import debug_assistent_session as das def test_extract_assistent_patch_last_fence(): text = ( "Сначала prose.\n" "```json\n" '{"steps": 8, "cfg": 1}\n' "```\n" "ещё текст\n" "```json\n" '{"prompt": "a cat", "actions": ["generate"], "aspect": "16:9"}\n' "```\n" ) out = extract_assistent_patch(text) assert out["patch"] is not None assert out["patch"]["prompt"] == "a cat" assert out["patch"]["generate"] is True assert out["patch"]["aspect"] == "16:9" assert "Сначала prose" in out["prose"] def test_extract_assistent_patch_ignores_non_patch_json(): text = 'hello\n```json\n{"foo": 1}\n```\n' out = extract_assistent_patch(text) assert out["patch"] is None def test_analyze_exact_merge_fills_omitted_params(): patch = {"actions": ["generate"], "prompt": "x"} exact = { "generation": {"profile": "turbo", "steps": 8, "cfg": 1, "sigma_shift": 1.15}, "profiles": { "turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}, "raw": {"steps": 28, "cfg": 4.5, "sigma_shift": 1.15}, }, } result = das.analyze_exact_merge( patch, krea_profile="turbo", exact=exact, ) assert result["wants_generate"] is True assert result["would_fill"]["steps"] == 8 assert result["would_fill"]["cfg"] == 1 assert "steps omitted" in " ".join(result["hints"]) def test_analyze_exact_merge_forces_foreign_leftovers(): patch = {"generate": True, "steps": 20, "cfg": 7} exact = { "profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}}, "generation": {"profile": "turbo"}, } result = das.analyze_exact_merge( patch, krea_profile="turbo", exact=exact, user_param_intent=False ) assert result["would_force_to_exact"]["steps"]["to"] == 8 assert result["would_force_to_exact"]["cfg"]["to"] == 1 def test_summarize_compact_context_sizes(): summary = das.summarize_compact_context( {"persona": "neutral", "krea_profile": "turbo", "recommended_params": {"steps": 8}} ) assert "persona" in summary["keys"] assert summary["chars"] > 0 assert summary["token_ish"] >= 1 assert "does not return compactContext" in summary["note"] def test_session_store_create_get_delete_without_swarm(monkeypatch): store = das.reset_session_store_for_tests() class Cfg: swarmui_local_port = 17801 # No local tunnel monkeypatch.setattr(das, "_swarm_base", lambda cfg: (None, "none")) monkeypatch.setattr( das, "_resolve_chat_model", lambda cfg, base, sid, model=None: (None, None) ) created = das.create_debug_session( Cfg(), # type: ignore[arg-type] persona="neutral", pack="ordinary", probe_config=False, ) assert created["ok"] is True sid = created["debug_session_id"] assert sid in store.list_ids() got = das.get_debug_session(sid) assert got["ok"] is True assert got["session"]["persona"] == "neutral" assert got["session"]["turn_count"] == 0 deleted = das.delete_debug_session(sid) assert deleted["ok"] is True assert das.get_debug_session(sid)["ok"] is False def test_session_store_ttl_eviction(monkeypatch): store = das.reset_session_store_for_tests() store.ttl_sec = 0.05 class Cfg: swarmui_local_port = 17801 monkeypatch.setattr(das, "_swarm_base", lambda cfg: (None, "none")) monkeypatch.setattr( das, "_resolve_chat_model", lambda cfg, base, sid, model=None: ("m", "m") ) created = das.create_debug_session(Cfg(), probe_config=False) # type: ignore[arg-type] sid = created["debug_session_id"] time.sleep(0.08) assert das.get_debug_session(sid)["ok"] is False def test_session_store_max_cap(monkeypatch): store = das.reset_session_store_for_tests() store.max_sessions = 2 class Cfg: swarmui_local_port = 17801 monkeypatch.setattr(das, "_swarm_base", lambda cfg: (None, "none")) monkeypatch.setattr( das, "_resolve_chat_model", lambda cfg, base, sid, model=None: ("m", "m") ) ids = [] for _ in range(3): created = das.create_debug_session(Cfg(), probe_config=False) # type: ignore[arg-type] ids.append(created["debug_session_id"]) time.sleep(0.01) assert len(store.list_ids()) == 2 assert ids[0] not in store.list_ids() def test_clamp_message(): short, trunc = das.clamp_message("hi", max_chars=10) assert short == "hi" and trunc is False long, trunc = das.clamp_message("x" * 20, max_chars=10) assert len(long) == 10 and trunc is True def test_multi_turn_chat_keeps_history(monkeypatch): store = das.reset_session_store_for_tests() class Cfg: swarmui_local_port = 17801 monkeypatch.setattr( das, "_swarm_base", lambda cfg: ("http://127.0.0.1:17801", "local") ) monkeypatch.setattr(das, "_session_id", lambda base: ("swarm-1", None, 3.0)) monkeypatch.setattr( das, "_resolve_chat_model", lambda cfg, base, sid, model=None: ("qwen", "qwen"), ) payloads: list[dict] = [] def fake_http(url, *, method="GET", body=None, timeout=12.0): if url.endswith("/API/AssistentGetConfig"): return True, { "success": True, "persona": "neutral", "exact": { "profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}}, "generation": {"profile": "turbo"}, }, "skills": [], "enabled_skills": [], }, 5.0 if url.endswith("/API/AssistentChat"): payloads.append(body or {}) n = len(payloads) reply = f'turn{n}\n```json\n{{"steps": {4 + n}, "actions": ["generate"]}}\n```' return True, {"success": True, "reply": reply, "model": "qwen"}, 20.0 return False, "unexpected " + url, 1.0 monkeypatch.setattr(das, "_http_json", fake_http) created = das.create_debug_session(Cfg(), probe_config=True) # type: ignore[arg-type] sid = created["debug_session_id"] t1 = das.chat_debug_session(Cfg(), sid, message="first") # type: ignore[arg-type] t2 = das.chat_debug_session(Cfg(), sid, message="second") # type: ignore[arg-type] assert t1["ok"] and t2["ok"] assert len(payloads) == 2 assert len(payloads[0]["messages"]) == 1 assert len(payloads[1]["messages"]) == 3 # user, assistant, user assert payloads[1]["messages"][0]["content"] == "first" assert payloads[1]["messages"][2]["content"] == "second" assert t2["trace"]["patch"]["steps"] == 6 assert t2["trace"]["exact_merge"]["wants_generate"] is True assert "gaps" in t2["trace"] assert store.get(sid).turn_count == 2 das.delete_debug_session(sid) def test_build_chat_trace_soft_sqlite(): reply = 'ok\n```json\n{"steps": 8, "actions": ["generate"]}\n```' trace = das.build_chat_trace( ok=True, timings_ms={"chat": 12.0, "total": 20.0}, model="qwen", preferred="qwen", persona="neutral", pack="ordinary", context={"krea_profile": "turbo", "recommended_params": {"steps": 8, "cfg": 1}}, skills=[], config_probe={ "ok": True, "_exact": { "profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}} }, "data": {"enabled_skills": []}, }, reply=reply, response={ "reply": reply, "system_chars": 1000, "system_layers": {"core": 100, "pack": 50}, "error": "SaveChat sqlite fail", }, errors=[], warnings=[], hints=[], ) assert trace["ok"] is True assert trace["patch"]["steps"] == 8 assert trace["exact_merge"]["wants_generate"] is True assert any("Sqlite" in e or "sqlite" in e.lower() for e in trace["errors"]) assert "gaps" in trace and len(trace["gaps"]) >= 3 def test_openapi_lists_session_paths(monkeypatch, tmp_path): import socket import urllib.request from gpu_rent import debug_api from gpu_rent.config import load_config # Reuse same env helper pattern as test_debug_api monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.chdir(tmp_path) for key, val in { "OS_AUTH_URL": "https://example/v3", "OS_USER_DOMAIN_NAME": "default", "OS_USERNAME": "u", "OS_PASSWORD": "secret-password", "OS_PROJECT_ID": "proj", "OS_REGION_NAME": "ru-7", "GPU_RENT_AZ": "ru-7a", "CIVITAI_API_TOKEN": "civ", "HF_TOKEN": "hf", "GIT_TOKEN": "git", "SELECTEL_API_TOKEN": "sel", }.items(): monkeypatch.setenv(key, val) cfg = load_config(require_auth=True) sock = socket.socket() sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() srv = debug_api.start_debug_server(cfg, port=port, print_urls=False) assert srv is not None try: with urllib.request.urlopen( f"http://127.0.0.1:{port}/openapi.json", timeout=5 ) as resp: doc = __import__("json").loads(resp.read().decode()) assert "/assistent/session" in doc["paths"] assert "/assistent/session/{id}/chat" in doc["paths"] assert "/assistent/diagnose" in doc["paths"] assert "VRAM" in doc["info"]["description"] finally: debug_api.stop_debug_server(srv) def test_http_session_chat_mocked(monkeypatch, tmp_path): """POST session → chat → GET → DELETE via Debug API (mocked Swarm).""" import json import socket import urllib.request from gpu_rent import debug_api from gpu_rent.config import load_config monkeypatch.setenv("HOME", str(tmp_path)) monkeypatch.setenv("USERPROFILE", str(tmp_path)) monkeypatch.chdir(tmp_path) for key, val in { "OS_AUTH_URL": "https://example/v3", "OS_USER_DOMAIN_NAME": "default", "OS_USERNAME": "u", "OS_PASSWORD": "secret", "OS_PROJECT_ID": "proj", "OS_REGION_NAME": "ru-7", "GPU_RENT_AZ": "ru-7a", "CIVITAI_API_TOKEN": "civ", "HF_TOKEN": "hf", "GIT_TOKEN": "git", "SELECTEL_API_TOKEN": "sel", }.items(): monkeypatch.setenv(key, val) cfg = load_config(require_auth=True) das.reset_session_store_for_tests() monkeypatch.setattr( das, "_swarm_base", lambda cfg: ("http://127.0.0.1:17801", "local") ) monkeypatch.setattr(das, "_session_id", lambda base: ("swarm-sid", None, 3.0)) monkeypatch.setattr( das, "_resolve_chat_model", lambda cfg, base, sid, model=None: ("qwen3-vl:8b", "qwen3-vl:8b"), ) def fake_http(url, *, method="GET", body=None, timeout=12.0): if url.endswith("/API/AssistentGetConfig"): return ( True, { "exact": { "profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}}, "generation": {"profile": "turbo"}, }, "enabled_skills": [], }, 4.0, ) if url.endswith("/API/AssistentChat"): n = len((body or {}).get("messages") or []) reply = ( f"turn-{n}\n" '```json\n{"prompt":"cat","steps":8,"cfg":1,"actions":["generate"]}\n```' ) return True, {"reply": reply, "system_chars": 1200, "model": "qwen3-vl:8b"}, 20.0 return False, "unexpected " + url, 1.0 monkeypatch.setattr(das, "_http_json", fake_http) sock = socket.socket() sock.bind(("127.0.0.1", 0)) port = sock.getsockname()[1] sock.close() srv = debug_api.start_debug_server(cfg, port=port, print_urls=False) assert srv is not None base = f"http://127.0.0.1:{port}" def post(path: str, payload: dict) -> dict: req = urllib.request.Request( base + path, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}, method="POST", ) with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode()) def get(path: str) -> dict: with urllib.request.urlopen(base + path, timeout=10) as resp: return json.loads(resp.read().decode()) def delete(path: str) -> dict: req = urllib.request.Request(base + path, method="DELETE") with urllib.request.urlopen(req, timeout=10) as resp: return json.loads(resp.read().decode()) try: created = post( "/assistent/session", { "persona": "neutral", "pack": "ordinary", "context": {"krea_profile": "turbo"}, }, ) assert created["ok"] is True sid = created["debug_session_id"] turn1 = post(f"/assistent/session/{sid}/chat", {"message": "какие steps?"}) assert turn1["ok"] is True assert turn1["trace"]["patch"]["steps"] == 8 assert turn1["trace"]["exact_merge"]["wants_generate"] is True assert "compact_context" in turn1["trace"] assert turn1["turn_count"] == 1 turn2 = post(f"/assistent/session/{sid}/chat", {"message": "ещё раз"}) assert turn2["ok"] is True assert turn2["turn_count"] == 2 assert turn2["session"]["turn_count"] == 2 got = get(f"/assistent/session/{sid}") assert got["ok"] is True assert got["session"]["turn_count"] == 2 assert got["last_trace"]["ok"] is True deleted = delete(f"/assistent/session/{sid}") assert deleted["ok"] is True gone = get(f"/assistent/session/{sid}") assert gone["ok"] is False finally: debug_api.stop_debug_server(srv)