From c642250ace20f825a8958f7fbe11bcd4892ff209 Mon Sep 17 00:00:00 2001 From: FrundlesTian <2929608755@qq.com> Date: Wed, 23 Sep 2026 00:05:18 +0800 Subject: [PATCH 1/2] feat: add per-conversation effort control --- CHANGELOG.md | 3 + README.md | 10 ++- docs/configuration.md | 4 +- docs/project-overview.md | 2 +- src/bot/handlers/callback.py | 8 +- src/bot/handlers/command.py | 55 +++++++++++- src/bot/handlers/message.py | 5 ++ src/bot/orchestrator.py | 25 ++++-- src/bot/utils/effort.py | 41 +++++++++ src/claude/facade.py | 8 ++ src/claude/sdk_integration.py | 3 + tests/unit/test_bot/test_effort.py | 90 +++++++++++++++++++ tests/unit/test_claude/test_facade.py | 22 +++++ .../unit/test_claude/test_sdk_integration.py | 38 ++++++++ tests/unit/test_orchestrator.py | 40 ++++++--- 15 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 src/bot/utils/effort.py create mode 100644 tests/unit/test_bot/test_effort.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 61734139d..ef62d7875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Per-conversation Claude effort**: `/effort low|medium|high|xhigh|max` sets the Agent SDK's reasoning effort for the current conversation, `/effort default` restores the SDK default, and `/status` shows the active value. The override reaches text, document, photo, voice, continue and quick-action runs in both agentic and classic mode, including project-topic state (#233) + ### Fixed - **The Claude review workflow posts one review per pull request instead of one per push**: the job reruns on every `synchronize` and posted a fresh comment each time, so #236 collected nine full reviews in four and a half hours, each restating what the last had already settled. `use_sticky_comment` was set but does nothing here — it only applies to the action's tag mode, and this workflow supplies `prompt`, so the action posts nothing itself and the review is whatever the prompt tells Claude to post. The prompt now edits its own previous comment with `gh pr comment --edit-last --create-if-none`, so the pull request carries one review at the current head and GitHub keeps the superseded text in the comment's edit history. The inert input is removed rather than left to look load-bearing - **The review reports only what should block the merge**: most of the length of those nine reviews was praise, an account of what had been checked, and cosmetic nits ("after 1 turns"), and every nit drew another push, which triggered another review — that loop, not the reviewing, was the spam. The prompt now names what qualifies (a security regression, a bug, an untested behaviour change, a missing setting or CHANGELOG entry) and rules out the rest, including anything `black`, `isort` or `flake8` already gates, and findings that cannot be confirmed from the code. It also reads its own previous review first so it does not repeat itself, but what settles a finding is the code at the current head rather than a reply claiming a fix: the replies are contributor-authored and untrusted like the rest of the pull request, so an earlier finding is re-checked against the diff and raised again unchanged when the code does not carry the claimed fix diff --git a/README.md b/README.md index 3b335a032..bcee70fe6 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ The bot supports two interaction modes: The default conversational mode. Just talk to Claude naturally -- no special commands required. -**Commands:** `/start`, `/new`, `/status`, `/verbose`, `/repo` +**Commands:** `/start`, `/new`, `/status`, `/verbose`, `/effort`, `/repo` If `ENABLE_PROJECT_THREADS=true`: `/sync_threads` ``` @@ -129,6 +129,10 @@ Use `/verbose 0|1|2` to control how much background activity is shown: | **1** (normal, default) | Tool names + reasoning snippets in real-time | | **2** (detailed) | Tool names with inputs + longer reasoning text | +Use `/effort low|medium|high|xhigh|max` to set Claude's reasoning effort for +the current conversation. `/effort` shows the current value, and +`/effort default` returns to the SDK default. + #### GitHub Workflow Claude Code already knows how to use `gh` CLI and `git`. Authenticate on your server with `gh auth login`, then work with repos conversationally: @@ -155,9 +159,9 @@ Use `/repo` to list cloned repos in your workspace, or `/repo ` to switch ### Classic Mode -Set `AGENTIC_MODE=false` to enable the full 13-command terminal-like interface with directory navigation, inline keyboards, quick actions, git integration, and session export. +Set `AGENTIC_MODE=false` to enable the full terminal-like interface with directory navigation, inline keyboards, quick actions, git integration, and session export. -**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git` +**Commands:** `/start`, `/help`, `/new`, `/continue`, `/end`, `/status`, `/effort`, `/cd`, `/ls`, `/pwd`, `/projects`, `/export`, `/actions`, `/git` If `ENABLE_PROJECT_THREADS=true`: `/sync_threads` ``` diff --git a/docs/configuration.md b/docs/configuration.md index ecd91207f..6d955b12d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -129,8 +129,8 @@ AUDIT_LOG_RETENTION_DAYS=365 # Days to keep audit logs ```bash # Agentic mode (default: true) -# true = conversational mode with 3 commands (/start, /new, /status) -# false = classic terminal mode with 13 commands and inline keyboards +# true = conversational mode with a compact command set +# false = classic terminal mode with navigation and inline keyboards AGENTIC_MODE=true ``` diff --git a/docs/project-overview.md b/docs/project-overview.md index 86597333c..bbf68b7a6 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -2,7 +2,7 @@ ## Project Description -A Telegram bot that provides remote access to Claude Code, allowing developers to interact with their projects from anywhere. The default interaction model is **agentic mode** -- a conversational interface where users chat naturally with Claude. A classic terminal-like mode with 13 commands is also available. +A Telegram bot that provides remote access to Claude Code, allowing developers to interact with their projects from anywhere. The default interaction model is **agentic mode** -- a conversational interface where users chat naturally with Claude. A classic terminal-like mode with navigation and inline keyboards is also available. ## Core Objectives diff --git a/src/bot/handlers/callback.py b/src/bot/handlers/callback.py index 66dd660c4..20c567cc4 100644 --- a/src/bot/handlers/callback.py +++ b/src/bot/handlers/callback.py @@ -11,6 +11,7 @@ from ...config.settings import Settings from ...security.audit import AuditLogger from ...security.validators import SecurityValidator +from ..utils.effort import get_effort from ..utils.html_format import escape_html logger = structlog.get_logger() @@ -565,6 +566,7 @@ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) -> working_directory=current_dir, user_id=user_id, session_id=claude_session_id, + effort=get_effort(context), ) else: # No session in context, try to find the most recent session @@ -578,6 +580,7 @@ async def _handle_continue_action(query, context: ContextTypes.DEFAULT_TYPE) -> user_id=user_id, working_directory=current_dir, prompt=None, # No prompt = use --continue + effort=get_effort(context), ) if claude_response: @@ -920,7 +923,10 @@ async def handle_quick_action_callback( # Run the action through Claude claude_response = await claude_integration.run_command( - prompt=action.prompt, working_directory=current_dir, user_id=user_id + prompt=action.prompt, + working_directory=current_dir, + user_id=user_id, + effort=get_effort(context), ) if claude_response: diff --git a/src/bot/handlers/command.py b/src/bot/handlers/command.py index 651a08f8c..79c972f88 100644 --- a/src/bot/handlers/command.py +++ b/src/bot/handlers/command.py @@ -4,9 +4,10 @@ import signal from datetime import datetime, timezone from pathlib import Path -from typing import Optional +from typing import Optional, cast import structlog +from claude_agent_sdk import EffortLevel from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update from telegram.ext import ContextTypes @@ -16,6 +17,7 @@ from ...security.audit import AuditLogger from ...security.validators import SecurityValidator from ...storage.models import SessionModel +from ..utils.effort import EFFORT_LEVELS, get_effort, set_effort from ..utils.html_format import escape_html logger = structlog.get_logger() @@ -121,6 +123,7 @@ async def start_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N f"• /cd <dir> - Change directory\n" f"• /projects - Show available projects\n" f"• /status - Show session status\n" + f"• /effort [level] - Show or set reasoning effort\n" f"• /actions - Show quick actions\n" f"• /git - Git repository commands\n\n" f"Quick Start:\n" @@ -172,6 +175,7 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No "• /continue [message] - Explicitly continue last session\n" "• /end - End current session and clear context\n" "• /status - Show session and usage status\n" + "• /effort [level] - Show or set reasoning effort\n" "• /export - Export session history\n" "• /actions - Show context-aware quick actions\n" "• /git - Git repository information\n\n" @@ -205,6 +209,52 @@ async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No await update.message.reply_text(help_text, parse_mode="HTML") +async def effort_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + """Show or set the SDK effort override for the current conversation.""" + args = list(context.args or []) + current = get_effort(context) + + if not args: + value = current or "SDK default" + await update.message.reply_text( + f"⚙️ Claude Effort: {value}\n\n" + "Usage: /effort low|medium|high|xhigh|max|default", + parse_mode="HTML", + ) + return + + requested = args[0].lower() + valid_values = (*EFFORT_LEVELS, "default") + if len(args) != 1 or requested not in valid_values: + await update.message.reply_text( + "❌ Invalid effort level\n\n" + "Usage: /effort low|medium|high|xhigh|max|default", + parse_mode="HTML", + ) + return + + if requested == "default": + set_effort(context, None) + display = "SDK default" + else: + set_effort(context, cast(EffortLevel, requested)) + display = requested + + await update.message.reply_text( + f"✅ Claude effort set to {display} for this conversation.", + parse_mode="HTML", + ) + + audit_logger: AuditLogger = context.bot_data.get("audit_logger") + if audit_logger: + await audit_logger.log_command( + user_id=update.effective_user.id, + command="effort", + args=args, + success=True, + ) + + async def sync_threads(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: """Synchronize project topics in the configured forum chat.""" settings: Settings = context.bot_data["settings"] @@ -401,6 +451,7 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) - working_directory=current_dir, user_id=user_id, session_id=claude_session_id, + effort=get_effort(context), ) else: # No session in context, try to find the most recent session @@ -415,6 +466,7 @@ async def continue_session(update: Update, context: ContextTypes.DEFAULT_TYPE) - user_id=user_id, working_directory=current_dir, prompt=prompt or default_prompt, + effort=get_effort(context), ) if claude_response: @@ -910,6 +962,7 @@ async def session_status(update: Update, context: ContextTypes.DEFAULT_TYPE) -> "", f"📂 Directory: {relative_path}/", f"🤖 Claude Session: {'✅ Active' if claude_session_id else '❌ None'}", + f"⚙️ Effort: {get_effort(context) or 'default'}", usage_info.rstrip(), f"🕐 Last Update: {update.message.date.strftime('%H:%M:%S UTC')}", ] diff --git a/src/bot/handlers/message.py b/src/bot/handlers/message.py index bbd240840..fb0589abb 100644 --- a/src/bot/handlers/message.py +++ b/src/bot/handlers/message.py @@ -19,6 +19,7 @@ from ...security.audit import AuditLogger from ...security.rate_limiter import RateLimiter from ...security.validators import SecurityValidator +from ..utils.effort import get_effort from ..utils.html_format import escape_html from ..utils.image_extractor import ( ImageAttachment, @@ -393,6 +394,7 @@ async def stream_handler(update_obj): session_id=session_id, on_stream=stream_handler, force_new=force_new, + effort=get_effort(context), ) # New session created successfully — clear the one-shot flag @@ -818,6 +820,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE) -> working_directory=current_dir, user_id=user_id, session_id=session_id, + effort=get_effort(context), ) # Update session ID @@ -945,6 +948,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No working_directory=current_dir, user_id=user_id, session_id=session_id, + effort=get_effort(context), ) # Update session ID @@ -1073,6 +1077,7 @@ async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE) -> No working_directory=current_dir, user_id=user_id, session_id=session_id, + effort=get_effort(context), ) context.user_data["claude_session_id"] = claude_response.session_id diff --git a/src/bot/orchestrator.py b/src/bot/orchestrator.py index 5436ba199..3b3c8ed7e 100644 --- a/src/bot/orchestrator.py +++ b/src/bot/orchestrator.py @@ -1,8 +1,8 @@ """Message orchestrator — single entry point for all Telegram updates. Routes messages based on agentic vs classic mode. In agentic mode, provides -a minimal conversational interface (3 commands, no inline keyboards). In -classic mode, delegates to existing full-featured handlers. +a minimal conversational interface without inline keyboards. In classic mode, +delegates to existing full-featured handlers. """ import asyncio @@ -34,6 +34,7 @@ from ..config.settings import Settings from ..projects import PrivateTopicsUnavailableError from .utils.draft_streamer import DraftStreamer, generate_draft_id +from .utils.effort import EFFORT_LEVELS, EFFORT_STATE_KEY, get_effort from .utils.html_format import escape_html from .utils.image_extractor import ( ImageAttachment, @@ -244,6 +245,11 @@ async def _apply_thread_routing_context( context.user_data["current_directory"] = current_dir context.user_data["claude_session_id"] = state.get("claude_session_id") + effort = state.get(EFFORT_STATE_KEY) + if effort in EFFORT_LEVELS: + context.user_data[EFFORT_STATE_KEY] = effort + else: + context.user_data.pop(EFFORT_STATE_KEY, None) context.user_data["_thread_context"] = { "chat_id": chat.id, "message_thread_id": message_thread_id, @@ -272,6 +278,7 @@ def _persist_thread_state(self, context: ContextTypes.DEFAULT_TYPE) -> None: thread_states[thread_context["state_key"]] = { "current_directory": str(current_dir), "claude_session_id": context.user_data.get("claude_session_id"), + EFFORT_STATE_KEY: get_effort(context), "project_slug": thread_context["project_slug"], } @@ -336,6 +343,7 @@ def _register_agentic_handlers(self, app: Application) -> None: ("new", self.agentic_new), ("status", self.agentic_status), ("verbose", self.agentic_verbose), + ("effort", command.effort_command), ("repo", self.agentic_repo), ("restart", command.restart_command), ] @@ -431,6 +439,7 @@ def _register_classic_handlers(self, app: Application) -> None: ("pwd", command.print_working_directory), ("projects", command.show_projects), ("status", command.session_status), + ("effort", command.effort_command), ("export", command.export_session), ("actions", command.quick_actions), ("git", command.git_command), @@ -467,7 +476,7 @@ def _register_classic_handlers(self, app: Application) -> None: CallbackQueryHandler(self._inject_deps(callback.handle_callback_query)) ) - logger.info("Classic handlers registered (13 commands + full handler set)") + logger.info("Classic handlers registered (15 commands + full handler set)") async def get_bot_commands(self) -> list: # type: ignore[type-arg] """Return bot commands appropriate for current mode.""" @@ -477,6 +486,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] BotCommand("new", "Start a fresh session"), BotCommand("status", "Show session status"), BotCommand("verbose", "Set output verbosity (0/1/2)"), + BotCommand("effort", "Set Claude reasoning effort"), BotCommand("repo", "List repos / switch workspace"), BotCommand("restart", "Restart the bot"), ] @@ -495,6 +505,7 @@ async def get_bot_commands(self) -> list: # type: ignore[type-arg] BotCommand("pwd", "Show current directory"), BotCommand("projects", "Show all projects"), BotCommand("status", "Show session status"), + BotCommand("effort", "Set Claude reasoning effort"), BotCommand("export", "Export current session"), BotCommand("actions", "Show quick actions"), BotCommand("git", "Git repository commands"), @@ -555,7 +566,7 @@ async def agentic_start( f"Hi {safe_name}! I'm your AI coding assistant.\n" f"Just tell me what you need — I can read, write, and run code.\n\n" f"Working in: {dir_display}\n" - f"Commands: /new (reset) · /status" + f"Commands: /new (reset) · /status · /effort" f"{sync_line}", parse_mode="HTML", ) @@ -581,6 +592,7 @@ async def agentic_status( session_id = context.user_data.get("claude_session_id") session_status = "active" if session_id else "none" + effort = get_effort(context) or "default" # Cost info cost_str = "" @@ -595,7 +607,7 @@ async def agentic_status( pass await update.message.reply_text( - f"📂 {dir_display} · Session: {session_status}{cost_str}" + f"📂 {dir_display} · Session: {session_status} · Effort: {effort}{cost_str}" ) def _get_verbose_level(self, context: ContextTypes.DEFAULT_TYPE) -> int: @@ -1083,6 +1095,7 @@ async def agentic_text( force_new=force_new, interrupt_event=interrupt_event, approval_callback=approval_cb, + effort=get_effort(context), ) # New session created successfully — clear the one-shot flag @@ -1333,6 +1346,7 @@ async def agentic_document( session_id=session_id, on_stream=on_stream, force_new=force_new, + effort=get_effort(context), ) if force_new: @@ -1543,6 +1557,7 @@ async def _handle_agentic_media_message( on_stream=on_stream, force_new=force_new, images=images, + effort=get_effort(context), ) finally: heartbeat.cancel() diff --git a/src/bot/utils/effort.py b/src/bot/utils/effort.py new file mode 100644 index 000000000..6d1df55ec --- /dev/null +++ b/src/bot/utils/effort.py @@ -0,0 +1,41 @@ +"""Helpers for the per-conversation Claude effort override.""" + +from typing import Optional, cast + +from claude_agent_sdk import EffortLevel +from telegram.ext import ContextTypes + +EFFORT_LEVELS: tuple[EffortLevel, ...] = ( + "low", + "medium", + "high", + "xhigh", + "max", +) +EFFORT_STATE_KEY = "claude_effort" + + +def get_effort(context: ContextTypes.DEFAULT_TYPE) -> Optional[EffortLevel]: + """Return the validated effort override for the current conversation.""" + user_data = context.user_data + if user_data is None: + return None + + value = user_data.get(EFFORT_STATE_KEY) + if value in EFFORT_LEVELS: + return cast(EffortLevel, value) + return None + + +def set_effort( + context: ContextTypes.DEFAULT_TYPE, effort: Optional[EffortLevel] +) -> None: + """Set an effort override, or remove it to restore the SDK default.""" + user_data = context.user_data + if user_data is None: + return + + if effort is None: + user_data.pop(EFFORT_STATE_KEY, None) + else: + user_data[EFFORT_STATE_KEY] = effort diff --git a/src/claude/facade.py b/src/claude/facade.py index 6b7bc6f31..44a4feef3 100644 --- a/src/claude/facade.py +++ b/src/claude/facade.py @@ -8,6 +8,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional import structlog +from claude_agent_sdk import EffortLevel from ..config.settings import Settings from .sdk_integration import ClaudeResponse, ClaudeSDKManager, StreamUpdate @@ -43,6 +44,7 @@ async def run_command( approval_callback: Optional[ Callable[[str, Dict[str, Any]], Awaitable[bool]] ] = None, + effort: Optional[EffortLevel] = None, ) -> ClaudeResponse: """Run Claude Code command with full integration.""" logger.info( @@ -94,6 +96,7 @@ async def run_command( interrupt_event=interrupt_event, images=images, approval_callback=approval_callback, + effort=effort, ) except Exception as resume_error: # If resume failed (e.g., session expired/missing on Claude's side), @@ -121,6 +124,7 @@ async def run_command( interrupt_event=interrupt_event, images=images, approval_callback=approval_callback, + effort=effort, ) else: raise @@ -169,6 +173,7 @@ async def _execute( approval_callback: Optional[ Callable[[str, Dict[str, Any]], Awaitable[bool]] ] = None, + effort: Optional[EffortLevel] = None, ) -> ClaudeResponse: """Execute command via SDK.""" return await self.sdk_manager.execute_command( @@ -180,6 +185,7 @@ async def _execute( interrupt_event=interrupt_event, images=images, approval_callback=approval_callback, + effort=effort, ) async def _find_resumable_session( @@ -214,6 +220,7 @@ async def continue_session( working_directory: Path, prompt: Optional[str] = None, on_stream: Optional[Callable[[StreamUpdate], None]] = None, + effort: Optional[EffortLevel] = None, ) -> Optional[ClaudeResponse]: """Continue the most recent session.""" logger.info( @@ -248,6 +255,7 @@ async def continue_session( user_id=user_id, session_id=latest_session.session_id, on_stream=on_stream, + effort=effort, ) async def get_session_info( diff --git a/src/claude/sdk_integration.py b/src/claude/sdk_integration.py index d740e135e..51a0669eb 100644 --- a/src/claude/sdk_integration.py +++ b/src/claude/sdk_integration.py @@ -24,6 +24,7 @@ CLIConnectionError, CLIJSONDecodeError, CLINotFoundError, + EffortLevel, Message, PermissionResultAllow, PermissionResultDeny, @@ -339,6 +340,7 @@ async def execute_command( approval_callback: Optional[ Callable[[str, Dict[str, Any]], Awaitable[bool]] ] = None, + effort: Optional[EffortLevel] = None, ) -> ClaudeResponse: """Execute Claude Code command via SDK.""" start_time = asyncio.get_event_loop().time() @@ -439,6 +441,7 @@ def _stderr_callback(line: str) -> None: options = ClaudeAgentOptions( max_turns=self.config.claude_max_turns, model=self.config.claude_model or None, + effort=effort, max_budget_usd=self.config.claude_max_cost_per_request, cwd=str(working_directory), allowed_tools=sdk_allowed_tools, diff --git a/tests/unit/test_bot/test_effort.py b/tests/unit/test_bot/test_effort.py new file mode 100644 index 000000000..2582368fb --- /dev/null +++ b/tests/unit/test_bot/test_effort.py @@ -0,0 +1,90 @@ +"""Tests for the per-conversation /effort command and propagation contract.""" + +import ast +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import src.bot as bot_package +from src.bot.handlers.command import effort_command +from src.bot.utils.effort import get_effort + + +def _command_context(args, user_data=None): + context = MagicMock() + context.args = args + context.user_data = user_data if user_data is not None else {} + context.bot_data = {"audit_logger": None} + return context + + +def _update(): + update = MagicMock() + update.effective_user.id = 123 + update.message.reply_text = AsyncMock() + return update + + +async def test_effort_without_argument_shows_sdk_default(): + update = _update() + context = _command_context([]) + + await effort_command(update, context) + + text = update.message.reply_text.call_args.args[0] + assert "SDK default" in text + assert "low|medium|high|xhigh|max|default" in text + + +async def test_effort_sets_valid_level_case_insensitively(): + update = _update() + context = _command_context(["XHIGH"]) + + await effort_command(update, context) + + assert get_effort(context) == "xhigh" + assert "xhigh" in update.message.reply_text.call_args.args[0] + + +async def test_effort_default_removes_override(): + update = _update() + context = _command_context(["default"], {"claude_effort": "max"}) + + await effort_command(update, context) + + assert "claude_effort" not in context.user_data + assert "SDK default" in update.message.reply_text.call_args.args[0] + + +async def test_effort_rejects_invalid_or_extra_arguments(): + for args in (["turbo"], ["high", "extra"]): + update = _update() + context = _command_context(args, {"claude_effort": "medium"}) + + await effort_command(update, context) + + assert get_effort(context) == "medium" + assert "Invalid effort level" in update.message.reply_text.call_args.args[0] + + +def test_invalid_stored_effort_falls_back_to_default(): + context = _command_context([], {"claude_effort": "turbo"}) + + assert get_effort(context) is None + + +def test_every_bot_claude_call_passes_effort(): + """All user-triggered Claude entry points must carry the override.""" + offenders = [] + for path in sorted(Path(bot_package.__file__).parent.rglob("*.py")): + tree = ast.parse(path.read_text(), filename=str(path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance( + node.func, ast.Attribute + ): + continue + if node.func.attr not in {"run_command", "continue_session"}: + continue + if not any(keyword.arg == "effort" for keyword in node.keywords): + offenders.append(f"{path.name}:{node.lineno}") + + assert offenders == [], f"Claude calls missing effort override: {offenders}" diff --git a/tests/unit/test_claude/test_facade.py b/tests/unit/test_claude/test_facade.py index 666a22464..b4c1340ad 100644 --- a/tests/unit/test_claude/test_facade.py +++ b/tests/unit/test_claude/test_facade.py @@ -294,3 +294,25 @@ async def test_empty_session_id_warning_in_facade(self, facade, session_manager) # Session ID should be empty on the response assert not result.session_id + + +class TestEffortPropagation: + """Verify the facade preserves an effort override.""" + + async def test_run_command_passes_effort_to_execute(self, facade): + project = Path("/test/project") + + with patch.object( + facade, + "_execute", + return_value=_make_mock_response(), + ) as execute: + await facade.run_command( + prompt="hello", + working_directory=project, + user_id=123, + force_new=True, + effort="high", + ) + + assert execute.await_args.kwargs["effort"] == "high" diff --git a/tests/unit/test_claude/test_sdk_integration.py b/tests/unit/test_claude/test_sdk_integration.py index 7183f3340..c2deef91c 100644 --- a/tests/unit/test_claude/test_sdk_integration.py +++ b/tests/unit/test_claude/test_sdk_integration.py @@ -367,6 +367,44 @@ async def test_execute_command_passes_max_budget_usd(self, sdk_manager, config): assert len(captured_options) == 1 assert captured_options[0].max_budget_usd == config.claude_max_cost_per_request + async def test_execute_command_passes_effort_override(self, sdk_manager): + """A conversation effort override reaches ClaudeAgentOptions.""" + captured_options = [] + mock_factory = _mock_client_factory( + _make_assistant_message("Test response"), + _make_result_message(), + capture_options=captured_options, + ) + + with patch( + "src.claude.sdk_integration.ClaudeSDKClient", side_effect=mock_factory + ): + await sdk_manager.execute_command( + prompt="Test prompt", + working_directory=Path("/test"), + effort="xhigh", + ) + + assert captured_options[0].effort == "xhigh" + + async def test_execute_command_leaves_effort_unset_by_default(self, sdk_manager): + """Existing callers retain the SDK default when no override is set.""" + captured_options = [] + mock_factory = _mock_client_factory( + _make_assistant_message("Test response"), + _make_result_message(), + capture_options=captured_options, + ) + + with patch( + "src.claude.sdk_integration.ClaudeSDKClient", side_effect=mock_factory + ): + await sdk_manager.execute_command( + prompt="Test prompt", working_directory=Path("/test") + ) + + assert captured_options[0].effort is None + async def test_execute_command_no_resume_for_new_session(self, sdk_manager): """Test that resume is not set for new sessions.""" captured_options = [] diff --git a/tests/unit/test_orchestrator.py b/tests/unit/test_orchestrator.py index 108e0f808..30aaf6a50 100644 --- a/tests/unit/test_orchestrator.py +++ b/tests/unit/test_orchestrator.py @@ -82,8 +82,8 @@ def deps(): } -def test_agentic_registers_6_commands(agentic_settings, deps): - """Agentic mode registers start, new, status, verbose, repo, restart commands.""" +def test_agentic_registers_7_commands(agentic_settings, deps): + """Agentic mode registers its seven supported commands.""" orchestrator = MessageOrchestrator(agentic_settings, deps) app = MagicMock() app.add_handler = MagicMock() @@ -100,17 +100,18 @@ def test_agentic_registers_6_commands(agentic_settings, deps): ] commands = [h[0][0].commands for h in cmd_handlers] - assert len(cmd_handlers) == 6 + assert len(cmd_handlers) == 7 assert frozenset({"start"}) in commands assert frozenset({"new"}) in commands assert frozenset({"status"}) in commands assert frozenset({"verbose"}) in commands + assert frozenset({"effort"}) in commands assert frozenset({"repo"}) in commands assert frozenset({"restart"}) in commands -def test_classic_registers_14_commands(classic_settings, deps): - """Classic mode registers all 14 commands.""" +def test_classic_registers_15_commands(classic_settings, deps): + """Classic mode registers all 15 commands.""" orchestrator = MessageOrchestrator(classic_settings, deps) app = MagicMock() app.add_handler = MagicMock() @@ -125,7 +126,7 @@ def test_classic_registers_14_commands(classic_settings, deps): if isinstance(call[0][0], CommandHandler) ] - assert len(cmd_handlers) == 14 + assert len(cmd_handlers) == 15 def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): @@ -156,25 +157,34 @@ def test_agentic_registers_text_document_photo_handlers(agentic_settings, deps): async def test_agentic_bot_commands(agentic_settings, deps): - """Agentic mode returns 6 bot commands.""" + """Agentic mode returns 7 bot commands.""" orchestrator = MessageOrchestrator(agentic_settings, deps) commands = await orchestrator.get_bot_commands() - assert len(commands) == 6 + assert len(commands) == 7 cmd_names = [c.command for c in commands] - assert cmd_names == ["start", "new", "status", "verbose", "repo", "restart"] + assert cmd_names == [ + "start", + "new", + "status", + "verbose", + "effort", + "repo", + "restart", + ] async def test_classic_bot_commands(classic_settings, deps): - """Classic mode returns 14 bot commands.""" + """Classic mode returns 15 bot commands.""" orchestrator = MessageOrchestrator(classic_settings, deps) commands = await orchestrator.get_bot_commands() - assert len(commands) == 14 + assert len(commands) == 15 cmd_names = [c.command for c in commands] assert "start" in cmd_names assert "help" in cmd_names assert "git" in cmd_names + assert "effort" in cmd_names assert "restart" in cmd_names @@ -264,6 +274,7 @@ async def test_agentic_status_compact(agentic_settings, deps): call_args = update.message.reply_text.call_args text = call_args.args[0] assert "Session: none" in text + assert "Effort: default" in text async def test_agentic_text_calls_claude(agentic_settings, deps): @@ -813,7 +824,9 @@ async def test_thread_mode_loads_and_persists_thread_state(group_thread_settings async def dummy_handler(update, context): assert context.user_data["claude_session_id"] == "old-session" + assert context.user_data["claude_effort"] == "high" context.user_data["claude_session_id"] = "new-session" + context.user_data["claude_effort"] = "max" wrapped = orchestrator._inject_deps(dummy_handler) @@ -830,6 +843,7 @@ async def dummy_handler(update, context): "-1001234567890:777": { "current_directory": str(project_path), "claude_session_id": "old-session", + "claude_effort": "high", } } } @@ -840,6 +854,10 @@ async def dummy_handler(update, context): context.user_data["thread_state"]["-1001234567890:777"]["claude_session_id"] == "new-session" ) + assert ( + context.user_data["thread_state"]["-1001234567890:777"]["claude_effort"] + == "max" + ) async def test_sync_threads_bypasses_thread_gate(group_thread_settings, deps): From fd72027d588467c2d417d00f7779031108f196dd Mon Sep 17 00:00:00 2001 From: FrundlesTian <92493187+FrundlesTian@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:51:42 +0800 Subject: [PATCH 2/2] docs: update CLAUDE.md command counts and agentic command list Classic mode registers 15 commands and agentic mode now includes /effort and /restart, which CLAUDE.md hadn't been updated to reflect. Co-Authored-By: Claude Sonnet 5 --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c7b24d168..63ce87566 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Webhook POST /webhooks/{provider} -> Signature verification -> Deduplication -> NotificationService -> Rate-limited Telegram delivery ``` -**Classic mode** (`AGENTIC_MODE=false`): Same middleware chain, but routes through full command/message handlers in `src/bot/handlers/` with 13 commands and inline keyboards. +**Classic mode** (`AGENTIC_MODE=false`): Same middleware chain, but routes through full command/message handlers in `src/bot/handlers/` with 15 commands and inline keyboards. ### Dependency Injection @@ -130,7 +130,7 @@ All datetimes use timezone-aware UTC: `datetime.now(UTC)` (not `datetime.utcnow( ### Agentic mode -Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/repo`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. To add a new command: +Agentic mode commands: `/start`, `/new`, `/status`, `/verbose`, `/effort`, `/repo`, `/restart`. If `ENABLE_PROJECT_THREADS=true`: `/sync_threads`. To add a new command: 1. Add handler function in `src/bot/orchestrator.py` 2. Register in `MessageOrchestrator._register_agentic_handlers()`