diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py index 824c3b653b..69af3fe6be 100644 --- a/astrbot/core/star/star_manager.py +++ b/astrbot/core/star/star_manager.py @@ -860,6 +860,18 @@ async def reload(self, specified_plugin_name=None): specified_module_path = smd.module_path break + if specified_module_path: + inactivated_plugins = await sp.global_get("inactivated_plugins", []) + if specified_module_path in inactivated_plugins: + # 已停用插件没有实例,无需重载;此处若继续执行 _unbind_plugin, + # 会将其在 __init__ 中通过 add_llm_tools 注册的工具从 + # func_list 中移除且无法恢复(load 会跳过停用插件的实例化)。 + # 新配置会在重新启用插件时由完整的 reload 流程应用。见 #8582。 + logger.info( + f"插件 {specified_plugin_name} 处于禁用状态,跳过重载。", + ) + return True, None + # 终止插件 if not specified_module_path: # 重载所有插件 diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 632d312999..6d1277fa65 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -527,6 +527,120 @@ async def mock_load( assert unbound == plugin_names +@pytest.mark.asyncio +async def test_reload_skips_deactivated_plugin( + plugin_manager_pm: PluginManager, monkeypatch +): + """Reloading a deactivated plugin must not unbind it (#8582). + + _terminate_plugin short-circuits for deactivated plugins, but + _unbind_plugin still removed their tools from func_list while load() + skipped re-instantiation, losing every tool registered via + add_llm_tools() in the plugin's __init__. + """ + _clear_star_runtime_state() + plugin_name = "plugin_one" + module_path = f"data.plugins.{plugin_name}.main" + metadata = star_manager_module.StarMetadata( + name=plugin_name, + root_dir_name=plugin_name, + module_path=module_path, + activated=False, + ) + star_manager_module.star_map[module_path] = metadata + star_manager_module.star_registry.append(metadata) + + calls = [] + + async def mock_global_get(key, default=None): + if key == "inactivated_plugins": + return [module_path] + return default + + async def mock_terminate(plugin): + calls.append(("terminate", plugin.name)) + + async def mock_unbind(plugin_name, plugin_module_path): + calls.append(("unbind", plugin_name)) + + async def mock_load( + specified_module_path=None, + specified_dir_name=None, + ignore_version_check=False, + ): + calls.append(("load", specified_module_path)) + return True, None + + monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get) + monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate) + monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind) + monkeypatch.setattr(plugin_manager_pm, "load", mock_load) + + try: + result = await plugin_manager_pm.reload(plugin_name) + assert result == (True, None) + assert calls == [] + assert module_path in star_manager_module.star_map + assert any(smd.name == plugin_name for smd in star_manager_module.star_registry) + finally: + _clear_star_runtime_state() + + +@pytest.mark.asyncio +async def test_reload_runs_full_cycle_for_activated_plugin( + plugin_manager_pm: PluginManager, monkeypatch +): + """Activated plugins keep the full terminate -> unbind -> load cycle.""" + _clear_star_runtime_state() + plugin_name = "plugin_one" + module_path = f"data.plugins.{plugin_name}.main" + metadata = star_manager_module.StarMetadata( + name=plugin_name, + root_dir_name=plugin_name, + module_path=module_path, + ) + star_manager_module.star_map[module_path] = metadata + star_manager_module.star_registry.append(metadata) + + calls = [] + + async def mock_global_get(key, default=None): + if key == "inactivated_plugins": + return [] + return default + + async def mock_terminate(plugin): + calls.append(("terminate", plugin.name)) + + async def mock_unbind(plugin_name, plugin_module_path): + calls.append(("unbind", plugin_name)) + star_manager_module.star_map.pop(plugin_module_path, None) + + async def mock_load( + specified_module_path=None, + specified_dir_name=None, + ignore_version_check=False, + ): + calls.append(("load", specified_module_path)) + return True, None + + monkeypatch.setattr(star_manager_module.sp, "global_get", mock_global_get) + monkeypatch.setattr(plugin_manager_pm, "_terminate_plugin", mock_terminate) + monkeypatch.setattr(plugin_manager_pm, "_unbind_plugin", mock_unbind) + monkeypatch.setattr(plugin_manager_pm, "load", mock_load) + + try: + result = await plugin_manager_pm.reload(plugin_name) + assert result == (True, None) + assert calls == [ + ("terminate", plugin_name), + ("unbind", plugin_name), + ("load", module_path), + ] + finally: + _clear_star_runtime_state() + + @pytest.mark.asyncio async def test_load_reports_unregistered_plugin_without_index_error( plugin_manager_pm: PluginManager, monkeypatch