Skip to content

Commit 1257203

Browse files
Tallaclaude
andcommitted
fix: address reviewer feedback — type annotations, closure, model aliases
- callback.py: replace shared _model_effort_handler closure with two explicit lambdas (model:/effort:) — eliminates outer-scope capture; move import to module level - command.py: drop _MODELS dict with hardcoded version IDs; use _MODEL_FAMILIES list of short CLI aliases ("opus"/"sonnet"/"haiku") which the CLI resolves to current latest automatically - command.py: add CallbackQuery + ContextTypes type annotations to _handle_model_selection (fixes mypy strict mode) - command.py: simplify _current_model_label (no reverse-map needed) - command.py: add PR #165 compatibility comment on force_new_session - tests: update imports/assertions for _MODEL_FAMILIES; add 3 tests: closure regression guard, effort: prefix isolation, force_new_session Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5db910a commit 1257203

3 files changed

Lines changed: 84 additions & 44 deletions

File tree

src/bot/handlers/callback.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from ...security.audit import AuditLogger
1313
from ...security.validators import SecurityValidator
1414
from ..utils.html_format import escape_html
15+
from .command import _handle_model_selection
1516

1617
logger = structlog.get_logger()
1718

@@ -57,11 +58,6 @@ async def handle_callback_query(
5758
action, param = data, None
5859

5960
# Route to appropriate handler
60-
from .command import _handle_model_selection
61-
62-
async def _model_effort_handler(query, param, context):
63-
await _handle_model_selection(query, f"{action}:{param}", context)
64-
6561
handlers = {
6662
"cd": handle_cd_callback,
6763
"action": handle_action_callback,
@@ -71,8 +67,8 @@ async def _model_effort_handler(query, param, context):
7167
"conversation": handle_conversation_callback,
7268
"git": handle_git_callback,
7369
"export": handle_export_callback,
74-
"model": _model_effort_handler,
75-
"effort": _model_effort_handler,
70+
"model": lambda q, p, ctx: _handle_model_selection(q, f"model:{p}", ctx),
71+
"effort": lambda q, p, ctx: _handle_model_selection(q, f"effort:{p}", ctx),
7672
}
7773

7874
handler = handlers.get(action)

src/bot/handlers/command.py

Lines changed: 23 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from typing import Optional
88

99
import structlog
10-
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
10+
from telegram import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Update
1111
from telegram.ext import ContextTypes
1212

1313
from ...claude.facade import ClaudeIntegration
@@ -1233,14 +1233,13 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
12331233
logger.error("Error in git_command", error=str(e), user_id=user_id)
12341234

12351235

1236-
# Model IDs mapped to user-friendly labels
1237-
_MODELS = {
1238-
"opus": "claude-opus-4-6",
1239-
"sonnet": "claude-sonnet-4-6",
1240-
"haiku": "claude-haiku-4-5-20251001",
1241-
}
1236+
# Short CLI aliases passed directly to the Claude CLI, which resolves them to
1237+
# the current latest model of each family. No version numbers to maintain here.
1238+
# See: https://docs.anthropic.com/en/docs/about-claude/models/overview
1239+
_MODEL_FAMILIES = ["opus", "sonnet", "haiku"]
12421240

1243-
# Effort levels per model (Haiku doesn't support effort; "max" is Opus-only)
1241+
# Effort levels per model family. Haiku has none; "max" is Opus-only.
1242+
# Update here if a future model's effort support changes.
12441243
_EFFORT_BY_MODEL = {
12451244
"opus": ["low", "medium", "high", "max"],
12461245
"sonnet": ["low", "medium", "high"],
@@ -1250,23 +1249,15 @@ async def git_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> Non
12501249

12511250
def _current_model_label(context: ContextTypes.DEFAULT_TYPE) -> str:
12521251
"""Return a human-friendly label for the active model + effort."""
1253-
override = context.user_data.get("model_override")
1252+
override = context.user_data.get("model_override") # "opus", "sonnet", "haiku", or None
12541253
effort = context.user_data.get("effort_override")
1255-
# Reverse-map model ID to short name
1256-
model_id = override or ""
1257-
label = model_id
1258-
for short, full in _MODELS.items():
1259-
if full == model_id:
1260-
label = short.capitalize()
1261-
break
12621254
if not override:
12631255
settings = context.bot_data.get("settings")
12641256
server_model = getattr(settings, "claude_model", None) if settings else None
12651257
label = f"Default ({server_model or 'CLI default'})"
1266-
parts = [label]
1267-
if effort:
1268-
parts.append(f"effort={effort}")
1269-
return " | ".join(parts)
1258+
else:
1259+
label = override.capitalize()
1260+
return f"{label} | effort={effort}" if effort else label
12701261

12711262

12721263
async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
@@ -1291,14 +1282,19 @@ async def model_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> N
12911282
)
12921283

12931284

1294-
async def _handle_model_selection(query, data: str, context) -> None:
1285+
async def _handle_model_selection(
1286+
query: CallbackQuery,
1287+
data: str,
1288+
context: ContextTypes.DEFAULT_TYPE,
1289+
) -> None:
12951290
"""Shared logic for model/effort selection (used by both callback routes)."""
12961291
if data.startswith("model:"):
12971292
choice = data.split(":", 1)[1]
12981293

12991294
if choice == "default":
13001295
context.user_data.pop("model_override", None)
13011296
context.user_data.pop("effort_override", None)
1297+
# Note: if PR #165 merges first, change this to context.chat_data
13021298
context.user_data["force_new_session"] = True
13031299
await query.edit_message_text(
13041300
"🤖 Model and effort reset to server defaults.\n"
@@ -1308,21 +1304,23 @@ async def _handle_model_selection(query, data: str, context) -> None:
13081304
logger.info("Model override cleared", user_id=query.from_user.id)
13091305
return
13101306

1311-
model_id = _MODELS.get(choice)
1312-
if not model_id:
1307+
if choice not in _MODEL_FAMILIES:
13131308
await query.edit_message_text("Unknown model.")
13141309
return
13151310

1316-
context.user_data["model_override"] = model_id
1311+
# Store short CLI alias ("opus"/"sonnet"/"haiku") — the CLI resolves it
1312+
# to the current latest model, so no version numbers to maintain.
1313+
context.user_data["model_override"] = choice
13171314
# Clear stale effort when switching models
13181315
context.user_data.pop("effort_override", None)
13191316
# Force new session so the model change takes effect immediately
1317+
# Note: if PR #165 merges first, change this to context.chat_data
13201318
context.user_data["force_new_session"] = True
13211319

13221320
logger.info(
13231321
"Model override set",
13241322
user_id=query.from_user.id,
1325-
model=model_id,
1323+
model=choice,
13261324
)
13271325

13281326
# Show effort level selection (if supported by this model)

tests/unit/test_bot/test_model_command.py

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
- Haiku skips effort keyboard (not supported)
99
- Opus shows "max" effort, Sonnet does not
1010
- _current_model_label returns correct labels
11+
- Regression: model: callback data prefix is never rewritten as effort:
12+
- force_new_session is always set on model change
1113
"""
1214

1315
from unittest.mock import AsyncMock, MagicMock
@@ -17,7 +19,7 @@
1719

1820
from src.bot.handlers.command import (
1921
_EFFORT_BY_MODEL,
20-
_MODELS,
22+
_MODEL_FAMILIES,
2123
_current_model_label,
2224
_handle_model_selection,
2325
model_command,
@@ -85,7 +87,7 @@ async def test_model_command_shows_keyboard(update, context):
8587
@pytest.mark.asyncio
8688
async def test_model_command_shows_current_override(update, context):
8789
"""When an override is active, /model should show it."""
88-
context.user_data["model_override"] = _MODELS["sonnet"]
90+
context.user_data["model_override"] = "sonnet"
8991
await model_command(update, context)
9092

9193
text = update.message.reply_text.call_args.args[0]
@@ -99,27 +101,27 @@ async def test_model_command_shows_current_override(update, context):
99101

100102
@pytest.mark.asyncio
101103
async def test_select_opus_sets_override(callback_query, context):
102-
"""Selecting Opus sets model_override and force_new_session."""
104+
"""Selecting Opus sets model_override to short alias and force_new_session."""
103105
await _handle_model_selection(callback_query, "model:opus", context)
104106

105-
assert context.user_data["model_override"] == _MODELS["opus"]
107+
assert context.user_data["model_override"] == "opus"
106108
assert context.user_data["force_new_session"] is True
107109

108110

109111
@pytest.mark.asyncio
110112
async def test_select_sonnet_sets_override(callback_query, context):
111-
"""Selecting Sonnet sets the correct model ID."""
113+
"""Selecting Sonnet sets the correct short alias."""
112114
await _handle_model_selection(callback_query, "model:sonnet", context)
113115

114-
assert context.user_data["model_override"] == _MODELS["sonnet"]
116+
assert context.user_data["model_override"] == "sonnet"
115117

116118

117119
@pytest.mark.asyncio
118120
async def test_select_haiku_skips_effort(callback_query, context):
119121
"""Selecting Haiku should not show effort keyboard (not supported)."""
120122
await _handle_model_selection(callback_query, "model:haiku", context)
121123

122-
assert context.user_data["model_override"] == _MODELS["haiku"]
124+
assert context.user_data["model_override"] == "haiku"
123125
# Final message, no reply_markup (no effort keyboard)
124126
call_kwargs = callback_query.edit_message_text.call_args
125127
assert "reply_markup" not in call_kwargs.kwargs or call_kwargs.kwargs.get("reply_markup") is None
@@ -156,7 +158,7 @@ async def test_select_sonnet_shows_effort_without_max(callback_query, context):
156158
@pytest.mark.asyncio
157159
async def test_default_clears_overrides(callback_query, context):
158160
"""Selecting 'default' clears model, effort, and forces new session."""
159-
context.user_data["model_override"] = _MODELS["opus"]
161+
context.user_data["model_override"] = "opus"
160162
context.user_data["effort_override"] = "high"
161163

162164
await _handle_model_selection(callback_query, "model:default", context)
@@ -174,7 +176,7 @@ async def test_default_clears_overrides(callback_query, context):
174176
@pytest.mark.asyncio
175177
async def test_effort_sets_override(callback_query, context):
176178
"""Selecting an effort level stores it in user_data."""
177-
context.user_data["model_override"] = _MODELS["opus"]
179+
context.user_data["model_override"] = "opus"
178180

179181
await _handle_model_selection(callback_query, "effort:high", context)
180182

@@ -184,7 +186,7 @@ async def test_effort_sets_override(callback_query, context):
184186
@pytest.mark.asyncio
185187
async def test_effort_skip_keeps_existing(callback_query, context):
186188
"""Selecting 'skip' should not set effort_override."""
187-
context.user_data["model_override"] = _MODELS["sonnet"]
189+
context.user_data["model_override"] = "sonnet"
188190

189191
await _handle_model_selection(callback_query, "effort:skip", context)
190192

@@ -201,6 +203,44 @@ async def test_model_switch_clears_stale_effort(callback_query, context):
201203
assert "effort_override" not in context.user_data
202204

203205

206+
# ---------------------------------------------------------------------------
207+
# Regression: callback data prefix integrity (closure-bug guard)
208+
# ---------------------------------------------------------------------------
209+
210+
211+
@pytest.mark.asyncio
212+
async def test_model_callback_sets_model_not_effort(callback_query, context):
213+
"""model: callback must set model_override, not effort_override.
214+
215+
Regression guard: a shared closure capturing 'action' from outer scope
216+
could silently rewrite 'model:opus' as 'effort:opus'. This test would
217+
have caught that.
218+
"""
219+
await _handle_model_selection(callback_query, "model:sonnet", context)
220+
221+
assert context.user_data.get("model_override") == "sonnet"
222+
assert "effort_override" not in context.user_data
223+
224+
225+
@pytest.mark.asyncio
226+
async def test_effort_callback_sets_effort_not_model(callback_query, context):
227+
"""effort: callback must set effort_override and not overwrite model_override."""
228+
context.user_data["model_override"] = "sonnet"
229+
230+
await _handle_model_selection(callback_query, "effort:high", context)
231+
232+
assert context.user_data.get("effort_override") == "high"
233+
assert context.user_data.get("model_override") == "sonnet" # unchanged
234+
235+
236+
@pytest.mark.asyncio
237+
async def test_force_new_session_set_on_model_switch(callback_query, context):
238+
"""force_new_session must be True after any model switch."""
239+
await _handle_model_selection(callback_query, "model:opus", context)
240+
241+
assert context.user_data.get("force_new_session") is True
242+
243+
204244
# ---------------------------------------------------------------------------
205245
# Label helper
206246
# ---------------------------------------------------------------------------
@@ -222,13 +262,13 @@ def test_label_default_with_server_model():
222262

223263
def test_label_with_model_and_effort():
224264
ctx = MagicMock()
225-
ctx.user_data = {"model_override": _MODELS["sonnet"], "effort_override": "medium"}
265+
ctx.user_data = {"model_override": "sonnet", "effort_override": "medium"}
226266
assert _current_model_label(ctx) == "Sonnet | effort=medium"
227267

228268

229269
def test_label_model_only():
230270
ctx = MagicMock()
231-
ctx.user_data = {"model_override": _MODELS["opus"]}
271+
ctx.user_data = {"model_override": "opus"}
232272
assert _current_model_label(ctx) == "Opus"
233273

234274

@@ -248,3 +288,9 @@ def test_sonnet_has_no_max():
248288

249289
def test_opus_has_max():
250290
assert "max" in _EFFORT_BY_MODEL["opus"]
291+
292+
293+
def test_model_families_contains_expected():
294+
assert "opus" in _MODEL_FAMILIES
295+
assert "sonnet" in _MODEL_FAMILIES
296+
assert "haiku" in _MODEL_FAMILIES

0 commit comments

Comments
 (0)