Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 37 additions & 20 deletions src/bot/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
Expand Down Expand Up @@ -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: <b>{current}</b> ({labels.get(current, '?')})\n\n"
"Usage: <code>/verbose 0|1|2</code>\n"
"Usage: <code>/verbose 0|1|2|3</code>\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 <b>{level}</b> ({labels[level]})",
parse_mode="HTML",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
92 changes: 92 additions & 0 deletions tests/unit/test_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down