From 3b1ea99ee10574109ad439bfb9f29edfc8e76c01 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Sat, 25 Apr 2026 12:42:09 +0000 Subject: [PATCH] fix(agent): render ask_user options without buttons Made-with: Cursor --- nanobot/agent/loop.py | 33 ++++++++++++++++++++++------- tests/agent/test_ask_user.py | 40 +++++++++++++++++++++++++++++++++--- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 637bb512..d87ad1a8 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -54,6 +54,7 @@ if TYPE_CHECKING: UNIFIED_SESSION_KEY = "unified:default" +BUTTON_CHANNELS = frozenset({"telegram"}) class _LoopHook(AgentHook): @@ -459,6 +460,19 @@ class AgentLoop: return [str(option) for option in options if isinstance(option, str)] return [] + @staticmethod + def _ask_user_outbound( + content: str | None, + options: list[str], + channel: str, + ) -> tuple[str | None, list[list[str]]]: + if not options: + return content, [] + if channel in BUTTON_CHANNELS: + return content, [options] + option_text = "\n".join(f"{index}. {option}" for index, option in enumerate(options, 1)) + return f"{content}\n\n{option_text}" if content else option_text, [] + async def _run_agent_loop( self, initial_messages: list[dict], @@ -861,11 +875,16 @@ class AgentLoop: self.sessions.save(session) self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session)) options = self._ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [] + content, buttons = self._ask_user_outbound( + final_content or "Background task completed.", + options, + channel, + ) return OutboundMessage( channel=channel, chat_id=chat_id, - content=final_content or "Background task completed.", - buttons=[options] if options else [], + content=content, + buttons=buttons, ) # Extract document text from media at the processing boundary so all @@ -1011,11 +1030,11 @@ class AgentLoop: logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview) meta = dict(msg.metadata or {}) - buttons: list[list[str]] = [] - if stop_reason == "ask_user": - options = self._ask_user_options_from_messages(all_msgs) - if options: - buttons = [options] + final_content, buttons = self._ask_user_outbound( + final_content, + self._ask_user_options_from_messages(all_msgs) if stop_reason == "ask_user" else [], + msg.channel, + ) if on_stream is not None and stop_reason != "error": meta["_streamed"] = True return OutboundMessage( diff --git a/tests/agent/test_ask_user.py b/tests/agent/test_ask_user.py index fd8993ce..bdf49663 100644 --- a/tests/agent/test_ask_user.py +++ b/tests/agent/test_ask_user.py @@ -93,7 +93,7 @@ async def test_runner_pauses_on_ask_user_without_executing_later_tools(): @pytest.mark.asyncio -async def test_ask_user_sends_buttons_and_resumes_with_next_message(tmp_path): +async def test_ask_user_text_fallback_resumes_with_next_message(tmp_path): seen_messages: list[list[dict]] = [] async def chat_with_retry(**kwargs): @@ -127,8 +127,8 @@ async def test_ask_user_sends_buttons_and_resumes_with_next_message(tmp_path): ) assert first is not None - assert first.content == "Install the optional package?" - assert first.buttons == [["Install", "Skip"]] + assert first.content == "Install the optional package?\n\n1. Install\n2. Skip" + assert first.buttons == [] session = loop.sessions.get_or_create("cli:direct") assert any(message.get("role") == "assistant" and message.get("tool_calls") for message in session.messages) @@ -156,3 +156,37 @@ async def test_ask_user_sends_buttons_and_resumes_with_next_message(tmp_path): and message.get("content") == "Skip" for message in session.messages ) + + +@pytest.mark.asyncio +async def test_ask_user_keeps_buttons_for_telegram(tmp_path): + async def chat_with_retry(**kwargs): + return LLMResponse( + content="", + finish_reason="tool_calls", + tool_calls=[ + ToolCallRequest( + id="call_ask", + name="ask_user", + arguments={ + "question": "Install the optional package?", + "options": ["Install", "Skip"], + }, + ) + ], + ) + + loop = AgentLoop( + bus=MessageBus(), + provider=_make_provider(chat_with_retry), + workspace=tmp_path, + model="test-model", + ) + + response = await loop._process_message( + InboundMessage(channel="telegram", sender_id="user", chat_id="123", content="set it up") + ) + + assert response is not None + assert response.content == "Install the optional package?" + assert response.buttons == [["Install", "Skip"]]