Add multi-turn Assistent session diagnostics to the Debug API.

POST /assistent/session + /chat with rich per-turn traces (patch, Exact merge, compact_context); chat-eval wraps one session turn. Docs playbook and unit/HTTP tests included.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-23 07:32:58 +03:00
co-authored by Cursor
parent 719d77efd9
commit b83d1d1e9c
6 changed files with 1965 additions and 292 deletions
+419
View File
@@ -0,0 +1,419 @@
"""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)
+21 -3
View File
@@ -193,24 +193,41 @@ def test_extract_assistent_patch():
def test_chat_eval_mocked_http(monkeypatch, tmp_path):
from gpu_rent import debug_assistent
from gpu_rent import debug_assistent_session as das
_auth_env(monkeypatch, tmp_path)
cfg = load_config(require_auth=True)
das.reset_session_store_for_tests()
monkeypatch.setattr(
debug_assistent,
das,
"_swarm_base",
lambda cfg: ("http://127.0.0.1:17801", "local"),
)
monkeypatch.setattr(
debug_assistent,
das,
"_session_id",
lambda base: ("sess-1", None, 5.0),
)
monkeypatch.setattr(
das,
"_resolve_chat_model",
lambda cfg, base, sid, model=None: ("qwen3-vl:8b", "qwen3-vl:8b"),
)
calls: list[tuple[str, dict]] = []
def fake_http(url, *, method="GET", body=None, timeout=12.0):
if url.endswith("/API/AssistentGetConfig"):
return True, {
"success": True,
"persona": "leonid",
"exact": {
"profiles": {"turbo": {"steps": 8, "cfg": 1, "sigma_shift": 1.15}}
},
"skills": [],
"enabled_skills": [],
}, 8.0
if url.endswith("/API/AssistentListModels"):
return True, {"preferred": "qwen3-vl:8b", "models": ["qwen3-vl:8b"]}, 10.0
if url.endswith("/API/AssistentChat"):
@@ -223,7 +240,7 @@ def test_chat_eval_mocked_http(monkeypatch, tmp_path):
return True, {"success": True, "reply": reply, "model": "qwen3-vl:8b"}, 42.0
return False, "unexpected " + url, 1.0
monkeypatch.setattr(debug_assistent, "_http_json", fake_http)
monkeypatch.setattr(das, "_http_json", fake_http)
out = debug_assistent.run_assistent_chat_eval(
cfg,
@@ -240,6 +257,7 @@ def test_chat_eval_mocked_http(monkeypatch, tmp_path):
assert out["patch"]["cfg"] == 1
assert out["patch"]["aspect"] == "3:4"
assert "turbo" in (out["reply_prose"] or "").lower() or "turbo" in (out["reply"] or "").lower()
assert out.get("trace") and out["trace"].get("exact_merge")
assert calls and calls[0][1]["session_id"] == "sess-1"
assert calls[0][1]["messages"][0]["content"] == "какой checkpoint?"
assert calls[0][1]["includeBase"] is True