diff --git a/app/api/endpoints/media.py b/app/api/endpoints/media.py index 6fc15e8b2..769b7368f 100644 --- a/app/api/endpoints/media.py +++ b/app/api/endpoints/media.py @@ -68,11 +68,11 @@ _BUILTIN_MEDIA_SOURCES = ( def _registered_media_sources() -> list[_SchemaMediaSourceInfo]: """合并内置与启用插件声明的媒体来源,并按来源标识去重。""" - from app.runtime.extensions.plugin_manager import PluginManager + from app.application.plugin.runtime import get_plugin_manager result = list(_BUILTIN_MEDIA_SOURCES) seen = {source.media_source for source in result} - for raw_source in PluginManager().get_media_sources(): + for raw_source in get_plugin_manager().get_media_sources(): try: source = _SchemaMediaSourceInfo.model_validate(raw_source) except Exception: diff --git a/app/runtime/extensions/module/dispatcher.py b/app/runtime/extensions/module/dispatcher.py index 4c5c43d50..b1aff963c 100644 --- a/app/runtime/extensions/module/dispatcher.py +++ b/app/runtime/extensions/module/dispatcher.py @@ -99,10 +99,15 @@ class ModuleInvocationDispatcher: """同步执行插件方法,保留插件顺序、短路和列表合并语义。""" for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items(): plugin_id, plugin_name = plugin - func = module_dict.get(method) - if not func: - continue try: + # 防御坏插件把方法表声明成非映射类型,避免击穿整个模块调度 + if not isinstance(module_dict, Mapping): + raise TypeError( + f"插件 {plugin_id} 的模块声明必须是映射,实际是 {type(module_dict).__name__}" + ) + func = module_dict.get(method) + if not func: + continue logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): result = func(*args, **kwargs) @@ -140,10 +145,15 @@ class ModuleInvocationDispatcher: """异步执行插件方法,并把同步函数移入线程池。""" for plugin, module_dict in self._plugin_catalog.get_plugin_modules().items(): plugin_id, plugin_name = plugin - func = module_dict.get(method) - if not func: - continue try: + # 防御坏插件把方法表声明成非映射类型,避免击穿整个模块调度 + if not isinstance(module_dict, Mapping): + raise TypeError( + f"插件 {plugin_id} 的模块声明必须是映射,实际是 {type(module_dict).__name__}" + ) + func = module_dict.get(method) + if not func: + continue logger.info("请求插件 %s 执行:%s ...", plugin_name, method) if self.is_valid_empty(result): result = await self._async_call(func, *args, **kwargs) diff --git a/app/runtime/extensions/plugin/projection.py b/app/runtime/extensions/plugin/projection.py index 7d3402cf1..26b9a21dc 100644 --- a/app/runtime/extensions/plugin/projection.py +++ b/app/runtime/extensions/plugin/projection.py @@ -87,7 +87,16 @@ class PluginProjection: continue try: if plugin.get_state(): - modules[(plugin_id, plugin.get_name())] = plugin.get_module() or [] + declared = plugin.get_module() + # 基类默认实现返回 None;只接受映射,防止把 list 当成方法表传入调度器 + if declared is None: + continue + if not isinstance(declared, Mapping): + self._logger.error( + f"插件 {plugin_id} 的 get_module() 返回值必须是字典,实际是 {type(declared).__name__}" + ) + continue + modules[(plugin_id, plugin.get_name())] = declared except Exception as error: self._logger.error(f"获取插件 {plugin_id} 模块出错:{str(error)}") return modules diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 1cf874d0a..9b065c97d 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -13,8 +13,8 @@ "runtime_to_db": [], "workflow_to_db": [] }, - "edge_count": 6024, - "edge_sha256": "c0e243d720b7edbd3b842d8f9afd4ae5c7e0be6c8c745601986d2c938e4f23d8", + "edge_count": 6028, + "edge_sha256": "5d7edd263b74841b2b98cfdb032190e4a2dd2961733402eacbeba31b21ecf136", "edges": [ "app -> app.runtime", "app -> app.runtime.compat", @@ -1762,6 +1762,9 @@ "app.api.endpoints.media -> app.api", "app.api.endpoints.media -> app.api.deps", "app.api.endpoints.media -> app.api.response", + "app.api.endpoints.media -> app.application", + "app.api.endpoints.media -> app.application.plugin", + "app.api.endpoints.media -> app.application.plugin.runtime", "app.api.endpoints.media -> app.chain", "app.api.endpoints.media -> app.chain.media", "app.api.endpoints.media -> app.chain.scraping", @@ -1778,6 +1781,7 @@ "app.api.endpoints.media -> app.schemas", "app.api.endpoints.media -> app.schemas.category", "app.api.endpoints.media -> app.schemas.context", + "app.api.endpoints.media -> app.schemas.event", "app.api.endpoints.media -> app.schemas.media", "app.api.endpoints.media -> app.schemas.response", "app.api.endpoints.media -> app.schemas.token", diff --git a/tests/fixtures/architecture/runtime-contract-baseline.json b/tests/fixtures/architecture/runtime-contract-baseline.json index 1c3b2ee20..97c4f1b70 100644 --- a/tests/fixtures/architecture/runtime-contract-baseline.json +++ b/tests/fixtures/architecture/runtime-contract-baseline.json @@ -1612,7 +1612,7 @@ "consumers": [ { "caller": "app.chain.scraping", - "line": 586 + "line": 597 } ], "producers": [ @@ -1871,7 +1871,7 @@ "producer_count": 66 }, "run_module": { - "call_count": 259, + "call_count": 260, "dynamic_call_count": 0, "dynamic_calls": [], "method_count": 211, @@ -3010,6 +3010,11 @@ "caller": "app.chain", "line": 1005, "mode": "sync" + }, + { + "caller": "app.chain.scraping", + "line": 573, + "mode": "sync" } ], "metadata_nfo": [ diff --git a/tests/test_agent_image_capability.py b/tests/test_agent_image_capability.py index 194fe0a38..3a028b518 100644 --- a/tests/test_agent_image_capability.py +++ b/tests/test_agent_image_capability.py @@ -1,13 +1,39 @@ from unittest.mock import AsyncMock, patch +import pytest + from app.agent import MoviePilotAgent from app.agent.llm import AgentCapabilityManager, LLMHelper +from app.agent.llm.provider import LLMProviderManager from app.chain.message import MessageChain from app.runtime.config import settings from app.schemas.types import NotificationChannel -def test_llm_supports_image_input_uses_model_catalog_text_only(monkeypatch): +@pytest.fixture +def stub_llm_model_catalog(monkeypatch): + """打桩 models.dev 目录查询边界,离线文件随仓库保持为空,不依赖真实目录数据。""" + catalog = { + ("minimax", "MiniMax-M2.7"): { + "modalities": {"input": ["text"], "output": ["text"]}, + }, + ("zhipuai", "glm-5v-turbo"): { + "modalities": {"input": ["text", "image"], "output": ["text"]}, + }, + } + + def fake_resolve(self, provider_id, model_id, base_url=None, base_url_preset_id=None): + """按 provider 与模型标识返回目录元数据,未知模型返回 None。""" + return catalog.get((provider_id, model_id)) + + monkeypatch.setattr( + LLMProviderManager, "resolve_cached_model_metadata", fake_resolve + ) + + +def test_llm_supports_image_input_uses_model_catalog_text_only( + monkeypatch, stub_llm_model_catalog +): """内置目录明确为纯文本模型时,应自动关闭图片输入。""" monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True) @@ -17,7 +43,9 @@ def test_llm_supports_image_input_uses_model_catalog_text_only(monkeypatch): ) -def test_llm_supports_image_input_keeps_known_vision_model(monkeypatch): +def test_llm_supports_image_input_keeps_known_vision_model( + monkeypatch, stub_llm_model_catalog +): """内置目录明确为视觉模型时,应允许图片输入。""" monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True) @@ -45,13 +73,21 @@ def test_agent_capability_manager_delegates_image_support(): supports.assert_called_once_with() -def test_handle_ai_message_routes_text_only_model_images_to_files(monkeypatch): +def test_handle_ai_message_routes_text_only_model_images_to_files( + monkeypatch, stub_llm_model_catalog +): """纯文本模型收到图片消息时,应降级为文件附件而非 image_url 内容块。""" chain = MessageChain() monkeypatch.setattr(settings, "AI_AGENT_ENABLE", True) monkeypatch.setattr(settings, "LLM_SUPPORT_IMAGE_INPUT", True) monkeypatch.setattr(settings, "LLM_PROVIDER", "minimax") monkeypatch.setattr(settings, "LLM_MODEL", "MiniMax-M2.7") + # 测试绕过完整启动组合根,按需装配 llm_helper provider 以走真实能力判断 + import app.application.agent as agent_facade + + monkeypatch.setattr( + agent_facade, "_llm_helper_provider", lambda: LLMHelper + ) with patch.object( chain, "_get_or_create_session_id", return_value="session-1" diff --git a/tests/test_module_invocation_dispatcher.py b/tests/test_module_invocation_dispatcher.py index a6182a02e..b276b7bc3 100644 --- a/tests/test_module_invocation_dispatcher.py +++ b/tests/test_module_invocation_dispatcher.py @@ -207,3 +207,30 @@ async def test_async_dispatch_awaits_coroutines_and_offloads_sync_functions() -> assert await dispatcher.async_dispatch("execute") == ["plugin", "system"] assert offloaded == [sync_module.execute] + + +def test_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None: + """插件把方法表声明成 list 时走错误策略,且不影响后续健康插件。""" + dispatcher, plugin_error, _, _ = _dispatcher( + plugins={ + ("Bad", "坏插件"): ["not-a-mapping"], + ("Good", "好插件"): {"execute": lambda: "ok"}, + }, + ) + + assert dispatcher.dispatch("execute") == "ok" + plugin_error.assert_called_once() + + +@pytest.mark.asyncio +async def test_async_plugin_non_mapping_module_decl_is_reported_and_skipped() -> None: + """异步路径下坏插件同样被隔离,嵌套补丁场景不再冒泡击穿调度。""" + dispatcher, plugin_error, _, _ = _dispatcher( + plugins={ + ("Bad", "坏插件"): ["not-a-mapping"], + ("Good", "好插件"): {"execute": lambda: "ok"}, + }, + ) + + assert await dispatcher.async_dispatch("execute") == "ok" + plugin_error.assert_called_once() diff --git a/tests/test_plugin_projection.py b/tests/test_plugin_projection.py index 0142d5363..80ea6d3b6 100644 --- a/tests/test_plugin_projection.py +++ b/tests/test_plugin_projection.py @@ -80,6 +80,23 @@ def test_projection_preserves_services_modules_actions_and_pid_filter(): }] +def test_projection_modules_skips_none_and_non_mapping_declarations(): + """get_module 未返回映射的插件被跳过并记日志,不污染整体模块投影。""" + errors = [] + log = SimpleNamespace(error=lambda message: errors.append(message)) + projection = PluginProjection( + { + "NoDecl": _Plugin(get_module=lambda: None), + "ListDecl": _Plugin(get_module=lambda: ["not-a-mapping"]), + "Healthy": _Plugin(get_module=lambda: {"recognize": "handler"}), + }, + log=log, + ) + + assert projection.modules() == {("Healthy", "测试插件"): {"recognize": "handler"}} + assert any("ListDecl" in message for message in errors) + + def test_projection_collects_enabled_media_source_declarations(): """只投影启用插件的媒体来源声明,并附带插件 ID 便于诊断。""" demo = _Plugin(