diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index f79678bc61a..558903ba297 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -111,6 +111,7 @@ def check_discord_requirements() -> bool: Intents = _Intents commands = _commands DISCORD_AVAILABLE = True + _define_discord_view_classes() return True @@ -4949,7 +4950,17 @@ def _component_check_auth( return False -if DISCORD_AVAILABLE: +def _define_discord_view_classes() -> None: + """Register Discord UI view classes as module globals. + + Called at module load (when discord.py is pre-installed) and also from + check_discord_requirements() after a lazy install, so view classes are + always defined whenever DISCORD_AVAILABLE is True. Without this, + ExecApprovalView and siblings are only defined at import time; a later + lazy install sets DISCORD_AVAILABLE=True but leaves the classes + undefined, causing NameError on the first button interaction. + """ + global ExecApprovalView, SlashConfirmView, UpdatePromptView, ModelPickerView, ClarifyChoiceView class ExecApprovalView(discord.ui.View): """ @@ -5649,3 +5660,7 @@ if DISCORD_AVAILABLE: self.resolved = True for child in self.children: child.disabled = True + + +if DISCORD_AVAILABLE: + _define_discord_view_classes() diff --git a/tests/gateway/test_discord_lazy_install_views.py b/tests/gateway/test_discord_lazy_install_views.py new file mode 100644 index 00000000000..62f2b974e02 --- /dev/null +++ b/tests/gateway/test_discord_lazy_install_views.py @@ -0,0 +1,81 @@ +"""Regression: Discord UI view classes must be defined after lazy-install. + +When discord.py is NOT installed at module load time, the +``if DISCORD_AVAILABLE:`` guard at the bottom of gateway/platforms/discord.py +evaluates to False and is skipped — leaving ExecApprovalView and its four +siblings undefined in the module globals. + +check_discord_requirements() must call _define_discord_view_classes() after +a successful lazy install so that all view classes are available the moment +DISCORD_AVAILABLE flips to True. Without this, the first button interaction +(exec approval, slash confirm, etc.) raises NameError even though +DISCORD_AVAILABLE=True. + +Fixes: lazy-install path NameError for ExecApprovalView, SlashConfirmView, +UpdatePromptView, ModelPickerView, ClarifyChoiceView. +""" +import importlib +import sys +from unittest.mock import patch + +import pytest + +_VIEW_NAMES = [ + "ExecApprovalView", + "SlashConfirmView", + "UpdatePromptView", + "ModelPickerView", + "ClarifyChoiceView", +] + + +class TestDefineDiscordViewClasses: + """_define_discord_view_classes() registers all UI view classes in module globals.""" + + def test_registers_all_five_view_classes(self, monkeypatch): + """Calling _define_discord_view_classes() must (re)define all 5 view classes.""" + dp = importlib.import_module("gateway.platforms.discord") + + # Remove the classes to simulate the state where the module was loaded + # with DISCORD_AVAILABLE=False (the lazy-install scenario). + for name in _VIEW_NAMES: + monkeypatch.delattr(dp, name) + + # Pre-condition: classes are gone + for name in _VIEW_NAMES: + assert not hasattr(dp, name), f"{name} should be absent before the call" + + dp._define_discord_view_classes() + + for name in _VIEW_NAMES: + assert hasattr(dp, name), f"{name} must be defined after _define_discord_view_classes()" + assert isinstance(getattr(dp, name), type), f"{name} must be a class" + + def test_check_discord_requirements_calls_define_on_lazy_install(self, monkeypatch): + """check_discord_requirements() must call _define_discord_view_classes() on + a successful lazy install so view classes exist when DISCORD_AVAILABLE=True.""" + dp = importlib.import_module("gateway.platforms.discord") + + # Simulate discord not yet available at module load. + monkeypatch.setattr(dp, "DISCORD_AVAILABLE", False) + + define_called = [False] + orig_define = dp._define_discord_view_classes + + def _spy_define(): + define_called[0] = True + orig_define() + + monkeypatch.setattr(dp, "_define_discord_view_classes", _spy_define) + + # Patch lazy_deps.ensure to be a no-op (pretend install succeeds). + # The discord imports inside check_discord_requirements() succeed because + # _ensure_discord_mock() in conftest.py already registered the mock. + with patch("tools.lazy_deps.ensure"): + result = dp.check_discord_requirements() + + assert result is True, "check_discord_requirements() should return True after lazy install" + assert define_called[0], ( + "check_discord_requirements() must call _define_discord_view_classes() " + "after a successful lazy install so view classes are not undefined" + )