From 215637a93cdb48ac3205a8ebb06a973c37c0976f Mon Sep 17 00:00:00 2001 From: code-server Date: Mon, 23 Feb 2026 21:08:32 +0000 Subject: [PATCH] fix: heartbeat idle detection now correctly identifies real user messages The heartbeat service was incorrectly counting ALL messages with role="user" as user activity, including system-generated messages (heartbeat prompts, subagent announcements). This caused the idle detection to never trigger because heartbeat's own messages were counted as user activity. Changes: 1. Store sender_id in session messages (loop.py) - Added sender_id=msg.sender_id to session.add_message() call - Allows distinguishing real user messages from system-generated ones 2. Filter by sender_id in heartbeat idle detection (service.py) - Real Telegram messages have sender_id like "239824268|username" - System messages via process_direct have sender_id="user" (hardcoded) - Heartbeat now skips messages with sender_id="user" - Backwards compatible: messages without sender_id are treated as real This is a robust, source-based solution that checks how messages are CREATED rather than pattern-matching their content. Co-Authored-By: Claude Sonnet 4.5 --- nanobot/agent/loop.py | 3 +- nanobot/heartbeat/service.py | 10 +- tests/test_heartbeat_idle_detection.py | 146 +++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 tests/test_heartbeat_idle_detection.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 92f710c..b28d7fc 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -465,7 +465,8 @@ class AgentLoop: # Save to session: user message + full tool chain (tool_use, tool_results, thinking, final reply) # Store current_message (not msg.content) so the time prefix is preserved # and cache keys match on subsequent turns - session.add_message("user", current_message) + # Include sender_id to distinguish real user messages from system-generated ones + session.add_message("user", current_message, sender_id=msg.sender_id) for chain_msg in messages[turn_start:]: session.add_raw_message(chain_msg) self.sessions.save(session) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 9e62787..171f6cd 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -118,10 +118,18 @@ class HeartbeatService: try: session = self.session_manager.get_or_create(self.target_session_key) - # Find last user message timestamp + # Find last real user message timestamp (exclude system-generated messages) + # Real Telegram messages have sender_id like "239824268|username" + # System messages (heartbeat, cron) created via process_direct have sender_id="user" + # Old messages may not have sender_id field (backwards compat: treat as real user messages) last_user_timestamp = None for msg in reversed(session.messages): if msg.get("role") == "user": + sender_id = msg.get("sender_id") + # Skip if explicitly marked as system-generated + if sender_id == "user": + continue + # Accept if no sender_id (old message) or if real user ID last_user_timestamp = msg.get("timestamp") break diff --git a/tests/test_heartbeat_idle_detection.py b/tests/test_heartbeat_idle_detection.py new file mode 100644 index 0000000..827affa --- /dev/null +++ b/tests/test_heartbeat_idle_detection.py @@ -0,0 +1,146 @@ +# tests/test_heartbeat_idle_detection.py +"""Tests for heartbeat idle detection with sender_id filtering.""" +import pytest +from datetime import datetime, timedelta +from pathlib import Path +from nanobot.heartbeat.service import HeartbeatService +from nanobot.session.manager import Session, SessionManager + + +@pytest.mark.asyncio +async def test_idle_detection_ignores_system_messages(): + """Test that heartbeat only counts real user messages for idle detection.""" + # Create session manager and session + session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat")) + session = session_manager.get_or_create("telegram:239824268") + + # Add a real user message 45 minutes ago + real_user_time = datetime.now() - timedelta(minutes=45) + session.add_message( + "user", + "This is a real user message", + sender_id="239824268|testuser", + timestamp=real_user_time.isoformat() + ) + + # Add a heartbeat system message 10 minutes ago (should be ignored) + heartbeat_time = datetime.now() - timedelta(minutes=10) + session.add_message( + "user", + "Read HEARTBEAT.md...", + sender_id="user", + timestamp=heartbeat_time.isoformat() + ) + + # Add an assistant response + session.add_message("assistant", "Response to heartbeat") + + # Create heartbeat service with 30 minute idle threshold + heartbeat = HeartbeatService( + workspace=Path("/tmp/test-heartbeat"), + session_manager=session_manager, + target_session_key="telegram:239824268", + idle_threshold_s=30 * 60, # 30 minutes + interval_s=30 * 60, + enabled=False # Don't actually start the loop + ) + + # Manually check idle logic (replicate _tick logic) + session = session_manager.get_or_create("telegram:239824268") + + # Find last user message timestamp (should find the 45-minute-old message, not the 10-minute-old one) + last_user_timestamp = None + for msg in reversed(session.messages): + if msg.get("role") == "user": + sender_id = msg.get("sender_id") + if sender_id == "user": + continue + last_user_timestamp = msg.get("timestamp") + break + + assert last_user_timestamp is not None + last_dt = datetime.fromisoformat(last_user_timestamp) + elapsed = (datetime.now() - last_dt).total_seconds() + + # Should detect user is idle (45 minutes > 30 minute threshold) + assert elapsed >= 30 * 60, f"Expected idle (45min), but elapsed={elapsed/60:.1f}min" + # Should NOT be 10 minutes (heartbeat message was ignored) + assert elapsed >= 40 * 60, f"Heartbeat message was not ignored, elapsed={elapsed/60:.1f}min" + + +@pytest.mark.asyncio +async def test_idle_detection_counts_real_user_messages(): + """Test that heartbeat correctly identifies when user is active.""" + # Create session manager and session + session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat")) + session = session_manager.get_or_create("telegram:239824268") + + # Add a real user message 10 minutes ago (recent activity) + real_user_time = datetime.now() - timedelta(minutes=10) + session.add_message( + "user", + "This is a recent user message", + sender_id="239824268|testuser", + timestamp=real_user_time.isoformat() + ) + + # Create heartbeat service with 30 minute idle threshold + heartbeat = HeartbeatService( + workspace=Path("/tmp/test-heartbeat"), + session_manager=session_manager, + target_session_key="telegram:239824268", + idle_threshold_s=30 * 60, # 30 minutes + interval_s=30 * 60, + enabled=False + ) + + # Find last user message timestamp + session = session_manager.get_or_create("telegram:239824268") + last_user_timestamp = None + for msg in reversed(session.messages): + if msg.get("role") == "user": + sender_id = msg.get("sender_id") + if sender_id == "user": + continue + last_user_timestamp = msg.get("timestamp") + break + + assert last_user_timestamp is not None + last_dt = datetime.fromisoformat(last_user_timestamp) + elapsed = (datetime.now() - last_dt).total_seconds() + + # Should detect user is active (10 minutes < 30 minute threshold) + assert elapsed < 30 * 60, f"Expected active (10min), but elapsed={elapsed/60:.1f}min" + + +@pytest.mark.asyncio +async def test_backwards_compat_messages_without_sender_id(): + """Test that old messages without sender_id are treated as real user messages.""" + # Create session manager and session + session_manager = SessionManager(workspace=Path("/tmp/test-heartbeat")) + session = session_manager.get_or_create("telegram:239824268") + + # Add an old message without sender_id (backwards compat) + old_time = datetime.now() - timedelta(minutes=20) + session.add_message( + "user", + "Old message without sender_id", + timestamp=old_time.isoformat() + ) + + # Find last user message timestamp (should find the old message) + last_user_timestamp = None + for msg in reversed(session.messages): + if msg.get("role") == "user": + sender_id = msg.get("sender_id") + if sender_id == "user": + continue + last_user_timestamp = msg.get("timestamp") + break + + assert last_user_timestamp is not None + last_dt = datetime.fromisoformat(last_user_timestamp) + elapsed = (datetime.now() - last_dt).total_seconds() + + # Should accept old message (backwards compat) + assert elapsed < 25 * 60, f"Old message not counted, elapsed={elapsed/60:.1f}min"