diff --git a/tests/e2e/test_e2e_dm_engine.py b/tests/e2e/test_e2e_dm_engine.py
index 9e4e82e..77740ce 100644
--- a/tests/e2e/test_e2e_dm_engine.py
+++ b/tests/e2e/test_e2e_dm_engine.py
@@ -1,15 +1,429 @@
+"""
+π΄ RED Phase β DM Engine Integrity Tests
+==========================================
+
+These tests expose 4 critical production bugs discovered in run 0f1475ff:
+1. DM Engine ignores `dm_reply.enabled: false` β fires DMs anyway
+2. DM Engine logs "Successfully sent" without verifying actual send
+3. DM Engine generates replies with "No previous context" β garbage output
+4. DM Engine lacks max-iteration guard β sent 8 DMs in 2 minutes
+
+Each test MUST fail before any production code is touched (TDD RED).
+"""
+
+import types
+from unittest.mock import MagicMock, patch
+
import pytest
-@pytest.mark.skip(
- reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
-)
-def test_e2e_dm_full_flow_success_real():
- pass
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Helpers β Minimal realistic mocks (no lying)
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
-@pytest.mark.skip(
- reason="Previous tests were 'mock-theater' using hierarchy iterators instead of real visual validation. Needs rewrite."
-)
-def test_e2e_dm_no_messages_real():
- pass
+def _make_dm_inbox_xml():
+ """Real-world DM inbox XML with unread thread markers."""
+ return """
+
+
+
+
+
+
+"""
+
+
+def _make_dm_thread_xml(last_message="Hey what's up?"):
+ """Real-world DM thread XML with message content."""
+ return f"""
+
+
+
+
+
+
+
+"""
+
+
+def _make_dm_thread_xml_no_context():
+ """DM thread XML with a story reply β NO extractable text message."""
+ return """
+
+
+
+
+
+
+
+"""
+
+
+def _make_configs(dm_reply_enabled=False):
+ """Create a realistic Config mock that mirrors get_plugin_config behavior."""
+ configs = MagicMock()
+ configs.get_plugin_config.return_value = {"enabled": dm_reply_enabled}
+ configs.args = types.SimpleNamespace(
+ disable_ai_messaging=False,
+ ai_condenser_model="qwen3.5:latest",
+ ai_condenser_url="http://localhost:11434/api/generate",
+ )
+ return configs
+
+
+def _make_session_state():
+ session = MagicMock()
+ session.totalMessages = 0
+ session.check_limit.return_value = (False,)
+ return session
+
+
+def _make_dopamine(boredom_sequence=None):
+ """Dopamine engine that exits after N iterations."""
+ dopamine = MagicMock()
+ if boredom_sequence is None:
+ # Default: 3 iterations then session over
+ call_count = {"n": 0}
+
+ def _is_over():
+ call_count["n"] += 1
+ return call_count["n"] > 3
+
+ dopamine.is_app_session_over.side_effect = _is_over
+ else:
+ dopamine.is_app_session_over.side_effect = boredom_sequence
+
+ dopamine.boredom = 0.0
+ dopamine.wants_to_change_feed.return_value = False
+ return dopamine
+
+
+def _make_telepathic(unread_nodes=None, msg_nodes=None, input_nodes=None, send_nodes=None):
+ """Telepathic engine returning controlled semantic nodes."""
+ telepathic = MagicMock()
+
+ default_unread = [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
+ default_msg = [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
+ default_input = [{"x": 500, "y": 900, "text": "Messageβ¦", "skip": False}]
+ default_send = [{"x": 800, "y": 900, "text": "", "desc": "Send", "skip": False}]
+
+ def _extract(xml, intent, threshold=0.7):
+ if "unread" in intent.lower():
+ return unread_nodes if unread_nodes is not None else default_unread
+ elif "last received" in intent.lower():
+ return msg_nodes if msg_nodes is not None else default_msg
+ elif "input" in intent.lower():
+ return input_nodes if input_nodes is not None else default_input
+ elif "send" in intent.lower():
+ return send_nodes if send_nodes is not None else default_send
+ return []
+
+ telepathic._extract_semantic_nodes.side_effect = _extract
+ return telepathic
+
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Test 1: DM Engine MUST respect dm_reply.enabled config
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestDMConfigGating:
+ """Verifies that dm_reply.enabled=false prevents ALL DM interactions."""
+
+ def test_dm_engine_blocks_when_dm_reply_disabled(self):
+ """BUG: dm_engine.py:96 checks 'disable_ai_messaging' (doesn't exist)
+ instead of dm_reply.enabled from config. This means DMs fire even when
+ config says enabled: false.
+
+ EXPECTED: DM engine should refuse to send any messages when dm_reply
+ is disabled in the config.
+ """
+ from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
+
+ device = MagicMock()
+ device.dump_hierarchy.return_value = _make_dm_inbox_xml()
+
+ configs = _make_configs(dm_reply_enabled=False)
+ session_state = _make_session_state()
+ dopamine = _make_dopamine(boredom_sequence=[False, True])
+ telepathic = _make_telepathic()
+
+ cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
+
+ with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
+ patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
+ patch("GramAddict.core.bot_flow._humanized_click"), \
+ patch("GramAddict.core.bot_flow.sleep"):
+ _run_zero_latency_dm_loop(
+ device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
+ )
+
+ # The LLM should NEVER be called when dm_reply is disabled
+ mock_llm.assert_not_called()
+ # Ghost typing should NEVER happen
+ mock_type.assert_not_called()
+ # No messages should be counted
+ assert session_state.totalMessages == 0, (
+ f"DM Engine sent {session_state.totalMessages} messages with dm_reply DISABLED!"
+ )
+
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Test 2: DM Engine MUST verify send actually happened
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestDMSendVerification:
+ """Verifies that 'Successfully sent' is only logged when the message was actually sent."""
+
+ def test_dm_engine_rejects_click_on_wrong_element(self):
+ """BUG: dm_engine.py:138 logs success after clicking ANY element the
+ VLM returns β including 'Unflag', reaction containers, or input fields
+ themselves. There is ZERO structural verification.
+
+ Evidence from logs:
+ - Clicked 'message_reactions_pill_container' β logged success
+ - Clicked 'Unflag' button β logged success
+ - Clicked 'row_thread_composer_edittext' β logged success (clicked the INPUT not send!)
+
+ EXPECTED: DM engine must verify the clicked element is actually
+ a "Send" button (desc='Send' or id contains 'send_button').
+ """
+ from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
+
+ device = MagicMock()
+ # First dump = inbox, second = thread, third = thread (for send button search)
+ device.dump_hierarchy.side_effect = [
+ _make_dm_inbox_xml(),
+ _make_dm_thread_xml(),
+ _make_dm_thread_xml(), # after typing, re-dump for send button
+ _make_dm_inbox_xml(), # after pressing back
+ _make_dm_inbox_xml(), # no more unread
+ ]
+
+ configs = _make_configs(dm_reply_enabled=True)
+ session_state = _make_session_state()
+ dopamine = _make_dopamine(boredom_sequence=[False, False, True])
+
+ # Telepathic returns WRONG element for "send button" β the reactions container
+ wrong_send_node = [{"x": 500, "y": 800, "text": "", "desc": "", "skip": False,
+ "original_attribs": {"resource-id": "com.instagram.android:id/message_reactions_pill_container"}}]
+ telepathic = _make_telepathic(send_nodes=wrong_send_node)
+
+ cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
+
+ with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey! Nice to meet you!"}), \
+ patch("GramAddict.core.stealth_typing.ghost_type"), \
+ patch("GramAddict.core.bot_flow._humanized_click"), \
+ patch("GramAddict.core.bot_flow.sleep"):
+ _run_zero_latency_dm_loop(
+ device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
+ )
+
+ # Should NOT count as a successful message
+ assert session_state.totalMessages == 0, (
+ f"DM Engine counted {session_state.totalMessages} messages after clicking "
+ f"'message_reactions_pill_container' instead of the Send button!"
+ )
+
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Test 3: DM Engine MUST NOT reply to context-less threads
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestDMContextRequirement:
+ """Verifies that the DM engine refuses to generate replies without context."""
+
+ def test_dm_engine_skips_thread_with_no_extractable_message(self):
+ """BUG: dm_engine.py:89-93 sets context_text='No previous context'
+ when no message text is found (story replies, media-only threads).
+ Then proceeds to call the LLM with that string, producing garbage
+ like 'the to the'.
+
+ Evidence from logs:
+ 7 out of 8 threads had 'Last received message context: No previous context'
+ All 7 were blindly replied to anyway.
+
+ EXPECTED: When context_text is 'No previous context' or empty,
+ the DM engine must SKIP the thread entirely (press back, continue).
+ """
+ from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
+
+ device = MagicMock()
+ device.dump_hierarchy.side_effect = [
+ _make_dm_inbox_xml(),
+ _make_dm_thread_xml_no_context(), # Thread with no text
+ _make_dm_inbox_xml(), # Back to inbox
+ _make_dm_inbox_xml(), # No more unread (empty)
+ ]
+
+ configs = _make_configs(dm_reply_enabled=True)
+ session_state = _make_session_state()
+ dopamine = _make_dopamine(boredom_sequence=[False, False, True])
+
+ # No extractable text from thread
+ no_text_msg_nodes = [{"x": 500, "y": 600, "text": "", "skip": False}]
+ telepathic = _make_telepathic(msg_nodes=no_text_msg_nodes)
+
+ cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
+
+ with patch("GramAddict.core.llm_provider.query_llm") as mock_llm, \
+ patch("GramAddict.core.stealth_typing.ghost_type") as mock_type, \
+ patch("GramAddict.core.bot_flow._humanized_click"), \
+ patch("GramAddict.core.bot_flow.sleep"):
+ _run_zero_latency_dm_loop(
+ device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
+ )
+
+ # LLM should NOT be called for a context-less thread
+ mock_llm.assert_not_called()
+ mock_type.assert_not_called()
+ assert session_state.totalMessages == 0, (
+ f"DM Engine replied to {session_state.totalMessages} threads with NO message context!"
+ )
+
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Test 4: DM Engine MUST have max-iteration guard
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestDMIterationLimit:
+ """Verifies the DM engine doesn't spam infinite replies."""
+
+ def test_dm_engine_caps_replies_per_session(self):
+ """BUG: dm_engine.py:34 while loop only exits on session timeout or
+ boredom. With 'aggressive_growth' strategy, boredom increments are
+ tiny (5-15 per DM) and the engine sent 8 DMs in 2 minutes.
+
+ EXPECTED: DM engine must have an explicit max_replies_per_inbox
+ cap (e.g., 3) to prevent spam behavior. After reaching the cap,
+ it should return 'BOREDOM_CHANGE_FEED'.
+ """
+ from GramAddict.core.dm_engine import _run_zero_latency_dm_loop
+
+ device = MagicMock()
+ # Infinite supply of "unread" threads
+ device.dump_hierarchy.return_value = _make_dm_inbox_xml()
+
+ configs = _make_configs(dm_reply_enabled=True)
+ session_state = _make_session_state()
+
+ # Dopamine never gets bored (simulates aggressive_growth with low boredom)
+ dopamine = MagicMock()
+ dopamine.is_app_session_over.return_value = False
+ dopamine.wants_to_change_feed.return_value = False
+ dopamine.boredom = 0.0
+
+ telepathic = _make_telepathic()
+ cognitive_stack = {"telepathic": telepathic, "dopamine": dopamine, "dm_memory": MagicMock()}
+
+ send_count = {"n": 0}
+ original_check_limit = session_state.check_limit
+
+ def _counting_check(*args, **kwargs):
+ if send_count["n"] > 20:
+ pytest.fail(
+ f"DM Engine sent {send_count['n']} messages without hitting any cap! "
+ f"Expected a hard limit of <= 5 replies per inbox visit."
+ )
+ return (False,)
+
+ session_state.check_limit.side_effect = _counting_check
+
+ with patch("GramAddict.core.llm_provider.query_llm", return_value={"response": "Hey!"}), \
+ patch("GramAddict.core.stealth_typing.ghost_type"), \
+ patch("GramAddict.core.bot_flow._humanized_click"), \
+ patch("GramAddict.core.bot_flow.sleep"):
+
+ # Monkey-patch totalMessages tracking
+ original_total = 0
+
+ class CountingProxy:
+ def __init__(self):
+ self._val = 0
+
+ def __iadd__(self, other):
+ self._val += other
+ send_count["n"] = self._val
+ if self._val > 20:
+ pytest.fail(
+ f"DM Engine sent {self._val} messages! No iteration guard present."
+ )
+ return self
+
+ def __int__(self):
+ return self._val
+
+ # Force the session to never hit limits (simulating the real scenario)
+ result = _run_zero_latency_dm_loop(
+ device, MagicMock(), MagicMock(), configs, session_state, "MessageInbox", cognitive_stack
+ )
+
+ # The engine should have self-limited to at most 5 replies
+ assert session_state.totalMessages <= 5, (
+ f"DM Engine sent {session_state.totalMessages} messages in one inbox visit. "
+ f"Expected hard cap of <= 5 to prevent spam."
+ )
+
+
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# Test 5: Bot Flow MUST NOT route to DM Engine when disabled
+# βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+
+class TestBotFlowDMGating:
+ """Verifies that bot_flow.py never calls _run_zero_latency_dm_loop
+ when dm_reply is disabled β even if SocialReciprocity desire fires."""
+
+ def test_social_reciprocity_never_includes_message_inbox_when_disabled(self):
+ """The target_map for SocialReciprocity should NEVER contain
+ 'MessageInbox' when dm_reply.enabled is false.
+
+ This is a defense-in-depth test: even if GrowthBrain randomly
+ selects SocialReciprocity 100% of the time, MessageInbox must
+ not appear as an option.
+ """
+ configs = _make_configs(dm_reply_enabled=False)
+
+ # Simulate bot_flow.py target_map construction (lines 460-468)
+ target_map = {
+ "DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
+ "NurtureCommunity": ["HomeFeed", "StoriesFeed"],
+ "SocialReciprocity": ["FollowingList"],
+ }
+
+ dm_config = configs.get_plugin_config("dm_reply")
+ if dm_config.get("enabled", False):
+ target_map["SocialReciprocity"].append("MessageInbox")
+
+ assert "MessageInbox" not in target_map["SocialReciprocity"], (
+ "MessageInbox was added to SocialReciprocity targets despite dm_reply.enabled=false!"
+ )
+
+ def test_social_reciprocity_includes_message_inbox_when_enabled(self):
+ """Positive test: When dm_reply.enabled is true, MessageInbox
+ SHOULD be in the target map."""
+ configs = _make_configs(dm_reply_enabled=True)
+
+ target_map = {
+ "DiscoverNewContent": ["ExploreFeed", "ReelsFeed"],
+ "NurtureCommunity": ["HomeFeed", "StoriesFeed"],
+ "SocialReciprocity": ["FollowingList"],
+ }
+
+ dm_config = configs.get_plugin_config("dm_reply")
+ if dm_config.get("enabled", False):
+ target_map["SocialReciprocity"].append("MessageInbox")
+
+ assert "MessageInbox" in target_map["SocialReciprocity"], (
+ "MessageInbox should be in SocialReciprocity when dm_reply is enabled!"
+ )