from __future__ import annotations
import json
import re
import sys
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.error import HTTPError
from urllib.request import Request, urlopen
import pytest
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "plugins" / "1c" / "agent"))
import agent_server as onec_agent # noqa: E402
from core.observability import JsonlAuditStore # noqa: E402
try:
import yaml
except ModuleNotFoundError:
yaml = None
def request_json(
method: str,
*,
url: str,
path: str,
payload: dict[str, Any] | None = None,
request_id: str | None = None,
) -> tuple[int, dict[str, Any], bool]:
data = json.dumps(payload or {}).encode("utf-8") if payload is not None else None
req = Request(
f"{url}{path}",
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
if request_id:
req.add_header("X-Request-Id", request_id)
try:
with urlopen(req, timeout=3) as response:
body = response.read().decode("utf-8")
return response.status, json.loads(body) if body else {}, False
except HTTPError as exc:
body = exc.read().decode("utf-8")
return exc.code, json.loads(body) if body else {}, True
def request_raw(method: str, *, url: str, path: str) -> tuple[int, str]:
req = Request(f"{url}{path}", method=method)
try:
with urlopen(req, timeout=3) as response:
return response.status, response.read().decode("utf-8")
except HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
@pytest.fixture
def onec_agent_server(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(
onec_agent,
"route_for_model",
lambda model_id=None, *, plugin=onec_agent.DEFAULT_PLUGIN: {
"plugin": plugin,
"model": {"id": model_id or "stub-model", "task": ["1c"]},
"base_url": "http://127.0.0.1:8000",
"served_model_name": "stub-model",
"container_name": None,
"host": None,
"role": None,
},
)
monkeypatch.setattr(
onec_agent,
"call_model",
lambda provider, model, messages, temperature=0.2, max_tokens=1200, base_url=None: {
"text": f"Stub answer for {model} via {provider.name}",
"raw": {"model": model, "provider": provider.name, "messages": messages},
"latency_ms": 0,
"provider_base_url": base_url or provider.base_url,
},
)
providers = {
"default": onec_agent.ProviderConfig(
name="default",
data={
"type": "openai-compatible",
"base_url": "http://127.0.0.1:8000",
"model": "stub-model",
},
),
}
store = onec_agent.Store(tmp_path / "onec-agent.db")
audit_store = JsonlAuditStore(tmp_path / "reports" / "observability" / "onec-agent", service="onec-agent")
def _handler(*args: Any, **kwargs: Any) -> None:
onec_agent.AgentHandler(
*args,
store=store,
providers=providers,
system_prompt="Ты помощник по 1С",
audit_store=audit_store,
**kwargs,
)
server = ThreadingHTTPServer(("127.0.0.1", 0), _handler)
thread = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True)
thread.start()
try:
yield {
"url": f"http://127.0.0.1:{server.server_address[1]}",
"audit_root": str(tmp_path / "reports" / "observability" / "onec-agent"),
}
finally:
server.shutdown()
thread.join(timeout=3)
server.server_close()
def _assert_has_trace(payload: dict[str, Any], expected: str | None = None) -> None:
trace = payload.get("trace_id")
assert isinstance(trace, str)
if expected is not None:
assert trace == expected
return
assert re.fullmatch(r"[0-9a-f]{32}", trace) is not None
def test_health_is_alive(onec_agent_server: dict[str, str]) -> None:
status, payload, is_error = request_json("GET", url=onec_agent_server["url"], path="/v1/health")
assert not is_error
assert status == 200
assert payload["status"] == "ok"
_assert_has_trace(payload)
def test_ui_served(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, body = request_raw("GET", url=url, path="/")
assert status == 200
assert "
1С Agent" in body
status, js_body = request_raw("GET", url=url, path="/ui/assets/app.js")
assert status == 200
assert "sendTurn" in js_body
def test_state_endpoint(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
request_json("POST", url=url, path="/v1/projects", payload={"name": "Состояние"})
request_json("POST", url=url, path="/v1/projects", payload={"name": "Состояние-2"})
status, listing, _ = request_json("GET", url=url, path="/v1/projects")
project_id = listing["projects"][0]["id"]
status, got_project, _ = request_json("GET", url=url, path=f"/v1/projects/{project_id}")
assert status == 200
assert got_project["project"]["id"] == project_id
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "СтатусДиалог"})
status, state, is_error = request_json("GET", url=url, path="/v1/state")
assert not is_error
assert status == 200
assert state["service"] == "onec-agent"
assert state["state"]["projects"] >= 2
assert state["state"]["chats"] >= 1
assert "started_at" in state
assert "uptime_seconds" in state
assert "default" in state["providers"]["ids"]
_assert_has_trace(state)
def test_openapi_contract_smoke() -> None:
contract = ROOT / "plugins" / "1c" / "agent" / "openapi.yaml"
assert contract.exists()
if yaml is None:
return
with contract.open("r", encoding="utf-8") as stream:
data = yaml.safe_load(stream)
assert data["openapi"].startswith("3.")
paths = set(data.get("paths", {}).keys())
expected = {
"/v1/health",
"/v1/state",
"/v1/projects",
"/v1/projects/{project_id}",
"/v1/projects/{project_id}/chats",
"/v1/projects/{project_id}/chats/{chat_id}",
"/v1/projects/{project_id}/chats/{chat_id}/messages",
"/v1/projects/{project_id}/chats/{chat_id}/runtime",
"/v1/projects/{project_id}/chats/{chat_id}/turn",
"/v1/projects/{project_id}/chats/{chat_id}/onec-tool",
"/v1/models",
"/v1/providers",
}
assert expected.issubset(paths)
def test_project_chat_crud_flow(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, created, _ = request_json(
"POST",
url=url,
path="/v1/projects",
payload={"name": "Проект", "description": "Исходный", "metadata": {"owner": "test"}},
request_id="req-1",
)
assert status == 201
project_id = created["project"]["id"]
_assert_has_trace(created, expected="req-1")
status, listing, _ = request_json("GET", url=url, path="/v1/projects")
assert status == 200
assert [p["id"] for p in listing["projects"]] == [project_id]
status, updated, _ = request_json("PATCH", url=url, path=f"/v1/projects/{project_id}", payload={"description": "Обновленный"})
assert status == 200
assert updated["project"]["description"] == "Обновленный"
status, chat_created, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "Диалог", "temperature": 0.12, "max_tokens": 512},
)
assert status == 201
chat_id = chat_created["chat"]["id"]
status, got_chat, _ = request_json("GET", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}")
assert status == 200
assert got_chat["chat"]["id"] == chat_id
status, messages_before, _ = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages",
)
assert status == 200
assert messages_before["messages"] == []
status, _msg, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages",
payload={"role": "user", "content": "Проверка"},
)
assert status == 201
status, messages, _ = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=10",
)
assert status == 200
assert len(messages["messages"]) == 1
assert messages["messages"][0]["content"] == "Проверка"
status, chat, _ = request_json(
"PATCH",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}",
payload={"title": "Диалог 2", "rag_profile": "official", "temperature": 0.5},
)
assert status == 200
assert chat["chat"]["title"] == "Диалог 2"
assert chat["chat"]["temperature"] == 0.5
status, bad, _is_error = request_json(
"PATCH",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}",
payload={"metadata": []},
)
assert status == 400
assert bad["error"]["code"] == "invalid_argument"
_assert_has_trace(bad)
def test_delete_and_validation_paths(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, created, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "ДляУдаления"})
project_id = created["project"]["id"]
status, chat_created, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "Удаляемый"},
)
chat_id = chat_created["chat"]["id"]
status, bad, is_error = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=abc",
)
assert status == 400
assert is_error
assert bad["error"]["code"] == "invalid_argument"
status, bad_limit, is_error = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=0",
)
assert status == 400
assert is_error
assert bad_limit["error"]["code"] == "invalid_argument"
status, _, _ = request_json("DELETE", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}")
assert status == 204
status, _, is_error = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}",
)
assert status == 404
assert is_error
status, _, _ = request_json("DELETE", url=url, path=f"/v1/projects/{project_id}")
assert status == 204
status, _, is_error = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}",
)
assert status == 404
assert is_error
def test_turn_runtime_question_fastpath(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Проверка runtime вопроса"})
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "Runtime"},
)
assert status == 201
chat_id = chat_data["chat"]["id"]
status, turn_data, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={"message": "Какая модель ии подключена? Какой адаптер и rag?"},
)
assert status == 200
assert not is_error
assert "runtime-конфигурация" in turn_data["assistant_message"]["content"]
assert turn_data["assistant_message"]["payload"]["guardrail"] == {"type": "runtime_direct_answer"}
assert turn_data["assistant_message"]["payload"]["raw"]["reason"] == "runtime_question_fastpath"
_assert_has_trace(turn_data)
def test_turn_persists_full_payload_journal(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Журнал-чата"})
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "Смотрим payload"},
)
assert status == 201
chat_id = chat_data["chat"]["id"]
status, turn_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={"message": "Опиши коротко, как проверить проблему в 1С.", "use_rag": True},
)
assert status == 200
assistant_payload = turn_data["assistant_message"]["payload"]
assert assistant_payload["transport"]["outbound"]["provider_id"]
assert assistant_payload["transport"]["inbound"]["provider_response"]
assert assistant_payload["transport"]["outbound"]["model"]["id"]
user_payload = turn_data["user_message"]["payload"]
assert user_payload["transport"]["outbound"]["runtime"]["history_limit"]
status, all_messages, _ = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/messages?limit=50",
)
assert status == 200
assert len(all_messages["messages"]) == 2
assert "transport" in all_messages["messages"][0]["payload"]
assert "transport" in all_messages["messages"][1]["payload"]
def test_turn_writes_audit_jsonl(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Audit log"}, request_id="req-audit-1")
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "Audit chat"},
request_id="req-audit-2",
)
assert status == 201
chat_id = chat_data["chat"]["id"]
status, turn_data, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={"message": "Опиши коротко, как проверить проблему в 1С.", "use_rag": False},
request_id="req-audit-3",
)
assert status == 200
assert not is_error
assert turn_data["turn_id"]
audit_root = Path(onec_agent_server["audit_root"])
access_files = sorted((audit_root / "access_events").glob("*.jsonl"))
turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl"))
model_files = sorted((audit_root / "model_calls").glob("*.jsonl"))
assert access_files
assert turn_files
assert model_files
access_records = [json.loads(line) for line in access_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
turn_records = [json.loads(line) for line in turn_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
model_records = [json.loads(line) for line in model_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
assert any(record["request_id"] == "req-audit-3" and record["status_code"] == 200 for record in access_records)
assert any(record["turn_id"] == turn_data["turn_id"] and record["outcome"] == "success" for record in turn_records)
assert any(record["turn_id"] == turn_data["turn_id"] and record["served_model_name"] == "stub-model" for record in model_records)
def test_turn_builds_agent_context_with_rag_tools_and_order(
onec_agent_server: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
url = onec_agent_server["url"]
monkeypatch.setattr(
onec_agent,
"build_rag_prompt",
lambda question, profile_name="auto", limit=None: {
"prompt": f"RAG PROMPT profile={profile_name} limit={limit}: {question}",
"profile": profile_name,
"context_count": 1,
"sources": [{"source_path": "stub.md", "title": "Stub", "chunk_index": 0, "score": 1.0}],
},
)
monkeypatch.setattr(
onec_agent,
"call_adapter",
lambda method, params, base_url=None: {"ok": True, "method": method, "params": params},
)
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Контекст turn"})
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "context", "system_prompt": "Chat-only policy", "rag_profile": "official", "rag_limit": 3},
)
assert status == 201
chat_id = chat_data["chat"]["id"]
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}/messages", payload={"role": "user", "content": "first"})
request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats/{chat_id}/messages", payload={"role": "assistant", "content": "second"})
status, turn_data, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={
"message": "Найди реквизит документа",
"adapter_calls": [{"method": "tool.echo", "params": {"value": 7}}],
"use_rag": True,
},
)
assert status == 200
assert not is_error
assert turn_data["rag"]["profile"] == "official"
assert turn_data["tools"][0]["method"] == "tool.echo"
messages = turn_data["assistant_message"]["payload"]["raw"]["messages"]
assert messages[0]["role"] == "system"
assert "Ты помощник по 1С" in messages[0]["content"]
assert "Chat-only policy" in messages[0]["content"]
assert messages[1]["role"] == "system"
assert "tool.echo" in messages[1]["content"]
assert messages[2]["role"] == "system"
assert "RAG PROMPT profile=official limit=3" in messages[2]["content"]
assert [(item["role"], item["content"]) for item in messages[-3:]] == [
("user", "first"),
("assistant", "second"),
("user", "Найди реквизит документа"),
]
audit_root = Path(onec_agent_server["audit_root"])
retrieval_files = sorted((audit_root / "retrieval_events").glob("*.jsonl"))
tool_files = sorted((audit_root / "tool_calls").glob("*.jsonl"))
assert retrieval_files
assert tool_files
retrieval_records = [json.loads(line) for line in retrieval_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
tool_records = [json.loads(line) for line in tool_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
assert any(record["profile"] == "official" and record["plugin"] == "1c" for record in retrieval_records)
assert any(record["tool_name"] == "tool.echo" and record["target_service"] == "1c-adapter" for record in tool_records)
def test_adapter_live_methods_require_base_id(onec_agent_server: dict[str, str]) -> None:
onec_agent.validate_adapter_call("help.methods", {})
onec_agent.validate_adapter_call("metadata.saved_state.forms.search", {"base_id": "upo_test"})
with pytest.raises(ValueError, match="requires params.base_id"):
onec_agent.validate_adapter_call("metadata.saved_state.forms.search", {})
with pytest.raises(ValueError, match="requires params.base_id"):
onec_agent.validate_adapter_call("storage.files.list", {"table": "ConfigSave"})
url = onec_agent_server["url"]
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "base-id guard"})
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "guard"})
assert status == 201
chat_id = chat_data["chat"]["id"]
status, turn_data, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={
"message": "Проверь сохраненное состояние",
"adapter_calls": [{"method": "metadata.saved_state.forms.search", "params": {"table": "ConfigSave"}}],
},
)
assert status == 400
assert is_error
assert turn_data["error"]["code"] == "adapter_error"
assert "requires params.base_id" in turn_data["error"]["message"]
audit_root = Path(onec_agent_server["audit_root"])
turn_files = sorted((audit_root / "turn_audit").glob("*.jsonl"))
assert turn_files
turn_records = [json.loads(line) for line in turn_files[-1].read_text(encoding="utf-8").splitlines() if line.strip()]
assert any(
record["outcome"] == "failure"
and record["failure_type"] == "adapter_error"
and record["error_code"] == "adapter_error"
and "requires params.base_id" in str(record["error_message"])
for record in turn_records
)
def test_get_chat_runtime(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, project_data, _ = request_json(
"POST",
url=url,
path="/v1/projects",
payload={"name": "runtime-check"},
)
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "runtime", "model_id": "stub-model", "provider_id": "default", "temperature": 0.2, "max_tokens": 100},
)
assert status == 201
chat_id = chat_data["chat"]["id"]
status, runtime_data, _ = request_json(
"GET",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/runtime",
)
assert status == 200
assert runtime_data["project_id"] == project_id
assert runtime_data["chat_id"] == chat_id
runtime = runtime_data["runtime"]
assert runtime["provider"]["id"] == "default"
assert runtime["project"]["id"] == project_id
assert runtime["chat"]["id"] == chat_id
assert runtime["model"]["selected_model_id"] == "stub-model"
assert runtime["model"]["chat_model_id"] == "stub-model"
def test_runtime_question_does_not_fire_on_general_1c_question(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, project_data, _ = request_json(
"POST",
url=url,
path="/v1/projects",
payload={"name": "runtime-false-positive-check"},
)
assert status == 201
project_id = project_data["project"]["id"]
status, chat_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "test"},
)
assert status == 201
chat_id = chat_data["chat"]["id"]
status, turn_data, _ = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={"message": "что такое 1С?", "use_rag": False},
)
assert status == 200
assert "runtime-конфигурация" not in str(turn_data["assistant_message"]["content"]).lower()
assert "Stub answer for stub-model via default" in turn_data["assistant_message"]["content"]
def test_validation_paths_return_client_errors(onec_agent_server: dict[str, str]) -> None:
url = onec_agent_server["url"]
status, bad_project, is_error = request_json("POST", url=url, path="/v1/projects", payload={"name": "Bad", "metadata": []})
assert status == 400
assert is_error
assert bad_project["error"]["code"] == "invalid_argument"
status, project_data, _ = request_json("POST", url=url, path="/v1/projects", payload={"name": "Validation"})
assert status == 201
project_id = project_data["project"]["id"]
status, bad_chat, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats",
payload={"title": "bad provider", "provider_id": "missing"},
)
assert status == 400
assert is_error
assert bad_chat["error"]["code"] == "invalid_argument"
status, chat_data, _ = request_json("POST", url=url, path=f"/v1/projects/{project_id}/chats", payload={"title": "ok"})
assert status == 201
chat_id = chat_data["chat"]["id"]
status, bad_turn, is_error = request_json(
"POST",
url=url,
path=f"/v1/projects/{project_id}/chats/{chat_id}/turn",
payload={"message": "Привет", "history_limit": "bad"},
)
assert status == 400
assert is_error
assert bad_turn["error"]["code"] == "invalid_argument"