763 lines
28 KiB
Python
763 lines
28 KiB
Python
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 "<title>1С Agent</title>" 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 "forbidden technical fields: table" 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 "forbidden technical fields: table" in str(record["error_message"])
|
||
for record in turn_records
|
||
)
|
||
|
||
|
||
def test_agent_adapter_policy_uses_effective_view_and_blocks_storage_selectors(
|
||
monkeypatch: pytest.MonkeyPatch,
|
||
) -> None:
|
||
prepared = onec_agent.prepare_agent_adapter_call(
|
||
"metadata.object.full",
|
||
{"base_id": "upo_test", "name": "Отчет.Продажи"},
|
||
)
|
||
assert prepared["configuration_view"] == "effective_working"
|
||
assert prepared["source_state"] == "working"
|
||
|
||
with pytest.raises(ValueError, match="forbidden technical fields: table"):
|
||
onec_agent.prepare_agent_adapter_call(
|
||
"metadata.saved_state.forms.search",
|
||
{"base_id": "upo_test", "table": "ConfigSave"},
|
||
)
|
||
|
||
with pytest.raises(ValueError, match="forbidden technical fields: module_ref"):
|
||
onec_agent.prepare_agent_adapter_call(
|
||
"code.read",
|
||
{"base_id": "upo_test", "selector": {"module_ref": "private"}},
|
||
)
|
||
|
||
monkeypatch.setenv("ONEC_AGENT_ALLOW_DIAGNOSTIC", "true")
|
||
assert onec_agent.prepare_agent_adapter_call(
|
||
"metadata.saved_state.forms.search",
|
||
{"base_id": "upo_test", "table": "ConfigSave"},
|
||
)["table"] == "ConfigSave"
|
||
|
||
|
||
def test_agent_calls_adapter_through_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
requests: list[tuple[dict[str, Any], dict[str, str], str]] = []
|
||
|
||
class FakeResponse:
|
||
def __init__(self, body: dict[str, Any], headers: dict[str, str] | None = None) -> None:
|
||
self._body = json.dumps(body).encode("utf-8")
|
||
self.headers = headers or {}
|
||
|
||
def read(self) -> bytes:
|
||
return self._body
|
||
|
||
def __enter__(self):
|
||
return self
|
||
|
||
def __exit__(self, *_args: object) -> None:
|
||
return None
|
||
|
||
def fake_urlopen(request: Request, timeout: float):
|
||
body = json.loads(request.data.decode("utf-8"))
|
||
requests.append((body, dict(request.headers), request.full_url))
|
||
if body["method"] == "initialize":
|
||
return FakeResponse({"jsonrpc": "2.0", "id": body["id"], "result": {}}, {"Mcp-Session-Id": "test-session"})
|
||
return FakeResponse(
|
||
{
|
||
"jsonrpc": "2.0",
|
||
"id": body["id"],
|
||
"result": {"content": [{"type": "text", "text": json.dumps({"status": "ok"})}]},
|
||
}
|
||
)
|
||
|
||
monkeypatch.setattr(onec_agent, "urlopen", fake_urlopen)
|
||
result = onec_agent.call_adapter("metadata.object.forms", {"base_id": "upo_test", "ref": "Report.Тест"})
|
||
|
||
assert result == {"status": "ok"}
|
||
assert len(requests) == 2
|
||
assert requests[0][0]["method"] == "initialize"
|
||
assert requests[1][0]["method"] == "tools/call"
|
||
assert requests[1][0]["params"]["name"] == "onec_request"
|
||
assert requests[1][0]["params"]["arguments"]["payload"]["source_state"] == "working"
|
||
assert requests[1][2] == "http://docker.cin.su:8021/mcp"
|
||
|
||
|
||
def test_agent_system_prompt_requires_mcp_name_first_navigation() -> None:
|
||
prompt = onec_agent.DEFAULT_SYSTEM_PROMPT_PATH.read_text(encoding="utf-8")
|
||
assert "только инструментом MCP `onec_request`" in prompt
|
||
assert "source_state=working" in prompt
|
||
assert "Не проси и не подставляй GUID либо `module_ref`" in prompt
|
||
|
||
|
||
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"
|
||
|