mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/jxxghp/MoviePilot.git
synced 2026-09-20 08:03:34 +08:00
494 lines
24 KiB
Python
494 lines
24 KiB
Python
"""真实 Agent 图与 SQLite 回执联合验证写工具防重和未知结果核验。"""
|
||
|
||
import asyncio
|
||
import json
|
||
from typing import Any
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
from langchain.agents import create_agent
|
||
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||
from langchain_core.tools import StructuredTool
|
||
from langgraph.checkpoint.memory import InMemorySaver
|
||
from pydantic import Field
|
||
from sqlalchemy import create_engine, select
|
||
from sqlalchemy.orm import sessionmaker
|
||
|
||
from app.agent.mcp import AgentMcpToolSpec
|
||
from app.agent.middleware.invocation import GET_TOOL_EXECUTION_NAME, InvocationMiddleware
|
||
from app.agent.middleware.policy import AgentPolicyMiddleware
|
||
from app.agent.policy.contracts import AuthSource, PrincipalType, ToolOrigin, ToolPolicyContext
|
||
from app.agent.tools.base import ToolExecutionTimeoutError
|
||
from app.agent.tools.impl.api import MoviePilotApiTool
|
||
from app.agent.tools.impl.mcp import McpExternalTool, create_external_mcp_tools
|
||
from app.application.invocation import InvocationIdentity
|
||
from app.db.adapters.invocation import TransactionalInvocationRepository
|
||
from app.db.models.agentinvocation import AgentInvocation
|
||
from app.schemas.agent import AgentMcpServerConfig
|
||
|
||
WRITE_ARGUMENTS = {"operation_id": "download.add", "body": {
|
||
"torrent_in": {"title": "Test Movie", "enclosure": "https://example.invalid/test.torrent"},
|
||
}}
|
||
|
||
|
||
class _ScriptModel(FakeMessagesListChatModel):
|
||
"""按固定响应驱动真实图,记录模型绑定的实际工具实例。"""
|
||
|
||
bound_tools: list[list[Any]] = Field(default_factory=list)
|
||
|
||
def bind_tools(self, tools, **_kwargs):
|
||
"""接受真实 ToolNode 的工具声明,无需访问任何模型供应商。"""
|
||
self.bound_tools.append(list(tools))
|
||
return self
|
||
|
||
|
||
@pytest.fixture
|
||
def invocation_runtime(tmp_path):
|
||
"""提供线程可见的真实 SQLite 文件及只读回执快照函数。"""
|
||
engine = create_engine(f"sqlite:///{tmp_path / 'middleware.db'}", connect_args={"timeout": 20})
|
||
AgentInvocation.__table__.create(engine)
|
||
factory = sessionmaker(bind=engine)
|
||
repository = TransactionalInvocationRepository(factory)
|
||
|
||
def records():
|
||
"""在独立连接读取持久化事实,避免只断言模拟端口调用。"""
|
||
with factory() as session:
|
||
return [row.to_dict() for row in session.execute(select(AgentInvocation).order_by(AgentInvocation.id)).scalars()]
|
||
|
||
yield repository, records
|
||
engine.dispose()
|
||
|
||
|
||
def _context(user_id="owner", session_id="invocation-chat"):
|
||
"""以宿主身份约束所有回执查询和写入。"""
|
||
return ToolPolicyContext(
|
||
session_id=session_id, user_id=user_id, origin=ToolOrigin.AGENT_INTERACTIVE,
|
||
principal_type=PrincipalType.HUMAN, auth_source=AuthSource.INTERNAL,
|
||
agent_context={"is_admin": True},
|
||
)
|
||
|
||
|
||
def _call(call_id, arguments=None, name="moviepilot_api"):
|
||
"""生成模型标准工具调用,参数默认是固定下载写操作。"""
|
||
return {"id": call_id, "name": name, "args": WRITE_ARGUMENTS if arguments is None else arguments}
|
||
|
||
|
||
def _graph(repository, responses, *, context=None, tools=None):
|
||
"""使用生产顺序的策略和持久回执中间件构造可重复调用的真实图。"""
|
||
context = context or _context()
|
||
tools = tools if tools is not None else [MoviePilotApiTool(session_id=context.session_id, user_id=context.user_id)]
|
||
middleware = InvocationMiddleware(context, repository, tools)
|
||
model = _ScriptModel(responses=responses)
|
||
graph = create_agent(
|
||
model=model, tools=tools,
|
||
middleware=[AgentPolicyMiddleware(context=context, tools=tools), middleware],
|
||
checkpointer=InMemorySaver(),
|
||
)
|
||
return graph, model, middleware
|
||
|
||
|
||
async def _invoke(graph, prompt="请执行操作", thread_id="graph-thread"):
|
||
"""以真实用户消息进入图,触发每轮私有意图身份更新。"""
|
||
return await graph.ainvoke(
|
||
{"messages": [HumanMessage(content=prompt)]},
|
||
{"configurable": {"thread_id": thread_id}},
|
||
)
|
||
|
||
|
||
def _tool_messages(result):
|
||
"""提取实际进入模型历史的工具响应。"""
|
||
return [message for message in result["messages"] if isinstance(message, ToolMessage)]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_parallel_equivalent_api_calls_execute_once_but_new_request_can_repeat(invocation_runtime, monkeypatch):
|
||
"""并行同参数且默认字段写法不同只执行一次,下一条用户意图仍可有意重做。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value=json.dumps({"success": True, "data": {"id": "download-1"}}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, model, middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[
|
||
_call("first"),
|
||
_call("duplicate", {**WRITE_ARGUMENTS, "path_params": {}, "query": {}}),
|
||
]),
|
||
AIMessage(content="已提交"),
|
||
AIMessage(content="", tool_calls=[_call("intentional-repeat")]),
|
||
AIMessage(content="按新的请求再次提交"),
|
||
])
|
||
first = await _invoke(graph)
|
||
assert run.await_count == 1
|
||
assert len(records()) == 1
|
||
assert records()[0]["status"] == "succeeded"
|
||
messages = _tool_messages(first)
|
||
assert len(messages) == 2
|
||
invocation_ids = {
|
||
payload.get("invocation_id", payload.get("_tool_execution", {}).get("invocation_id"))
|
||
for payload in (json.loads(message.content) for message in messages)
|
||
}
|
||
assert invocation_ids == {records()[0]["invocation_id"]}
|
||
await _invoke(graph, "我确认需要再添加一次")
|
||
assert run.await_count == 2
|
||
assert len(records()) == 2
|
||
assert len({record["invocation_id"] for record in records()}) == 2
|
||
assert middleware.tools[0] in model.bound_tools[0]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unknown_api_result_blocks_same_input_in_new_turn(invocation_runtime, monkeypatch):
|
||
"""投递结果未知时保留恢复状态,跨用户请求不能直接重放原写入。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "unknown", "task_id": "job-1"}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("start")]), AIMessage(content="等待核验"),
|
||
AIMessage(content="", tool_calls=[_call("retry")]), AIMessage(content="先核验状态"),
|
||
])
|
||
first = await _invoke(graph)
|
||
assert _tool_messages(first)[0].additional_kwargs["moviepilot_execution_outcome"] == "unknown"
|
||
assert records()[0]["status"] == "unknown"
|
||
result = await _invoke(graph, "重试刚才的下载")
|
||
assert run.await_count == 1
|
||
assert len(records()) == 1
|
||
replay = json.loads(_tool_messages(result)[-1].content)
|
||
assert replay["execution_outcome"] == "unknown"
|
||
assert replay["replayed"] is True
|
||
assert "先调用只读查询" in replay["recovery"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_confirmed_submission_deduplicates_turn_but_allows_new_intent(invocation_runtime, monkeypatch):
|
||
"""明确接受的异步提交保留 pending;同一轮不重复,新用户意图可再次提交。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "pending", "task_id": "job-1"}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("submit")]),
|
||
AIMessage(content="", tool_calls=[_call("same-turn-repeat")]),
|
||
AIMessage(content="已提交,等待后台任务完成"),
|
||
AIMessage(content="", tool_calls=[_call("new-user-intent")]),
|
||
AIMessage(content="按新的明确请求再次提交"),
|
||
])
|
||
first = await _invoke(graph)
|
||
messages = _tool_messages(first)
|
||
assert run.await_count == 1
|
||
assert len(records()) == 1
|
||
assert records()[0]["status"] == "pending"
|
||
assert all(message.additional_kwargs["moviepilot_execution_outcome"] == "pending" for message in messages)
|
||
repeated = json.loads(messages[-1].content)
|
||
assert repeated["replayed"] is True
|
||
assert repeated["invocation_id"] == records()[0]["invocation_id"]
|
||
await _invoke(graph, "我明确需要再次提交同一个后台任务")
|
||
assert run.await_count == 2
|
||
assert len(records()) == 2
|
||
assert {record["status"] for record in records()} == {"pending"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_timeout_remains_unknown_and_new_turn_does_not_reexecute(invocation_runtime, monkeypatch):
|
||
"""真实工具超时经策略层转为未知消息,已认领的副作用不会再次执行。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(side_effect=ToolExecutionTimeoutError("timed out"))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("timeout")]), AIMessage(content="查询状态"),
|
||
AIMessage(content="", tool_calls=[_call("retry")]), AIMessage(content="不能盲目重复"),
|
||
])
|
||
first = await _invoke(graph)
|
||
assert _tool_messages(first)[0].additional_kwargs["moviepilot_execution_outcome"] == "unknown"
|
||
assert records()[0]["status"] == "unknown"
|
||
await _invoke(graph, "请继续刚才的任务")
|
||
assert run.await_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("setting_key,current_value,redacted,operation,expected_reads,reconciled", [
|
||
("PROJECT_NAME", "My MoviePilot", False, "replace", 1, True),
|
||
("PROJECT_NAME", "Another name", False, "replace", 1, False),
|
||
("PROJECT_NAME", "My MoviePilot", True, "replace", 1, False),
|
||
("API_TOKEN", "My MoviePilot", False, "replace", 0, False),
|
||
("PROJECT_NAME", "My MoviePilot", False, "merge_dict", 0, False),
|
||
])
|
||
async def test_setting_unknown_is_reconciled_only_by_safe_matching_read(
|
||
invocation_runtime, monkeypatch, setting_key, current_value, redacted, operation, expected_reads, reconciled,
|
||
):
|
||
"""只有非敏感完整替换且只读确认实际值一致时才收口,其他情况保持未知。"""
|
||
repository, records = invocation_runtime
|
||
operations = []
|
||
|
||
async def run(_self, operation_id, **kwargs):
|
||
"""首次写入模拟结果丢失,核验必须调用同一个工具的真实只读入口。"""
|
||
operations.append((operation_id, kwargs))
|
||
if operation_id == "config.system.update":
|
||
return json.dumps({"execution_outcome": "unknown"})
|
||
assert operation_id == "config.system.get"
|
||
return json.dumps({"success": True, "data": {"settings": [{
|
||
"setting_key": setting_key, "value": current_value, "redacted": redacted,
|
||
}]}})
|
||
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
arguments = {"operation_id": "config.system.update", "body": {
|
||
"setting_key": setting_key, "value": "My MoviePilot", "operation": operation,
|
||
}}
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("setting-write", arguments)]), AIMessage(content="等待确认"),
|
||
AIMessage(content="", tool_calls=[_call("setting-retry", arguments)]), AIMessage(content="已核验"),
|
||
])
|
||
await _invoke(graph)
|
||
result = await _invoke(graph, "确认刚才的设置是否生效")
|
||
assert [operation for operation, _kwargs in operations].count("config.system.update") == 1
|
||
reads = [kwargs for operation, kwargs in operations if operation == "config.system.get"]
|
||
assert len(reads) == expected_reads
|
||
if reads:
|
||
assert reads[0]["query"] == {"setting_key": setting_key, "include_values": True, "show_secrets": False}
|
||
assert len(records()) == 1
|
||
assert records()[0]["status"] == ("succeeded" if reconciled else "unknown")
|
||
payload = json.loads(_tool_messages(result)[-1].content)
|
||
assert payload.get("reconciled", False) is reconciled
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("failing_method", ["find_unresolved", "claim", "finish"])
|
||
async def test_persistence_failure_never_grants_unrecorded_or_repeat_execution(
|
||
invocation_runtime, monkeypatch, failing_method,
|
||
):
|
||
"""认领失败不执行,收口失败保留认领并阻止下一轮重复副作用。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value=json.dumps({"success": True}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
|
||
def unavailable(*_args, **_kwargs):
|
||
"""模拟真实仓储事务故障,原仓储其他操作仍访问 SQLite。"""
|
||
raise RuntimeError("storage unavailable")
|
||
|
||
monkeypatch.setattr(repository, failing_method, unavailable)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("first")]), AIMessage(content="状态未确认"),
|
||
AIMessage(content="", tool_calls=[_call("second")]), AIMessage(content="不能重复"),
|
||
])
|
||
await _invoke(graph)
|
||
await _invoke(graph, "继续操作")
|
||
assert run.await_count == (1 if failing_method == "finish" else 0)
|
||
assert len(records()) == (1 if failing_method == "finish" else 0)
|
||
if failing_method == "finish":
|
||
assert records()[0]["status"] == "running"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancellation_propagates_and_preserves_unknown_receipt(invocation_runtime, monkeypatch):
|
||
"""取消图任务必须传播到调用方,同时记录已开始写入的未知结果。"""
|
||
repository, records = invocation_runtime
|
||
started = asyncio.Event()
|
||
|
||
async def run(_self, **_kwargs):
|
||
"""模拟已发出副作用但结果尚未返回的工具。"""
|
||
started.set()
|
||
await asyncio.Event().wait()
|
||
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [AIMessage(content="", tool_calls=[_call("cancel")])])
|
||
task = asyncio.create_task(_invoke(graph))
|
||
await asyncio.wait_for(started.wait(), timeout=5)
|
||
task.cancel()
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await task
|
||
assert len(records()) == 1
|
||
assert records()[0]["status"] == "unknown"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_receipt_query_is_bound_to_host_owner_and_never_journaled(invocation_runtime, monkeypatch):
|
||
"""真实绑定的回执查询只读工具不能读取其他用户或会话,也不产生新回执。"""
|
||
repository, records = invocation_runtime
|
||
original = repository.claim(
|
||
InvocationIdentity("owner", "invocation-chat", "known-call"),
|
||
tool_name="moviepilot_api", arguments_digest="a" * 64,
|
||
)
|
||
repository.finish(original.record.identity, claim_token=original.record.claim_token, status="succeeded")
|
||
run = AsyncMock(return_value=json.dumps({"success": True, "data": []}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
for context, expected in ((_context(), True), (_context("another-user"), False), (_context(session_id="other-chat"), False)):
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[
|
||
_call("receipt", {"invocation_id": "known-call"}, GET_TOOL_EXECUTION_NAME),
|
||
_call("read-api", {"operation_id": "scheduler.list"}),
|
||
]), AIMessage(content="查询结束"),
|
||
], context=context)
|
||
result = await _invoke(graph)
|
||
receipt = next(message for message in _tool_messages(result) if message.name == GET_TOOL_EXECUTION_NAME)
|
||
assert json.loads(receipt.content)["success"] is expected
|
||
assert run.await_count == 3
|
||
assert len(records()) == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_unresolved_write_does_not_block_another_host_user_or_session(invocation_runtime, monkeypatch):
|
||
"""相同工具参数的未知写入只阻止原用户会话,模型参数无法替换宿主 owner。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value=json.dumps({"execution_outcome": "unknown"}))
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
contexts = [_context(), _context("another-user"), _context(session_id="other-chat")]
|
||
for context in contexts:
|
||
arguments = {**WRITE_ARGUMENTS, "user_id": "injected-user", "session_id": "injected-session"}
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("same-provider-call-id", arguments)]),
|
||
AIMessage(content="待核验"),
|
||
], context=context)
|
||
await _invoke(graph)
|
||
assert run.await_count == 3
|
||
assert {(row["principal_id"], row["session_id"]) for row in records()} == {
|
||
(context.user_id, context.session_id) for context in contexts
|
||
}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_generic_writes_keep_distinct_calls_and_read_tools_remain_unjournaled(invocation_runtime):
|
||
"""通用写工具保留调用 ID 语义,明确只读标签的工具不进入写回执系统。"""
|
||
repository, records = invocation_runtime
|
||
calls = []
|
||
|
||
async def perform(value: str):
|
||
"""记录通用工具的真实执行次数。"""
|
||
calls.append(value)
|
||
return json.dumps({"success": True})
|
||
|
||
write = StructuredTool.from_function(coroutine=perform, name="generic_write", description="Write data", tags=["write"])
|
||
read = StructuredTool.from_function(coroutine=perform, name="generic_read", description="Read data", tags=["read"])
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[
|
||
_call("generic-1", {"value": "same"}, write.name),
|
||
_call("generic-2", {"value": "same"}, write.name),
|
||
_call("generic-read", {"value": "read"}, read.name),
|
||
]), AIMessage(content="完成"),
|
||
], tools=[write, read])
|
||
await _invoke(graph)
|
||
assert calls.count("same") == 2
|
||
assert calls.count("read") == 1
|
||
assert {record["invocation_id"] for record in records()} == {"generic-1", "generic-2"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(("first", "retry"), [
|
||
(WRITE_ARGUMENTS, {**WRITE_ARGUMENTS, "body": {**WRITE_ARGUMENTS["body"], "allow_unrecognized": False}}),
|
||
({**WRITE_ARGUMENTS, "body": {**WRITE_ARGUMENTS["body"], "ignored_by_endpoint": "first"}}, WRITE_ARGUMENTS),
|
||
(
|
||
{"operation_id": "config.system.update", "body": {"setting_key": "PROJECT_NAME", "value": "desired"}},
|
||
{"operation_id": "config.system.update", "body": {"setting_key": "PROJECT_NAME", "value": "desired", "operation": "replace"}},
|
||
),
|
||
(
|
||
{"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "query": {"force": "false"}},
|
||
{"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "body": {"force": False, "repo_url": ""}},
|
||
),
|
||
])
|
||
async def test_api_effective_parameters_prevent_unknown_retry_bypass(invocation_runtime, monkeypatch, first, retry):
|
||
"""嵌套默认值、模型忽略字段和 GET 位置变化不能绕过未知写入防重。"""
|
||
repository, records = invocation_runtime
|
||
writes = []
|
||
|
||
async def run(_self, operation_id, **kwargs):
|
||
"""记录实际执行参数,核验读取返回未匹配值使旧写入继续保持未知。"""
|
||
if operation_id == "config.system.get":
|
||
return json.dumps({"success": True, "data": {"settings": [{
|
||
"setting_key": "PROJECT_NAME", "value": "not-yet-confirmed", "redacted": False,
|
||
}]}})
|
||
writes.append({"operation_id": operation_id, **kwargs})
|
||
return json.dumps({"execution_outcome": "unknown"})
|
||
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("initial", first)]), AIMessage(content="等待确认"),
|
||
AIMessage(content="", tool_calls=[_call("retry", retry)]), AIMessage(content="先核验"),
|
||
])
|
||
await _invoke(graph)
|
||
result = await _invoke(graph, "继续刚才的操作")
|
||
assert len(writes) == 1
|
||
assert len(records()) == 1
|
||
assert records()[0]["status"] == "unknown"
|
||
assert json.loads(_tool_messages(result)[-1].content)["replayed"] is True
|
||
tool = MoviePilotApiTool(session_id="canonical", user_id="owner")
|
||
normalized = tool.canonical_arguments(first)
|
||
assert normalized == tool.canonical_arguments(retry)
|
||
assert writes[0] == normalized
|
||
assert "ignored_by_endpoint" not in (normalized.get("body") or {})
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_api_forbidden_query_keys_do_not_execute_or_claim(invocation_runtime, monkeypatch):
|
||
"""路径和查询合同禁止的字段在认领前拒绝,不能仅从指纹丢弃后仍传给服务端。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value='{"success":true}')
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
arguments = {"operation_id": "plugin.install", "path_params": {"plugin_id": "Demo"}, "query": {"unlisted": True}}
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("invalid", arguments)]), AIMessage(content="修正参数"),
|
||
])
|
||
result = await _invoke(graph)
|
||
assert _tool_messages(result)[0].status == "error"
|
||
assert "输入合同校验" in _tool_messages(result)[0].content
|
||
payload = json.loads(_tool_messages(result)[0].content)
|
||
assert payload["operation_id"] == "plugin.install"
|
||
assert "path_params" in payload["input_contract"]["allowed_arguments"]
|
||
assert "query" in payload["input_contract"]["allowed_arguments"]
|
||
assert records() == []
|
||
run.assert_not_awaited()
|
||
|
||
|
||
def test_api_schema_normalization_preserves_free_setting_values_and_explicit_null():
|
||
"""自由字典和显式 null 保持原意,引用模型里的显式默认值仍可规范。"""
|
||
tool = MoviePilotApiTool(session_id="canonical", user_id="owner")
|
||
free_value = {"unknown_keys_are_business_data": {"force": "false"}, "optional": None}
|
||
result = tool.canonical_arguments({"operation_id": "config.system.update", "body": {
|
||
"setting_key": "CUSTOM_SETTING", "value": free_value, "match_field": None,
|
||
}})
|
||
assert result["body"]["value"] == free_value
|
||
assert result["body"]["match_field"] is None
|
||
assert result["body"]["operation"] == "replace"
|
||
nested = tool.canonical_arguments({**WRITE_ARGUMENTS, "body": {"torrent_in": {
|
||
**WRITE_ARGUMENTS["body"]["torrent_in"], "hit_and_run": "false", "grabs": "0",
|
||
}}})
|
||
assert nested["body"]["torrent_in"]["hit_and_run"] is False
|
||
assert nested["body"]["torrent_in"]["grabs"] == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("resource_id", [True, False])
|
||
async def test_boolean_resource_id_never_becomes_numeric_write_target(invocation_runtime, monkeypatch, resource_id):
|
||
"""布尔路径参数不能被规范成数字 ID,认领和真实删除都必须被阻止。"""
|
||
repository, records = invocation_runtime
|
||
run = AsyncMock(return_value='{"success":true}')
|
||
monkeypatch.setattr(MoviePilotApiTool, "run", run)
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call("bad-id", {
|
||
"operation_id": "subscription.delete", "path_params": {"subscribe_id": resource_id},
|
||
})]), AIMessage(content="需要有效的订阅编号"),
|
||
])
|
||
result = await _invoke(graph)
|
||
assert _tool_messages(result)[0].status == "error"
|
||
assert records() == []
|
||
run.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_mcp_read_tag_does_not_bypass_persistent_identity(invocation_runtime, monkeypatch):
|
||
"""外部 MCP 的固定 Read 标签不构成只读证明,同 ID 防重而新 ID 仍照常执行。"""
|
||
repository, records = invocation_runtime
|
||
spec = AgentMcpToolSpec(
|
||
server=AgentMcpServerConfig(id="external", name="Remote", transport="stdio", command="unused"),
|
||
name="operation", agent_tool_name="mcp_remote_operation", description="Remote operation",
|
||
input_schema={"type": "object", "properties": {"value": {"type": "string"}}},
|
||
)
|
||
tools = await create_external_mcp_tools(session_id="invocation-chat", user_id="owner", specs=[spec])
|
||
run = AsyncMock(return_value='{"success":true,"data":"remote-result"}')
|
||
monkeypatch.setattr(McpExternalTool, "run", run)
|
||
replies = []
|
||
for call_id in ("same-call", "same-call", "new-call"):
|
||
graph, _model, _middleware = _graph(repository, [
|
||
AIMessage(content="", tool_calls=[_call(call_id, {"value": "same"}, tools[0].name)]),
|
||
AIMessage(content="已核验"),
|
||
], tools=tools)
|
||
replies.append(json.loads(_tool_messages(await _invoke(graph))[0].content))
|
||
assert run.await_count == 2
|
||
assert len(records()) == 2
|
||
assert replies[0]["data"] == replies[2]["data"] == "remote-result"
|
||
assert replies[1]["replayed"] is True
|