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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user