mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-14 18:24:42 +08:00
160 lines
5.2 KiB
Python
160 lines
5.2 KiB
Python
"""Agent 本地工具目录身份、冲突与版本窗口测试。"""
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from pydantic import BaseModel
|
|
|
|
from app.agent.tools.catalog import (
|
|
ToolCatalogSnapshot,
|
|
ToolIdentityAmbiguousError,
|
|
)
|
|
from app.agent.tools.factory import MoviePilotToolFactory
|
|
from app.core.plugin import PluginManager
|
|
|
|
|
|
class _Arguments(BaseModel):
|
|
"""目录测试工具的参数契约。"""
|
|
|
|
query: str = ""
|
|
|
|
|
|
def _tool(name: str, source: str = "builtin") -> SimpleNamespace:
|
|
"""构造带稳定 schema 与来源的最小工具替身。"""
|
|
return SimpleNamespace(
|
|
name=name,
|
|
args_schema=_Arguments,
|
|
_agent_tool_source=source,
|
|
)
|
|
|
|
|
|
def test_catalog_preserves_order_and_resolves_exact_instance() -> None:
|
|
"""目录应保持构造顺序,并返回同一次构造的精确实例。"""
|
|
first = _tool("first")
|
|
second = _tool("second", "plugin:demo")
|
|
|
|
catalog = ToolCatalogSnapshot.from_tools(
|
|
[first, second],
|
|
plugin_revision=7,
|
|
factory_revision="factory-v1",
|
|
)
|
|
|
|
assert catalog.tools == [first, second]
|
|
assert catalog.resolve_unique("second").tool is second
|
|
assert catalog.resolve_unique("missing") is None
|
|
assert catalog.signature[0:2] == ("factory-v1", 7)
|
|
|
|
|
|
def test_catalog_records_all_duplicate_names_and_strict_lookup_fails() -> None:
|
|
"""内置与插件同名时必须保留双方身份并拒绝隐式选胜者。"""
|
|
builtin = _tool("query_system_settings")
|
|
plugin = _tool("query_system_settings", "plugin:demo")
|
|
catalog = ToolCatalogSnapshot.from_tools(
|
|
[builtin, plugin],
|
|
plugin_revision=3,
|
|
factory_revision="factory-v1",
|
|
)
|
|
|
|
assert [entry.tool for entry in catalog.collisions["query_system_settings"]] == [
|
|
builtin,
|
|
plugin,
|
|
]
|
|
with pytest.raises(
|
|
ToolIdentityAmbiguousError,
|
|
match="TOOL_IDENTITY_AMBIGUOUS",
|
|
):
|
|
catalog.resolve_unique("query_system_settings")
|
|
|
|
|
|
def test_catalog_subset_keeps_all_identities_for_selected_name() -> None:
|
|
"""子图执行只选首个实例时,严格目录仍必须保留全部同名身份。"""
|
|
first = _tool("shared", "mcp:one")
|
|
second = _tool("shared", "mcp:two")
|
|
catalog = ToolCatalogSnapshot.from_tools(
|
|
[first, second],
|
|
plugin_revision=0,
|
|
factory_revision="factory-v1",
|
|
)
|
|
|
|
selected = catalog.select([first])
|
|
|
|
assert [entry.tool for entry in selected.collisions["shared"]] == [first, second]
|
|
|
|
|
|
def test_catalog_signature_changes_with_schema_and_plugin_revision() -> None:
|
|
"""schema 或插件目录 revision 变化必须使图缓存签名失效。"""
|
|
tool = _tool("demo")
|
|
first = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
|
|
class _UpdatedArguments(BaseModel):
|
|
"""模拟热加载后的参数契约。"""
|
|
|
|
query: str = ""
|
|
limit: int = 10
|
|
|
|
tool.args_schema = _UpdatedArguments
|
|
schema_changed = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
revision_changed = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=2, factory_revision="factory-v1"
|
|
)
|
|
|
|
assert schema_changed.signature != first.signature
|
|
assert revision_changed.signature != schema_changed.signature
|
|
|
|
|
|
def test_catalog_signature_changes_with_json_schema_mapping() -> None:
|
|
"""MCP 使用的 dict JSON Schema 变化必须使目录签名失效。"""
|
|
tool = _tool("mcp_demo", "mcp:demo")
|
|
tool.args_schema = {"type": "object", "properties": {"query": {"type": "string"}}}
|
|
first = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
tool.args_schema = {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string"},
|
|
"limit": {"type": "integer"},
|
|
},
|
|
}
|
|
second = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
|
|
assert second.signature != first.signature
|
|
|
|
|
|
def test_catalog_signature_changes_with_tool_description() -> None:
|
|
"""影响模型选择的工具描述变化必须使目录签名失效。"""
|
|
tool = _tool("demo")
|
|
tool.description = "first description"
|
|
first = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
tool.description = "updated description"
|
|
second = ToolCatalogSnapshot.from_tools(
|
|
[tool], plugin_revision=1, factory_revision="factory-v1"
|
|
)
|
|
|
|
assert second.signature != first.signature
|
|
|
|
|
|
def test_factory_catalog_retries_plugin_revision_churn_with_bound() -> None:
|
|
"""插件构造期间持续 reload 时只能有界重试并失败关闭。"""
|
|
revisions = iter([1, 2, 3, 4, 5, 6])
|
|
plugin_manager = PluginManager()
|
|
with patch.object(
|
|
plugin_manager,
|
|
"get_plugin_agent_tools_revision",
|
|
side_effect=lambda: next(revisions),
|
|
), patch.object(MoviePilotToolFactory, "create_tools", return_value=[]):
|
|
with pytest.raises(RuntimeError, match="持续变化"):
|
|
MoviePilotToolFactory.create_catalog(
|
|
session_id="session",
|
|
user_id="user",
|
|
)
|