From f74608038780d1e6b674469d821feb58603b9642 Mon Sep 17 00:00:00 2001 From: Hari Patel Date: Fri, 6 Mar 2026 13:34:42 +0530 Subject: [PATCH 1/5] feat: passthrough unknown slash commands to Claude in agentic mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In agentic mode, unknown slash commands (e.g. /workflow, /skill, /add-memory) were silently dropped because the TEXT handler excluded all COMMAND messages. Remove ~filters.COMMAND from the agentic text handler so unrecognised slash commands are forwarded to Claude as natural language. Registered commands (/start, /new, /status, etc.) still take priority via higher-priority CommandHandlers and are unaffected. Classic (non-agentic) mode retains the original filter — unknown commands are not forwarded there. Closes #129 --- src/bot/orchestrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index ac1d5304..8d1a3ba7 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -318,7 +318,7 @@ def _register_agentic_handlers(self, app: Application) -> None: # Text messages -> Claude app.add_handler( MessageHandler( - filters.TEXT & ~filters.COMMAND, + filters.TEXT, self._inject_deps(self.agentic_text), ), group=10, From db9a0765f781e24894586a74aa94686afa57c8cc Mon Sep 17 00:00:00 2001 From: Hari Patel Date: Fri, 6 Mar 2026 13:47:33 +0530 Subject: [PATCH 2/5] fix: use fallback CommandHandler to avoid double-fire on known commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous approach (removing ~filters.COMMAND) caused registered commands like /start and /new to fire twice — once via CommandHandler (group 0) and again via the agentic text handler (group 10). Fix: revert TEXT filter and add a separate filters.COMMAND MessageHandler in group 10 that dispatches to _handle_unknown_command(). That method checks the command name against KNOWN_COMMANDS and returns early for registered ones, forwarding only truly unknown slash commands to Claude. Dispatch flow: - /start, /new, /status ... -> CommandHandler (group 0) only - /workflow, /skill, /add-memory ... -> forwarded to Claude via agentic_text - Regular text -> TEXT & ~filters.COMMAND handler as before Also updates test to expect 5 message handlers (was 4). --- src/bot/orchestrator.py | 58 ++++++++++++++++++++++++++++++++- tests/unit/test_orchestrator.py | 4 +-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 8d1a3ba7..bdc7ff7d 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -318,12 +318,25 @@ def _register_agentic_handlers(self, app: Application) -> None: # Text messages -> Claude app.add_handler( MessageHandler( - filters.TEXT, + filters.TEXT & ~filters.COMMAND, self._inject_deps(self.agentic_text), ), group=10, ) + # Unknown slash commands -> Claude (passthrough in agentic mode). + # Registered commands are handled by CommandHandlers in group 0 + # (higher priority). This catches any /command not matched there + # and forwards it to Claude, while skipping known commands to + # avoid double-firing. + app.add_handler( + MessageHandler( + filters.COMMAND, + self._inject_deps(self._handle_unknown_command), + ), + group=10, + ) + # File uploads -> Claude app.add_handler( MessageHandler( @@ -1450,6 +1463,49 @@ async def _handle_agentic_media_message( except Exception as img_err: logger.warning("Image send failed", error=str(img_err)) + # Commands registered via CommandHandler (group 0). Used by + # _handle_unknown_command to avoid forwarding known commands to Claude. + KNOWN_COMMANDS: frozenset = frozenset( + { + "start", + "help", + "new", + "continue", + "end", + "status", + "export", + "ls", + "cd", + "pwd", + "projects", + "actions", + "git", + "sync_threads", + "verbose", + "repo", + "restart", + } + ) + + async def _handle_unknown_command( + self, update: Update, context: ContextTypes.DEFAULT_TYPE + ) -> None: + """Forward unknown slash commands to Claude in agentic mode. + + Known commands are handled by their own CommandHandlers (group 0); + this handler fires for *every* COMMAND message in group 10 but + returns immediately when the command is registered, preventing + double execution. + """ + msg = update.effective_message + if not msg or not msg.text: + return + cmd = msg.text.split()[0].lstrip("/").split("@")[0].lower() + if cmd in self.KNOWN_COMMANDS: + return # let the registered CommandHandler take care of it + # Forward unrecognised /commands to Claude as natural language + await self.agentic_text(update, context) + def _voice_unavailable_message(self) -> str: """Return provider-aware guidance when voice feature is unavailable.""" return ( diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index cc02b7c0..3796c26c 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -149,8 +149,8 @@ def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): if isinstance(call[0][0], CallbackQueryHandler) ] - # 4 message handlers (text, document, photo, voice) - assert len(msg_handlers) == 4 + # 5 message handlers (text, document, photo, voice, unknown commands passthrough) + assert len(msg_handlers) == 5 # 1 callback handler (for cd: only) assert len(cb_handlers) == 1 From de8924de21a7ee5b16d386c1a82964007b5c0ec7 Mon Sep 17 00:00:00 2001 From: Hari Patel Date: Fri, 6 Mar 2026 19:17:24 +0530 Subject: [PATCH 3/5] fix: address review feedback - dynamic known commands, ClassVar annotation, routing tests - Add ClassVar[frozenset[str]] type annotation on _known_commands (mypy strict) - Derive _known_commands dynamically from registered handlers list to prevent drift when new commands are added in future - Remove hardcoded KNOWN_COMMANDS class variable - Add 3 routing tests: - test_known_command_not_forwarded_to_claude - test_unknown_command_forwarded_to_claude - test_bot_suffixed_command_not_forwarded --- src/bot/orchestrator.py | 31 +++-------------- tests/unit/test_orchestrator.py | 60 +++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index bdc7ff7d..0f3c3a52 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -9,7 +9,7 @@ import re import time from pathlib import Path -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, ClassVar, Dict, List, Optional import structlog from telegram import ( @@ -312,6 +312,9 @@ def _register_agentic_handlers(self, app: Application) -> None: if self.settings.enable_project_threads: handlers.append(("sync_threads", command.sync_threads)) + # Derive known commands dynamically — avoids drift when new commands are added + self._known_commands: frozenset[str] = frozenset(cmd for cmd, _ in handlers) + for cmd, handler in handlers: app.add_handler(CommandHandler(cmd, self._inject_deps(handler))) @@ -1463,30 +1466,6 @@ async def _handle_agentic_media_message( except Exception as img_err: logger.warning("Image send failed", error=str(img_err)) - # Commands registered via CommandHandler (group 0). Used by - # _handle_unknown_command to avoid forwarding known commands to Claude. - KNOWN_COMMANDS: frozenset = frozenset( - { - "start", - "help", - "new", - "continue", - "end", - "status", - "export", - "ls", - "cd", - "pwd", - "projects", - "actions", - "git", - "sync_threads", - "verbose", - "repo", - "restart", - } - ) - async def _handle_unknown_command( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: @@ -1501,7 +1480,7 @@ async def _handle_unknown_command( if not msg or not msg.text: return cmd = msg.text.split()[0].lstrip("/").split("@")[0].lower() - if cmd in self.KNOWN_COMMANDS: + if cmd in self._known_commands: return # let the registered CommandHandler take care of it # Forward unrecognised /commands to Claude as natural language await self.agentic_text(update, context) diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 3796c26c..f203879b 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -926,3 +926,63 @@ async def help_command(update, context): assert called["value"] is False update.effective_message.reply_text.assert_called_once() + + +async def test_known_command_not_forwarded_to_claude(agentic_settings, deps): + """Known commands must NOT be forwarded to agentic_text.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/start" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_not_called() + + +async def test_unknown_command_forwarded_to_claude(agentic_settings, deps): + """Unknown slash commands must be forwarded to agentic_text.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/workflow activate job-hunter" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_called_once_with(update, context) + + +async def test_bot_suffixed_command_not_forwarded(agentic_settings, deps): + """Bot-suffixed known commands like /start@mybot must not reach Claude.""" + from unittest.mock import AsyncMock, MagicMock, patch + + orchestrator = MessageOrchestrator(agentic_settings, deps) + app = MagicMock() + app.add_handler = MagicMock() + orchestrator.register_handlers(app) + + update = MagicMock() + update.effective_message.text = "/start@mybot" + context = MagicMock() + + with patch.object( + orchestrator, "agentic_text", new_callable=AsyncMock + ) as mock_claude: + await orchestrator._handle_unknown_command(update, context) + mock_claude.assert_not_called() From 22d16ee92d4ee85e39add26a83658f85a4359260 Mon Sep 17 00:00:00 2001 From: Hari Patel Date: Fri, 6 Mar 2026 19:20:23 +0530 Subject: [PATCH 4/5] fix: remove unused ClassVar import --- src/bot/orchestrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 0f3c3a52..f2f73a6d 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -9,7 +9,7 @@ import re import time from pathlib import Path -from typing import Any, Callable, ClassVar, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import structlog from telegram import ( From 60d794ecb22babd16503dc3e4b67f45d87919b22 Mon Sep 17 00:00:00 2001 From: Hari Patel Date: Fri, 6 Mar 2026 19:39:10 +0530 Subject: [PATCH 5/5] fix: initialise _known_commands in __init__ to guard against AttributeError --- src/bot/orchestrator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index f2f73a6d..6bfa6d52 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -115,6 +115,7 @@ class MessageOrchestrator: def __init__(self, settings: Settings, deps: Dict[str, Any]): self.settings = settings self.deps = deps + self._known_commands: frozenset[str] = frozenset() def _inject_deps(self, handler: Callable) -> Callable: # type: ignore[type-arg] """Wrap handler to inject dependencies into context.bot_data."""