diff --git a/GramAddict/core/bot_flow.py b/GramAddict/core/bot_flow.py index 5cea769..3296921 100644 --- a/GramAddict/core/bot_flow.py +++ b/GramAddict/core/bot_flow.py @@ -1,6 +1,7 @@ import logging import os import random +import re try: import psutil @@ -857,6 +858,20 @@ def _run_zero_latency_stories_loop(device, configs, session_state, cognitive_sta logger.warning("Failed to dump UI hierarchy in StoriesFeed.") return "CONTEXT_LOST" + # ── Perimeter Guard: Verify we're still inside Instagram ── + # Production bug 2026-05-03: A story's swipe-up link opened the Play Store, + # and the loop kept tapping blindly on com.android.vending for 5+ iterations. + packages = set(re.findall(r'package="([^"]+)"', xml_dump)) + app_id = getattr(device, "app_id", "com.instagram.android") + if packages and app_id not in packages: + logger.error( + f"🚨 [StoriesFeed] FOREIGN APP DETECTED! Packages: {packages}. " + f"A story link likely opened an external app. Aborting loop." + ) + device.press("back") + sleep(1.5) + return "CONTEXT_LOST" + if getattr(configs.args, "ignore_close_friends", False): if "enge freunde" in xml_dump.lower() or "close friend" in xml_dump.lower(): logger.info( @@ -970,6 +985,17 @@ def _run_zero_latency_feed_loop( if cognitive_stack.get("radome"): context_xml = cognitive_stack.get("radome").sanitize_xml(context_xml) + # ── Perimeter Guard: Verify we're still inside Instagram ── + # Parity with story loop guard (production bug 2026-05-03). + if context_xml: + feed_packages = set(re.findall(r'package="([^"]+)"', context_xml)) + feed_app_id = getattr(device, "app_id", "com.instagram.android") + if feed_packages and feed_app_id not in feed_packages: + logger.error(f"🚨 [FeedLoop] FOREIGN APP DETECTED! Packages: {feed_packages}. Aborting loop.") + device.press("back") + sleep(1.5) + return "CONTEXT_LOST" + # ── Execute Plugin Registry Behaviors (Feed Level) ── from GramAddict.core.behaviors import BehaviorContext, PluginRegistry diff --git a/GramAddict/core/goap.py b/GramAddict/core/goap.py index 66bdab0..ebce795 100644 --- a/GramAddict/core/goap.py +++ b/GramAddict/core/goap.py @@ -247,34 +247,37 @@ class GoalExecutor: for k in keys_to_clear: del self.action_failures[k] - # ── Back-Press Circuit Breaker ── + # ── Back-Press Circuit Breaker → Escalation ── if action == "press back": consecutive_back_presses += 1 if consecutive_back_presses >= MAX_CONSECUTIVE_BACK: - logger.error( + logger.warning( f"🛑 [GOAP] Back-pressed {MAX_CONSECUTIVE_BACK} times with no screen transition. " - f"Aborting goal '{goal}' to prevent app exit." + f"Escalating to force restart." ) - self.path_memory.learn_path(goal, start_screen, steps_taken, False) - # Phase 3 GREEN: Unlearn the trap path + # Unlearn the trap path from GramAddict.core.qdrant_memory import NavigationMemoryDB - # We don't know the exact action that got us here easily without analyzing steps_taken, - # but we can grab the first action taken from start_screen in this chain if available. if len(steps_taken) > consecutive_back_presses: last_real_action = steps_taken[-consecutive_back_presses - 1]["action"] logger.debug( f"[GOAP Unlearn] last_real_action={last_real_action}, " f"start_screen={start_screen}" ) NavigationMemoryDB().unlearn_transition(start_screen, last_real_action) - else: - logger.debug( - f"[GOAP Unlearn] No real action to unlearn. " - f"steps={len(steps_taken)}, back_presses={consecutive_back_presses}" - ) - return False + # ── ESCALATION: Force restart instead of aborting ── + app_id = getattr(self.device, "app_id", "com.instagram.android") + self.device.app_start(app_id, use_monkey=True) + random_sleep(2.0, 3.5) + steps_taken.append({"action": "force start instagram"}) + + logger.info("🔄 [GOAP Escalation] App restarted. Purging all failure state for fresh attempt.") + self.action_failures.clear() + explored_nav_actions.clear() + visited_screens.clear() + consecutive_back_presses = 0 + continue else: consecutive_back_presses = 0 else: @@ -388,6 +391,7 @@ class GoalExecutor: # Determine if this was a navigation or an interaction from GramAddict.core.screen_topology import ScreenTopology + is_navigation = any(k in action.lower() for k in ["tab", "open", "go to", "navigate", "following list"]) if not is_navigation: is_navigation = ScreenTopology.is_structural_action(pre_action_screen_type, action) diff --git a/GramAddict/core/situational_awareness.py b/GramAddict/core/situational_awareness.py index b96b982..851094b 100644 --- a/GramAddict/core/situational_awareness.py +++ b/GramAddict/core/situational_awareness.py @@ -350,8 +350,31 @@ class SituationalAwarenessEngine: is_foreign = True if is_foreign: - # We explicitly ask the TelepathicEngine to classify this to avoid writing brittle substring hacks - # for Android System UI variations across different device manufacturers. + # ── Tier 1: Known Foreign Packages (O(1) — ZERO LLM) ── + # Production bug 2026-05-03: Play Store was detected via slow LLM path. + # For these well-known packages, a set lookup is instant and infallible. + KNOWN_FOREIGN_PACKAGES = { + "com.android.vending", # Play Store + "com.android.chrome", # Chrome + "com.google.android.chrome", # Chrome (Google build) + "com.google.android.youtube", # YouTube + "org.mozilla.firefox", # Firefox + "com.opera.browser", # Opera + "com.brave.browser", # Brave + "com.microsoft.emmx", # Edge + "com.sec.android.app.sbrowser", # Samsung Browser + } + dominant_pkgs = packages - {"com.android.systemui"} + fast_match = dominant_pkgs & KNOWN_FOREIGN_PACKAGES + if fast_match: + logger.info( + f"🚨 [SAE Perceive] Known foreign package: {fast_match} → " + f"OBSTACLE_FOREIGN_APP (O(1) fast-path, no LLM needed)" + ) + return SituationType.OBSTACLE_FOREIGN_APP + + # ── Tier 2: Unknown/Ambiguous Packages → LLM Classification ── + # Only SystemUI-only or rare custom packages reach this path. try: from GramAddict.core.config import Config from GramAddict.core.llm_provider import query_telepathic_llm diff --git a/tests/e2e/device_emulator.py b/tests/e2e/device_emulator.py index 63bc519..f868b17 100644 --- a/tests/e2e/device_emulator.py +++ b/tests/e2e/device_emulator.py @@ -247,7 +247,8 @@ class InstagramEmulator: # Could implement swipe transitions here (e.g. scroll down loads new feed) def app_start(self, pkg, use_monkey=False): - pass + self.app_starts.append(pkg) + logger.info(f"[Emulator] app_start({pkg})") def app_stop(self, pkg): pass diff --git a/tests/e2e/test_goap_trap_recovery.py b/tests/e2e/test_goap_trap_recovery.py index 64af014..d223873 100644 --- a/tests/e2e/test_goap_trap_recovery.py +++ b/tests/e2e/test_goap_trap_recovery.py @@ -34,7 +34,7 @@ def test_goap_recovers_from_trapped_state_with_restart(monkeypatch): # We just want to see that AFTER the restart, it tries 'tap reels tab' again, # meaning it cleared the state. - goap.achieve("open reels", max_steps=6) + goap.achieve("open reels", max_steps=10) # It should have tried 'tap reels tab' twice, failed both times, # then triggered 'force start instagram'. diff --git a/tests/e2e/test_production_bug_play_store_trap_20260503.py b/tests/e2e/test_production_bug_play_store_trap_20260503.py new file mode 100644 index 0000000..f549e6e --- /dev/null +++ b/tests/e2e/test_production_bug_play_store_trap_20260503.py @@ -0,0 +1,52 @@ +""" +Production Bug Regression: Play Store Trap (2026-05-03) +======================================================== +Session: 2026-05-03_18-03-15 +Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending) + +Root Cause: Story loop had no perimeter guard. A story's swipe-up link +opened the Play Store, and the bot continued tapping at (w*0.85, h*0.5) +on Play Store UI for 5+ iterations until user killed the process. + +This test uses the EXACT production XML dumps to validate: +1. ScreenIdentity correctly classifies Play Store as FOREIGN_APP +2. SAE correctly classifies Play Store as OBSTACLE_FOREIGN_APP +3. Story frame before the linkout is correctly classified as STORY_VIEW +""" + +from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType +from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType +from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml + + +class TestPlayStoreTrapRegression: + def test_screen_identity_classifies_play_store_as_foreign(self): + """ScreenIdentity must classify com.android.vending as FOREIGN_APP.""" + xml = load_fixture_xml("play_store_from_story_link.xml") + sid = ScreenIdentity("testuser") + result = sid.identify(xml) + assert result["screen_type"] == ScreenType.FOREIGN_APP, ( + f"ScreenIdentity classified Play Store as {result['screen_type'].value}! " + f"This is the exact production bug from 2026-05-03." + ) + + def test_sae_classifies_play_store_as_obstacle_foreign_app(self): + """SAE must classify com.android.vending as OBSTACLE_FOREIGN_APP.""" + xml = load_fixture_xml("play_store_from_story_link.xml") + device = E2EDeviceStub([xml]) + SituationalAwarenessEngine.reset() + sae = SituationalAwarenessEngine(device) + result = sae.perceive(xml) + assert result == SituationType.OBSTACLE_FOREIGN_APP, ( + f"SAE classified Play Store as {result.value}! " f"Exact production bug regression from 2026-05-03." + ) + + def test_story_view_before_linkout_is_story(self): + """The last story frame before the Play Store must be STORY_VIEW.""" + xml = load_fixture_xml("story_view_before_linkout.xml") + sid = ScreenIdentity("testuser") + result = sid.identify(xml) + assert result["screen_type"] == ScreenType.STORY_VIEW, ( + f"Frame before Play Store was classified as {result['screen_type'].value}, " + f"not STORY_VIEW. This could cause false positives." + ) diff --git a/tests/e2e/test_sae_foreign_app_fastpath.py b/tests/e2e/test_sae_foreign_app_fastpath.py new file mode 100644 index 0000000..951e4fa --- /dev/null +++ b/tests/e2e/test_sae_foreign_app_fastpath.py @@ -0,0 +1,56 @@ +""" +E2E: SAE Foreign App Fast-Path +================================ +Validates that the SAE classifies known foreign packages (Play Store, +Chrome, Settings) as OBSTACLE_FOREIGN_APP using O(1) structural detection +WITHOUT falling through to LLM classification. + +This eliminates 2-5 seconds of LLM inference for detections that +should be instantaneous set lookups. +""" + +from GramAddict.core.situational_awareness import SituationalAwarenessEngine, SituationType +from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml + + +def test_sae_classifies_play_store_without_llm(): + """Play Store XML must be classified as FOREIGN_APP via fast-path, not LLM.""" + xml = load_fixture_xml("play_store_from_story_link.xml") + device = E2EDeviceStub([xml]) + SituationalAwarenessEngine.reset() + sae = SituationalAwarenessEngine(device) + + result = sae.perceive(xml) + + assert result == SituationType.OBSTACLE_FOREIGN_APP, ( + f"SAE classified Play Store as {result.value} instead of OBSTACLE_FOREIGN_APP! " + f"The bot cannot detect it left Instagram." + ) + + +def test_sae_fast_path_handles_known_foreign_packages(): + """ + Verify the fast-path handles com.android.vending, com.android.chrome, + com.google.android.youtube etc. without LLM calls. + com.android.settings may classify as OBSTACLE_SYSTEM which is also valid. + """ + known_foreign_pkgs = { + "com.android.vending": SituationType.OBSTACLE_FOREIGN_APP, + "com.android.chrome": SituationType.OBSTACLE_FOREIGN_APP, + "com.google.android.youtube": SituationType.OBSTACLE_FOREIGN_APP, + } + for pkg, expected in known_foreign_pkgs.items(): + xml = f""" + + + + + """ + device = E2EDeviceStub([xml]) + SituationalAwarenessEngine.reset() + sae = SituationalAwarenessEngine(device) + + result = sae.perceive(xml) + assert result == expected, f"Package {pkg} was classified as {result.value}, expected {expected.value}!" diff --git a/tests/e2e/test_system_sae.py b/tests/e2e/test_system_sae.py index f5d0f71..bb4dccf 100644 --- a/tests/e2e/test_system_sae.py +++ b/tests/e2e/test_system_sae.py @@ -154,6 +154,10 @@ class TestSAELoop: from GramAddict.core.perception.screen_identity import ScreenIdentity from GramAddict.core.qdrant_memory import ScreenMemoryDB + db = ScreenMemoryDB() + if not db.is_connected: + pytest.skip("Qdrant is not running — this test requires vector store for NORMAL override storage") + # Load real home feed XML xml_dump = Path("tests/e2e/fixtures/home_feed_real.xml").read_text() @@ -168,7 +172,7 @@ class TestSAELoop: # Simulate LLM unlearning by storing this exact state as NORMAL compressed = sae._compress_xml(xml_dump) - ScreenMemoryDB().store_screen(compressed, "NORMAL") + db.store_screen(compressed, "NORMAL") identity = ScreenIdentity("testuser") # Ensure we inject the device so get_screenshot_b64 doesn't crash if it falls back diff --git a/tests/e2e/test_workflow_feed_foreign_app_escape.py b/tests/e2e/test_workflow_feed_foreign_app_escape.py new file mode 100644 index 0000000..6361c0e --- /dev/null +++ b/tests/e2e/test_workflow_feed_foreign_app_escape.py @@ -0,0 +1,67 @@ +""" +E2E: Feed Loop Foreign App Escape +==================================== +Validates that the feed loop detects a foreign app takeover +(e.g. Play Store opened) and immediately aborts with CONTEXT_LOST. +Parity with story loop guard. +""" + +import datetime + +from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml + + +def test_feed_loop_escapes_foreign_app(e2e_configs): + """ + Simulates: Feed → Play Store. + The loop must detect com.android.vending and abort immediately. + """ + from GramAddict.core.bot_flow import _run_zero_latency_feed_loop + from GramAddict.core.dopamine_engine import DopamineEngine + + feed_xml = load_fixture_xml("home_feed_with_ad.xml") + play_store_xml = load_fixture_xml("play_store_from_story_link.xml") + + # Feed -> Play Store + device = E2EDeviceStub([feed_xml, play_store_xml]) + + dopamine = DopamineEngine() + dopamine.session_limit_seconds = 999 + + # Needs a dummy zero_engine and nav_graph + class DummyZeroEngine: + pass + + class DummyNavGraph: + def do(self, action): + pass + + def navigate_to(self, state, engine): + pass + + cognitive_stack = { + "dopamine": dopamine, + "zero_engine": DummyZeroEngine(), + "nav_graph": DummyNavGraph(), + "radome": None, + "active_inference": None, + } + + session_state = type( + "S", (), {"startTime": datetime.datetime.now(), "check_limit": lambda *args, **kwargs: (False, False)} + )() + + # Override speed_multiplier to avoid multi-minute sleeps in CI + e2e_configs.args.speed_multiplier = 0.01 + + result = _run_zero_latency_feed_loop( + device, DummyZeroEngine(), DummyNavGraph(), e2e_configs, session_state, "HomeFeed", cognitive_stack + ) + + # Must return CONTEXT_LOST + assert ( + result == "CONTEXT_LOST" + ), f"Feed loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground." + + # Must have pressed back to attempt recovery + assert "back" in device.pressed_keys, "Feed loop detected foreign app but never pressed BACK to recover!" diff --git a/tests/e2e/test_workflow_stories_foreign_app_escape.py b/tests/e2e/test_workflow_stories_foreign_app_escape.py new file mode 100644 index 0000000..ce79278 --- /dev/null +++ b/tests/e2e/test_workflow_stories_foreign_app_escape.py @@ -0,0 +1,54 @@ +""" +E2E: Story Loop Foreign App Escape +==================================== +Validates that the story binging loop detects a foreign app takeover +(e.g. Play Store opened via story link) and immediately aborts with +CONTEXT_LOST instead of continuing to tap blindly. + +Production Bug Reproduction: + Session: 2026-05-03_18-03-15 + Trace: Frames 14-40 (Instagram Stories) → Frame 41 (com.android.vending) + Root Cause: _run_zero_latency_stories_loop had no perimeter guard. + +Uses the REAL production XML dumps from the 2026-05-03 session trace. +""" + +import datetime + +from tests.e2e.conftest import E2EDeviceStub, load_fixture_xml + + +def test_story_loop_escapes_foreign_app(e2e_configs): + """ + Simulates: Story → Story → Story → Play Store (via swipe-up link). + The loop must detect com.android.vending and abort immediately. + """ + from GramAddict.core.bot_flow import _run_zero_latency_stories_loop + from GramAddict.core.dopamine_engine import DopamineEngine + + story_xml = load_fixture_xml("story_view_before_linkout.xml") + play_store_xml = load_fixture_xml("play_store_from_story_link.xml") + + # 3 story iterations → then Play Store appears + device = E2EDeviceStub([story_xml, story_xml, story_xml, play_store_xml]) + + dopamine = DopamineEngine() + dopamine.session_limit_seconds = 999 + cognitive_stack = {"dopamine": dopamine} + + session_state = type("S", (), {"startTime": datetime.datetime.now()})() + + # Override speed_multiplier to avoid multi-minute sleeps in CI + e2e_configs.args.speed_multiplier = 0.01 + + result = _run_zero_latency_stories_loop(device, e2e_configs, session_state, cognitive_stack) + + # Must return CONTEXT_LOST, NOT FEED_EXHAUSTED + assert result == "CONTEXT_LOST", ( + f"Story loop returned '{result}' instead of 'CONTEXT_LOST' when Play Store was in foreground. " + f"The bot would have tapped blindly on the Play Store! " + f"This is the exact production bug from 2026-05-03." + ) + + # Must have pressed back to attempt recovery + assert "back" in device.pressed_keys, "Story loop detected foreign app but never pressed BACK to recover!" diff --git a/tests/fixtures/play_store_from_story_link.xml b/tests/fixtures/play_store_from_story_link.xml new file mode 100644 index 0000000..642e546 --- /dev/null +++ b/tests/fixtures/play_store_from_story_link.xml @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/fixtures/story_view_before_linkout.xml b/tests/fixtures/story_view_before_linkout.xml new file mode 100644 index 0000000..0ccec3d --- /dev/null +++ b/tests/fixtures/story_view_before_linkout.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +