diff --git a/CLAUDE.md b/CLAUDE.md index b29f5871..32145753 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Security relaxation (trusted environments only): `DISABLE_SECURITY_PATTERNS` (de Multi-project topics: `ENABLE_PROJECT_THREADS` (default false), `PROJECT_THREADS_MODE` (`private`|`group`), `PROJECT_THREADS_CHAT_ID` (required for group mode), `PROJECTS_CONFIG_PATH` (path to YAML project registry), `PROJECT_THREADS_SYNC_ACTION_INTERVAL_SECONDS` (default `1.1`, set `0` to disable pacing). See `config/projects.example.yaml`. -Output verbosity: `VERBOSE_LEVEL` (default 1, range 0-2). Controls how much of Claude's background activity is shown to the user in real-time. 0 = quiet (only final response, typing indicator still active), 1 = normal (tool names + reasoning snippets shown during execution), 2 = detailed (tool names with input summaries + longer reasoning text). Users can override per-session via `/verbose 0|1|2`. A persistent typing indicator is refreshed every ~2 seconds at all levels. +Output verbosity: `VERBOSE_LEVEL` (default 1, range 0-3). Controls how much of Claude's background activity is shown to the user in real-time. 0 = quiet (only final response, typing indicator still active), 1 = normal (tool names + reasoning snippets shown during execution), 2 = detailed (tool names with input summaries + longer reasoning text), 3 = detailed **and** preserve the progress message after the final response (Stop button stripped, text kept as a read-only log). Users can override per-session via `/verbose 0|1|2|3`. A persistent typing indicator is refreshed every ~2 seconds at all levels. Voice transcription: `ENABLE_VOICE_MESSAGES` (default true), `VOICE_PROVIDER` (`mistral`|`openai`|`local`, default `mistral`), `MISTRAL_API_KEY`, `OPENAI_API_KEY`, `VOICE_TRANSCRIPTION_MODEL`. For local provider: `WHISPER_CPP_BINARY_PATH`, `WHISPER_CPP_MODEL_PATH` (requires ffmpeg + whisper.cpp installed). Provider implementation is in `src/bot/features/voice_handler.py`. diff --git a/README.md b/README.md index e30bb05b..0962464b 100644 --- a/README.md +++ b/README.md @@ -121,13 +121,14 @@ You: /verbose 0 Bot: Verbosity set to 0 (quiet) ``` -Use `/verbose 0|1|2` to control how much background activity is shown: +Use `/verbose 0|1|2|3` to control how much background activity is shown: | Level | Shows | |-------|-------| | **0** (quiet) | Final response only (typing indicator stays active) | | **1** (normal, default) | Tool names + reasoning snippets in real-time | | **2** (detailed) | Tool names with inputs + longer reasoning text | +| **3** (detailed + keep log) | Same as 2, but the progress log is **preserved** after the final response as a read-only audit trail | #### GitHub Workflow @@ -235,7 +236,7 @@ CLAUDE_TIMEOUT_SECONDS=300 # Operation timeout # Mode AGENTIC_MODE=true # Agentic (default) or classic mode -VERBOSE_LEVEL=1 # 0=quiet, 1=normal (default), 2=detailed +VERBOSE_LEVEL=1 # 0=quiet, 1=normal (default), 2=detailed, 3=detailed+keep-log # Rate Limiting RATE_LIMIT_REQUESTS=10 # Requests per window diff --git a/docs/tools.md b/docs/tools.md index 54010cc4..a3ecf599 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -83,6 +83,7 @@ Control verbosity with `/verbose`: | `/verbose 0` | Final response only (typing indicator stays active) | | `/verbose 1` | Tool names + reasoning snippets (default) | | `/verbose 2` | Tool names with input details + longer reasoning text | +| `/verbose 3` | Same as 2, and the progress message is kept visible after the final response (Stop button stripped, text preserved as a read-only log) | ## Configuration diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 6d9719f0..430cec10 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -458,7 +458,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] BotCommand("start", "Start the bot"), BotCommand("new", "Start a fresh session"), BotCommand("status", "Show session status"), - BotCommand("verbose", "Set output verbosity (0/1/2)"), + BotCommand("verbose", "Set output verbosity (0/1/2/3)"), BotCommand("repo", "List repos / switch workspace"), BotCommand("restart", "Restart the bot"), ] @@ -587,36 +587,62 @@ def _get_verbose_level(self, context: ContextTypes.DEFAULT_TYPE) -> int: return int(user_override) return self.settings.verbose_level + async def _finalize_progress_msg( + self, progress_msg: Any, verbose_level: int + ) -> None: + """Delete the progress message, or keep it (sans Stop button) as a log. + + At verbose_level 3 the progress text is preserved as a read-only + record of Claude's tool calls and reasoning (the Stop button is + stripped). At levels 0-2 the message is deleted (the original + behavior). + """ + if verbose_level >= 3: + try: + await progress_msg.edit_reply_markup(reply_markup=None) + except Exception: + logger.debug("Failed to clear progress reply markup, ignoring") + return + try: + await progress_msg.delete() + except Exception: + logger.debug("Failed to delete progress message, ignoring") + async def agentic_verbose( self, update: Update, context: ContextTypes.DEFAULT_TYPE ) -> None: - """Set output verbosity: /verbose [0|1|2].""" + """Set output verbosity: /verbose [0|1|2|3].""" args = update.message.text.split()[1:] if update.message.text else [] + labels = { + 0: "quiet", + 1: "normal", + 2: "detailed", + 3: "detailed+keep-log", + } if not args: current = self._get_verbose_level(context) - labels = {0: "quiet", 1: "normal", 2: "detailed"} await update.message.reply_text( f"Verbosity: {current} ({labels.get(current, '?')})\n\n" - "Usage: /verbose 0|1|2\n" + "Usage: /verbose 0|1|2|3\n" " 0 = quiet (final response only)\n" " 1 = normal (tools + reasoning)\n" - " 2 = detailed (tools with inputs + reasoning)", + " 2 = detailed (tools with inputs + reasoning)\n" + " 3 = detailed, keep progress log visible", parse_mode="HTML", ) return try: level = int(args[0]) - if level not in (0, 1, 2): + if level not in (0, 1, 2, 3): raise ValueError except ValueError: await update.message.reply_text( - "Please use: /verbose 0, /verbose 1, or /verbose 2" + "Please use: /verbose 0, /verbose 1, /verbose 2, or /verbose 3" ) return context.user_data["verbose_level"] = level - labels = {0: "quiet", 1: "normal", 2: "detailed"} await update.message.reply_text( f"Verbosity set to {level} ({labels[level]})", parse_mode="HTML", @@ -1074,10 +1100,7 @@ async def agentic_text( except Exception: logger.debug("Draft flush failed in finally block", user_id=user_id) - try: - await progress_msg.delete() - except Exception: - logger.debug("Failed to delete progress message, ignoring") + await self._finalize_progress_msg(progress_msg, verbose_level) # Use MCP-collected images (from send_image_to_user tool calls) images: List[ImageAttachment] = mcp_images @@ -1284,10 +1307,7 @@ async def agentic_document( claude_response.content ) - try: - await progress_msg.delete() - except Exception: - logger.debug("Failed to delete progress message, ignoring") + await self._finalize_progress_msg(progress_msg, verbose_level) # Use MCP-collected images (from send_image_to_user tool calls) images: List[ImageAttachment] = mcp_images_doc @@ -1494,10 +1514,7 @@ async def _handle_agentic_media_message( formatter = ResponseFormatter(self.settings) formatted_messages = formatter.format_claude_response(claude_response.content) - try: - await progress_msg.delete() - except Exception: - logger.debug("Failed to delete progress message, ignoring") + await self._finalize_progress_msg(progress_msg, verbose_level) # Use MCP-collected images (from send_image_to_user tool calls). images: List[ImageAttachment] = mcp_images_media diff --git a/src/config/settings.py b/src/config/settings.py index c4f7cb18..68ebc6f2 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -257,16 +257,19 @@ class Settings(BaseSettings): ), ) - # Output verbosity (0=quiet, 1=normal, 2=detailed) + # Output verbosity (0=quiet, 1=normal, 2=detailed, 3=detailed+keep-log) verbose_level: int = Field( 1, description=( "Bot output verbosity: 0=quiet (final response only), " "1=normal (tool names + reasoning), " - "2=detailed (tool inputs + longer reasoning)" + "2=detailed (tool inputs + longer reasoning), " + "3=detailed and keep the progress message visible after " + "the final response (Stop button stripped, text preserved " + "as a read-only log)" ), ge=0, - le=2, + le=3, ) # Streaming drafts (Telegram sendMessageDraft) diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index ce5e419e..0b8175bb 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -322,6 +322,98 @@ async def test_agentic_text_calls_claude(agentic_settings, deps): assert call.kwargs.get("reply_markup") is None +async def test_verbose_level_3_preserves_log(tmp_dir, deps): + """At verbose_level=3 the progress message is kept (Stop button stripped).""" + settings = create_test_config( + approved_directory=str(tmp_dir), + agentic_mode=True, + verbose_level=3, + ) + orchestrator = MessageOrchestrator(settings, deps) + + mock_response = MagicMock() + mock_response.session_id = "session-abc" + mock_response.content = "Done!" + mock_response.tools_used = [] + + claude_integration = AsyncMock() + claude_integration.run_command = AsyncMock(return_value=mock_response) + + update = MagicMock() + update.effective_user.id = 123 + update.message.text = "hi" + update.message.message_id = 1 + update.message.chat.send_action = AsyncMock() + update.message.reply_text = AsyncMock() + + progress_msg = AsyncMock() + progress_msg.delete = AsyncMock() + progress_msg.edit_reply_markup = AsyncMock() + update.message.reply_text.return_value = progress_msg + + context = MagicMock() + context.user_data = {} + context.bot_data = { + "settings": settings, + "claude_integration": claude_integration, + "storage": None, + "rate_limiter": None, + "audit_logger": None, + } + + await orchestrator.agentic_text(update, context) + + # Progress message was NOT deleted; Stop button was stripped. + progress_msg.delete.assert_not_called() + progress_msg.edit_reply_markup.assert_called_once_with(reply_markup=None) + + +async def test_verbose_level_2_still_deletes_progress(tmp_dir, deps): + """At verbose_level<=2 the progress message is deleted (original behavior).""" + settings = create_test_config( + approved_directory=str(tmp_dir), + agentic_mode=True, + verbose_level=2, + ) + orchestrator = MessageOrchestrator(settings, deps) + + mock_response = MagicMock() + mock_response.session_id = "session-abc" + mock_response.content = "Done!" + mock_response.tools_used = [] + + claude_integration = AsyncMock() + claude_integration.run_command = AsyncMock(return_value=mock_response) + + update = MagicMock() + update.effective_user.id = 123 + update.message.text = "hi" + update.message.message_id = 1 + update.message.chat.send_action = AsyncMock() + update.message.reply_text = AsyncMock() + + progress_msg = AsyncMock() + progress_msg.delete = AsyncMock() + progress_msg.edit_reply_markup = AsyncMock() + update.message.reply_text.return_value = progress_msg + + context = MagicMock() + context.user_data = {} + context.bot_data = { + "settings": settings, + "claude_integration": claude_integration, + "storage": None, + "rate_limiter": None, + "audit_logger": None, + } + + await orchestrator.agentic_text(update, context) + + # At verbose_level<3 the progress msg is deleted. + progress_msg.delete.assert_called_once() + progress_msg.edit_reply_markup.assert_not_called() + + async def test_agentic_callback_scoped_to_cd_pattern(agentic_settings, deps): """Agentic callback handler is registered with cd: pattern filter.""" orchestrator = MessageOrchestrator(agentic_settings, deps)