mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-22 23:23:55 +08:00
679 lines
27 KiB
Python
679 lines
27 KiB
Python
from __future__ import annotations
|
|
|
|
import copy
|
|
import json
|
|
import subprocess
|
|
from collections.abc import Awaitable
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, cast
|
|
from urllib.parse import quote_plus
|
|
|
|
import httpx
|
|
import pytest
|
|
import respx
|
|
from fastapi import Request
|
|
from fastapi.routing import APIRoute
|
|
from pydantic import ValidationError
|
|
from sqlalchemy import text
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app.core.errors import AppError
|
|
from app.core.redis import JavaRedisCodec
|
|
from app.core.responses import error_response
|
|
from app.core.security import AuthUser
|
|
from app.integrations.llm import openai_completion
|
|
from app.integrations.mcp import build_agent_mcp_address, encrypt_agent_token
|
|
from app.integrations.voiceprint import VoicePrintClient, VoicePrintEndpoint, VoicePrintIntegrationError
|
|
from app.repositories.agent import AgentRepository
|
|
from app.routers.agent import router
|
|
from app.routers.agent import update_template as update_template_route
|
|
from app.schemas.agent import (
|
|
AgentChatHistoryReport,
|
|
AgentCreate,
|
|
AgentSnapshotPage,
|
|
AgentSnapshotRestore,
|
|
AgentTemplate,
|
|
AgentUpdate,
|
|
FunctionInfo,
|
|
)
|
|
from app.services.agent import (
|
|
SECRET_PLACEHOLDER,
|
|
AgentService,
|
|
_cached_datetime,
|
|
_changed_snapshot_fields,
|
|
_parse_snapshot_data,
|
|
_preserve_snapshot_sensitive,
|
|
_redact_snapshot_data,
|
|
_snapshot_state_token,
|
|
)
|
|
from tests.domain_support import sqlite_session
|
|
|
|
TARGET_ROOT = Path(__file__).resolve().parents[1]
|
|
REPOSITORY_ROOT = TARGET_ROOT.parents[1]
|
|
JAVA_BIN = REPOSITORY_ROOT / ".runtime" / "jdk" / "bin"
|
|
MCP_VECTOR_PATH = TARGET_ROOT / "tests" / "fixtures" / "agent-mcp-aes-golden.json"
|
|
|
|
EXPECTED_AGENT_ROUTES = {
|
|
("POST", "/agent/chat-history/report"),
|
|
("POST", "/agent/chat-history/getDownloadUrl/{agentId}/{sessionId}"),
|
|
("GET", "/agent/chat-history/download/{uuid}/current"),
|
|
("GET", "/agent/chat-history/download/{uuid}/previous"),
|
|
("GET", "/agent/template/page"),
|
|
("POST", "/agent/template/batch-remove"),
|
|
("GET", "/agent/template/{id}"),
|
|
("POST", "/agent/template"),
|
|
("PUT", "/agent/template"),
|
|
("DELETE", "/agent/template/{id}"),
|
|
("POST", "/agent/voice-print"),
|
|
("PUT", "/agent/voice-print"),
|
|
("DELETE", "/agent/voice-print/{id}"),
|
|
("GET", "/agent/voice-print/list/{id}"),
|
|
("GET", "/agent/mcp/address/{agentId}"),
|
|
("GET", "/agent/mcp/tools/{agentId}"),
|
|
("GET", "/agent/tag/list"),
|
|
("POST", "/agent/tag"),
|
|
("DELETE", "/agent/tag/{id}"),
|
|
("POST", "/agent/audio/{audioId}"),
|
|
("GET", "/agent/play/{uuid}"),
|
|
("PUT", "/agent/saveMemory/{macAddress}"),
|
|
("POST", "/agent/chat-summary/{sessionId}/save"),
|
|
("POST", "/agent/chat-title/{sessionId}/generate"),
|
|
("GET", "/agent/all"),
|
|
("GET", "/agent/list"),
|
|
("GET", "/agent/template"),
|
|
("GET", "/agent/{agentId}/snapshots"),
|
|
("GET", "/agent/{agentId}/snapshots/{snapshotId}"),
|
|
("POST", "/agent/{agentId}/snapshots/{snapshotId}/restore"),
|
|
("DELETE", "/agent/{agentId}/snapshots/{snapshotId}"),
|
|
("GET", "/agent/{id}/sessions"),
|
|
("GET", "/agent/{id}/chat-history/{sessionId}"),
|
|
("GET", "/agent/{id}/chat-history/user"),
|
|
("GET", "/agent/{id}/chat-history/audio"),
|
|
("GET", "/agent/{id}/tags"),
|
|
("PUT", "/agent/{id}/tags"),
|
|
("POST", "/agent"),
|
|
("PUT", "/agent/{id}"),
|
|
("DELETE", "/agent/{id}"),
|
|
("GET", "/agent/{id}"),
|
|
}
|
|
|
|
AGENT_SCHEMA = [
|
|
"""
|
|
CREATE TABLE ai_agent (
|
|
id TEXT PRIMARY KEY, user_id INTEGER, agent_code TEXT, agent_name TEXT, asr_model_id TEXT,
|
|
vad_model_id TEXT, llm_model_id TEXT, slm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT,
|
|
tts_voice_id TEXT, tts_language TEXT, tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER,
|
|
mem_model_id TEXT, intent_model_id TEXT, chat_history_conf INTEGER, system_prompt TEXT,
|
|
summary_memory TEXT, lang_code TEXT, language TEXT, sort INTEGER, creator INTEGER, created_at DATETIME,
|
|
updater INTEGER, updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_template (
|
|
id TEXT PRIMARY KEY, agent_code TEXT, agent_name TEXT, asr_model_id TEXT, vad_model_id TEXT,
|
|
llm_model_id TEXT, vllm_model_id TEXT, tts_model_id TEXT, tts_voice_id TEXT, tts_language TEXT,
|
|
tts_volume INTEGER, tts_rate INTEGER, tts_pitch INTEGER, mem_model_id TEXT, intent_model_id TEXT,
|
|
chat_history_conf INTEGER, system_prompt TEXT, summary_memory TEXT, lang_code TEXT, language TEXT,
|
|
sort INTEGER, creator INTEGER, created_at DATETIME, updater INTEGER, updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_model_config (
|
|
id TEXT PRIMARY KEY, model_type TEXT, model_code TEXT, model_name TEXT, is_default INTEGER,
|
|
is_enabled INTEGER, config_json TEXT, sort INTEGER
|
|
)
|
|
""",
|
|
"CREATE TABLE ai_model_provider (id TEXT PRIMARY KEY, provider_code TEXT, fields TEXT)",
|
|
"CREATE TABLE ai_tts_voice (id TEXT PRIMARY KEY, tts_model_id TEXT, tts_voice TEXT, name TEXT, languages TEXT)",
|
|
"CREATE TABLE ai_voice_clone (id TEXT PRIMARY KEY, name TEXT, languages TEXT)",
|
|
"""
|
|
CREATE TABLE ai_agent_plugin_mapping (
|
|
id INTEGER PRIMARY KEY, agent_id TEXT, plugin_id TEXT, param_info TEXT, UNIQUE(agent_id, plugin_id)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_context_provider (
|
|
id TEXT PRIMARY KEY, agent_id TEXT, context_providers TEXT, creator INTEGER, created_at DATETIME,
|
|
updater INTEGER, updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_correct_word_mapping (
|
|
id TEXT PRIMARY KEY, agent_id TEXT, file_id TEXT, creator INTEGER, created_at DATETIME,
|
|
updater INTEGER, updated_at DATETIME, UNIQUE(agent_id, file_id)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_tag (
|
|
id TEXT PRIMARY KEY, tag_name TEXT, sort INTEGER, deleted INTEGER, creator INTEGER,
|
|
created_at DATETIME, updater INTEGER, updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_tag_relation (
|
|
id TEXT PRIMARY KEY, agent_id TEXT, tag_id TEXT, sort INTEGER, creator INTEGER,
|
|
created_at DATETIME, updater INTEGER, updated_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_snapshot (
|
|
id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL,
|
|
snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT,
|
|
restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER,
|
|
UNIQUE(agent_id, version_no)
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_device (
|
|
id TEXT PRIMARY KEY, user_id INTEGER, mac_address TEXT, agent_id TEXT, last_connected_at DATETIME
|
|
)
|
|
""",
|
|
"CREATE TABLE ai_device_address_book (mac_address TEXT, target_mac TEXT)",
|
|
"""
|
|
CREATE TABLE ai_agent_chat_history (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT, mac_address TEXT, agent_id TEXT, session_id TEXT,
|
|
chat_type INTEGER, content TEXT, audio_id TEXT, created_at DATETIME
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE ai_agent_chat_title (
|
|
id TEXT PRIMARY KEY, session_id TEXT, title TEXT, created_at DATETIME, updated_at DATETIME
|
|
)
|
|
""",
|
|
"CREATE TABLE ai_agent_chat_audio (id TEXT PRIMARY KEY, audio BLOB)",
|
|
]
|
|
|
|
|
|
def normal_user(user_id: int = 7) -> AuthUser:
|
|
return AuthUser(
|
|
id=user_id,
|
|
username="agent-user",
|
|
super_admin=0,
|
|
status=1,
|
|
token="test-token", # noqa: S106 - isolated authentication fixture
|
|
row={"id": user_id},
|
|
)
|
|
|
|
|
|
async def java_error_payload(awaitable: Awaitable[Any]) -> dict[str, Any]:
|
|
with pytest.raises(AppError) as captured:
|
|
await awaitable
|
|
request = Request({"type": "http", "method": "GET", "path": "/agent/test", "headers": []})
|
|
payload = json.loads(bytes(error_response(request, captured.value.code, captured.value.message).body))
|
|
assert isinstance(payload, dict)
|
|
return cast(dict[str, Any], payload)
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def mcp_vectors() -> list[dict[str, str]]:
|
|
value = json.loads(MCP_VECTOR_PATH.read_text(encoding="utf-8"))
|
|
assert isinstance(value, list)
|
|
return value
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def java_mcp_classpath(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|
classes = tmp_path_factory.mktemp("agent-mcp-java")
|
|
subprocess.run( # noqa: S603
|
|
[
|
|
str(JAVA_BIN / "javac"),
|
|
"-d",
|
|
str(classes),
|
|
str(
|
|
REPOSITORY_ROOT
|
|
/ "main"
|
|
/ "manager-api"
|
|
/ "src"
|
|
/ "main"
|
|
/ "java"
|
|
/ "xiaozhi"
|
|
/ "common"
|
|
/ "utils"
|
|
/ "AESUtils.java"
|
|
),
|
|
str(TARGET_ROOT / "tests" / "java" / "AgentMcpCryptoInteropCli.java"),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return classes
|
|
|
|
|
|
def test_agent_router_matches_all_six_java_controllers() -> None:
|
|
routes = [cast(APIRoute, route) for route in router.routes]
|
|
actual = {
|
|
(method, route.path)
|
|
for route in routes
|
|
for method in sorted(getattr(route, "methods", set()) or set())
|
|
}
|
|
assert actual == EXPECTED_AGENT_ROUTES
|
|
assert len(actual) == 41
|
|
|
|
paths = [route.path for route in routes]
|
|
assert paths.index("/agent/template/page") < paths.index("/agent/template/{id}")
|
|
assert paths.index("/agent/list") < paths.index("/agent/{id}")
|
|
assert paths.index("/agent/{agentId}/snapshots") < paths.index("/agent/{id}")
|
|
assert paths.index("/agent/{id}/chat-history/user") < paths.index("/agent/{id}/chat-history/{sessionId}")
|
|
assert paths.index("/agent/{id}/chat-history/audio") < paths.index("/agent/{id}/chat-history/{sessionId}")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("model", "payload", "message"),
|
|
[
|
|
(AgentCreate, {"agentName": " "}, "智能体名称不能为空"),
|
|
(
|
|
AgentChatHistoryReport,
|
|
{"macAddress": " ", "sessionId": "session", "chatType": 1, "content": "content"},
|
|
"不能为空",
|
|
),
|
|
(
|
|
AgentChatHistoryReport,
|
|
{"macAddress": "mac", "sessionId": "session", "chatType": None, "content": "content"},
|
|
"不能为空",
|
|
),
|
|
(AgentSnapshotRestore, {"currentStateToken": ""}, "不能为空"),
|
|
],
|
|
)
|
|
def test_agent_not_blank_constraints_keep_java_messages(
|
|
model: type[Any], payload: dict[str, Any], message: str
|
|
) -> None:
|
|
with pytest.raises(ValidationError) as captured:
|
|
model.model_validate(payload)
|
|
assert captured.value.errors()[0]["msg"] == message
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_missing_record_permission_and_validation_order_match_java() -> None:
|
|
missing = "contract-missing-agent"
|
|
snapshot_error = {"code": 500, "msg": "没有权限访问该智能体快照", "data": None}
|
|
permission_error = {"code": 10169, "msg": "您没有权限操作该记录", "data": None}
|
|
|
|
async with sqlite_session(AGENT_SCHEMA) as session:
|
|
service = AgentService(session, normal_user())
|
|
|
|
assert await java_error_payload(service.snapshot_page(missing, AgentSnapshotPage())) == snapshot_error
|
|
assert await java_error_payload(service.snapshot_detail(missing, "missing-snapshot")) == snapshot_error
|
|
assert await java_error_payload(
|
|
service.restore_snapshot(missing, "missing-snapshot", "valid-state-token")
|
|
) == snapshot_error
|
|
assert await java_error_payload(service.delete_snapshot(missing, "missing-snapshot")) == snapshot_error
|
|
|
|
assert await java_error_payload(service.sessions(missing, None, None)) == permission_error
|
|
assert await java_error_payload(service.audio_content("missing-audio")) == permission_error
|
|
assert await java_error_payload(service.agent_tags(missing)) == permission_error
|
|
assert await java_error_payload(service.save_agent_tags(missing, None, None)) == permission_error
|
|
|
|
await session.execute(
|
|
text("INSERT INTO ai_agent (id,user_id,agent_name) VALUES ('owned-agent',7,'Owned')")
|
|
)
|
|
await session.commit()
|
|
assert await java_error_payload(service.sessions("owned-agent", None, None)) == {
|
|
"code": 500,
|
|
"msg": "服务器内部异常",
|
|
"data": None,
|
|
}
|
|
assert await java_error_payload(service.sessions("owned-agent", "bad", "10")) == {
|
|
"code": 500,
|
|
"msg": "服务器内部异常",
|
|
"data": None,
|
|
}
|
|
assert await service.sessions("owned-agent", "1", "10") == {"list": [], "total": 0}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_template_update_without_id_keeps_java_generic_error() -> None:
|
|
request = Request({"type": "http", "method": "PUT", "path": "/agent/template", "headers": []})
|
|
async with sqlite_session(AGENT_SCHEMA) as session:
|
|
response = await update_template_route(AgentTemplate(), request, session, normal_user())
|
|
assert json.loads(bytes(response.body)) == {"code": 500, "msg": "服务器内部异常", "data": None}
|
|
|
|
|
|
def test_mcp_tokens_match_static_and_live_java_golden_vectors(
|
|
mcp_vectors: list[dict[str, str]], java_mcp_classpath: Path
|
|
) -> None:
|
|
for vector in mcp_vectors:
|
|
python_token = encrypt_agent_token(vector["agentId"], vector["key"])
|
|
java_token = subprocess.run( # noqa: S603
|
|
[
|
|
str(JAVA_BIN / "java"),
|
|
"-cp",
|
|
str(java_mcp_classpath),
|
|
"AgentMcpCryptoInteropCli",
|
|
vector["agentId"],
|
|
vector["key"],
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout
|
|
assert python_token == vector["token"]
|
|
assert java_token == vector["token"]
|
|
|
|
|
|
def test_mcp_address_preserves_java_path_scheme_and_form_encoding(mcp_vectors: list[dict[str, str]]) -> None:
|
|
vector = mcp_vectors[0]
|
|
endpoint = f"https://broker.example/xiaozhi/v1/?key={vector['key']}"
|
|
assert build_agent_mcp_address(endpoint, vector["agentId"]) == (
|
|
f"wss://broker.example/xiaozhi/v1/mcp/?token={quote_plus(vector['token'], safe='')}"
|
|
)
|
|
assert build_agent_mcp_address("null", vector["agentId"]) is None
|
|
|
|
|
|
def _snapshot_fixture() -> dict[str, Any]:
|
|
return {
|
|
"agentCode": "AGT_1",
|
|
"agentName": "agent",
|
|
"summaryMemory": None,
|
|
"functions": [
|
|
{
|
|
"pluginId": "plugin-a",
|
|
"paramInfo": {
|
|
"apiKey": "function-secret",
|
|
"safe": "visible",
|
|
"nested": {"password": "nested-secret"},
|
|
"webhookUrl": "https://hooks.slack.com/services/T/B/slack-secret?key=query-secret&safe=1",
|
|
},
|
|
}
|
|
],
|
|
"contextProviders": [
|
|
{
|
|
"url": "https://user:pass@example.com/context?api_key=url-secret&safe=1#token=fragment-secret",
|
|
"headers": {"Authorization": "Bearer header-secret", "X-Safe": "visible"},
|
|
}
|
|
],
|
|
"correctWordFileIds": ["file-b", "file-a"],
|
|
"tagNames": ["tag-b", "tag-a"],
|
|
"tags": [{"id": "tag-id", "tagName": "tag-a", "sort": 0}],
|
|
}
|
|
|
|
|
|
def test_snapshot_redaction_is_secret_free_and_reversible() -> None:
|
|
current = _parse_snapshot_data(_snapshot_fixture())
|
|
assert current is not None
|
|
redacted = _redact_snapshot_data(current)
|
|
assert redacted is not None
|
|
wire = json.dumps(redacted, ensure_ascii=False)
|
|
for secret in (
|
|
"function-secret",
|
|
"nested-secret",
|
|
"slack-secret",
|
|
"query-secret",
|
|
"user:pass",
|
|
"url-secret",
|
|
"fragment-secret",
|
|
"header-secret",
|
|
):
|
|
assert secret not in wire
|
|
assert wire.count(SECRET_PLACEHOLDER) >= 8
|
|
assert "safe=1" in wire
|
|
assert "visible" in wire
|
|
|
|
restored = _preserve_snapshot_sensitive(redacted, current)
|
|
assert restored == current
|
|
|
|
|
|
def test_snapshot_state_token_and_change_detection_match_java_normalization() -> None:
|
|
first = _parse_snapshot_data(_snapshot_fixture())
|
|
assert first is not None
|
|
second = copy.deepcopy(first)
|
|
second["functions"][0]["paramInfo"]["apiKey"] = "rotated-function-secret"
|
|
second["contextProviders"][0]["headers"]["Authorization"] = "Bearer rotated-header-secret"
|
|
second["correctWordFileIds"].reverse()
|
|
second["tagNames"].reverse()
|
|
second["summaryMemory"] = " "
|
|
assert _snapshot_state_token(first) == _snapshot_state_token(second)
|
|
assert _changed_snapshot_fields(first, second) == []
|
|
|
|
second["agentName"] = "changed"
|
|
assert _changed_snapshot_fields(first, second) == ["agentName"]
|
|
|
|
|
|
def test_snapshot_url_redaction_keeps_public_parameters_and_rejects_cross_origin_restore() -> None:
|
|
current = _parse_snapshot_data(_snapshot_fixture())
|
|
assert current is not None
|
|
redacted = _redact_snapshot_data(current)
|
|
assert redacted is not None
|
|
provider = redacted["contextProviders"][0]
|
|
assert provider["url"] == (
|
|
f"https://{SECRET_PLACEHOLDER}@example.com/context?api_key={SECRET_PLACEHOLDER}&safe=1"
|
|
f"#token={SECRET_PLACEHOLDER}"
|
|
)
|
|
|
|
provider["url"] = provider["url"].replace("example.com", "attacker.example")
|
|
with pytest.raises(Exception, match="URL 标识"):
|
|
_preserve_snapshot_sensitive(redacted, current)
|
|
|
|
|
|
def test_function_info_accepts_java_string_or_map_param_info() -> None:
|
|
from_string = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": '{"answer":42}'})
|
|
from_map = FunctionInfo.model_validate({"pluginId": "p", "paramInfo": {"answer": 42}})
|
|
assert from_string.param_info == from_map.param_info == {"answer": 42}
|
|
|
|
|
|
def test_java_date_redis_wire_round_trip() -> None:
|
|
value = datetime(2026, 7, 20, 12, 34, 56, 789000)
|
|
encoded = JavaRedisCodec.encode(value)
|
|
assert json.loads(encoded) == ["java.util.Date", 1784522096789]
|
|
assert _cached_datetime(JavaRedisCodec.decode(encoded)) == value
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot_repository_allocates_monotonic_versions_and_prunes() -> None:
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
async with engine.begin() as connection:
|
|
await connection.execute(
|
|
text(
|
|
"CREATE TABLE ai_agent_snapshot ("
|
|
"id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, user_id INTEGER, version_no INTEGER NOT NULL,"
|
|
"snapshot_data TEXT NOT NULL, changed_fields TEXT, source TEXT, restore_from_snapshot_id TEXT,"
|
|
"restore_from_version_no INTEGER, creator INTEGER, created_at DATETIME, redaction_version INTEGER,"
|
|
"UNIQUE(agent_id, version_no))"
|
|
)
|
|
)
|
|
async with factory() as session:
|
|
repository = AgentRepository(session)
|
|
for index in range(1, 5):
|
|
assert (
|
|
await repository.insert_snapshot_next_version(
|
|
{
|
|
"id": f"snapshot-{index}",
|
|
"agent_id": "agent",
|
|
"user_id": 1,
|
|
"snapshot_data": "{}",
|
|
"changed_fields": "[]",
|
|
"source": "config",
|
|
"restore_from_snapshot_id": None,
|
|
"restore_from_version_no": None,
|
|
"creator": 1,
|
|
"created_at": datetime(2026, 7, 20, 12, index),
|
|
"redaction_version": 2,
|
|
}
|
|
)
|
|
== 1
|
|
)
|
|
assert await repository.snapshot_max_version("agent") == 4
|
|
assert await repository.prune_snapshots("agent", 2) == 2
|
|
rows = await repository.fetch_all(
|
|
"SELECT id,version_no FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no", {"id": "agent"}
|
|
)
|
|
assert rows == [{"id": "snapshot-3", "version_no": 3}, {"id": "snapshot-4", "version_no": 4}]
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_agent_create_update_restore_rollback_and_delete_are_transactional() -> None:
|
|
async with sqlite_session(AGENT_SCHEMA) as session:
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO ai_agent_template "
|
|
"(id,agent_name,llm_model_id,mem_model_id,system_prompt,summary_memory,lang_code,language,sort) "
|
|
"VALUES ('template','默认模板','llm-default','Memory_local_short','默认提示词','初始记忆',"
|
|
"'zh_CN','中文',1)"
|
|
)
|
|
)
|
|
await session.execute(
|
|
text(
|
|
"INSERT INTO ai_model_config "
|
|
"(id,model_type,model_name,is_default,is_enabled,config_json,sort) "
|
|
"VALUES ('llm-default','LLM','默认模型',1,1,:config,1)"
|
|
),
|
|
{
|
|
"config": json.dumps(
|
|
{
|
|
"type": "openai",
|
|
"base_url": "https://llm.invalid/v1",
|
|
"api_key": "mock-only",
|
|
"model_name": "mock",
|
|
}
|
|
)
|
|
},
|
|
)
|
|
for provider_id in ("SYSTEM_PLUGIN_MUSIC", "SYSTEM_PLUGIN_WEATHER", "SYSTEM_PLUGIN_NEWS_NEWSNOW"):
|
|
await session.execute(
|
|
text("INSERT INTO ai_model_provider (id,provider_code,fields) VALUES (:id,:id,:fields)"),
|
|
{
|
|
"id": provider_id,
|
|
"fields": json.dumps([{"key": "region", "default": "cn"}]),
|
|
},
|
|
)
|
|
await session.commit()
|
|
|
|
service = AgentService(session, normal_user())
|
|
agent_id = await service.create_agent(AgentCreate(agent_name="测试智能体"))
|
|
detail = await service.agent_detail(agent_id)
|
|
assert detail["agent_name"] == "测试智能体"
|
|
assert detail["chat_history_conf"] == 2
|
|
assert detail["current_version_no"] == 1
|
|
assert {item["plugin_id"] for item in detail["functions"]} == {
|
|
"SYSTEM_PLUGIN_MUSIC",
|
|
"SYSTEM_PLUGIN_WEATHER",
|
|
"SYSTEM_PLUGIN_NEWS_NEWSNOW",
|
|
}
|
|
|
|
await service.update_agent(
|
|
agent_id,
|
|
AgentUpdate.model_validate(
|
|
{
|
|
"agentName": "更新后的智能体",
|
|
"functions": [{"pluginId": "SYSTEM_PLUGIN_WEATHER", "paramInfo": {"region": "shanghai"}}],
|
|
"contextProviders": [{"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}}],
|
|
"correctWordFileIds": ["words-1"],
|
|
"tagNames": ["家庭"],
|
|
}
|
|
),
|
|
)
|
|
updated = await service.agent_detail(agent_id)
|
|
assert updated["agent_name"] == "更新后的智能体"
|
|
assert [item["plugin_id"] for item in updated["functions"]] == ["SYSTEM_PLUGIN_WEATHER"]
|
|
assert updated["context_providers"] == [
|
|
{"url": "https://context.example/v1", "headers": {"X-Safe": "yes"}}
|
|
]
|
|
assert updated["correct_word_file_ids"] == ["words-1"]
|
|
assert updated["current_version_no"] == 2
|
|
assigned_tags = await service.agent_tags(agent_id)
|
|
assert len(assigned_tags) == 1
|
|
assert assigned_tags[0]["tagName"] == "家庭"
|
|
|
|
snapshot_rows = await service.repo.fetch_all(
|
|
"SELECT id,version_no,source FROM ai_agent_snapshot WHERE agent_id=:id ORDER BY version_no",
|
|
{"id": agent_id},
|
|
)
|
|
assert [(row["version_no"], row["source"]) for row in snapshot_rows] == [(1, "initial"), (2, "config")]
|
|
|
|
with pytest.raises(AppError):
|
|
await service.update_agent(
|
|
agent_id,
|
|
AgentUpdate.model_validate(
|
|
{"agentName": "不应提交", "llmModelId": "missing-model", "functions": []}
|
|
),
|
|
)
|
|
after_rollback = await service.agent_detail(agent_id)
|
|
assert after_rollback["agent_name"] == "更新后的智能体"
|
|
assert [item["plugin_id"] for item in after_rollback["functions"]] == ["SYSTEM_PLUGIN_WEATHER"]
|
|
assert after_rollback["current_version_no"] == 2
|
|
|
|
first_snapshot = snapshot_rows[0]
|
|
preview = await service.snapshot_detail(agent_id, str(first_snapshot["id"]))
|
|
await service.restore_snapshot(agent_id, str(first_snapshot["id"]), str(preview["currentStateToken"]))
|
|
restored = await service.agent_detail(agent_id)
|
|
assert restored["agent_name"] == "测试智能体"
|
|
assert restored["current_version_no"] == 3
|
|
assert {item["plugin_id"] for item in restored["functions"]} == {
|
|
"SYSTEM_PLUGIN_MUSIC",
|
|
"SYSTEM_PLUGIN_WEATHER",
|
|
"SYSTEM_PLUGIN_NEWS_NEWSNOW",
|
|
}
|
|
assert await service.agent_tags(agent_id) == []
|
|
assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1
|
|
|
|
await service.delete_agent(agent_id)
|
|
assert await service.repo.get_agent(agent_id) is None
|
|
assert await service.repo.scalar(
|
|
"SELECT COUNT(*) FROM ai_agent_snapshot WHERE agent_id=:id", {"id": agent_id}
|
|
) == 0
|
|
assert await service.repo.scalar("SELECT COUNT(*) FROM ai_agent_tag") == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_voiceprint_mock_validates_java_multipart_and_error_mapping() -> None:
|
|
requests: list[httpx.Request] = []
|
|
|
|
async def handler(request: httpx.Request) -> httpx.Response:
|
|
requests.append(request)
|
|
body = await request.aread()
|
|
if request.url.path == "/voiceprint/identify":
|
|
assert b'name="speaker_ids"' in body
|
|
assert b"speaker-a,speaker-b" in body
|
|
assert b'filename="VoicePrint.WAV"' in body
|
|
return httpx.Response(200, json={"speaker_id": "speaker-a", "score": 0.75})
|
|
if request.url.path == "/voiceprint/register":
|
|
assert b'name="speaker_id"' in body
|
|
return httpx.Response(200, text="true")
|
|
return httpx.Response(503, text="unavailable")
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
|
voiceprint = VoicePrintClient("http://voiceprint.example/service?key=test-secret", client=client)
|
|
assert await voiceprint.identify(["speaker-a", "speaker-b"], b"audio") == ("speaker-a", 0.75)
|
|
await voiceprint.register("speaker-a", b"audio")
|
|
with pytest.raises(VoicePrintIntegrationError) as error:
|
|
await voiceprint.cancel("speaker-a")
|
|
assert error.value.code == 10089
|
|
|
|
assert all(request.headers["Authorization"] == "Bearer test-secret" for request in requests)
|
|
assert VoicePrintEndpoint.parse("https://voiceprint.example:9443/path?key=secret") == VoicePrintEndpoint(
|
|
"https://voiceprint.example:9443", "Bearer secret"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@respx.mock
|
|
async def test_llm_mock_validates_openai_request_and_thinking_policy() -> None:
|
|
route = respx.post("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions").mock(
|
|
return_value=httpx.Response(200, json={"choices": [{"message": {"content": "summary"}}]})
|
|
)
|
|
result = await openai_completion(
|
|
{
|
|
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
|
"api_key": "mock-key",
|
|
"model_name": "mock-model",
|
|
},
|
|
"prompt",
|
|
temperature=0.2,
|
|
max_tokens=2000,
|
|
timeout=1,
|
|
)
|
|
assert result == "summary"
|
|
request = route.calls.last.request
|
|
payload = json.loads(request.content)
|
|
assert payload == {
|
|
"model": "mock-model",
|
|
"messages": [{"role": "user", "content": "prompt"}],
|
|
"temperature": 0.2,
|
|
"max_tokens": 2000,
|
|
"enable_thinking": False,
|
|
}
|
|
assert request.headers["Authorization"] == "Bearer mock-key"
|