diff --git a/src/coding_agent_telegram/bot.py b/src/coding_agent_telegram/bot.py index 56bc217..cd77091 100644 --- a/src/coding_agent_telegram/bot.py +++ b/src/coding_agent_telegram/bot.py @@ -170,6 +170,7 @@ async def log_incoming_private_message(update, _context) -> None: ) app.add_handler(CallbackQueryHandler(router.handle_queue_batch_callback, pattern=r"^queuebatch:(group|single|cancel)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_queue_continue_callback, pattern=r"^queuecontinue:(yes|no)$", block=False)) + app.add_handler(CallbackQueryHandler(router.handle_agent_reply_option_callback, pattern=r"^agentopt:[0-9a-f]{12}:[0-9]$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_source_callback, pattern=r"^branchsource:[0-9a-f]{12}$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_branch_discrepancy_callback, pattern=r"^branchdiscrepancy:(stored|current)$", block=False)) app.add_handler(CallbackQueryHandler(router.handle_commit_generate_callback, pattern=r"^commitgen:(confirm|cancel)$")) diff --git a/src/coding_agent_telegram/resources/locales/en.json b/src/coding_agent_telegram/resources/locales/en.json index 8dfd74e..959203b 100644 --- a/src/coding_agent_telegram/resources/locales/en.json +++ b/src/coding_agent_telegram/resources/locales/en.json @@ -66,6 +66,9 @@ "runtime.provider_output_index": "{provider} output {index}/{total}", "runtime.provider_output_single": "{provider} output", "runtime.replacement_session_stall": "Replacement session creation appears stuck.\nThe local agent process is still running but has not produced output.\nOn macOS, this might be because a hidden permission dialog is waiting for confirmation on the machine running the bot.", + "runtime.reply_option_select_button": "✅ Select this option", + "runtime.reply_option_selected": "▶️ Continuing with: {choice}", + "runtime.reply_options_prompt": "⚡ Action needed — choose one to continue:", "runtime.resume_created_new": "Resume failed, so a new session was created.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.resume_id_changed": "Resume succeeded, but the session ID changed.\nNew session ID: {session_id}\nNew session name: {session_name}", "runtime.sensitive_diff_omitted": "{path}\nThis file contains sensitive content and was omitted.", diff --git a/src/coding_agent_telegram/router/base.py b/src/coding_agent_telegram/router/base.py index 6bf0760..261e93a 100644 --- a/src/coding_agent_telegram/router/base.py +++ b/src/coding_agent_telegram/router/base.py @@ -6,6 +6,7 @@ import logging import os import re +import secrets import shlex from collections import deque from concurrent.futures import CancelledError, Future @@ -27,7 +28,11 @@ from coding_agent_telegram.session_runtime import PhotoAttachmentStore, SessionRuntime from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.speech_to_text import WhisperSpeechToText -from coding_agent_telegram.telegram_sender import send_text +from coding_agent_telegram.telegram_sender import ( + affirmative_inline_button_kwargs, + negative_inline_button_kwargs, + send_text, +) logger = logging.getLogger(__name__) @@ -118,6 +123,7 @@ def __init__(self, deps: RouterDeps) -> None: bot_id=deps.bot_id, git=self.git, run_with_typing=self._run_with_typing, + register_reply_options=self._register_agent_reply_options, ) # Per-workspace asyncio locks keyed by project_folder name. # Prevents concurrent agent runs on the same workspace regardless of @@ -132,6 +138,7 @@ def __init__(self, deps: RouterDeps) -> None: self._chat_message_queue_draining: set[int] = set() self._last_run_results: dict[int, object] = {} self._branch_source_tokens: dict[str, tuple[str, str, str]] = {} + self._agent_reply_option_tokens: dict[str, tuple[int, tuple[str, ...]]] = {} def _register_branch_source_token(self, source_kind: str, source_branch: str, new_branch: str) -> str: key = f"{source_kind}:{source_branch}:{new_branch}" @@ -142,6 +149,11 @@ def _register_branch_source_token(self, source_kind: str, source_branch: str, ne def _lookup_branch_source_token(self, token: str) -> tuple[str, str, str] | None: return self._branch_source_tokens.get(token) + def _register_agent_reply_options(self, chat_id: int, options: tuple[str, ...]) -> str: + token = secrets.token_hex(6) + self._agent_reply_option_tokens[token] = (chat_id, options) + return token + def _sorted_sessions(self, sessions: dict[str, dict[str, str]]) -> list[tuple[str, dict[str, str]]]: indexed_sessions = list(enumerate(sessions.items())) sorted_indexed_sessions = sorted( @@ -227,10 +239,10 @@ def _t(self, update: Update | None, key: str, **kwargs) -> str: return translate(self._locale(update), key, **kwargs) def _affirmative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: - return {"api_kwargs": {"style": "primary"}} + return affirmative_inline_button_kwargs() def _negative_inline_button_kwargs(self) -> dict[str, dict[str, str]]: - return {"api_kwargs": {"style": "danger"}} + return negative_inline_button_kwargs() async def _notify_if_current_project_busy(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool: chat = update.effective_chat diff --git a/src/coding_agent_telegram/router/message_commands.py b/src/coding_agent_telegram/router/message_commands.py index 923dfc8..7a94178 100644 --- a/src/coding_agent_telegram/router/message_commands.py +++ b/src/coding_agent_telegram/router/message_commands.py @@ -73,6 +73,37 @@ async def _process_user_message( finally: await self._drain_chat_message_queue(chat_id, context) + @require_allowed_chat(answer_callback=True) + async def handle_agent_reply_option_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + query = update.callback_query + if query is None or not query.data: + return + + await query.answer() + parts = query.data.split(":", 2) + if len(parts) != 3: + return + _, token, index_text = parts + entry = self._agent_reply_option_tokens.pop(token, None) + if entry is None: + if hasattr(query, "edit_message_reply_markup"): + await query.edit_message_reply_markup(reply_markup=None) + return + + chat_id, options = entry + if update.effective_chat is None or update.effective_chat.id != chat_id: + return + try: + option_text = options[int(index_text)] + except (ValueError, IndexError): + return + + if hasattr(query, "edit_message_reply_markup"): + await query.edit_message_reply_markup(reply_markup=None) + + await send_text(update, context, self._t(update, "runtime.reply_option_selected", choice=option_text)) + await self._process_user_message(update, context, option_text) + @require_allowed_chat() async def handle_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message is None or not update.message.text: diff --git a/src/coding_agent_telegram/session_runtime.py b/src/coding_agent_telegram/session_runtime.py index cd3fe7e..7beb383 100644 --- a/src/coding_agent_telegram/session_runtime.py +++ b/src/coding_agent_telegram/session_runtime.py @@ -10,7 +10,7 @@ import os from typing import Awaitable, Callable, Optional, Sequence -from telegram import Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes from coding_agent_telegram.agent_runner import AgentRunResult, MultiAgentRunner @@ -32,6 +32,7 @@ from coding_agent_telegram.providers import provider_label as provider_display_label from coding_agent_telegram.session_store import SessionStore from coding_agent_telegram.telegram_sender import ( + affirmative_inline_button_kwargs, markdownish_to_html, send_code_block, send_html_text, @@ -58,6 +59,42 @@ # Matches absolute filesystem paths (Unix and Windows styles) in error messages. _ABSOLUTE_PATH_RE = re.compile(r"(?:^|(?<=\s)|(?<=[\"'(]))((?:/[^\s\"',;)]+)+|[A-Za-z]:\\[^\s\"',;)]+)") +# Matches a numbered/lettered list line, e.g. "1. Do X" or "a) Do Y". +_OPTION_LINE_RE = re.compile(r"^\s*(?:[0-9]{1,2}[.)]|[A-Za-z][.)])\s+(.{2,140}?)\s*$") +# Requires an explicit "which one do you want" style cue near the option list, +# so an ordinary numbered list in a reply doesn't get mistaken for a menu. +_OPTION_QUESTION_CUE_RE = re.compile( + r"\b(which (one|option|approach|way)|let me know which|should i|shall i|" + r"would you like me to|which would you|go with|pick one|choose one|which do you want)\b", + re.IGNORECASE, +) +_MAX_REPLY_OPTIONS = 6 +_REPLY_OPTION_TAIL_LINES = 12 + + +def _detect_reply_options(text: str) -> tuple[str, ...]: + """Return option labels if the assistant's reply is asking the user to pick one.""" + stripped = text.strip() + if not stripped: + return () + tail_lines = stripped.splitlines()[-_REPLY_OPTION_TAIL_LINES:] + if not _OPTION_QUESTION_CUE_RE.search("\n".join(tail_lines)): + return () + + options: list[str] = [] + for line in tail_lines: + match = _OPTION_LINE_RE.match(line) + if match: + options.append(match.group(1).strip()) + + if len(options) < 2: + return () + return tuple(options[:_MAX_REPLY_OPTIONS]) + + +def _session_provider(session: dict[str, str]) -> str: + return str(session.get("provider") or "codex").strip().lower() or "codex" + def _reply_to_message_id(update: Update) -> int | None: message = getattr(update, "message", None) @@ -163,6 +200,7 @@ def build_prompt(self, attachment_path: Path, project_path: Path, caption: str) RunWithTyping = Callable[..., Awaitable[object]] +RegisterReplyOptions = Callable[[int, tuple[str, ...]], str] class SessionRuntime: @@ -175,6 +213,7 @@ def __init__( bot_id: str, git: GitWorkspaceManager, run_with_typing: RunWithTyping, + register_reply_options: RegisterReplyOptions, ) -> None: self.cfg = cfg self.store = store @@ -182,6 +221,7 @@ def __init__( self.bot_id = bot_id self.git = git self.run_with_typing = run_with_typing + self.register_reply_options = register_reply_options def _locale(self, update: Update | None) -> str: return self.cfg.locale @@ -253,7 +293,7 @@ async def run_active_session( return None project_folder = session["project_folder"] - provider = session.get("provider", "codex") + provider = _session_provider(session) branch_name = session.get("branch_name", "") logger.info( "Running message for chat %s on session '%s' (%s) in project '%s' with provider '%s'. " @@ -400,7 +440,7 @@ async def compact_active_session( return None project_folder = session["project_folder"] - provider = session.get("provider", "codex") + provider = _session_provider(session) branch_name = session.get("branch_name", "") session_name = session["name"] logger.info( @@ -688,6 +728,16 @@ async def _send_assistant_chunks( return total = len(segments) + + # If the agent's final reply reads like it's asking the user to pick between a + # few options, detect them now so we can offer buttons after the reply is sent. + # Tapping one just sends the option text back as the next chat message — same + # as if the user had typed it — so this never needs to interrupt or hold open + # the CLI process. + reply_options: tuple[str, ...] = () + if provider == "claude" and segments[-1].kind == "prose" and update.effective_chat is not None: + reply_options = _detect_reply_options(segments[-1].text) + for index, segment in enumerate(segments, start=1): if segment.kind == "code": await send_code_block( @@ -700,7 +750,7 @@ async def _send_assistant_chunks( ) continue - provider_label = provider_display_label(provider) or "Codex" + provider_label = provider_display_label(provider) or "Agent" title_prefix = ( self._t(update, "runtime.provider_output_single", provider=provider_label) if total == 1 @@ -720,6 +770,32 @@ async def _send_assistant_chunks( reply_to_message_id=self._take_reply_to_message_id(reply_state), ) + if reply_options and update.effective_chat is not None: + token = self.register_reply_options(update.effective_chat.id, reply_options) + await send_html_text( + update, + context, + f"{html.escape(self._t(update, 'runtime.reply_options_prompt'))}", + ) + # Each option gets its own message with a single button right under it, so + # the full option text is always visible next to the button that picks it — + # no truncation, no guessing which button maps to which paragraph. + for index, option in enumerate(reply_options): + await send_html_text( + update, + context, + html.escape(option), + reply_markup=self._reply_option_keyboard(update, token, index), + ) + + def _reply_option_keyboard(self, update: Update, token: str, index: int) -> InlineKeyboardMarkup: + button = InlineKeyboardButton( + self._t(update, "runtime.reply_option_select_button"), + callback_data=f"agentopt:{token}:{index}", + **affirmative_inline_button_kwargs(), + ) + return InlineKeyboardMarkup([[button]]) + def _chunk_assistant_prose(self, title_prefix: str, text: str) -> list[str]: normalized = text.strip() if not normalized: diff --git a/src/coding_agent_telegram/session_store.py b/src/coding_agent_telegram/session_store.py index e32ba9b..348a530 100644 --- a/src/coding_agent_telegram/session_store.py +++ b/src/coding_agent_telegram/session_store.py @@ -11,6 +11,10 @@ T = TypeVar("T") +def _normalize_provider(provider: str) -> str: + return str(provider or "codex").strip().lower() or "codex" + + class SessionStoreError(Exception): """Raised when the session store cannot be accessed due to a file-lock conflict.""" @@ -149,10 +153,11 @@ def _write_session_record( initialized_from: Optional[str] = None, ) -> dict[str, str]: now = self._now() + normalized_provider = _normalize_provider(provider) sessions[session_id] = { "name": session_name, "project_folder": project_folder, - "provider": provider, + "provider": normalized_provider, "branch_name": branch_name or "", "origin": origin, "origin_label": origin_label or ("Bot managed session" if origin == "bot" else origin), @@ -193,7 +198,7 @@ def mutate(chat_data: dict[str, Any]) -> None: def set_current_provider(self, bot_id: str, chat_id: int, provider: str) -> None: def mutate(chat_data: dict[str, Any]) -> None: - chat_data["current_provider"] = provider + chat_data["current_provider"] = _normalize_provider(provider) self._mutate_chat_data(bot_id, chat_id, mutate, create=True) @@ -257,7 +262,7 @@ def mutate(chat_data: dict[str, Any]) -> None: ) chat_data["active_session_id"] = session_id chat_data["current_project_folder"] = project_folder - chat_data["current_provider"] = provider + chat_data["current_provider"] = _normalize_provider(provider) if branch_name: chat_data["current_branch"] = branch_name @@ -376,7 +381,7 @@ def mutate(chat_data: dict[str, Any]) -> bool: chat_data["active_session_id"] = session_id chat_data["current_project_folder"] = session["project_folder"] - chat_data["current_provider"] = session.get("provider", "codex") + chat_data["current_provider"] = _normalize_provider(session.get("provider", "codex")) if session.get("branch_name"): chat_data["current_branch"] = session["branch_name"] else: diff --git a/src/coding_agent_telegram/telegram_sender.py b/src/coding_agent_telegram/telegram_sender.py index 127835d..52c41e8 100644 --- a/src/coding_agent_telegram/telegram_sender.py +++ b/src/coding_agent_telegram/telegram_sender.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from typing import Optional -from telegram import Update +from telegram import InlineKeyboardMarkup, Update from telegram.constants import ParseMode from telegram.error import BadRequest from telegram.ext import ContextTypes @@ -68,6 +68,14 @@ class AssistantSegment: language: Optional[str] = None +def affirmative_inline_button_kwargs() -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "primary"}} + + +def negative_inline_button_kwargs() -> dict[str, dict[str, str]]: + return {"api_kwargs": {"style": "danger"}} + + def _max_telegram_message_length(context: ContextTypes.DEFAULT_TYPE) -> int: bot_data = getattr(context, "bot_data", None) if isinstance(bot_data, dict): @@ -87,6 +95,7 @@ async def send_text( text: str, *, reply_to_message_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> None: if update.effective_chat is None: return @@ -100,12 +109,14 @@ async def send_text( resolved_reply_to_message_id, text, ) + last_index = len(chunks) - 1 for index, chunk in enumerate(chunks): await context.bot.send_message( chat_id=update.effective_chat.id, text=html.escape(chunk), parse_mode=ParseMode.HTML, reply_to_message_id=resolved_reply_to_message_id if index == 0 else None, + reply_markup=reply_markup if index == last_index else None, ) @@ -138,6 +149,7 @@ async def send_html_text( text: str, *, reply_to_message_id: Optional[int] = None, + reply_markup: Optional[InlineKeyboardMarkup] = None, ) -> None: if update.effective_chat is None: return @@ -150,7 +162,13 @@ async def send_html_text( text, ) if len(text) > max_length: - await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) + await send_text( + update, + context, + _strip_html_tags(text), + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + ) return try: await context.bot.send_message( @@ -158,11 +176,18 @@ async def send_html_text( text=text, parse_mode=ParseMode.HTML, reply_to_message_id=_default_reply_to_message_id(update, reply_to_message_id), + reply_markup=reply_markup, ) except BadRequest as exc: if "Can't parse entities" not in str(exc): raise - await send_text(update, context, _strip_html_tags(text), reply_to_message_id=reply_to_message_id) + await send_text( + update, + context, + _strip_html_tags(text), + reply_to_message_id=reply_to_message_id, + reply_markup=reply_markup, + ) def markdownish_to_html(text: str) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 99f1af1..6822df4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,12 @@ import sys +import asyncio from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + + +def pytest_runtest_setup(item): + try: + asyncio.get_event_loop() + except RuntimeError: + asyncio.set_event_loop(asyncio.new_event_loop()) diff --git a/tests/test_command_router.py b/tests/test_command_router.py index e5576df..262f0e2 100644 --- a/tests/test_command_router.py +++ b/tests/test_command_router.py @@ -184,6 +184,37 @@ def resume_session( ) +class ReplyOptionsRunner(DummyRunner): + def resume_session( + self, + provider, + session_id, + project_path, + user_message, + *, + skip_git_repo_check=False, + image_paths=(), + on_stall=None, + on_progress=None, + ): + self.resume_calls.append({"provider": provider, "user_message": user_message}) + if len(self.resume_calls) == 1: + text = ( + "I found two ways to fix this. Which approach would you like me to take?\n" + "1. Patch the validator directly\n" + "2. Rewrite the parser" + ) + else: + text = "Done." + return AgentRunResult( + session_id=session_id, + success=True, + assistant_text=text, + error_message=None, + raw_events=[], + ) + + class CommandBlockRunner(DummyRunner): def resume_session( self, @@ -2932,6 +2963,118 @@ def test_copilot_output_uses_copilot_label(tmp_path: Path): assert any("Copilot output" in message[1] for message in bot.messages) +def test_claude_output_uses_claude_label(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = MarkdownRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_md", "markdown-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="check formatting") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert any("Claude output" in message[1] for message in bot.messages) + assert not any("Codex output" in message[1] for message in bot.messages) + + +def test_claude_reply_with_options_offers_buttons_and_resends_choice(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ReplyOptionsRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + update = make_update(text="how should I fix this bug?") + bot = FakeBot() + context = SimpleNamespace(args=[], bot=bot) + + asyncio.run(router.handle_message(update, context)) + + assert len(runner.resume_calls) == 1 + assert any("Which approach would you like" in message[1] for message in bot.messages) + assert any("Action needed" in message[1] for message in bot.messages) + + option_messages = [message for message in bot.messages if message[3] is not None] + assert [message[1] for message in option_messages] == [ + "Patch the validator directly", + "Rewrite the parser", + ] + for message in option_messages: + buttons = [button for row in message[3].inline_keyboard for button in row] + assert len(buttons) == 1 + assert buttons[0].api_kwargs == {"style": "primary"} + assert buttons[0].callback_data.startswith("agentopt:") + + token_callback_data = option_messages[0][3].inline_keyboard[0][0].callback_data + + query = SimpleNamespace(data=token_callback_data, answer=None, edit_message_reply_markup=None) + edited_markup = [] + + async def fake_answer(): + return None + + async def fake_edit_markup(reply_markup=None): + edited_markup.append(reply_markup) + + query.answer = fake_answer + query.edit_message_reply_markup = fake_edit_markup + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + ) + + asyncio.run(router.handle_agent_reply_option_callback(callback_update, context)) + + assert edited_markup == [None] + assert len(runner.resume_calls) == 2 + assert runner.resume_calls[1]["user_message"] == "Patch the validator directly" + assert any( + "Continuing with: Patch the validator directly" in message[1] for message in bot.messages + ) + + +def test_agent_reply_option_callback_ignores_unknown_token(tmp_path: Path): + backend = tmp_path / "backend" + backend.mkdir() + runner = ReplyOptionsRunner() + cfg = make_config(tmp_path) + store = SessionStore(cfg.state_file, cfg.state_backup_file) + store.create_session("bot-a", 123, "sess_opt", "opt-session", "backend", "claude") + router = CommandRouter(RouterDeps(cfg=cfg, store=store, agent_runner=runner, bot_id="bot-a")) + router.git = FakeGitManager(is_git_repo=False) + + query = SimpleNamespace(data="agentopt:deadbeef0000:0", answer=None, edit_message_reply_markup=None) + edited_markup = [] + + async def fake_answer(): + return None + + async def fake_edit_markup(reply_markup=None): + edited_markup.append(reply_markup) + + query.answer = fake_answer + query.edit_message_reply_markup = fake_edit_markup + callback_update = SimpleNamespace( + effective_chat=SimpleNamespace(id=123, type="private"), + callback_query=query, + ) + context = SimpleNamespace(args=[], bot=FakeBot()) + + asyncio.run(router.handle_agent_reply_option_callback(callback_update, context)) + + assert edited_markup == [None] + assert runner.resume_calls == [] + + def test_message_reports_missing_project_folder_before_running_agent(tmp_path: Path): backend = tmp_path / "backend" backend.mkdir() diff --git a/tests/test_session_runtime_diff_merge.py b/tests/test_session_runtime_diff_merge.py index d9916a4..477da3c 100644 --- a/tests/test_session_runtime_diff_merge.py +++ b/tests/test_session_runtime_diff_merge.py @@ -1,5 +1,5 @@ from coding_agent_telegram.diff_utils import FileDiff, TEXTUAL_DIFF_UNAVAILABLE -from coding_agent_telegram.session_runtime import SessionRuntime +from coding_agent_telegram.session_runtime import SessionRuntime, _detect_reply_options def _runtime() -> SessionRuntime: @@ -10,6 +10,7 @@ def _runtime() -> SessionRuntime: bot_id="bot-a", git=None, run_with_typing=None, + register_reply_options=None, ) @@ -113,3 +114,44 @@ def test_merge_snapshot_diffs_handles_empty_inputs(): runtime = _runtime() merged = runtime._merge_snapshot_diffs([], {}) assert merged == [] + + +# --------------------------------------------------------------------------- +# _detect_reply_options +# --------------------------------------------------------------------------- + + +def test_detect_reply_options_finds_numbered_choices_with_question_cue(): + text = ( + "I found two ways to fix this. Which approach would you like me to take?\n" + "1. Patch the validator directly\n" + "2. Rewrite the parser" + ) + assert _detect_reply_options(text) == ("Patch the validator directly", "Rewrite the parser") + + +def test_detect_reply_options_ignores_plain_numbered_list_without_question_cue(): + text = "Here is what I changed:\n1. Updated the validator\n2. Added a regression test" + assert _detect_reply_options(text) == () + + +def test_detect_reply_options_ignores_question_without_option_list(): + text = "Should I proceed with these changes? Let me know and I'll continue." + assert _detect_reply_options(text) == () + + +def test_detect_reply_options_ignores_single_option_line(): + text = "Which approach would you like me to take?\n1. Patch the validator directly" + assert _detect_reply_options(text) == () + + +def test_detect_reply_options_caps_at_max_options(): + lines = [f"{i}. Option {i}" for i in range(1, 10)] + text = "Which one do you want?\n" + "\n".join(lines) + options = _detect_reply_options(text) + assert len(options) == 6 + assert options[0] == "Option 1" + + +def test_detect_reply_options_returns_empty_for_blank_text(): + assert _detect_reply_options(" ") == () diff --git a/tests/test_session_store.py b/tests/test_session_store.py index 9f26d9b..a5cd30b 100644 --- a/tests/test_session_store.py +++ b/tests/test_session_store.py @@ -39,6 +39,55 @@ def test_set_current_provider_persists_in_chat_state(tmp_path: Path): assert chat["current_provider"] == "copilot" +def test_empty_provider_normalizes_to_codex_when_creating_session(tmp_path: Path): + state = tmp_path / "state.json" + backup = tmp_path / "state.json.bak" + store = SessionStore(state, backup) + + store.create_session("bot-a", 123, "sess_1", "backend-fix", "backend", "") + + chat = store.get_chat_state("bot-a", 123) + assert chat["current_provider"] == "codex" + assert chat["sessions"]["sess_1"]["provider"] == "codex" + + +def test_empty_current_provider_normalizes_to_codex(tmp_path: Path): + state = tmp_path / "state.json" + backup = tmp_path / "state.json.bak" + store = SessionStore(state, backup) + + store.set_current_provider("bot-a", 123, "") + + assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex" + + +def test_switch_session_normalizes_empty_legacy_provider_to_codex(tmp_path: Path): + state = tmp_path / "state.json" + backup = tmp_path / "state.json.bak" + state.write_text( + json.dumps( + { + "chats": { + "bot-a:123": { + "sessions": { + "sess_legacy": { + "name": "legacy", + "project_folder": "backend", + "provider": "", + } + } + } + } + } + ), + encoding="utf-8", + ) + store = SessionStore(state, backup) + + assert store.switch_session("bot-a", 123, "sess_legacy") + assert store.get_chat_state("bot-a", 123)["current_provider"] == "codex" + + def test_set_pending_action_persists_and_clears(tmp_path: Path): state = tmp_path / "state.json" backup = tmp_path / "state.json.bak" diff --git a/tests/test_telegram_sender.py b/tests/test_telegram_sender.py index fd768bf..6a2f908 100644 --- a/tests/test_telegram_sender.py +++ b/tests/test_telegram_sender.py @@ -36,7 +36,7 @@ def test_send_html_text_falls_back_to_plain_text_on_parse_error(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append((chat_id, text, parse_mode)) if len(calls) == 1: raise BadRequest("Can't parse entities: can't find end tag corresponding to start tag \"code\"") @@ -54,7 +54,7 @@ def test_send_text_chunks_long_messages(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -70,7 +70,7 @@ def test_send_html_text_chunks_long_messages_as_plain_text(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -86,7 +86,7 @@ def test_send_code_block_chunks_long_code_blocks(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append((chat_id, text, parse_mode)) update = SimpleNamespace(effective_chat=SimpleNamespace(id=123)) @@ -108,7 +108,7 @@ def test_send_text_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -122,7 +122,7 @@ def test_send_html_text_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -136,7 +136,7 @@ def test_send_code_block_does_nothing_when_effective_chat_is_none(): called = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): called.append(text) update = SimpleNamespace(effective_chat=None) @@ -156,7 +156,7 @@ def test_send_text_uses_default_length_when_no_bot_data(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append(text) update = SimpleNamespace(effective_chat=SimpleNamespace(id=1)) @@ -230,7 +230,7 @@ def test_send_markdown_text_sends_message(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append((chat_id, text, parse_mode)) from telegram.constants import ParseMode @@ -270,7 +270,7 @@ def test_send_html_text_reraises_non_parse_bad_request(): from telegram.error import BadRequest class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): raise BadRequest("Message is too long") update = SimpleNamespace(effective_chat=SimpleNamespace(id=1)) @@ -387,7 +387,7 @@ def test_send_code_block_without_language(): calls = [] class FakeBot: - async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None): + async def send_message(self, chat_id, text, parse_mode=None, reply_to_message_id=None, reply_markup=None): calls.append(text) update = SimpleNamespace(effective_chat=SimpleNamespace(id=7))