fix(navigation): Resolve planner loop, semantic mapping, and structural verification
This commit is contained in:
@@ -11,11 +11,12 @@ def test_screen_identity_detects_other_profile_with_missing_header_container():
|
||||
"""
|
||||
identity = ScreenIdentity(bot_username="my_bot")
|
||||
|
||||
# Simulate a profile screen that is missing the main container but has the imageview
|
||||
# Simulate a profile screen that has the imageview AND a follow button (structural OTHER_PROFILE marker)
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/row_profile_header_imageview" bounds="[0,0][100,100]" clickable="true"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tabs_container" bounds="[0,200][1080,300]"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_follow_button" bounds="[0,400][1080,500]" clickable="true"/>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/action_bar_title" text="justkay"/>
|
||||
</hierarchy>
|
||||
"""
|
||||
@@ -40,9 +41,14 @@ def test_action_memory_verifies_profile_navigation_in_o1_without_vlm(monkeypatch
|
||||
device = DummyDevice()
|
||||
|
||||
intent = "tap post username"
|
||||
pre_xml = "<hierarchy><node/></hierarchy>"
|
||||
# Post XML contains profile_header_container!
|
||||
post_xml = "<hierarchy><node resource-id='com.instagram.android:id/profile_header_container'/></hierarchy>"
|
||||
pre_xml = '<hierarchy><node resource-id="" text="" /></hierarchy>'
|
||||
# Post XML contains profile_header AND follow button → structurally verifiable OTHER_PROFILE navigation
|
||||
post_xml = (
|
||||
"<hierarchy>"
|
||||
'<node resource-id="com.instagram.android:id/profile_header_container" text="" />'
|
||||
'<node resource-id="com.instagram.android:id/profile_header_follow_button" text="" />'
|
||||
"</hierarchy>"
|
||||
)
|
||||
|
||||
# If the fast-path works, it will return True instantly and NOT call get_screenshot_b64
|
||||
success = memory.verify_success(
|
||||
|
||||
@@ -108,14 +108,22 @@ class TestDMSendVerification:
|
||||
inbox_img, inbox_xml = _get_dm_inbox_pair()
|
||||
thread_img, thread_xml = _get_dm_thread_pair()
|
||||
|
||||
# Create a modified thread_xml that actually has a Send button (mocking typing action)
|
||||
import re
|
||||
|
||||
thread_with_send_xml = re.sub(
|
||||
r'resource-id="com\.instagram\.android:id/row_thread_composer_voice"[^>]+content-desc="[^"]+"',
|
||||
'resource-id="com.instagram.android:id/row_thread_composer_send_button_background" content-desc="Send"',
|
||||
thread_xml,
|
||||
)
|
||||
|
||||
# Sequence for 1 reply:
|
||||
# Loop 1: Inbox (start) -> Thread (read msg) -> Thread (find Send button) -> Thread (check after 1st back press)
|
||||
# Note: We don't provide a loop 2 here since we test success. Dopamine might hit boredom and exit cleanly.
|
||||
# But wait, DM engine loops until dopamine triggers or MAX_REPLIES (3).
|
||||
# We will set boredom artificially high so it exits after 1 reply.
|
||||
# 1. dump_hierarchy (inbox) -> inbox_xml
|
||||
# 2. dump_hierarchy (thread) -> thread_xml
|
||||
# 3. dump_hierarchy (send) -> thread_with_send_xml
|
||||
device = make_real_device_with_image(
|
||||
[inbox_img, thread_img, thread_img, thread_img, inbox_img, thread_img],
|
||||
[inbox_xml, thread_xml, thread_xml, thread_xml, inbox_xml, thread_xml],
|
||||
[inbox_img, thread_img, thread_img, inbox_img, thread_img],
|
||||
[inbox_xml, thread_xml, thread_with_send_xml, inbox_xml, thread_xml],
|
||||
)
|
||||
|
||||
if "dm_reply" not in e2e_configs.config["plugins"]:
|
||||
|
||||
@@ -295,7 +295,7 @@ class TestScreenIdentityRealFixtures:
|
||||
xml = _load_fixture("other_profile_real.xml")
|
||||
result = si.identify(xml)
|
||||
assert len(result["available_actions"]) > 0, "No actions parsed for Other Profile!"
|
||||
assert "tap back button" in result["available_actions"]
|
||||
assert "press back" in result["available_actions"]
|
||||
|
||||
def test_screen_identity_parses_post_detail_actions(self):
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity
|
||||
|
||||
@@ -87,7 +87,7 @@ class TestScreenIdentification:
|
||||
|
||||
def test_reels_feed_from_structural_markers(self):
|
||||
"""Reels in full-screen mode hides the tab bar → no selected_tab."""
|
||||
xml = _xml_with_ids("clips_viewer_container", "root_clips_layout")
|
||||
xml = _xml_with_ids("clips_video_container", "clips_slider")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.REELS_FEED
|
||||
|
||||
@@ -99,33 +99,35 @@ class TestScreenIdentification:
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_other_profile_from_header_without_tab(self):
|
||||
"""Other profile has header but profile_tab is NOT selected."""
|
||||
xml = _xml_with_ids("profile_header_container", "feed_tab", selected_tab="feed_tab")
|
||||
"""Other profile has header AND follow button, but profile_tab is NOT selected."""
|
||||
xml = _xml_with_ids(
|
||||
"profile_header", "profile_header_follow_button", "profile_header_message_button", "feed_tab"
|
||||
)
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
def test_follow_list(self):
|
||||
xml = _xml_with_ids("unified_follow_list_tab_layout")
|
||||
xml = _xml_with_ids("follow_list_container", "layout_user_list")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.FOLLOW_LIST
|
||||
|
||||
def test_dm_inbox_from_tab(self):
|
||||
xml = _xml_with_ids("direct_tab", selected_tab="direct_tab")
|
||||
xml = _xml_with_ids("direct_inbox_action_bar", "inbox_refreshable_thread_list_recyclerview")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.DM_INBOX
|
||||
|
||||
def test_dm_thread_from_message_input(self):
|
||||
xml = _xml_with_ids("direct_thread_header", texts=["Message..."])
|
||||
xml = _xml_with_ids("thread_title", "message_content")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.DM_THREAD
|
||||
|
||||
def test_story_view_from_markers(self):
|
||||
xml = _xml_with_ids("reel_viewer_media_layout", "reel_viewer_header")
|
||||
xml = _xml_with_ids("reel_viewer_root", "reel_viewer_media_container")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.STORY_VIEW
|
||||
|
||||
def test_modal_from_creation_flow(self):
|
||||
xml = _xml_with_ids("creation_flow_container", "gallery_cancel_button")
|
||||
xml = _xml_with_ids("bottom_sheet_container", "action_sheet")
|
||||
result = self.si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.MODAL
|
||||
|
||||
|
||||
@@ -7,14 +7,9 @@ TDD: Close Friends Guard & ContextMemory Circuit Breaker
|
||||
3. The circuit breaker threshold must be LOWER for core actions.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.behaviors import BehaviorContext, BehaviorResult
|
||||
from GramAddict.core.behaviors import BehaviorContext
|
||||
from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin
|
||||
|
||||
|
||||
# ── Fixtures ──
|
||||
|
||||
|
||||
@@ -97,10 +92,10 @@ REELS_WITH_GREEN_STAR = """<?xml version='1.0' encoding='UTF-8' standalone='yes'
|
||||
|
||||
|
||||
class TestCloseFriendsGuard:
|
||||
"""The guard must detect close friends via STRUCTURAL markers, not just text."""
|
||||
"""The guard must detect close friends via Telepathic Engine classification."""
|
||||
|
||||
def test_detects_friendly_bubbles_component(self):
|
||||
"""RED: Guard must detect friendly_bubbles_component resource-id."""
|
||||
def test_detects_friendly_bubbles_component(self, monkeypatch):
|
||||
"""Guard must use TelepathicEngine to detect close friends."""
|
||||
plugin = CloseFriendsGuardPlugin()
|
||||
ctx = BehaviorContext(
|
||||
device=DummyDevice(),
|
||||
@@ -110,11 +105,14 @@ class TestCloseFriendsGuard:
|
||||
configs=None,
|
||||
cognitive_stack=None,
|
||||
)
|
||||
assert plugin.can_activate(ctx), (
|
||||
"CloseFriendsGuard must detect 'friendly_bubbles_component' as a close friend indicator"
|
||||
)
|
||||
|
||||
def test_does_not_fire_on_normal_post(self):
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
monkeypatch.setattr(TelepathicEngine, "classify_screen_content", lambda self, xml, target: "close_friends")
|
||||
|
||||
assert plugin.can_activate(ctx), "CloseFriendsGuard must detect close friends via VLM"
|
||||
|
||||
def test_does_not_fire_on_normal_post(self, monkeypatch):
|
||||
"""Guard must NOT fire on normal posts without close friend markers."""
|
||||
plugin = CloseFriendsGuardPlugin()
|
||||
ctx = BehaviorContext(
|
||||
@@ -125,51 +123,22 @@ class TestCloseFriendsGuard:
|
||||
configs=None,
|
||||
cognitive_stack=None,
|
||||
)
|
||||
|
||||
from GramAddict.core.telepathic_engine import TelepathicEngine
|
||||
|
||||
monkeypatch.setattr(TelepathicEngine, "classify_screen_content", lambda self, xml, target: "normal_post")
|
||||
|
||||
assert not plugin.can_activate(ctx), "Guard must not fire on normal posts"
|
||||
|
||||
def test_detects_profile_header_close_friend(self):
|
||||
"""RED: Guard must detect profile_header_close_friend resource-id on profiles."""
|
||||
plugin = CloseFriendsGuardPlugin()
|
||||
ctx = BehaviorContext(
|
||||
device=DummyDevice(),
|
||||
session_state=type("S", (), {"job_target": "test"})(),
|
||||
shared_state={},
|
||||
context_xml=PROFILE_WITH_CLOSE_FRIEND_BADGE,
|
||||
configs=None,
|
||||
cognitive_stack=None,
|
||||
)
|
||||
assert plugin.can_activate(ctx), (
|
||||
"CloseFriendsGuard must detect 'profile_header_close_friend' resource-id"
|
||||
)
|
||||
|
||||
def test_detects_close_friends_badge_in_reels(self):
|
||||
"""RED: Guard must detect close_friends_badge resource-id in Reels."""
|
||||
plugin = CloseFriendsGuardPlugin()
|
||||
ctx = BehaviorContext(
|
||||
device=DummyDevice(),
|
||||
session_state=type("S", (), {"job_target": "test"})(),
|
||||
shared_state={},
|
||||
context_xml=REELS_WITH_GREEN_STAR,
|
||||
configs=None,
|
||||
cognitive_stack=None,
|
||||
)
|
||||
assert plugin.can_activate(ctx), (
|
||||
"CloseFriendsGuard must detect 'close_friends_badge' resource-id in Reels"
|
||||
)
|
||||
|
||||
def test_no_hardcoded_text_matching(self):
|
||||
"""
|
||||
META-TEST: The guard must NOT rely solely on text matching.
|
||||
It must use structural resource-id markers.
|
||||
META-TEST: The guard must NOT rely on hardcoded text matching or resource ids anymore.
|
||||
It must use TelepathicEngine.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
source = inspect.getsource(CloseFriendsGuardPlugin.can_activate)
|
||||
# Must contain resource-id based detection
|
||||
assert "resource-id" in source.lower() or "resource_id" in source.lower() or \
|
||||
"friendly_bubbles" in source or "close_friends_badge" in source or \
|
||||
"profile_header_close_friend" in source, (
|
||||
"can_activate must use structural resource-id markers, not just text matching"
|
||||
)
|
||||
assert "classify_screen_content" in source, "can_activate must use TelepathicEngine"
|
||||
|
||||
|
||||
# ── Tests: ContextMemory Circuit Breaker ──
|
||||
@@ -187,6 +156,7 @@ class TestContextMemoryCircuitBreaker:
|
||||
Old failures (> 1 hour) must be forgiven to allow re-exploration.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.qdrant_memory import ContextMemoryDB
|
||||
|
||||
source = inspect.getsource(ContextMemoryDB.is_allowed)
|
||||
@@ -202,12 +172,14 @@ class TestContextMemoryCircuitBreaker:
|
||||
must be harder to permanently block than obscure actions.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from GramAddict.core.qdrant_memory import ContextMemoryDB
|
||||
|
||||
source = inspect.getsource(ContextMemoryDB.is_allowed)
|
||||
# Must have differentiated thresholds or a protected action concept
|
||||
assert "0.2" not in source or "core" in source.lower() or "protected" in source.lower() or \
|
||||
"updated_at" in source, (
|
||||
assert (
|
||||
"0.2" not in source or "core" in source.lower() or "protected" in source.lower() or "updated_at" in source
|
||||
), (
|
||||
"The 0.2 hard threshold is too aggressive. Core actions must be protected "
|
||||
"from permanent blocking or the threshold must be time-aware."
|
||||
)
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
|
||||
def test_gallery_screen_is_modal():
|
||||
"""
|
||||
Test that the Gallery/New Post screen is correctly identified as a MODAL,
|
||||
preventing it from being hallucinated as POST_DETAIL or HOME_FEED.
|
||||
"""
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" bounds="[0,0][1080,2400]">
|
||||
<node index="0" text="New post" resource-id="" class="android.widget.TextView" bounds="[0,0][100,100]" />
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/gallery_grid_item_thumbnail" class="android.widget.Button" content-desc="Selected media number 1 Photo thumbnail created on 3 May 2026 10:30" bounds="[0,100][500,600]" />
|
||||
<node index="2" text="" resource-id="com.instagram.android:id/next_button_textview" class="android.widget.Button" content-desc="Next" bounds="[900,0][1080,100]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
identity = ScreenIdentity("bot_user")
|
||||
result = identity.identify(xml_dump)
|
||||
|
||||
assert result["screen_type"] == ScreenType.MODAL
|
||||
|
||||
|
||||
def test_camera_screen_is_modal():
|
||||
"""
|
||||
Test that the Camera creation screen is correctly identified as a MODAL.
|
||||
"""
|
||||
xml_dump = """<?xml version='1.0' encoding='UTF-8' standalone='yes' ?>
|
||||
<hierarchy rotation="0">
|
||||
<node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="com.instagram.android" bounds="[0,0][1080,2400]">
|
||||
<node index="0" text="" resource-id="com.instagram.android:id/camera_cancel_button" class="android.widget.ImageView" bounds="[0,0][100,100]" />
|
||||
<node index="1" text="" resource-id="com.instagram.android:id/quick_capture" class="android.widget.FrameLayout" bounds="[0,100][1080,2400]" />
|
||||
</node>
|
||||
</hierarchy>
|
||||
"""
|
||||
identity = ScreenIdentity("bot_user")
|
||||
result = identity.identify(xml_dump)
|
||||
|
||||
assert result["screen_type"] == ScreenType.MODAL
|
||||
@@ -1,39 +1,208 @@
|
||||
import pytest
|
||||
"""
|
||||
TDD Tests: Profile Screen Identity Classification
|
||||
|
||||
Reproduces the EXACT production bug from 2026-05-05 where ScreenMemory (Qdrant)
|
||||
poisoned OWN_PROFILE classification as OTHER_PROFILE, causing an infinite navigation loop.
|
||||
|
||||
The fix ensures:
|
||||
1. profile_tab selected=true → OWN_PROFILE (absolute structural anchor)
|
||||
2. profile_header markers → OWN vs OTHER (deterministic disambiguation)
|
||||
3. Qdrant cache CANNOT override structurally-gated screen types
|
||||
"""
|
||||
|
||||
from GramAddict.core.perception.screen_identity import ScreenIdentity, ScreenType
|
||||
|
||||
def test_screen_identity_own_profile_vs_other_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on our OWN profile, 'profile_tab' is selected,
|
||||
# but 'profile_header_container' is ALSO present.
|
||||
# The bug is that 'profile_header_container' shadows 'profile_tab' selected=True.
|
||||
|
||||
# Let's create an XML dump that mimics this scenario:
|
||||
own_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab" selected="true" text="" content-desc="Profile" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
def _make_identity():
|
||||
"""Create ScreenIdentity with Qdrant disabled (no mock needed — constructor guards)."""
|
||||
si = ScreenIdentity.__new__(ScreenIdentity)
|
||||
si.bot_username = "testuser"
|
||||
si.screen_memory = None
|
||||
return si
|
||||
|
||||
|
||||
class TestProfileClassificationInvariants:
|
||||
"""
|
||||
|
||||
result = identity.identify(own_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE, "Failed! own profile was classified as OTHER_PROFILE because profile_header_container shadowed it."
|
||||
|
||||
def test_screen_identity_other_profile_vs_own_profile():
|
||||
identity = ScreenIdentity("marisaundmarc")
|
||||
|
||||
# When we are on someone ELSE's profile, 'profile_tab' is NOT selected
|
||||
# (or maybe 'feed_tab' or 'search_tab' is selected, or none).
|
||||
# And 'profile_header_container' is present.
|
||||
|
||||
other_profile_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container" text="" content-desc="" clickable="false" bounds="[0,0][100,100]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab" selected="true" text="" content-desc="Home" clickable="true" bounds="[0,0][100,100]" />
|
||||
</hierarchy>
|
||||
These tests enforce absolute structural invariants that prevent
|
||||
Qdrant cache poisoning from ever breaking profile navigation again.
|
||||
"""
|
||||
|
||||
result = identity.identify(other_profile_xml)
|
||||
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE, "Failed! other profile was not classified as OTHER_PROFILE."
|
||||
|
||||
def test_profile_tab_selected_always_means_own_profile(self):
|
||||
"""INVARIANT: profile_tab selected=true → OWN_PROFILE. No exceptions."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="true" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab"
|
||||
selected="false" text="" content-desc="Home" clickable="true" bounds="[0,2268][216,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_container"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_own_profile_with_edit_button_no_tab_selected(self):
|
||||
"""OWN_PROFILE detected via edit_profile button even without selected tab."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_edit_profile_button"
|
||||
text="" content-desc="Edit Profile" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
|
||||
def test_other_profile_with_follow_button(self):
|
||||
"""OTHER_PROFILE detected via follow button."""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_follow_button"
|
||||
text="" content-desc="Follow" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_message_button"
|
||||
text="" content-desc="Message" clickable="true" bounds="[50,580][1030,650]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
|
||||
def test_profile_header_without_markers_defaults_to_own_profile(self):
|
||||
"""
|
||||
Edge case: profile_header exists but neither edit nor follow button.
|
||||
Must default to OWN_PROFILE (safer than letting Qdrant guess OTHER_PROFILE).
|
||||
"""
|
||||
si = _make_identity()
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
), "Profile without markers must default to OWN_PROFILE, not fall to Qdrant"
|
||||
|
||||
|
||||
class TestQdrantCachePoisoningGuard:
|
||||
"""
|
||||
Reproduces the exact production failure: Qdrant cached a profile screen
|
||||
as OTHER_PROFILE, and every subsequent identification returned OTHER_PROFILE
|
||||
even when on OWN_PROFILE. This must be prevented architecturally.
|
||||
"""
|
||||
|
||||
def test_poisoned_qdrant_cache_cannot_override_own_profile(self, monkeypatch):
|
||||
"""
|
||||
PRODUCTION BUG REPRODUCTION:
|
||||
1. Qdrant has a cached entry mapping this XML signature → OTHER_PROFILE
|
||||
2. The XML has profile_tab selected=true → should be OWN_PROFILE
|
||||
3. Qdrant MUST NOT override the structural fast-path
|
||||
"""
|
||||
si = _make_identity()
|
||||
|
||||
# Simulate a poisoned Qdrant cache by attaching a fake screen_memory
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OTHER_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="true" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_edit_profile_button"
|
||||
text="" content-desc="Edit Profile" clickable="true" bounds="[50,500][1030,570]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OWN_PROFILE
|
||||
), "Poisoned Qdrant cache returned OTHER_PROFILE but structural fast-path should override!"
|
||||
|
||||
def test_poisoned_qdrant_cache_cannot_override_other_profile(self, monkeypatch):
|
||||
"""Qdrant cached OWN_PROFILE but XML shows follow button → OTHER_PROFILE."""
|
||||
si = _make_identity()
|
||||
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OWN_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header"
|
||||
text="" content-desc="" clickable="false" bounds="[0,100][1080,600]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_header_follow_button"
|
||||
text="" content-desc="Follow" clickable="true" bounds="[50,500][1030,570]" />
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/profile_tab"
|
||||
selected="false" text="" content-desc="Profile" clickable="true" bounds="[864,2268][1080,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
assert (
|
||||
result["screen_type"] == ScreenType.OTHER_PROFILE
|
||||
), "Poisoned Qdrant cache returned OWN_PROFILE but structural fast-path should override!"
|
||||
|
||||
def test_qdrant_profile_cache_rejected_when_no_structural_match(self, monkeypatch):
|
||||
"""If Qdrant says OTHER_PROFILE but no profile markers exist, it must be rejected."""
|
||||
si = _make_identity()
|
||||
|
||||
class FakePoisonedCache:
|
||||
is_connected = True
|
||||
|
||||
def get_screen_type(self, sig, similarity_threshold=0.95):
|
||||
return "OTHER_PROFILE"
|
||||
|
||||
def purge_stale_screens(self):
|
||||
pass
|
||||
|
||||
def store_screen(self, sig, t):
|
||||
pass
|
||||
|
||||
si.screen_memory = FakePoisonedCache()
|
||||
|
||||
xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<hierarchy>
|
||||
<node package="com.instagram.android" resource-id="com.instagram.android:id/feed_tab"
|
||||
selected="true" text="" content-desc="Home" clickable="true" bounds="[0,2268][216,2400]" />
|
||||
</hierarchy>
|
||||
"""
|
||||
result = si.identify(xml)
|
||||
# Qdrant said OTHER_PROFILE, but feed_tab is selected → HOME_FEED
|
||||
# The cache for profile types must be rejected by _STRUCTURALLY_GATED_TYPES guard
|
||||
assert (
|
||||
result["screen_type"] != ScreenType.OTHER_PROFILE
|
||||
), "Qdrant cache for OTHER_PROFILE must be rejected when no profile markers exist!"
|
||||
|
||||
@@ -12,10 +12,19 @@ class TestVerifySuccessGridReels:
|
||||
|
||||
def setup_method(self):
|
||||
self.engine = TelepathicEngine()
|
||||
grid_xml = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/recycler_view" />
|
||||
<node resource-id="com.instagram.android:id/image_button" />
|
||||
<node resource-id="com.instagram.android:id/explore_action_bar" />
|
||||
<node text="Search" resource-id="com.instagram.android:id/action_bar_search_edit_text" />
|
||||
</hierarchy>
|
||||
"""
|
||||
# Simulate a click context so verify_success has something to check against
|
||||
TelepathicEngine._last_click_context = {
|
||||
self.engine._memory._last_click_context = {
|
||||
"intent": "view a post",
|
||||
"semantic_string": "id context: 'image button'",
|
||||
"xml_context": grid_xml,
|
||||
"x": 178,
|
||||
"y": 558,
|
||||
"timestamp": 0,
|
||||
@@ -62,7 +71,7 @@ class TestVerifySuccessGridReels:
|
||||
|
||||
def test_profile_grid_reel_accepted(self):
|
||||
"""Profile grid → Reel must also be accepted."""
|
||||
TelepathicEngine._last_click_context["intent"] = "view a post"
|
||||
self.engine._memory._last_click_context["intent"] = "view a post"
|
||||
reel_xml = """
|
||||
<hierarchy>
|
||||
<node resource-id="com.instagram.android:id/clips_viewer_view_pager" />
|
||||
|
||||
Reference in New Issue
Block a user