From 2d0d4da9926fd0449c100e5b1cdb5aa2d9fcd097 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 4 Feb 2026 22:10:02 +0000 Subject: [PATCH] fix(02-02): typing indicator lifecycle, model identity, tool permissions - Fix typing indicator not showing: clean up stale typing tasks between messages and use dynamic event lookup in callbacks instead of capturing a specific event at creation time - Fix stream-json input format: use nested message object for stdin NDJSON - Switch --system-prompt to --append-system-prompt so Claude Code's default system prompt (with model identity) is preserved - Add --dangerously-skip-permissions for full tool access in subprocess - Use full model ID (claude-sonnet-4-5-20250929) in default persona Co-Authored-By: Claude Opus 4.5 --- telegram/bot.py | 69 +++++++++++++++++++++------------- telegram/claude_subprocess.py | 20 +++++----- telegram/personas/default.json | 2 +- telegram/telegram_utils.py | 3 +- 4 files changed, 57 insertions(+), 37 deletions(-) diff --git a/telegram/bot.py b/telegram/bot.py index 5b1abf9..b3d629a 100755 --- a/telegram/bot.py +++ b/telegram/bot.py @@ -68,8 +68,18 @@ def is_authorized(user_id: int) -> bool: """Check if user is authorized.""" return user_id in get_authorized_users() -def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event): - """Create callbacks for ClaudeSubprocess bound to specific chat with typing control.""" +def make_callbacks(bot, chat_id, session_name: str): + """Create callbacks for ClaudeSubprocess bound to specific chat with dynamic typing control. + + Typing events are looked up dynamically from typing_tasks dict so callbacks + always reference the CURRENT typing indicator, not a stale one from creation time. + """ + def _stop_typing(): + """Stop the current typing indicator for this session (if any).""" + if session_name in typing_tasks: + _, stop_event = typing_tasks[session_name] + stop_event.set() + async def on_output(text): t0 = time.monotonic() @@ -86,8 +96,8 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event): logger.warning(f"MarkdownV2 parse failed, falling back to plain text: {e}") await bot.send_message(chat_id=chat_id, text=chunk) - # Stop typing indicator - stop_typing_event.set() + # Stop typing indicator (dynamic lookup) + _stop_typing() elapsed = time.monotonic() - t0 logger.info(f"[TIMING] Telegram send: {elapsed:.3f}s ({len(text)} chars, {len(chunks)} chunks)") @@ -95,11 +105,11 @@ def make_callbacks(bot, chat_id, stop_typing_event: asyncio.Event): async def on_error(error): await bot.send_message(chat_id=chat_id, text=f"Error: {error}") # Stop typing indicator on error - stop_typing_event.set() + _stop_typing() async def on_complete(): # Stop typing indicator on completion - stop_typing_event.set() + _stop_typing() async def on_status(status): await bot.send_message(chat_id=chat_id, text=f"[{status}]") @@ -372,11 +382,8 @@ async def new_session(update: Update, context: ContextTypes.DEFAULT_TYPE): persona_config = session_manager.get_session(name) persona_data = session_manager.load_persona(persona or 'default') - # Create stop typing event for this session - stop_typing = asyncio.Event() - - # Create callbacks bound to this chat - callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) + # Create callbacks bound to this chat (typing looked up dynamically) + callbacks = make_callbacks(context.bot, update.effective_chat.id, name) # Create subprocess subprocesses[name] = ClaudeSubprocess( @@ -459,11 +466,8 @@ async def switch_session_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) persona_name = session_data.get('persona', 'default') persona_data = session_manager.load_persona(persona_name) - # Create stop typing event for this session - stop_typing = asyncio.Event() - - # Create callbacks bound to this chat - callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) + # Create callbacks bound to this chat (typing looked up dynamically) + callbacks = make_callbacks(context.bot, update.effective_chat.id, name) # Create subprocess subprocesses[name] = ClaudeSubprocess( @@ -560,16 +564,19 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.info(f"[TIMING] Message received: session='{active_session}', age={msg_age:.1f}s, text={message[:50]}...") try: - # Start typing indicator immediately (or reuse existing) + # Clean up stale typing task (from previous message completion) + if active_session in typing_tasks: + old_task, old_event = typing_tasks[active_session] + if old_task.done(): + del typing_tasks[active_session] + + # Start fresh typing indicator for this message if active_session not in typing_tasks: stop_typing = asyncio.Event() typing_task = asyncio.create_task( typing_indicator_loop(context.bot, update.effective_chat.id, stop_typing) ) typing_tasks[active_session] = (typing_task, stop_typing) - else: - # Reuse existing typing indicator - typing_task, stop_typing = typing_tasks[active_session] # Get or create subprocess for active session (avoid double-start) already_alive = active_session in subprocesses and subprocesses[active_session].is_alive @@ -579,8 +586,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): persona_name = session_data.get('persona', 'default') persona_data = session_manager.load_persona(persona_name) - # Create callbacks bound to this chat with stop_typing event - callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) + # Create callbacks bound to this chat (typing looked up dynamically) + callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session) # Create subprocess subprocess_inst = ClaudeSubprocess( @@ -652,6 +659,12 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE): # Get caption if any caption = update.message.caption or "" + # Clean up stale typing task + if active_session in typing_tasks: + old_task, old_event = typing_tasks[active_session] + if old_task.done(): + del typing_tasks[active_session] + # Start typing indicator if active_session not in typing_tasks: stop_typing = asyncio.Event() @@ -672,8 +685,7 @@ async def handle_photo(update: Update, context: ContextTypes.DEFAULT_TYPE): persona_name = session_data.get('persona', 'default') persona_data = session_manager.load_persona(persona_name) - stop_typing = typing_tasks[active_session][1] - callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) + callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session) subprocess_inst = ClaudeSubprocess( session_dir=session_dir, @@ -724,6 +736,12 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): # Get caption if any caption = update.message.caption or "" + # Clean up stale typing task + if active_session in typing_tasks: + old_task, old_event = typing_tasks[active_session] + if old_task.done(): + del typing_tasks[active_session] + # Start typing indicator if active_session not in typing_tasks: stop_typing = asyncio.Event() @@ -744,8 +762,7 @@ async def handle_document(update: Update, context: ContextTypes.DEFAULT_TYPE): persona_name = session_data.get('persona', 'default') persona_data = session_manager.load_persona(persona_name) - stop_typing = typing_tasks[active_session][1] - callbacks = make_callbacks(context.bot, update.effective_chat.id, stop_typing) + callbacks = make_callbacks(context.bot, update.effective_chat.id, active_session) subprocess_inst = ClaudeSubprocess( session_dir=session_dir, diff --git a/telegram/claude_subprocess.py b/telegram/claude_subprocess.py index 66225cd..10b0173 100644 --- a/telegram/claude_subprocess.py +++ b/telegram/claude_subprocess.py @@ -134,6 +134,7 @@ class ClaudeSubprocess: "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", + "--dangerously-skip-permissions", ] # Add --continue if prior session exists @@ -141,15 +142,15 @@ class ClaudeSubprocess: cmd.append("--continue") logger.debug("Using --continue flag (found existing .claude/ directory)") - # Add persona settings + # Add persona settings (model FIRST, then system prompt) if self._persona: - if "system_prompt" in self._persona: - cmd.extend(["--system-prompt", self._persona["system_prompt"]]) settings = self._persona.get("settings", {}) - if "max_turns" in settings: - cmd.extend(["--max-turns", str(settings["max_turns"])]) if "model" in settings: cmd.extend(["--model", settings["model"]]) + if "max_turns" in settings: + cmd.extend(["--max-turns", str(settings["max_turns"])]) + if "system_prompt" in self._persona: + cmd.extend(["--append-system-prompt", self._persona["system_prompt"]]) # Prepare environment env = os.environ.copy() @@ -166,11 +167,10 @@ class ClaudeSubprocess: # Ensure session directory exists self._session_dir.mkdir(parents=True, exist_ok=True) - # Log command - cmd_flags = [c for c in cmd if c.startswith("--")] + # Log full command logger.info( f"[TIMING] Starting persistent subprocess: cwd={self._session_dir.name}, " - f"flags={' '.join(cmd_flags)}" + f"cmd={' '.join(cmd)}" ) try: @@ -226,7 +226,7 @@ class ClaudeSubprocess: # Write NDJSON to stdin try: - msg_dict = {"type": "user", "content": message} + msg_dict = {"type": "user", "message": {"role": "user", "content": message}} ndjson_line = json.dumps(msg_dict) + '\n' self._process.stdin.write(ndjson_line.encode()) await self._process.stdin.drain() # CRITICAL: flush buffer @@ -317,6 +317,8 @@ class ClaudeSubprocess: if event_type == "assistant": # Extract text from assistant message message = event.get("message", {}) + model = message.get("model", "unknown") + logger.info(f"Assistant response model: {model}") content = message.get("content", []) for block in content: if block.get("type") == "text": diff --git a/telegram/personas/default.json b/telegram/personas/default.json index d885c87..7356ea9 100644 --- a/telegram/personas/default.json +++ b/telegram/personas/default.json @@ -3,7 +3,7 @@ "description": "All-around helpful assistant", "system_prompt": "You are Claude, a helpful AI assistant. Be concise, practical, and direct. Help with whatever is asked — coding, writing, analysis, problem-solving, or just conversation. Adapt your tone and depth to the question.", "settings": { - "model": "sonnet", + "model": "claude-sonnet-4-5-20250929", "max_turns": 25 } } diff --git a/telegram/telegram_utils.py b/telegram/telegram_utils.py index befc31a..0b53042 100644 --- a/telegram/telegram_utils.py +++ b/telegram/telegram_utils.py @@ -153,7 +153,8 @@ async def typing_indicator_loop(bot, chat_id: int, stop_event: asyncio.Event): """ while not stop_event.is_set(): try: - await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + result = await bot.send_chat_action(chat_id=chat_id, action=ChatAction.TYPING) + logger.info(f"Typing indicator sent to chat_id={chat_id}, result={result}") except Exception as e: logger.warning(f"Failed to send typing indicator: {e}")