diff --git a/src/blacki/telegram/bot.py b/src/blacki/telegram/bot.py index e110b8a..48a837e 100644 --- a/src/blacki/telegram/bot.py +++ b/src/blacki/telegram/bot.py @@ -3,7 +3,6 @@ import asyncio import contextlib import logging -import os import re from collections.abc import Coroutine, Sequence from contextvars import ContextVar @@ -23,15 +22,8 @@ ) from blacki.inference import ( InferenceProfile, - ReasoningConfig, - ReasoningEffort, inference_profile_from_environment, load_inference_profile, - update_inference_profile, -) -from blacki.model_capabilities import ( - ModelCapabilities, - OpenRouterModelCapabilitiesResolver, ) from blacki.reminders.storage import Reminder from blacki.utils.preferences import get_preferences_storage @@ -39,6 +31,7 @@ from . import TelegramConfig from .api import TelegramApiClient, TelegramApiError from .formatting import escape_markdown_plain, format_for_telegram +from .settings_menu import SettingsMenu from .streaming import split_long_message from .types import ( BotCommand, @@ -101,39 +94,6 @@ def _format_health_sync_result(result: SyncResult) -> str: return "Google Health could not be refreshed right now. Please try again later." -MODEL_CHOICES = { - "m1": ("openrouter/openai/gpt-oss-120b", "GPT-OSS 120B"), - "m2": ("openrouter/x-ai/grok-4.3", "Grok 4.3"), - "m3": ("google/gemini-flash-latest", "Gemini Flash"), - "m4": ("openrouter/deepseek/deepseek-v4-pro", "DeepSeek v4 Pro"), - "m5": ("openrouter/deepseek/deepseek-v4-flash", "DeepSeek v4 Flash"), - "m6": ("google/gemini-pro-latest", "Gemini Pro"), - "m7": ("moonshotai/kimi-latest", "Kimi Latest"), - "m8": ("openrouter/minimax/minimax-m2.7", "MiniMax m2.7"), - "m9": ("openrouter/nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), - "m10": ("openrouter/z-ai/glm-5", "GLM 5"), - "m11": ("openrouter/openai/gpt-5.6-luna", "GPT-5.6 Luna"), - "m_default": ("default", "System Default"), -} - -_SETTINGS_MODEL_PREFIX = "s:m:" -_SETTINGS_REASONING_PREFIX = "s:r:" -_SETTINGS_THINKING = "s:t" -_SETTINGS_BACK = "s:b" -_SETTINGS_RESET = "s:x" -_INHERIT_REASONING = "inherit" -_REASONING_LABELS = { - "inherit": "Default", - "none": "Off", - "minimal": "Minimal", - "low": "Low", - "medium": "Medium", - "high": "High", - "xhigh": "XHigh", - "max": "Max", -} - - @dataclass(slots=True, frozen=True) class TelegramSessionIdentity: """Stable Telegram identifiers used to resolve ADK sessions.""" @@ -167,7 +127,10 @@ def __init__( self._chat_type_context: ContextVar[ChatType | None] = ContextVar( "telegram_chat_type", default=None ) - self._capabilities_resolver: OpenRouterModelCapabilitiesResolver | None = None + self._settings_menu = SettingsMenu( + api_provider=lambda: self.api, + load_profile=self._load_chat_profile, + ) @property def api(self) -> TelegramApiClient: @@ -214,10 +177,7 @@ async def stop(self) -> None: await self.runtime.close() - if self._capabilities_resolver is not None: - with contextlib.suppress(Exception): - await self._capabilities_resolver.aclose() - self._capabilities_resolver = None + await self._settings_menu.aclose() if self._api is not None: await self._api.close() @@ -806,9 +766,13 @@ async def _handle_command(self, message: Message, command: str) -> None: elif command == "/reset": await self._handle_reset(chat_id, message.message_thread_id) elif command == "/model": - await self._send_model_menu(chat_id, message.message_thread_id) + await self._settings_menu.send_model_menu( + chat_id, message.message_thread_id + ) elif command == "/thinking": - await self._send_thinking_menu(chat_id, message.message_thread_id) + await self._settings_menu.send_thinking_menu( + chat_id, message.message_thread_id + ) elif command == "/connect_health": await self._connect_health(message) elif command == "/health_summary": @@ -1008,285 +972,13 @@ async def notify_health_connection( protect_content=True, ) - async def _send_model_menu( - self, chat_id: int, message_thread_id: int | None - ) -> None: - """Send the compact model-and-thinking settings panel.""" - profile = await self._load_chat_profile(chat_id) - text, reply_markup = self._build_model_menu(profile) - try: - await self.api.send_message( - chat_id=chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - message_thread_id=message_thread_id, - reply_markup=reply_markup, - ) - except Exception: - logger.exception("Failed to send model menu") - - async def _send_thinking_menu( - self, chat_id: int, message_thread_id: int | None - ) -> None: - """Send the reasoning-effort menu for the effective model.""" - profile = await self._load_chat_profile(chat_id) - text, reply_markup = await self._build_thinking_menu(profile) - try: - await self.api.send_message( - chat_id=chat_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - message_thread_id=message_thread_id, - reply_markup=reply_markup, - ) - except Exception: - logger.exception("Failed to send thinking menu") - - @staticmethod - def _chunk_buttons( - buttons: Sequence[InlineKeyboardButton], per_row: int = 2 - ) -> list[list[InlineKeyboardButton]]: - """Group flat buttons into keyboard rows of a fixed width.""" - return [list(buttons[i : i + per_row]) for i in range(0, len(buttons), per_row)] - - def _build_model_menu( - self, profile: InferenceProfile - ) -> tuple[str, InlineKeyboardMarkup]: - """Build the model menu without performing network I/O.""" - effective_model = self._effective_model(profile) - current_display_name = self._model_display_name(effective_model) - current_thinking = self._reasoning_display(profile) - - model_buttons = [ - InlineKeyboardButton( - text=display_name, - callback_data=f"{_SETTINGS_MODEL_PREFIX}{key}", - ) - for key, (_, display_name) in MODEL_CHOICES.items() - ] - buttons = self._chunk_buttons(model_buttons) - - buttons.append( - [ - InlineKeyboardButton( - text=f"🧠 Thinking: {current_thinking}", - callback_data=_SETTINGS_THINKING, - ) - ] - ) - buttons.append( - [ - InlineKeyboardButton( - text="↩️ Reset settings", callback_data=_SETTINGS_RESET - ) - ] - ) - - text = format_for_telegram( - "⚙️ **Inference settings**\n\n" - f"Model: **{current_display_name}**\n" - f"Thinking: **{current_thinking}**\n\n" - "Choose a model or adjust Thinking. Changes apply to the next turn." - ) - return text, InlineKeyboardMarkup(inline_keyboard=buttons) - - async def _build_thinking_menu( - self, profile: InferenceProfile - ) -> tuple[str, InlineKeyboardMarkup]: - """Build a capability-aware reasoning menu.""" - effective_model = self._effective_model(profile) - capability = await self._resolve_capabilities(effective_model) - options = self._reasoning_options(capability) - current = self._reasoning_display(profile) - - reasoning_buttons = [ - InlineKeyboardButton( - text=f"{label}{' ✓' if label == current else ''}", - callback_data=f"{_SETTINGS_REASONING_PREFIX}{value}", - ) - for value, label in options - ] - buttons = self._chunk_buttons(reasoning_buttons) - buttons.append( - [ - InlineKeyboardButton( - text="⬅️ Back to settings", callback_data=_SETTINGS_BACK - ) - ] - ) - - if capability is None or capability.reasoning is None: - note = ( - "Thinking controls are not published for this model. " - "Only the provider default is available." - ) - elif not capability.reasoning.supports_effort: - note = "This model does not expose effort controls." - else: - note = "Only options supported by the selected model are shown." - - text = format_for_telegram( - f"🧠 **Thinking for {self._model_display_name(effective_model)}**\n\n" - f"Current: **{current}**\n" - f"{note}" - ) - return text, InlineKeyboardMarkup(inline_keyboard=buttons) - async def _handle_callback_query(self, query: CallbackQuery) -> None: """Handle incoming callback query.""" data = query.data or "" if data.startswith("health:"): await self._handle_health_callback(query) return - action, value = self._parse_settings_callback(data) - if action is None: - await self.api.answer_callback_query(query.id, text="Unknown action") - return - - if action == "model" and (value is None or value not in MODEL_CHOICES): - await self.api.answer_callback_query(query.id, text="Unknown model") - return - if action == "reasoning" and value not in { - _INHERIT_REASONING, - *tuple(_REASONING_LABELS), - }: - await self.api.answer_callback_query( - query.id, text="Unknown thinking option" - ) - return - - if query.message is None: - await self.api.answer_callback_query(query.id, text="Settings expired") - return - - chat_id = query.message.chat.id - await self.api.answer_callback_query(query.id, text="Updating settings…") - - try: - storage = get_preferences_storage() - if action == "model": - model_id, _ = MODEL_CHOICES[cast(str, value)] - await update_inference_profile( - storage, - str(chat_id), - { - "model": None if model_id == "default" else model_id, - "reasoning": None, - }, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "reasoning": - profile = await self._load_chat_profile(chat_id) - capability = await self._resolve_capabilities( - self._effective_model(profile) - ) - supported = { - option for option, _ in self._reasoning_options(capability) - } - if value not in supported: - await self._edit_error( - query, - chat_id, - "That thinking option is not available for this model.", - ) - return - reasoning = self._reasoning_config(value) - await update_inference_profile( - storage, - str(chat_id), - {"reasoning": reasoning}, - base_profile=profile, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "reset": - await update_inference_profile( - storage, - str(chat_id), - {"model": None, "reasoning": None}, - ) - await self._edit_model_menu(query, chat_id) - return - - if action == "thinking": - profile = await self._load_chat_profile(chat_id) - text, markup = await self._build_thinking_menu(profile) - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - return - - # All other parsed actions return above, so the only remaining - # valid action is Back. - await self._edit_model_menu(query, chat_id) - except Exception: - logger.exception("Failed to update Telegram inference settings") - await self._edit_error( - query, chat_id, "Could not save settings. Please try again." - ) - - @staticmethod - def _parse_settings_callback(data: str) -> tuple[str | None, str | None]: - """Parse current and legacy callback payloads.""" - if data.startswith("mod:"): - return "model", data.removeprefix("mod:") - if data.startswith(_SETTINGS_MODEL_PREFIX): - return "model", data.removeprefix(_SETTINGS_MODEL_PREFIX) - if data.startswith(_SETTINGS_REASONING_PREFIX): - return "reasoning", data.removeprefix(_SETTINGS_REASONING_PREFIX) - if data == _SETTINGS_THINKING: - return "thinking", None - if data == _SETTINGS_BACK: - return "back", None - if data == _SETTINGS_RESET: - return "reset", None - return None, None - - async def _edit_model_menu(self, query: CallbackQuery, chat_id: int) -> None: - """Render the settings panel into an existing callback message.""" - if query.message is None: - return - profile = await self._load_chat_profile(chat_id) - text, markup = self._build_model_menu(profile) - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=text, - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - - async def _edit_error( - self, query: CallbackQuery, chat_id: int, message: str - ) -> None: - """Show a recoverable settings error while retaining a back action.""" - if query.message is None: - return - try: - await self.api.edit_message_text( - chat_id=chat_id, - message_id=query.message.message_id, - text=format_for_telegram(f"⚠️ {message}"), - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=InlineKeyboardMarkup( - inline_keyboard=[ - [ - InlineKeyboardButton( - text="⬅️ Back to settings", callback_data=_SETTINGS_BACK - ) - ] - ] - ), - ) - except Exception: - logger.exception("Failed to render Telegram settings error") + await self._settings_menu.handle_callback(query) async def _load_chat_profile(self, chat_id: int | str) -> InferenceProfile: """Load a profile snapshot, retaining the process fallback on errors.""" @@ -1303,89 +995,6 @@ async def _load_chat_profile(self, chat_id: int | str) -> InferenceProfile: else inference_profile_from_environment() ) - async def _resolve_capabilities( - self, model_id: str | None - ) -> ModelCapabilities | None: - """Resolve OpenRouter reasoning metadata without blocking turns.""" - if not model_id or model_id == "default": - return None - try: - if self._capabilities_resolver is None: - self._capabilities_resolver = OpenRouterModelCapabilitiesResolver() - return await self._capabilities_resolver.resolve( - model_id, - openrouter_routed=bool(os.getenv("OPENROUTER_API_KEY")), - ) - except Exception: - logger.exception("Failed to resolve model capabilities for %s", model_id) - return None - - @staticmethod - def _effective_model(profile: InferenceProfile) -> str: - """Resolve the profile model, then the process-wide model setting.""" - return profile.model or os.getenv("ROOT_AGENT_MODEL") or "default" - - @staticmethod - def _model_display_name(model_id: str) -> str: - """Return a friendly label while preserving unknown model IDs.""" - for configured_id, display_name in MODEL_CHOICES.values(): - if configured_id == model_id: - return display_name - if model_id == "default": - return "System Default" - return model_id.rsplit("/", 1)[-1] - - @staticmethod - def _effort_value(value: object) -> str | None: - """Normalize enum or string effort values for Telegram labels.""" - raw = getattr(value, "value", value) - return raw.strip().lower() if isinstance(raw, str) and raw.strip() else None - - def _reasoning_display(self, profile: InferenceProfile) -> str: - """Render the profile's current reasoning setting.""" - reasoning = profile.reasoning - if reasoning is None: - return _REASONING_LABELS[_INHERIT_REASONING] - value = self._effort_value(reasoning.effort) - if value is None: - return _REASONING_LABELS[_INHERIT_REASONING] - return _REASONING_LABELS.get(value, value.title()) - - def _reasoning_options( - self, capability: ModelCapabilities | None - ) -> list[tuple[str, str]]: - """Return default plus only the effort values the model supports.""" - options: list[tuple[str, str]] = [ - (_INHERIT_REASONING, _REASONING_LABELS[_INHERIT_REASONING]) - ] - reasoning = getattr(capability, "reasoning", None) - if reasoning is None or not reasoning.supports_effort: - return options - - supported = reasoning.supported_efforts - if supported is None: - supported = tuple(_REASONING_LABELS) - for effort in supported: - value = self._effort_value(effort) - if value is None or value == _INHERIT_REASONING: - continue - if value == "none" and reasoning.mandatory: - continue - label = _REASONING_LABELS.get(value, value.title()) - options.append((value, label)) - return options - - @staticmethod - def _reasoning_config(value: str) -> ReasoningConfig | None: - """Convert a Telegram value into the typed profile update.""" - if value == _INHERIT_REASONING: - return None - try: - effort = ReasoningEffort(value) - except ValueError: - return None - return ReasoningConfig(effort=effort) - async def _send_start_message(self, chat_id: int) -> None: """Send the start/welcome message.""" health_commands = "" diff --git a/src/blacki/telegram/settings_menu.py b/src/blacki/telegram/settings_menu.py new file mode 100644 index 0000000..d7ea1ae --- /dev/null +++ b/src/blacki/telegram/settings_menu.py @@ -0,0 +1,449 @@ +"""Inline-keyboard settings UI for choosing model and reasoning effort.""" + +import contextlib +import logging +import os +from collections.abc import Awaitable, Callable, Sequence +from typing import cast + +from blacki.inference import ( + InferenceProfile, + ReasoningConfig, + ReasoningEffort, + update_inference_profile, +) +from blacki.model_capabilities import ( + ModelCapabilities, + OpenRouterModelCapabilitiesResolver, +) +from blacki.utils.preferences import get_preferences_storage + +from .api import TelegramApiClient +from .formatting import format_for_telegram +from .types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode + +logger = logging.getLogger(__name__) + +MODEL_CHOICES = { + "m1": ("openrouter/openai/gpt-oss-120b", "GPT-OSS 120B"), + "m2": ("openrouter/x-ai/grok-4.3", "Grok 4.3"), + "m3": ("google/gemini-flash-latest", "Gemini Flash"), + "m4": ("openrouter/deepseek/deepseek-v4-pro", "DeepSeek v4 Pro"), + "m5": ("openrouter/deepseek/deepseek-v4-flash", "DeepSeek v4 Flash"), + "m6": ("google/gemini-pro-latest", "Gemini Pro"), + "m7": ("moonshotai/kimi-latest", "Kimi Latest"), + "m8": ("openrouter/minimax/minimax-m2.7", "MiniMax m2.7"), + "m9": ("openrouter/nvidia/nemotron-3-super-120b-a12b", "Nemotron 3 Super"), + "m10": ("openrouter/z-ai/glm-5", "GLM 5"), + "m11": ("openrouter/openai/gpt-5.6-luna", "GPT-5.6 Luna"), + "m_default": ("default", "System Default"), +} + +_SETTINGS_MODEL_PREFIX = "s:m:" +_SETTINGS_REASONING_PREFIX = "s:r:" +_SETTINGS_THINKING = "s:t" +_SETTINGS_BACK = "s:b" +_SETTINGS_RESET = "s:x" +_INHERIT_REASONING = "inherit" +_REASONING_LABELS = { + "inherit": "Default", + "none": "Off", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High", + "xhigh": "XHigh", + "max": "Max", +} + +ProfileLoader = Callable[[int | str], Awaitable[InferenceProfile]] + + +class SettingsMenu: + """Inline-keyboard settings UI for choosing model and reasoning effort. + + Owns no Telegram transport or session state of its own: it renders and + reacts to the `/model` and `/thinking` settings panels via an API client + obtained from ``api_provider`` and a chat's inference profile obtained + from ``load_profile``. + """ + + def __init__( + self, + api_provider: Callable[[], TelegramApiClient], + load_profile: ProfileLoader, + ) -> None: + self._api_provider = api_provider + self._load_profile = load_profile + self._capabilities_resolver: OpenRouterModelCapabilitiesResolver | None = None + + @property + def _api(self) -> TelegramApiClient: + return self._api_provider() + + async def aclose(self) -> None: + """Release the cached model-capabilities resolver, if any.""" + if self._capabilities_resolver is not None: + with contextlib.suppress(Exception): + await self._capabilities_resolver.aclose() + self._capabilities_resolver = None + + async def send_model_menu( + self, chat_id: int, message_thread_id: int | None + ) -> None: + """Send the compact model-and-thinking settings panel.""" + profile = await self._load_profile(chat_id) + text, reply_markup = self._build_model_menu(profile) + try: + await self._api.send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + message_thread_id=message_thread_id, + reply_markup=reply_markup, + ) + except Exception: + logger.exception("Failed to send model menu") + + async def send_thinking_menu( + self, chat_id: int, message_thread_id: int | None + ) -> None: + """Send the reasoning-effort menu for the effective model.""" + profile = await self._load_profile(chat_id) + text, reply_markup = await self._build_thinking_menu(profile) + try: + await self._api.send_message( + chat_id=chat_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + message_thread_id=message_thread_id, + reply_markup=reply_markup, + ) + except Exception: + logger.exception("Failed to send thinking menu") + + @staticmethod + def _chunk_buttons( + buttons: Sequence[InlineKeyboardButton], per_row: int = 2 + ) -> list[list[InlineKeyboardButton]]: + """Group flat buttons into keyboard rows of a fixed width.""" + return [list(buttons[i : i + per_row]) for i in range(0, len(buttons), per_row)] + + def _build_model_menu( + self, profile: InferenceProfile + ) -> tuple[str, InlineKeyboardMarkup]: + """Build the model menu without performing network I/O.""" + effective_model = self._effective_model(profile) + current_display_name = self._model_display_name(effective_model) + current_thinking = self._reasoning_display(profile) + + model_buttons = [ + InlineKeyboardButton( + text=display_name, + callback_data=f"{_SETTINGS_MODEL_PREFIX}{key}", + ) + for key, (_, display_name) in MODEL_CHOICES.items() + ] + buttons = self._chunk_buttons(model_buttons) + + buttons.append( + [ + InlineKeyboardButton( + text=f"🧠 Thinking: {current_thinking}", + callback_data=_SETTINGS_THINKING, + ) + ] + ) + buttons.append( + [ + InlineKeyboardButton( + text="↩️ Reset settings", callback_data=_SETTINGS_RESET + ) + ] + ) + + text = format_for_telegram( + "⚙️ **Inference settings**\n\n" + f"Model: **{current_display_name}**\n" + f"Thinking: **{current_thinking}**\n\n" + "Choose a model or adjust Thinking. Changes apply to the next turn." + ) + return text, InlineKeyboardMarkup(inline_keyboard=buttons) + + async def _build_thinking_menu( + self, profile: InferenceProfile + ) -> tuple[str, InlineKeyboardMarkup]: + """Build a capability-aware reasoning menu.""" + effective_model = self._effective_model(profile) + capability = await self._resolve_capabilities(effective_model) + options = self._reasoning_options(capability) + current = self._reasoning_display(profile) + + reasoning_buttons = [ + InlineKeyboardButton( + text=f"{label}{' ✓' if label == current else ''}", + callback_data=f"{_SETTINGS_REASONING_PREFIX}{value}", + ) + for value, label in options + ] + buttons = self._chunk_buttons(reasoning_buttons) + buttons.append( + [ + InlineKeyboardButton( + text="⬅️ Back to settings", callback_data=_SETTINGS_BACK + ) + ] + ) + + if capability is None or capability.reasoning is None: + note = ( + "Thinking controls are not published for this model. " + "Only the provider default is available." + ) + elif not capability.reasoning.supports_effort: + note = "This model does not expose effort controls." + else: + note = "Only options supported by the selected model are shown." + + text = format_for_telegram( + f"🧠 **Thinking for {self._model_display_name(effective_model)}**\n\n" + f"Current: **{current}**\n" + f"{note}" + ) + return text, InlineKeyboardMarkup(inline_keyboard=buttons) + + async def handle_callback(self, query: CallbackQuery) -> None: + """Handle a settings callback query (model, thinking, back, or reset).""" + data = query.data or "" + action, value = self._parse_settings_callback(data) + if action is None: + await self._api.answer_callback_query(query.id, text="Unknown action") + return + + if action == "model" and (value is None or value not in MODEL_CHOICES): + await self._api.answer_callback_query(query.id, text="Unknown model") + return + if action == "reasoning" and value not in { + _INHERIT_REASONING, + *tuple(_REASONING_LABELS), + }: + await self._api.answer_callback_query( + query.id, text="Unknown thinking option" + ) + return + + if query.message is None: + await self._api.answer_callback_query(query.id, text="Settings expired") + return + + chat_id = query.message.chat.id + await self._api.answer_callback_query(query.id, text="Updating settings…") + + try: + storage = get_preferences_storage() + if action == "model": + model_id, _ = MODEL_CHOICES[cast(str, value)] + await update_inference_profile( + storage, + str(chat_id), + { + "model": None if model_id == "default" else model_id, + "reasoning": None, + }, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "reasoning": + profile = await self._load_profile(chat_id) + capability = await self._resolve_capabilities( + self._effective_model(profile) + ) + supported = { + option for option, _ in self._reasoning_options(capability) + } + if value not in supported: + await self._edit_error( + query, + chat_id, + "That thinking option is not available for this model.", + ) + return + reasoning = self._reasoning_config(value) + await update_inference_profile( + storage, + str(chat_id), + {"reasoning": reasoning}, + base_profile=profile, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "reset": + await update_inference_profile( + storage, + str(chat_id), + {"model": None, "reasoning": None}, + ) + await self._edit_model_menu(query, chat_id) + return + + if action == "thinking": + profile = await self._load_profile(chat_id) + text, markup = await self._build_thinking_menu(profile) + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + return + + # All other parsed actions return above, so the only remaining + # valid action is Back. + await self._edit_model_menu(query, chat_id) + except Exception: + logger.exception("Failed to update Telegram inference settings") + await self._edit_error( + query, chat_id, "Could not save settings. Please try again." + ) + + @staticmethod + def _parse_settings_callback(data: str) -> tuple[str | None, str | None]: + """Parse current and legacy callback payloads.""" + if data.startswith("mod:"): + return "model", data.removeprefix("mod:") + if data.startswith(_SETTINGS_MODEL_PREFIX): + return "model", data.removeprefix(_SETTINGS_MODEL_PREFIX) + if data.startswith(_SETTINGS_REASONING_PREFIX): + return "reasoning", data.removeprefix(_SETTINGS_REASONING_PREFIX) + if data == _SETTINGS_THINKING: + return "thinking", None + if data == _SETTINGS_BACK: + return "back", None + if data == _SETTINGS_RESET: + return "reset", None + return None, None + + async def _edit_model_menu(self, query: CallbackQuery, chat_id: int) -> None: + """Render the settings panel into an existing callback message.""" + if query.message is None: + return + profile = await self._load_profile(chat_id) + text, markup = self._build_model_menu(profile) + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=text, + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + + async def _edit_error( + self, query: CallbackQuery, chat_id: int, message: str + ) -> None: + """Show a recoverable settings error while retaining a back action.""" + if query.message is None: + return + try: + await self._api.edit_message_text( + chat_id=chat_id, + message_id=query.message.message_id, + text=format_for_telegram(f"⚠️ {message}"), + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="⬅️ Back to settings", callback_data=_SETTINGS_BACK + ) + ] + ] + ), + ) + except Exception: + logger.exception("Failed to render Telegram settings error") + + async def _resolve_capabilities( + self, model_id: str | None + ) -> ModelCapabilities | None: + """Resolve OpenRouter reasoning metadata without blocking turns.""" + if not model_id or model_id == "default": + return None + try: + if self._capabilities_resolver is None: + self._capabilities_resolver = OpenRouterModelCapabilitiesResolver() + return await self._capabilities_resolver.resolve( + model_id, + openrouter_routed=bool(os.getenv("OPENROUTER_API_KEY")), + ) + except Exception: + logger.exception("Failed to resolve model capabilities for %s", model_id) + return None + + @staticmethod + def _effective_model(profile: InferenceProfile) -> str: + """Resolve the profile model, then the process-wide model setting.""" + return profile.model or os.getenv("ROOT_AGENT_MODEL") or "default" + + @staticmethod + def _model_display_name(model_id: str) -> str: + """Return a friendly label while preserving unknown model IDs.""" + for configured_id, display_name in MODEL_CHOICES.values(): + if configured_id == model_id: + return display_name + if model_id == "default": + return "System Default" + return model_id.rsplit("/", 1)[-1] + + @staticmethod + def _effort_value(value: object) -> str | None: + """Normalize enum or string effort values for Telegram labels.""" + raw = getattr(value, "value", value) + return raw.strip().lower() if isinstance(raw, str) and raw.strip() else None + + def _reasoning_display(self, profile: InferenceProfile) -> str: + """Render the profile's current reasoning setting.""" + reasoning = profile.reasoning + if reasoning is None: + return _REASONING_LABELS[_INHERIT_REASONING] + value = self._effort_value(reasoning.effort) + if value is None: + return _REASONING_LABELS[_INHERIT_REASONING] + return _REASONING_LABELS.get(value, value.title()) + + def _reasoning_options( + self, capability: ModelCapabilities | None + ) -> list[tuple[str, str]]: + """Return default plus only the effort values the model supports.""" + options: list[tuple[str, str]] = [ + (_INHERIT_REASONING, _REASONING_LABELS[_INHERIT_REASONING]) + ] + reasoning = getattr(capability, "reasoning", None) + if reasoning is None or not reasoning.supports_effort: + return options + + supported = reasoning.supported_efforts + if supported is None: + supported = tuple(_REASONING_LABELS) + for effort in supported: + value = self._effort_value(effort) + if value is None or value == _INHERIT_REASONING: + continue + if value == "none" and reasoning.mandatory: + continue + label = _REASONING_LABELS.get(value, value.title()) + options.append((value, label)) + return options + + @staticmethod + def _reasoning_config(value: str) -> ReasoningConfig | None: + """Convert a Telegram value into the typed profile update.""" + if value == _INHERIT_REASONING: + return None + try: + effort = ReasoningEffort(value) + except ValueError: + return None + return ReasoningConfig(effort=effort) diff --git a/tests/test_telegram_bot_model_override.py b/tests/test_telegram_bot_model_override.py index 0891b28..617193f 100644 --- a/tests/test_telegram_bot_model_override.py +++ b/tests/test_telegram_bot_model_override.py @@ -1,37 +1,22 @@ # mypy: ignore-errors -"""Tests for Telegram bot model override and callback queries.""" +"""Tests for TelegramBot's delegation to the settings menu and profile loading. + +Settings-menu behavior itself (model/thinking panels, callback handling, +reasoning options, etc.) is tested directly against SettingsMenu in +test_telegram_settings_menu.py. This file only covers the thin TelegramBot +glue: command dispatch, callback routing, and the shared profile loader. +""" -import asyncio -import os -from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, create_autospec, patch -import aiosqlite import pytest from blacki.adk_runtime import AdkRuntime -from blacki.inference import ( - INFERENCE_PROFILE_PREFERENCE_KEY, - LEGACY_MODEL_PREFERENCE_KEY, - InferenceProfile, - ReasoningConfig, - ReasoningEffort, - update_inference_profile, -) +from blacki.inference import InferenceProfile, ReasoningConfig, ReasoningEffort from blacki.telegram import TelegramConfig from blacki.telegram.api import TelegramApiClient from blacki.telegram.bot import TelegramBot -from blacki.telegram.types import ( - CallbackQuery, - Chat, - InlineKeyboardButton, - InlineKeyboardMarkup, - Message, - ParseMode, - Update, - User, -) -from blacki.utils.preferences import SqlitePreferencesStorage +from blacki.telegram.types import CallbackQuery, Chat, Message, Update, User @pytest.fixture @@ -56,32 +41,6 @@ def bot(telegram_config: TelegramConfig, mock_runtime: MagicMock) -> TelegramBot return bot_inst -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_send_model_menu_success(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = "openrouter/deepseek/deepseek-v4-pro" - mock_get_prefs.return_value = mock_storage - - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - kwargs = bot._api.send_message.call_args.kwargs - assert kwargs["chat_id"] == 123 - assert "reply_markup" in kwargs - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_send_model_menu_exception(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = None - mock_get_prefs.return_value = mock_storage - - bot._api.send_message.side_effect = Exception("failed") - # Should not raise - await bot._send_model_menu(chat_id=123, message_thread_id=None) - - @pytest.mark.asyncio async def test_safe_handle_update_callback_query(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") @@ -94,66 +53,27 @@ async def test_safe_handle_update_callback_query(bot: TelegramBot) -> None: @pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch("blacki.telegram.bot.MODEL_CHOICES", {"m1": ("m1", "M1"), "m2": ("m2", "M2")}) -async def test_send_model_menu_even_choices(mock_get_prefs, bot: TelegramBot) -> None: - mock_storage = AsyncMock() - mock_storage.get.return_value = "m1" - mock_get_prefs.return_value = mock_storage - - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch("blacki.telegram.bot.MODEL_CHOICES", {"m1": ("m1", "M1")}) -async def test_send_model_menu_unknown_current_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - - async def get_preference(_chat_id, key, default=None): - if key == INFERENCE_PROFILE_PREFERENCE_KEY: - return {"model": "unknown_model_id"} - return default - - mock_storage.get.side_effect = get_preference - mock_get_prefs.return_value = mock_storage - - with patch.dict(os.environ, {"ROOT_AGENT_MODEL": "default"}): - await bot._send_model_menu(chat_id=123, message_thread_id=None) - bot._api.send_message.assert_called_once() - kwargs = bot._api.send_message.call_args.kwargs - assert "unknown\\_model\\_id" in kwargs["text"] - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_no_message( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - # Message is None - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=None - ) +async def test_handle_command_model(bot: TelegramBot) -> None: + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once() - bot._api.edit_message_text.assert_not_called() + with patch.object( + bot._settings_menu, "send_model_menu", AsyncMock() + ) as mock_send_menu: + await bot._handle_command(msg, "/model") + mock_send_menu.assert_called_once_with(123, None) @pytest.mark.asyncio -async def test_handle_command_model(bot: TelegramBot) -> None: +async def test_handle_command_thinking(bot: TelegramBot) -> None: chat = Chat(id=123, type="private") msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - with patch.object(bot, "_send_model_menu", AsyncMock()) as mock_send_menu: - await bot._handle_command(msg, "/model") - mock_send_menu.assert_called_once_with(123, None) + with patch.object( + bot._settings_menu, "send_thinking_menu", AsyncMock() + ) as mock_send_menu: + await bot._handle_command(msg, "/thinking") + mock_send_menu.assert_awaited_once_with(123, None) @pytest.mark.asyncio @@ -174,645 +94,25 @@ async def test_build_session_state_no_pref(bot: TelegramBot) -> None: @pytest.mark.asyncio -async def test_handle_callback_query_invalid_data(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="invalid") - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once_with("cq1", text="Unknown action") - - -@pytest.mark.asyncio -async def test_handle_callback_query_unknown_model(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:unknown" - ) - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once_with("cq1", text="Unknown model") - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_valid_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as mock_update: - await bot._handle_callback_query(cq) - mock_update.assert_awaited_once_with( - mock_storage, - "123", - {"model": "openrouter/openai/gpt-oss-120b", "reasoning": None}, - ) - bot._api.answer_callback_query.assert_called_once() - bot._api.edit_message_text.assert_called_once() - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_default_model( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="mod:m_default", - message=msg, - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as mock_update: - await bot._handle_callback_query(cq) - mock_update.assert_awaited_once_with( - mock_storage, - "123", - {"model": None, "reasoning": None}, - ) - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -async def test_handle_callback_query_edit_msg_exception( - mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage - user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg - ) - - bot._api.edit_message_text.side_effect = Exception("fail") - with patch("blacki.telegram.bot.update_inference_profile", new=AsyncMock()): - await bot._handle_callback_query(cq) - bot._api.answer_callback_query.assert_called_once() - - -@pytest.mark.asyncio -async def test_handle_command_thinking(bot: TelegramBot) -> None: - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - - with patch.object(bot, "_send_thinking_menu", AsyncMock()) as mock_send_menu: - await bot._handle_command(msg, "/thinking") - mock_send_menu.assert_awaited_once_with(123, None) - - -def test_settings_callback_data_is_within_telegram_limit(bot: TelegramBot) -> None: - text, markup = bot._build_model_menu(InferenceProfile()) - assert text - callback_data = [ - button.callback_data - for row in markup.inline_keyboard - for button in row - if button.callback_data is not None - ] - assert callback_data - assert all(len(value.encode("utf-8")) <= 64 for value in callback_data) - - -@pytest.mark.asyncio -async def test_message_less_settings_mutation_does_not_write(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:m:m1", message=None - ) - - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update: - await bot._handle_callback_query(cq) - - update.assert_not_awaited() - bot._api.answer_callback_query.assert_called_once_with( - "cq1", text="Settings expired" - ) - - -@pytest.mark.asyncio -@patch("blacki.telegram.bot.get_preferences_storage") -@patch( - "blacki.telegram.bot.load_inference_profile", - new_callable=AsyncMock, - return_value=InferenceProfile( - model="openrouter/openai/gpt-oss-120b", - reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), - ), -) -async def test_reasoning_callback_preserves_model( - mock_load, mock_get_prefs, bot: TelegramBot -) -> None: - mock_storage = AsyncMock() - mock_get_prefs.return_value = mock_storage +async def test_handle_callback_query_routes_health_data(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:r:max", message=msg - ) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("low", "high", "max"), - mandatory=False, - ) + id="cq1", from_user=user, chat_instance="inst", data="health:cancel" ) - with ( - patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): + with patch.object(bot, "_handle_health_callback", AsyncMock()) as mock_health: await bot._handle_callback_query(cq) - - update.assert_awaited_once_with( - mock_storage, - "123", - {"reasoning": ReasoningConfig(effort=ReasoningEffort.MAX)}, - base_profile=InferenceProfile( - model="openrouter/openai/gpt-oss-120b", - reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), - ), - ) - - -async def _initialized_preferences_storage() -> SqlitePreferencesStorage: - connection = await aiosqlite.connect(":memory:", isolation_level=None) - connection.row_factory = aiosqlite.Row - storage = SqlitePreferencesStorage(connection, asyncio.Lock()) - await storage.initialize() - return storage - - -def _reasoning_callback_query() -> CallbackQuery: - return CallbackQuery( - id="cq1", - from_user=User(id=1, is_bot=False, first_name="Test"), - chat_instance="inst", - data="s:r:max", - message=Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ), - ) - - -def _max_reasoning_capability() -> SimpleNamespace: - return SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("max",), - mandatory=False, - ) - ) - - -@pytest.mark.asyncio -async def test_reasoning_callback_migrates_legacy_model(bot: TelegramBot) -> None: - storage = await _initialized_preferences_storage() - await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") - - try: - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=storage), - patch.object( - bot, - "_resolve_capabilities", - AsyncMock(return_value=_max_reasoning_capability()), - ), - ): - await bot._handle_callback_query(_reasoning_callback_query()) - - assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { - "model": "legacy-model", - "reasoning": {"effort": "max"}, - } - finally: - await storage.close() - await storage.conn.close() - - -@pytest.mark.asyncio -async def test_stale_reasoning_callback_preserves_new_model( - bot: TelegramBot, -) -> None: - storage = await _initialized_preferences_storage() - await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") - - async def select_new_model_during_capability_lookup( - model_id: str, - ) -> SimpleNamespace: - assert model_id == "legacy-model" - await update_inference_profile( - storage, - "123", - {"model": "new-model", "reasoning": None}, - ) - return _max_reasoning_capability() - - try: - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=storage), - patch.object( - bot, - "_resolve_capabilities", - side_effect=select_new_model_during_capability_lookup, - ), - ): - await bot._handle_callback_query(_reasoning_callback_query()) - - assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { - "model": "new-model", - "reasoning": None, - } - assert ( - "Could not save settings" - in bot._api.edit_message_text.await_args.kwargs["text"] - ) - finally: - await storage.close() - await storage.conn.close() - - -@pytest.mark.asyncio -async def test_reasoning_menu_hides_off_for_mandatory_model(bot: TelegramBot) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("none", "high", "max"), - mandatory=True, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - _, markup = await bot._build_thinking_menu(InferenceProfile()) - - labels = [button.text for row in markup.inline_keyboard for button in row] - assert not any(label.startswith("Off") for label in labels) - assert any(label.startswith("High") for label in labels) - - -@pytest.mark.asyncio -async def test_thinking_menu_falls_back_when_capability_client_fails( - bot: TelegramBot, -) -> None: - with ( - patch.object( - bot, - "_load_chat_profile", - AsyncMock( - return_value=InferenceProfile(model="openrouter/openai/gpt-5.6-luna") - ), - ), - patch( - "blacki.telegram.bot.OpenRouterModelCapabilitiesResolver", - side_effect=RuntimeError("capability client unavailable"), - ), - ): - await bot._send_thinking_menu(chat_id=123, message_thread_id=None) - - bot._api.send_message.assert_awaited_once() - markup = bot._api.send_message.call_args.kwargs["reply_markup"] - callback_data = [ - button.callback_data - for row in markup.inline_keyboard - for button in row - if button.callback_data is not None - ] - assert callback_data == ["s:r:inherit", "s:b"] + mock_health.assert_awaited_once_with(cq) @pytest.mark.asyncio -async def test_stale_reasoning_callback_does_not_write(bot: TelegramBot) -> None: +async def test_handle_callback_query_routes_settings_data(bot: TelegramBot) -> None: user = User(id=1, is_bot=False, first_name="Test") - chat = Chat(id=123, type="private") - msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) - cq = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:r:not-real", message=msg - ) + cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="s:b") - with patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update: + with patch.object(bot._settings_menu, "handle_callback", AsyncMock()) as mock_menu: await bot._handle_callback_query(cq) - - update.assert_not_awaited() - bot._api.answer_callback_query.assert_called_once_with( - "cq1", text="Unknown thinking option" - ) - - -@pytest.mark.asyncio -async def test_stop_closes_capability_resolver(bot: TelegramBot) -> None: - resolver = AsyncMock() - bot._capabilities_resolver = resolver - bot.runtime.close = AsyncMock() - bot._api.close = AsyncMock() - - await bot.stop() - - resolver.aclose.assert_awaited_once() - assert bot._capabilities_resolver is None - - -@pytest.mark.asyncio -async def test_stop_suppresses_capability_resolver_close_error( - bot: TelegramBot, -) -> None: - resolver = AsyncMock() - resolver.aclose.side_effect = RuntimeError("close failed") - bot._capabilities_resolver = resolver - bot.runtime.close = AsyncMock() - bot._api.close = AsyncMock() - - await bot.stop() - - resolver.aclose.assert_awaited_once() - assert bot._capabilities_resolver is None - - -@pytest.mark.asyncio -async def test_send_thinking_menu_handles_send_error(bot: TelegramBot) -> None: - bot._api.send_message.side_effect = RuntimeError("send failed") - - await bot._send_thinking_menu(chat_id=123, message_thread_id=None) - - bot._api.send_message.assert_awaited_once() - - -@pytest.mark.asyncio -async def test_thinking_menu_even_options_has_no_partial_row(bot: TelegramBot) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("max",), - mandatory=False, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - _, markup = await bot._build_thinking_menu(InferenceProfile()) - - assert [button.callback_data for button in markup.inline_keyboard[0]] == [ - "s:r:inherit", - "s:r:max", - ] - assert markup.inline_keyboard[-1][0].callback_data == "s:b" - - -@pytest.mark.asyncio -async def test_thinking_menu_notes_when_effort_is_unsupported( - bot: TelegramBot, -) -> None: - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=False, - supported_efforts=(), - mandatory=False, - ) - ) - with patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)): - text, markup = await bot._build_thinking_menu(InferenceProfile()) - - assert "does not expose effort controls" in text - assert [ - button.callback_data for row in markup.inline_keyboard for button in row - ] == [ - "s:r:inherit", - "s:b", - ] - - -@pytest.mark.asyncio -async def test_callback_model_none_value_is_rejected_without_write( - bot: TelegramBot, -) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:m:m1", - message=message, - ) - - with ( - patch.object(bot, "_parse_settings_callback", return_value=("model", None)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_not_awaited() - assert bot._api.answer_callback_query.await_args_list[-1].kwargs["text"] == ( - "Unknown model" - ) - - -@pytest.mark.asyncio -async def test_callback_rejects_effort_not_supported_by_model(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:r:max", - message=message, - ) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=("low",), - mandatory=False, - ) - ) - mock_storage = AsyncMock() - - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=mock_storage), - patch.object(bot, "_resolve_capabilities", AsyncMock(return_value=capability)), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_not_awaited() - assert bot._api.edit_message_text.await_count == 1 - - -@pytest.mark.asyncio -async def test_reset_callback_updates_both_profile_fields(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:x", - message=message, - ) - - mock_storage = AsyncMock() - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=mock_storage), - patch( - "blacki.telegram.bot.update_inference_profile", new=AsyncMock() - ) as update, - ): - await bot._handle_callback_query(query) - - update.assert_awaited_once_with( - mock_storage, - "123", - {"model": None, "reasoning": None}, - ) - - -@pytest.mark.asyncio -async def test_thinking_callback_edits_capability_menu(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:t", - message=message, - ) - markup = InlineKeyboardMarkup( - inline_keyboard=[ - [InlineKeyboardButton(text="Default", callback_data="s:r:inherit")] - ] - ) - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=AsyncMock()), - patch.object( - bot, - "_build_thinking_menu", - AsyncMock(return_value=("thinking", markup)), - ), - ): - await bot._handle_callback_query(query) - - bot._api.edit_message_text.assert_awaited_once_with( - chat_id=123, - message_id=42, - text="thinking", - parse_mode=ParseMode.MARKDOWN_V2, - reply_markup=markup, - ) - - -@pytest.mark.asyncio -async def test_back_callback_returns_to_model_menu(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - message = Message( - message_id=42, - date="2024-01-01T00:00:00Z", - chat=Chat(id=123, type="private"), - ) - query = CallbackQuery( - id="cq1", - from_user=user, - chat_instance="inst", - data="s:b", - message=message, - ) - - with ( - patch("blacki.telegram.bot.get_preferences_storage", return_value=AsyncMock()), - patch.object(bot, "_edit_model_menu", AsyncMock()) as edit_menu, - ): - await bot._handle_callback_query(query) - - edit_menu.assert_awaited_once_with(query, 123) - - -def test_settings_callback_parser_handles_navigation_actions() -> None: - assert TelegramBot._parse_settings_callback("s:t") == ("thinking", None) - assert TelegramBot._parse_settings_callback("s:b") == ("back", None) - assert TelegramBot._parse_settings_callback("s:x") == ("reset", None) - - -@pytest.mark.asyncio -async def test_edit_model_menu_ignores_message_less_callback(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - query = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None - ) - - await bot._edit_model_menu(query, 123) - - bot._api.edit_message_text.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_edit_error_ignores_message_less_callback(bot: TelegramBot) -> None: - user = User(id=1, is_bot=False, first_name="Test") - query = CallbackQuery( - id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None - ) - - await bot._edit_error(query, 123, "failed") - - bot._api.edit_message_text.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_resolve_capabilities_skips_missing_models(bot: TelegramBot) -> None: - assert await bot._resolve_capabilities(None) is None - assert await bot._resolve_capabilities("default") is None - - -@pytest.mark.asyncio -async def test_resolve_capabilities_uses_cached_resolver(bot: TelegramBot) -> None: - resolver = AsyncMock() - resolver.resolve.return_value = None - bot._capabilities_resolver = resolver - - assert await bot._resolve_capabilities("openrouter/openai/gpt-5.6-luna") is None - - resolver.resolve.assert_awaited_once() - assert resolver.resolve.await_args.args == ("openrouter/openai/gpt-5.6-luna",) + mock_menu.assert_awaited_once_with(cq) @pytest.mark.asyncio @@ -852,57 +152,3 @@ async def test_load_chat_profile_uses_environment_after_invalid_result( assert profile == InferenceProfile( reasoning=ReasoningConfig(effort=ReasoningEffort.MAX) ) - - -def test_model_display_name_handles_unknown_future_model(bot: TelegramBot) -> None: - assert bot._model_display_name("openrouter/acme/future-model") == "future-model" - - -def test_model_display_name_handles_system_default(bot: TelegramBot) -> None: - with patch("blacki.telegram.bot.MODEL_CHOICES", {}): - assert bot._model_display_name("default") == "System Default" - - -def test_reasoning_display_inherits_when_only_token_budget_is_set( - bot: TelegramBot, -) -> None: - profile = InferenceProfile(reasoning=ReasoningConfig(max_tokens=256)) - - assert bot._reasoning_display(profile) == "Default" - - -def test_reasoning_options_include_gateway_values_when_unspecified() -> None: - bot = TelegramBot.__new__(TelegramBot) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=None, - mandatory=False, - ) - ) - - options = bot._reasoning_options(capability) - - assert ("max", "Max") in options - assert ("none", "Off") in options - - -def test_reasoning_options_skip_empty_and_inherit_values() -> None: - bot = TelegramBot.__new__(TelegramBot) - capability = SimpleNamespace( - reasoning=SimpleNamespace( - supports_effort=True, - supported_efforts=(None, "inherit", "max"), - mandatory=False, - ) - ) - - assert bot._reasoning_options(capability) == [ - ("inherit", "Default"), - ("max", "Max"), - ] - - -def test_reasoning_config_handles_inherit_and_invalid_values() -> None: - assert TelegramBot._reasoning_config("inherit") is None - assert TelegramBot._reasoning_config("not-an-effort") is None diff --git a/tests/test_telegram_settings_menu.py b/tests/test_telegram_settings_menu.py new file mode 100644 index 0000000..4e9f94c --- /dev/null +++ b/tests/test_telegram_settings_menu.py @@ -0,0 +1,856 @@ +# mypy: ignore-errors +"""Tests for the standalone Telegram settings-menu UI (model/thinking panels).""" + +import asyncio +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch + +import aiosqlite +import pytest + +from blacki.inference import ( + INFERENCE_PROFILE_PREFERENCE_KEY, + LEGACY_MODEL_PREFERENCE_KEY, + InferenceProfile, + ReasoningConfig, + ReasoningEffort, + update_inference_profile, +) +from blacki.telegram.api import TelegramApiClient +from blacki.telegram.settings_menu import SettingsMenu +from blacki.telegram.types import ( + CallbackQuery, + Chat, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, + ParseMode, + User, +) +from blacki.utils.preferences import SqlitePreferencesStorage + + +@pytest.fixture +def mock_api() -> MagicMock: + return create_autospec(TelegramApiClient, instance=True) + + +@pytest.fixture +def load_profile() -> AsyncMock: + return AsyncMock(return_value=InferenceProfile()) + + +@pytest.fixture +def menu(mock_api: MagicMock, load_profile: AsyncMock) -> SettingsMenu: + return SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_send_model_menu_success( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_storage.get.return_value = "openrouter/deepseek/deepseek-v4-pro" + mock_get_prefs.return_value = mock_storage + + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + kwargs = mock_api.send_message.call_args.kwargs + assert kwargs["chat_id"] == 123 + assert "reply_markup" in kwargs + + +@pytest.mark.asyncio +async def test_send_model_menu_exception( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_api.send_message.side_effect = Exception("failed") + # Should not raise + await menu.send_model_menu(chat_id=123, message_thread_id=None) + + +@pytest.mark.asyncio +@patch( + "blacki.telegram.settings_menu.MODEL_CHOICES", + {"m1": ("m1", "M1"), "m2": ("m2", "M2")}, +) +async def test_send_model_menu_even_choices( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.MODEL_CHOICES", {"m1": ("m1", "M1")}) +async def test_send_model_menu_unknown_current_model( + menu: SettingsMenu, mock_api: MagicMock, load_profile: AsyncMock +) -> None: + load_profile.return_value = InferenceProfile(model="unknown_model_id") + + with patch.dict(os.environ, {"ROOT_AGENT_MODEL": "default"}): + await menu.send_model_menu(chat_id=123, message_thread_id=None) + mock_api.send_message.assert_called_once() + kwargs = mock_api.send_message.call_args.kwargs + assert "unknown\\_model\\_id" in kwargs["text"] + + +@pytest.mark.asyncio +async def test_handle_callback_query_no_message( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + # Message is None + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=None + ) + + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once() + mock_api.edit_message_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_handle_callback_query_invalid_data( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery(id="cq1", from_user=user, chat_instance="inst", data="invalid") + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once_with("cq1", text="Unknown action") + + +@pytest.mark.asyncio +async def test_handle_callback_query_unknown_model( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:unknown" + ) + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once_with("cq1", text="Unknown model") + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_valid_model( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as mock_update: + await menu.handle_callback(cq) + mock_update.assert_awaited_once_with( + mock_storage, + "123", + {"model": "openrouter/openai/gpt-oss-120b", "reasoning": None}, + ) + mock_api.answer_callback_query.assert_called_once() + mock_api.edit_message_text.assert_called_once() + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_default_model( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="mod:m_default", + message=msg, + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as mock_update: + await menu.handle_callback(cq) + mock_update.assert_awaited_once_with( + mock_storage, + "123", + {"model": None, "reasoning": None}, + ) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +async def test_handle_callback_query_edit_msg_exception( + mock_get_prefs, menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="mod:m1", message=msg + ) + + mock_api.edit_message_text.side_effect = Exception("fail") + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ): + await menu.handle_callback(cq) + mock_api.answer_callback_query.assert_called_once() + + +def test_settings_callback_data_is_within_telegram_limit(menu: SettingsMenu) -> None: + text, markup = menu._build_model_menu(InferenceProfile()) + assert text + callback_data = [ + button.callback_data + for row in markup.inline_keyboard + for button in row + if button.callback_data is not None + ] + assert callback_data + assert all(len(value.encode("utf-8")) <= 64 for value in callback_data) + + +@pytest.mark.asyncio +async def test_message_less_settings_mutation_does_not_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:m:m1", message=None + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update: + await menu.handle_callback(cq) + + update.assert_not_awaited() + mock_api.answer_callback_query.assert_called_once_with( + "cq1", text="Settings expired" + ) + + +@pytest.mark.asyncio +@patch("blacki.telegram.settings_menu.get_preferences_storage") +@patch( + "blacki.telegram.settings_menu.OpenRouterModelCapabilitiesResolver", +) +async def test_reasoning_callback_preserves_model( + mock_resolver_cls, + mock_get_prefs, + menu: SettingsMenu, + mock_api: MagicMock, + load_profile, +) -> None: + load_profile.return_value = InferenceProfile( + model="openrouter/openai/gpt-oss-120b", + reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), + ) + mock_storage = AsyncMock() + mock_get_prefs.return_value = mock_storage + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:r:max", message=msg + ) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("low", "high", "max"), + mandatory=False, + ) + ) + + with ( + patch.object(menu, "_resolve_capabilities", AsyncMock(return_value=capability)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(cq) + + update.assert_awaited_once_with( + mock_storage, + "123", + {"reasoning": ReasoningConfig(effort=ReasoningEffort.MAX)}, + base_profile=InferenceProfile( + model="openrouter/openai/gpt-oss-120b", + reasoning=ReasoningConfig(effort=ReasoningEffort.HIGH), + ), + ) + + +async def _initialized_preferences_storage() -> SqlitePreferencesStorage: + connection = await aiosqlite.connect(":memory:", isolation_level=None) + connection.row_factory = aiosqlite.Row + storage = SqlitePreferencesStorage(connection, asyncio.Lock()) + await storage.initialize() + return storage + + +def _reasoning_callback_query() -> CallbackQuery: + return CallbackQuery( + id="cq1", + from_user=User(id=1, is_bot=False, first_name="Test"), + chat_instance="inst", + data="s:r:max", + message=Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ), + ) + + +def _max_reasoning_capability() -> SimpleNamespace: + return SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("max",), + mandatory=False, + ) + ) + + +@pytest.mark.asyncio +async def test_reasoning_callback_migrates_legacy_model( + mock_api: MagicMock, +) -> None: + storage = await _initialized_preferences_storage() + await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") + + from blacki.inference import load_inference_profile + + async def load_profile(chat_id: int | str) -> InferenceProfile: + return await load_inference_profile(storage, str(chat_id)) + + menu = SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + try: + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=storage, + ), + patch.object( + menu, + "_resolve_capabilities", + AsyncMock(return_value=_max_reasoning_capability()), + ), + ): + await menu.handle_callback(_reasoning_callback_query()) + + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { + "model": "legacy-model", + "reasoning": {"effort": "max"}, + } + finally: + await storage.close() + await storage.conn.close() + + +@pytest.mark.asyncio +async def test_stale_reasoning_callback_preserves_new_model( + mock_api: MagicMock, +) -> None: + storage = await _initialized_preferences_storage() + await storage.set("123", LEGACY_MODEL_PREFERENCE_KEY, "legacy-model") + + from blacki.inference import load_inference_profile + + async def load_profile(chat_id: int | str) -> InferenceProfile: + return await load_inference_profile(storage, str(chat_id)) + + menu = SettingsMenu(api_provider=lambda: mock_api, load_profile=load_profile) + + async def select_new_model_during_capability_lookup( + model_id: str, + ) -> SimpleNamespace: + assert model_id == "legacy-model" + await update_inference_profile( + storage, + "123", + {"model": "new-model", "reasoning": None}, + ) + return _max_reasoning_capability() + + try: + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=storage, + ), + patch.object( + menu, + "_resolve_capabilities", + side_effect=select_new_model_during_capability_lookup, + ), + ): + await menu.handle_callback(_reasoning_callback_query()) + + assert await storage.get("123", INFERENCE_PROFILE_PREFERENCE_KEY) == { + "model": "new-model", + "reasoning": None, + } + assert ( + "Could not save settings" + in mock_api.edit_message_text.await_args.kwargs["text"] + ) + finally: + await storage.close() + await storage.conn.close() + + +@pytest.mark.asyncio +async def test_reasoning_menu_hides_off_for_mandatory_model(menu: SettingsMenu) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("none", "high", "max"), + mandatory=True, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + _, markup = await menu._build_thinking_menu(InferenceProfile()) + + labels = [button.text for row in markup.inline_keyboard for button in row] + assert not any(label.startswith("Off") for label in labels) + assert any(label.startswith("High") for label in labels) + + +@pytest.mark.asyncio +async def test_thinking_menu_falls_back_when_capability_client_fails( + menu: SettingsMenu, mock_api: MagicMock, load_profile: AsyncMock +) -> None: + load_profile.return_value = InferenceProfile(model="openrouter/openai/gpt-5.6-luna") + + with patch( + "blacki.telegram.settings_menu.OpenRouterModelCapabilitiesResolver", + side_effect=RuntimeError("capability client unavailable"), + ): + await menu.send_thinking_menu(chat_id=123, message_thread_id=None) + + mock_api.send_message.assert_awaited_once() + markup = mock_api.send_message.call_args.kwargs["reply_markup"] + callback_data = [ + button.callback_data + for row in markup.inline_keyboard + for button in row + if button.callback_data is not None + ] + assert callback_data == ["s:r:inherit", "s:b"] + + +@pytest.mark.asyncio +async def test_stale_reasoning_callback_does_not_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + chat = Chat(id=123, type="private") + msg = Message(message_id=42, date="2024-01-01T00:00:00Z", chat=chat) + cq = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:r:not-real", message=msg + ) + + with patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update: + await menu.handle_callback(cq) + + update.assert_not_awaited() + mock_api.answer_callback_query.assert_called_once_with( + "cq1", text="Unknown thinking option" + ) + + +@pytest.mark.asyncio +async def test_aclose_closes_capability_resolver(menu: SettingsMenu) -> None: + resolver = AsyncMock() + menu._capabilities_resolver = resolver + + await menu.aclose() + + resolver.aclose.assert_awaited_once() + assert menu._capabilities_resolver is None + + +@pytest.mark.asyncio +async def test_aclose_suppresses_capability_resolver_close_error( + menu: SettingsMenu, +) -> None: + resolver = AsyncMock() + resolver.aclose.side_effect = RuntimeError("close failed") + menu._capabilities_resolver = resolver + + await menu.aclose() + + resolver.aclose.assert_awaited_once() + assert menu._capabilities_resolver is None + + +@pytest.mark.asyncio +async def test_send_thinking_menu_handles_send_error( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + mock_api.send_message.side_effect = RuntimeError("send failed") + + await menu.send_thinking_menu(chat_id=123, message_thread_id=None) + + mock_api.send_message.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_thinking_menu_even_options_has_no_partial_row( + menu: SettingsMenu, +) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("max",), + mandatory=False, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + _, markup = await menu._build_thinking_menu(InferenceProfile()) + + assert [button.callback_data for button in markup.inline_keyboard[0]] == [ + "s:r:inherit", + "s:r:max", + ] + assert markup.inline_keyboard[-1][0].callback_data == "s:b" + + +@pytest.mark.asyncio +async def test_thinking_menu_notes_when_effort_is_unsupported( + menu: SettingsMenu, +) -> None: + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=False, + supported_efforts=(), + mandatory=False, + ) + ) + with patch.object( + menu, "_resolve_capabilities", AsyncMock(return_value=capability) + ): + text, markup = await menu._build_thinking_menu(InferenceProfile()) + + assert "does not expose effort controls" in text + assert [ + button.callback_data for row in markup.inline_keyboard for button in row + ] == [ + "s:r:inherit", + "s:b", + ] + + +@pytest.mark.asyncio +async def test_callback_model_none_value_is_rejected_without_write( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:m:m1", + message=message, + ) + + with ( + patch.object(menu, "_parse_settings_callback", return_value=("model", None)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_not_awaited() + assert mock_api.answer_callback_query.await_args_list[-1].kwargs["text"] == ( + "Unknown model" + ) + + +@pytest.mark.asyncio +async def test_callback_rejects_effort_not_supported_by_model( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:r:max", + message=message, + ) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=("low",), + mandatory=False, + ) + ) + mock_storage = AsyncMock() + + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=mock_storage, + ), + patch.object(menu, "_resolve_capabilities", AsyncMock(return_value=capability)), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_not_awaited() + assert mock_api.edit_message_text.await_count == 1 + + +@pytest.mark.asyncio +async def test_reset_callback_updates_both_profile_fields( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:x", + message=message, + ) + + mock_storage = AsyncMock() + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=mock_storage, + ), + patch( + "blacki.telegram.settings_menu.update_inference_profile", new=AsyncMock() + ) as update, + ): + await menu.handle_callback(query) + + update.assert_awaited_once_with( + mock_storage, + "123", + {"model": None, "reasoning": None}, + ) + + +@pytest.mark.asyncio +async def test_thinking_callback_edits_capability_menu( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:t", + message=message, + ) + markup = InlineKeyboardMarkup( + inline_keyboard=[ + [InlineKeyboardButton(text="Default", callback_data="s:r:inherit")] + ] + ) + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=AsyncMock(), + ), + patch.object( + menu, + "_build_thinking_menu", + AsyncMock(return_value=("thinking", markup)), + ), + ): + await menu.handle_callback(query) + + mock_api.edit_message_text.assert_awaited_once_with( + chat_id=123, + message_id=42, + text="thinking", + parse_mode=ParseMode.MARKDOWN_V2, + reply_markup=markup, + ) + + +@pytest.mark.asyncio +async def test_back_callback_returns_to_model_menu( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + message = Message( + message_id=42, + date="2024-01-01T00:00:00Z", + chat=Chat(id=123, type="private"), + ) + query = CallbackQuery( + id="cq1", + from_user=user, + chat_instance="inst", + data="s:b", + message=message, + ) + + with ( + patch( + "blacki.telegram.settings_menu.get_preferences_storage", + return_value=AsyncMock(), + ), + patch.object(menu, "_edit_model_menu", AsyncMock()) as edit_menu, + ): + await menu.handle_callback(query) + + edit_menu.assert_awaited_once_with(query, 123) + + +def test_settings_callback_parser_handles_navigation_actions() -> None: + assert SettingsMenu._parse_settings_callback("s:t") == ("thinking", None) + assert SettingsMenu._parse_settings_callback("s:b") == ("back", None) + assert SettingsMenu._parse_settings_callback("s:x") == ("reset", None) + + +@pytest.mark.asyncio +async def test_edit_model_menu_ignores_message_less_callback( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + query = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None + ) + + await menu._edit_model_menu(query, 123) + + mock_api.edit_message_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_edit_error_ignores_message_less_callback( + menu: SettingsMenu, mock_api: MagicMock +) -> None: + user = User(id=1, is_bot=False, first_name="Test") + query = CallbackQuery( + id="cq1", from_user=user, chat_instance="inst", data="s:b", message=None + ) + + await menu._edit_error(query, 123, "failed") + + mock_api.edit_message_text.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_resolve_capabilities_skips_missing_models(menu: SettingsMenu) -> None: + assert await menu._resolve_capabilities(None) is None + assert await menu._resolve_capabilities("default") is None + + +@pytest.mark.asyncio +async def test_resolve_capabilities_uses_cached_resolver(menu: SettingsMenu) -> None: + resolver = AsyncMock() + resolver.resolve.return_value = None + menu._capabilities_resolver = resolver + + assert await menu._resolve_capabilities("openrouter/openai/gpt-5.6-luna") is None + + resolver.resolve.assert_awaited_once() + assert resolver.resolve.await_args.args == ("openrouter/openai/gpt-5.6-luna",) + + +def test_model_display_name_handles_unknown_future_model(menu: SettingsMenu) -> None: + assert menu._model_display_name("openrouter/acme/future-model") == "future-model" + + +def test_model_display_name_handles_system_default(menu: SettingsMenu) -> None: + with patch("blacki.telegram.settings_menu.MODEL_CHOICES", {}): + assert menu._model_display_name("default") == "System Default" + + +def test_reasoning_display_inherits_when_only_token_budget_is_set( + menu: SettingsMenu, +) -> None: + profile = InferenceProfile(reasoning=ReasoningConfig(max_tokens=256)) + + assert menu._reasoning_display(profile) == "Default" + + +def test_reasoning_options_include_gateway_values_when_unspecified() -> None: + menu = SettingsMenu.__new__(SettingsMenu) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=None, + mandatory=False, + ) + ) + + options = menu._reasoning_options(capability) + + assert ("max", "Max") in options + assert ("none", "Off") in options + + +def test_reasoning_options_skip_empty_and_inherit_values() -> None: + menu = SettingsMenu.__new__(SettingsMenu) + capability = SimpleNamespace( + reasoning=SimpleNamespace( + supports_effort=True, + supported_efforts=(None, "inherit", "max"), + mandatory=False, + ) + ) + + assert menu._reasoning_options(capability) == [ + ("inherit", "Default"), + ("max", "Max"), + ] + + +def test_reasoning_config_handles_inherit_and_invalid_values() -> None: + assert SettingsMenu._reasoning_config("inherit") is None + assert SettingsMenu._reasoning_config("not-an-effort") is None