|
|
|
@@ -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"
|