diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index c4cd2b1b4..27a7f6ecb 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -812,11 +812,31 @@ def _run_gateway( return resp.content if resp else "" async def on_heartbeat_notify(response: str) -> None: - """Deliver a heartbeat response to the user's channel.""" + """Deliver a heartbeat response to the user's channel. + + In addition to publishing the outbound message, this injects the + delivered text as an assistant turn into the *target channel's* + session. Without this, a user reply on the channel (e.g. "Sure") + lands in a session that has no context about the heartbeat message + and the agent cannot follow through. + """ from nanobot.bus.events import OutboundMessage channel, chat_id = _pick_heartbeat_target() if channel == "cli": return # No external channel available to deliver to + + # Inject the delivered message into the channel session so that + # user replies have conversational context. + from nanobot.agent.loop import UNIFIED_SESSION_KEY + target_key = ( + UNIFIED_SESSION_KEY + if config.agents.defaults.unified_session + else f"{channel}:{chat_id}" + ) + target_session = agent.sessions.get_or_create(target_key) + target_session.add_message("assistant", response) + agent.sessions.save(target_session) + await bus.publish_outbound(OutboundMessage(channel=channel, chat_id=chat_id, content=response)) hb_cfg = config.gateway.heartbeat diff --git a/tests/heartbeat/test_heartbeat_context_bridge.py b/tests/heartbeat/test_heartbeat_context_bridge.py new file mode 100644 index 000000000..ced2ddddc --- /dev/null +++ b/tests/heartbeat/test_heartbeat_context_bridge.py @@ -0,0 +1,102 @@ +"""Tests for heartbeat context bridge — injecting delivered messages into channel session.""" + +from nanobot.session.manager import SessionManager + + +class TestHeartbeatContextBridge: + """Verify that on_heartbeat_notify injects the assistant message into the + channel session so user replies have conversational context.""" + + def test_notify_injects_into_channel_session(self, tmp_path): + """After notify, the target channel session should contain the + heartbeat response as an assistant turn.""" + session_mgr = SessionManager(tmp_path / "sessions") + target_key = "telegram:12345" + + # Simulate: session exists with one user message + target_session = session_mgr.get_or_create(target_key) + target_session.add_message("user", "hello earlier") + session_mgr.save(target_session) + + # Simulate what on_heartbeat_notify does + target_session = session_mgr.get_or_create(target_key) + target_session.add_message("assistant", "3 new emails — invoice, meeting, proposal.") + session_mgr.save(target_session) + + # Reload and verify + reloaded = session_mgr.get_or_create(target_key) + messages = reloaded.get_history(max_messages=0) + roles = [m["role"] for m in messages] + assert roles == ["user", "assistant"] + assert "3 new emails" in messages[-1]["content"] + + def test_reply_after_injection_has_context(self, tmp_path): + """Simulates the full flow: prior conversation exists, heartbeat + injects, then user replies. The session should have the heartbeat + message visible in get_history so the model sees the context.""" + session_mgr = SessionManager(tmp_path / "sessions") + target_key = "telegram:12345" + + # Pre-existing conversation (user has chatted before) + session = session_mgr.get_or_create(target_key) + session.add_message("user", "Hey") + session.add_message("assistant", "Hi there!") + session_mgr.save(session) + + # Step 1: heartbeat injects assistant message + session = session_mgr.get_or_create(target_key) + session.add_message("assistant", "If you want, I can mark that email as read.") + session_mgr.save(session) + + # Step 2: user replies "Sure" + session = session_mgr.get_or_create(target_key) + session.add_message("user", "Sure") + session_mgr.save(session) + + # Verify: get_history includes the heartbeat injection + reloaded = session_mgr.get_or_create(target_key) + history = reloaded.get_history(max_messages=0) + roles = [m["role"] for m in history] + assert roles == ["user", "assistant", "assistant", "user"] + assert "mark that email" in history[2]["content"] + assert history[3]["content"] == "Sure" + + def test_injection_does_not_duplicate_on_existing_history(self, tmp_path): + """If the channel session already has messages, the injection + appends cleanly without corruption.""" + session_mgr = SessionManager(tmp_path / "sessions") + target_key = "telegram:12345" + + # Pre-existing conversation + session = session_mgr.get_or_create(target_key) + session.add_message("user", "What time is it?") + session.add_message("assistant", "It's 2pm.") + session.add_message("user", "Thanks") + session_mgr.save(session) + + # Heartbeat injects + session = session_mgr.get_or_create(target_key) + session.add_message("assistant", "You have a meeting in 30 minutes.") + session_mgr.save(session) + + # Verify + reloaded = session_mgr.get_or_create(target_key) + history = reloaded.get_history(max_messages=0) + roles = [m["role"] for m in history] + assert roles == ["user", "assistant", "user", "assistant"] + assert "meeting in 30 minutes" in history[-1]["content"] + + def test_injection_to_empty_session(self, tmp_path): + """Injecting into a brand-new session (no prior messages) works.""" + session_mgr = SessionManager(tmp_path / "sessions") + target_key = "telegram:99999" + + session = session_mgr.get_or_create(target_key) + session.add_message("assistant", "Weather alert: sandstorm expected at 4pm.") + session_mgr.save(session) + + reloaded = session_mgr.get_or_create(target_key) + history = reloaded.get_history(max_messages=0) + assert len(history) == 1 + assert history[0]["role"] == "assistant" + assert "sandstorm" in history[0]["content"]