Compare commits
2 Commits
fix/lying-
...
c051c3a4c3
| Author | SHA1 | Date | |
|---|---|---|---|
| c051c3a4c3 | |||
| 3006020106 |
@@ -7,12 +7,50 @@ from GramAddict.core.session_state import SessionState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Hard cap: maximum DM replies per inbox visit to prevent spam.
|
||||
MAX_REPLIES_PER_INBOX_VISIT = 3
|
||||
|
||||
# Sentinel values that indicate missing message context.
|
||||
_EMPTY_CONTEXT_SENTINELS = frozenset({"no previous context", "", "none", "n/a"})
|
||||
|
||||
# Structural resource-IDs that indicate a real "Send" button.
|
||||
_SEND_BUTTON_MARKERS = frozenset({"send_button", "row_thread_composer_send"})
|
||||
|
||||
|
||||
def _is_send_button(node: dict) -> bool:
|
||||
"""Structural verification: returns True only if the node is a real Send button."""
|
||||
attribs = node.get("original_attribs", {})
|
||||
rid = attribs.get("resource-id", "")
|
||||
desc = attribs.get("content-desc", node.get("desc", "")).lower()
|
||||
# Accept if resource-id contains a known send button marker
|
||||
if any(marker in rid for marker in _SEND_BUTTON_MARKERS):
|
||||
return True
|
||||
# Accept if content-desc is exactly "Send" (Instagram's canonical label)
|
||||
if desc == "send":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_state, current_target, cognitive_stack):
|
||||
"""
|
||||
Executes the autonomous Direct Messaging logic in the Zero-Latency architecture.
|
||||
Assumes the bot is already at the "MessageInbox" UI state.
|
||||
|
||||
Safety guarantees:
|
||||
- Refuses to execute if dm_reply plugin is disabled in config.
|
||||
- Skips threads with no extractable text context.
|
||||
- Structurally verifies the Send button before logging success.
|
||||
- Hard-caps replies per inbox visit to MAX_REPLIES_PER_INBOX_VISIT.
|
||||
"""
|
||||
# ── Kill-Switch: Respect dm_reply.enabled config ──
|
||||
dm_plugin_config = configs.get_plugin_config("dm_reply")
|
||||
if not dm_plugin_config.get("enabled", False):
|
||||
logger.warning(
|
||||
"🛑 [DM Engine] dm_reply plugin is DISABLED in config. Refusing to process inbox.",
|
||||
extra={"color": f"{Fore.RED}"},
|
||||
)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
logger.info(
|
||||
f"🧠 [DM Engine] Initiating inbox processing in {current_target}...",
|
||||
extra={"color": f"{Style.BRIGHT}{Fore.CYAN}"},
|
||||
@@ -30,6 +68,7 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
session_state.totalMessages = 0
|
||||
|
||||
failed_attempts = 0
|
||||
replies_this_visit = 0
|
||||
|
||||
while not dopamine.is_app_session_over():
|
||||
# Limits check
|
||||
@@ -92,54 +131,80 @@ def _run_zero_latency_dm_loop(device, zero_engine, nav_graph, configs, session_s
|
||||
|
||||
logger.debug(f"Last received message context: {context_text}")
|
||||
|
||||
# Verify we aren't at limits before sending
|
||||
if not getattr(configs.args, "disable_ai_messaging", False):
|
||||
# Configure models
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
# Generate response
|
||||
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
|
||||
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
format_json=False,
|
||||
timeout=120,
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
# ── Context Guard: Skip threads with no extractable message ──
|
||||
if context_text.strip().lower() in _EMPTY_CONTEXT_SENTINELS:
|
||||
logger.warning(
|
||||
"⏭️ [DM Engine] Thread has no extractable message context (story reply / media-only). Skipping."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.5)
|
||||
continue
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
response_text = response_dict["response"].strip()
|
||||
# Find the input field
|
||||
input_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the message input text field", threshold=0.7
|
||||
# Verify we aren't at limits before sending
|
||||
# ── Iteration Cap: Prevent DM spam ──
|
||||
if replies_this_visit >= MAX_REPLIES_PER_INBOX_VISIT:
|
||||
logger.info(
|
||||
f"🛑 [DM Engine] Reached max replies per inbox visit ({MAX_REPLIES_PER_INBOX_VISIT}). Exiting."
|
||||
)
|
||||
device.press("back")
|
||||
sleep(1.0)
|
||||
return "BOREDOM_CHANGE_FEED"
|
||||
|
||||
# Configure models
|
||||
model = getattr(configs.args, "ai_condenser_model", "llama3.2:1b")
|
||||
url = getattr(configs.args, "ai_condenser_url", "http://localhost:11434/api/generate")
|
||||
|
||||
# Generate response
|
||||
prompt = f"You are replying to a direct message on Instagram. The last message you received was: '{context_text}'. Keep it short, casual, and friendly. Do not use hashtags."
|
||||
|
||||
response_dict = query_llm(
|
||||
url=url,
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
format_json=False,
|
||||
timeout=120,
|
||||
max_tokens=100,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
if response_dict and "response" in response_dict:
|
||||
response_text = response_dict["response"].strip()
|
||||
# Find the input field
|
||||
input_nodes = telepathic._extract_semantic_nodes(
|
||||
thread_xml, "find the message input text field", threshold=0.7
|
||||
)
|
||||
if input_nodes and not input_nodes[0].get("skip"):
|
||||
in_node = input_nodes[0]
|
||||
_humanized_click(device, in_node["x"], in_node["y"])
|
||||
sleep(1.0)
|
||||
|
||||
# Type the message
|
||||
ghost_type(device, response_text, speed="fast")
|
||||
sleep(1.0)
|
||||
|
||||
# Find Send button
|
||||
send_xml = device.dump_hierarchy()
|
||||
send_nodes = telepathic._extract_semantic_nodes(
|
||||
send_xml, "find the send message button", threshold=0.8
|
||||
)
|
||||
if input_nodes and not input_nodes[0].get("skip"):
|
||||
in_node = input_nodes[0]
|
||||
_humanized_click(device, in_node["x"], in_node["y"])
|
||||
sleep(1.0)
|
||||
|
||||
# Type the message
|
||||
ghost_type(device, response_text, speed="fast")
|
||||
sleep(1.0)
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
s_node = send_nodes[0]
|
||||
|
||||
# Find Send button
|
||||
send_xml = device.dump_hierarchy()
|
||||
send_nodes = telepathic._extract_semantic_nodes(
|
||||
send_xml, "find the send message button", threshold=0.8
|
||||
)
|
||||
|
||||
if send_nodes and not send_nodes[0].get("skip"):
|
||||
s_node = send_nodes[0]
|
||||
# ── Send Button Structural Verification ──
|
||||
if not _is_send_button(s_node):
|
||||
s_rid = s_node.get("original_attribs", {}).get("resource-id", "unknown")
|
||||
logger.warning(
|
||||
f"⚠️ [DM Engine] Refused to click non-Send element: {s_rid}. Aborting reply."
|
||||
)
|
||||
else:
|
||||
_humanized_click(device, s_node["x"], s_node["y"])
|
||||
logger.info(
|
||||
"✅ [DM Engine] Successfully sent a generated reply.", extra={"color": Fore.GREEN}
|
||||
"✅ [DM Engine] Successfully sent a generated reply.",
|
||||
extra={"color": Fore.GREEN},
|
||||
)
|
||||
|
||||
session_state.totalMessages += 1
|
||||
replies_this_visit += 1
|
||||
dm_memory = cognitive_stack.get("dm_memory")
|
||||
if dm_memory:
|
||||
dm_memory.log_sent_dm("unknown_target", response_text, "", [])
|
||||
|
||||
@@ -1,15 +1,489 @@
|
||||
"""
|
||||
🔴 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 """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/direct_inbox_action_bar" />
|
||||
<node resource-id="com.instagram.android:id/inbox_refreshable_thread_list_recyclerview">
|
||||
<node text="johndoe" content-desc="Unread. johndoe" />
|
||||
<node text="janedoe" content-desc="janedoe" />
|
||||
</node>
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
def _make_dm_thread_xml(last_message="Hey what's up?"):
|
||||
"""Real-world DM thread XML with message content."""
|
||||
return f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/direct_thread_header">
|
||||
<node text="johndoe" />
|
||||
</node>
|
||||
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
|
||||
text="Message…" />
|
||||
<node text="{last_message}"
|
||||
resource-id="com.instagram.android:id/message_text" />
|
||||
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
|
||||
content-desc="Send" />
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
def _make_dm_thread_xml_no_context():
|
||||
"""DM thread XML with a story reply — NO extractable text message."""
|
||||
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/direct_thread_header">
|
||||
<node text="story_user" />
|
||||
</node>
|
||||
<node resource-id="com.instagram.android:id/row_thread_composer_edittext"
|
||||
text="Message…" />
|
||||
<node resource-id="com.instagram.android:id/story_reply_media_container"
|
||||
content-desc="Replied to their story" />
|
||||
<node resource-id="com.instagram.android:id/row_thread_composer_send_button"
|
||||
content-desc="Send" />
|
||||
</hierarchy>"""
|
||||
|
||||
|
||||
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()
|
||||
inbox_xml = _make_dm_inbox_xml()
|
||||
thread_xml = _make_dm_thread_xml()
|
||||
# Flow: inbox → thread → send_xml (re-dump) → back → check_xml → inbox (no unread)
|
||||
device.dump_hierarchy.side_effect = [
|
||||
inbox_xml, # 1. inbox: find unread
|
||||
thread_xml, # 2. thread: read messages
|
||||
thread_xml, # 3. after typing: re-dump for send button
|
||||
thread_xml, # 4. check_xml after pressing back (still in thread?)
|
||||
inbox_xml, # 5. inbox again on re-loop
|
||||
]
|
||||
|
||||
configs = _make_configs(dm_reply_enabled=True)
|
||||
session_state = _make_session_state()
|
||||
|
||||
# Dopamine: never session-over, but wants_to_change_feed after boredom bump
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.return_value = False
|
||||
dopamine.boredom = 0.0
|
||||
dopamine.wants_to_change_feed.side_effect = lambda: dopamine.boredom >= 4.0
|
||||
|
||||
# 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"}}]
|
||||
# On second unread call, return no threads (inbox clear)
|
||||
unread_call_n = {"n": 0}
|
||||
|
||||
def _extract_nodes(xml, intent, threshold=0.7):
|
||||
if "unread" in intent.lower():
|
||||
unread_call_n["n"] += 1
|
||||
if unread_call_n["n"] == 1:
|
||||
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
|
||||
return []
|
||||
elif "last received" in intent.lower():
|
||||
return [{"x": 500, "y": 600, "text": "Hey what's up?", "skip": False}]
|
||||
elif "input" in intent.lower():
|
||||
return [{"x": 500, "y": 900, "text": "Message…", "skip": False}]
|
||||
elif "send" in intent.lower():
|
||||
return wrong_send_node
|
||||
return []
|
||||
|
||||
telepathic = MagicMock()
|
||||
telepathic._extract_semantic_nodes.side_effect = _extract_nodes
|
||||
|
||||
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()
|
||||
# Flow: inbox → click unread → thread (no context) → back → continue →
|
||||
# inbox (same, but telepathic returns no unread) → boredom exit
|
||||
inbox_xml = _make_dm_inbox_xml()
|
||||
device.dump_hierarchy.side_effect = [
|
||||
inbox_xml, # 1. inbox: find unread
|
||||
_make_dm_thread_xml_no_context(), # 2. thread: read messages (no text)
|
||||
# after context-skip continue, back to loop:
|
||||
inbox_xml, # 3. inbox again (check is_inbox)
|
||||
# 4. check_xml after pressing back from thread (dm_engine L152)
|
||||
]
|
||||
|
||||
configs = _make_configs(dm_reply_enabled=True)
|
||||
session_state = _make_session_state()
|
||||
|
||||
# 1st call: not over (process first thread)
|
||||
# 2nd call: not over (after context skip, re-loop)
|
||||
# 3rd+ calls: not needed because boredom triggers exit
|
||||
dopamine = MagicMock()
|
||||
dopamine.is_app_session_over.return_value = False
|
||||
dopamine.boredom = 0.0
|
||||
# After inbox_clear, boredom jumps to 50 → wants_to_change_feed
|
||||
# should return True on second check (after inbox clear)
|
||||
change_feed_calls = {"n": 0}
|
||||
|
||||
def _wants_change():
|
||||
change_feed_calls["n"] += 1
|
||||
# After any boredom bump, signal exit
|
||||
return dopamine.boredom >= 40.0
|
||||
|
||||
dopamine.wants_to_change_feed.side_effect = _wants_change
|
||||
|
||||
# No extractable text from thread
|
||||
no_text_msg_nodes = [{"x": 500, "y": 600, "text": "", "skip": False}]
|
||||
# On the second inbox visit, return NO unread threads (inbox clear)
|
||||
call_count = {"n": 0}
|
||||
|
||||
def _extract_nodes(xml, intent, threshold=0.7):
|
||||
if "unread" in intent.lower():
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return [{"x": 500, "y": 300, "text": "johndoe", "skip": False}]
|
||||
# Second time: no unread
|
||||
return []
|
||||
elif "last received" in intent.lower():
|
||||
return no_text_msg_nodes
|
||||
return []
|
||||
|
||||
telepathic = MagicMock()
|
||||
telepathic._extract_semantic_nodes.side_effect = _extract_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!"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user