#!/usr/bin/env python3 """Offline verification harness for a built .plugin bundle. Runs without a phone. Usage: python3 tools/verify_bundle.py """ from __future__ import annotations import ast import json import sys import tempfile import types from pathlib import Path from typing import Any, Callable, Dict, List, Optional REPO_ROOT = Path(__file__).resolve().parent.parent PLUGIN_JSON = REPO_ROOT / "plugin.json" _FAILURES = [] def check(name: str, condition: bool, detail: str = "") -> None: if condition: print(f"PASS {name}") else: message = f"FAIL {name}" if detail: message += f": {detail}" print(message, file=sys.stderr) _FAILURES.append(name) LOG_LINES: List[str] = [] MENU_ITEMS: List[Any] = [] BULLETINS: List[tuple] = [] HOOKS_INSTALLED: List[Any] = [] LAST_REQUEST: Dict[str, Any] = {} class MenuItemType: MESSAGE_CONTEXT_MENU = "MESSAGE_CONTEXT_MENU" DRAWER_MENU = "DRAWER_MENU" MAIN_MENU = "MAIN_MENU" CHAT_ACTION_MENU = "CHAT_ACTION_MENU" PROFILE_ACTION_MENU = "PROFILE_ACTION_MENU" class MenuItemData: def __init__(self, menu_type=None, text="", subtext="", icon=None, on_click=None, **kwargs): self.menu_type = menu_type self.text = text self.subtext = subtext self.icon = icon self.on_click = on_click self.__dict__.update(kwargs) class MethodHook: def before_hooked_method(self, param): pass def after_hooked_method(self, param): pass class BasePlugin: def __init__(self): self._settings: Dict[str, Any] = {} def get_setting(self, key, default=None): return self._settings.get(key, default) def set_setting(self, key, value, reload_settings=False): self._settings[key] = value def add_menu_item(self, item): MENU_ITEMS.append(item) return f"menu_item_{len(MENU_ITEMS)}" def hook_method(self, method, hook_instance, priority=0): HOOKS_INSTALLED.append((method, hook_instance)) return f"hook_ref_{len(HOOKS_INSTALLED)}" def unhook_method(self, ref): pass def on_plugin_load(self): pass def on_plugin_unload(self): pass def create_settings(self): return [] def fake_log(*args, **kwargs): LOG_LINES.append(" ".join(str(a) for a in args)) class _SettingsRow: def __init__(self, **kwargs): self.__dict__.update(kwargs) class _JavaList(list): """Python list that also answers the Java ArrayList calls our code makes.""" def add(self, *args): if len(args) == 2: self.insert(int(args[0]), args[1]) else: self.append(args[0]) return True def size(self): return len(self) def get(self, index): return self[index] def set(self, index, value): previous = self[index] self[index] = value return previous def remove(self, index): return self.pop(int(index)) class _JavaArrayList: """A Java ArrayList as Chaquopy exposes it: size()/get() but NOT iterable. list()/for-in over one raises TypeError on-device, so the fake withholds the iterator protocol to keep that mistake failing here. """ def __init__(self, items=()): self._items = list(items) def size(self): return len(self._items) def get(self, index): return self._items[index] def add(self, item): self._items.append(item) return True class _TLObj: """Duck-typed stand-in for a TLRPC/TL_iv object: any attribute is settable. `blocks`/`items`/`texts` start as list-likes because the production code calls `.add()` on them without constructing them first, exactly as the Java classes pre-initialize their ArrayList fields. """ def __init__(self, tl_name): self.tl_name = tl_name self.blocks = _JavaList() self.items = _JavaList() self.texts = _JavaList() self.flags = 0 self.flags2 = 0 def __repr__(self): return f"<{self.tl_name}>" def _tl_iv_factory(name): return lambda: _TLObj(name) class _FakeMessageObject: """Mirrors the MessageObject fields/behaviour the injector relies on.""" TYPE_RICH_MESSAGE = 36 def __init__(self, account, message, generate_layout=False, check_media=False): self.currentAccount = account self.messageOwner = message self.richLayout = None self.isOutOwnerCached = None self.type = None self.messageText = "" self.updateMessageText() self.setType() def updateMessageText(self): rich = getattr(self.messageOwner, "rich_message", None) self.messageText = "" if rich is not None else getattr(self.messageOwner, "message", "") def setType(self): rich = getattr(self.messageOwner, "rich_message", None) self.type = self.TYPE_RICH_MESSAGE if rich is not None else 0 def isOutOwner(self): if self.isOutOwnerCached is not None: return self.isOutOwnerCached return bool(getattr(self.messageOwner, "out", False)) def getId(self): return getattr(self.messageOwner, "id", 0) class _FakeMessagesController: """Per-account user cache; rejects non-positive ids the way getUser does.""" _instances = {} def __init__(self): self.users = {} @classmethod def getInstance(cls, account): return cls._instances.setdefault(account, cls()) def putUser(self, user, from_cache): self.users[int(user.id)] = user def getUser(self, user_id): user_id = int(user_id) if user_id <= 0: return None return self.users.get(user_id) class _FakeRequestCallback: """Mirrors client_utils.RequestCallback: the Java-listener wrapper. On-device, send_request hands its second argument to Java. A bare Python function is accepted but never invoked, so the response is silently dropped; only a RequestCallback-wrapped callable runs. The fake therefore refuses anything else, so that mistake fails here instead of on the phone. """ def __init__(self, fn): self.fn = fn def run(self, response, error): return self.fn(response, error) def _fake_send_request(request, callback, *, account=None): if not isinstance(callback, _FakeRequestCallback): raise TypeError( "send_request callback must be wrapped in RequestCallback(...); " f"got {type(callback).__name__}" ) callback.run(None, None) return 1 _KNOWN_METHODS = { "org.telegram.ui.LaunchActivity": ["dispatchTouchEvent"], "org.telegram.ui.ChatActivity": [ "onPageDownClicked", "updateMessagesVisiblePart", "didReceivedNotification_messagesDidLoad", ], } _KNOWN_CONSTRUCTOR_COUNTS = { "org.telegram.ui.Cells.ChatUnreadCell": 1, } class _FakeJavaMethod: """Simulates a resolved java.lang.reflect.Method for a known method name.""" def __init__(self, class_name, method_name): self.class_name = class_name self.method_name = method_name def getName(self): return self.method_name class _FakeJavaConstructor: def __init__(self, class_name): self.class_name = class_name class _FakeJavaReflectClass: """Simulates the real java.lang.Class object returned by .getClass().""" def __init__(self, name): self._name = name def getDeclaredMethods(self): return [_FakeJavaMethod(self._name, m) for m in _KNOWN_METHODS.get(self._name, [])] def getMethods(self): return self.getDeclaredMethods() def getDeclaredConstructors(self): count = _KNOWN_CONSTRUCTOR_COUNTS.get(self._name, 0) return [_FakeJavaConstructor(self._name) for _ in range(count)] class _FakeChaquopyClassWrapper: """Simulates what hook_utils.find_class(...) actually returns on-device: a Chaquopy Python-side class wrapper, NOT a java.lang.Class. It exposes no getDeclaredMethod(s) directly -- only .getClass() reaches the real reflection object. A fake that skipped this distinction (returning None, or returning something with getDeclaredMethods directly) would have hidden the real on-device bug where code called getDeclaredMethod(...) straight on find_class(...)'s result. """ def __init__(self, name): self._name = name def getClass(self): return _FakeJavaReflectClass(self._name) def fake_find_class(name: str): return _FakeChaquopyClassWrapper(name) def fake_get_private_field(obj, field_name): return None class FakeBulletinHelper: @staticmethod def show_info(message, fragment=None): BULLETINS.append(("info", message)) @staticmethod def show_error(message, fragment=None): BULLETINS.append(("error", message)) @staticmethod def show_success(message, fragment=None): BULLETINS.append(("success", message)) class FakeAlertDialogBuilder: ALERT_TYPE_MESSAGE = 0 ALERT_TYPE_SPINNER = 1 BUTTON_POSITIVE = 1 BUTTON_NEGATIVE = 2 def __init__(self, activity, progress_style=0, resources_provider=None): self.activity = activity self._view = None def set_title(self, *a, **k): return self def set_message(self, *a, **k): return self def set_view(self, view, height=-2): self._view = view return self def set_positive_button(self, text, listener=None): self._positive = listener return self def set_negative_button(self, text, listener=None): self._negative = listener return self def set_cancelable(self, *a, **k): return self def create(self): return self def show(self): return self def dismiss(self): pass def _java_widget_stub(name): def __init__(self, *args, **kwargs): self._children = [] self._text = "" def setOrientation(self, *a, **k): pass def setGravity(self, *a, **k): pass def setPadding(self, *a, **k): pass def addView(self, view, *a, **k): self._children.append(view) def setText(self, text): self._text = text def getText(self): return self._text def setHint(self, *a, **k): pass def setSingleLine(self, *a, **k): pass def setMinLines(self, *a, **k): pass def setMaxLines(self, *a, **k): pass def setTextSize(self, *a, **k): pass def setTextColor(self, *a, **k): pass def setHintTextColor(self, *a, **k): pass def setLineColors(self, *a, **k): pass def setCursorColor(self, *a, **k): pass def setBackgroundColor(self, *a, **k): pass return type( name, (object,), { "__init__": __init__, "setOrientation": setOrientation, "setGravity": setGravity, "setPadding": setPadding, "addView": addView, "setText": setText, "getText": getText, "setHint": setHint, "setSingleLine": setSingleLine, "setMinLines": setMinLines, "setMaxLines": setMaxLines, "setTextSize": setTextSize, "setTextColor": setTextColor, "setHintTextColor": setHintTextColor, "setLineColors": setLineColors, "setCursorColor": setCursorColor, "setBackgroundColor": setBackgroundColor, }, ) def install_fakes() -> None: base_plugin_mod = types.ModuleType("base_plugin") base_plugin_mod.BasePlugin = BasePlugin base_plugin_mod.MenuItemData = MenuItemData base_plugin_mod.MenuItemType = MenuItemType base_plugin_mod.MethodHook = MethodHook sys.modules["base_plugin"] = base_plugin_mod android_utils_mod = types.ModuleType("android_utils") android_utils_mod.log = fake_log android_utils_mod.run_on_ui_thread = lambda fn, delay=0: fn() android_utils_mod.copy_to_clipboard = lambda text: None android_utils_mod.OnClickListener = lambda fn: fn android_utils_mod.OnLongClickListener = lambda fn: fn sys.modules["android_utils"] = android_utils_mod hook_utils_mod = types.ModuleType("hook_utils") hook_utils_mod.find_class = fake_find_class hook_utils_mod.get_private_field = fake_get_private_field hook_utils_mod.set_private_field = lambda obj, field, value: False sys.modules["hook_utils"] = hook_utils_mod client_utils_mod = types.ModuleType("client_utils") client_utils_mod.PLUGINS_QUEUE = "pluginsQueue" client_utils_mod.run_on_queue = lambda fn, queue=None, delay=0: fn() client_utils_mod.get_last_fragment = lambda: None client_utils_mod.get_messages_controller = lambda: None client_utils_mod.get_selected_account = lambda: 0 client_utils_mod.RequestCallback = _FakeRequestCallback client_utils_mod.send_request = _fake_send_request sys.modules["client_utils"] = client_utils_mod # summary_db writes a real SQLite file; point it at a scratch dir so the # harness exercises the actual schema and queries. file_utils_mod = types.ModuleType("file_utils") file_utils_mod.get_files_dir = lambda: tempfile.mkdtemp(prefix="chat_summaries_verify_") sys.modules["file_utils"] = file_utils_mod ui_mod = types.ModuleType("ui") ui_mod.__path__ = [] sys.modules["ui"] = ui_mod ui_settings_mod = types.ModuleType("ui.settings") for cls_name in ("Header", "Divider", "Switch", "Selector", "Input", "Text", "EditText", "Custom"): setattr(ui_settings_mod, cls_name, type(cls_name, (_SettingsRow,), {})) sys.modules["ui.settings"] = ui_settings_mod ui_mod.settings = ui_settings_mod ui_alert_mod = types.ModuleType("ui.alert") ui_alert_mod.AlertDialogBuilder = FakeAlertDialogBuilder sys.modules["ui.alert"] = ui_alert_mod ui_mod.alert = ui_alert_mod ui_bulletin_mod = types.ModuleType("ui.bulletin") ui_bulletin_mod.BulletinHelper = FakeBulletinHelper sys.modules["ui.bulletin"] = ui_bulletin_mod ui_mod.bulletin = ui_bulletin_mod android_mod = types.ModuleType("android") android_mod.__path__ = [] sys.modules["android"] = android_mod android_view_mod = types.ModuleType("android.view") android_view_mod.Gravity = type("Gravity", (), {"CENTER": 17}) android_view_mod.View = type("View", (), {}) sys.modules["android.view"] = android_view_mod android_mod.view = android_view_mod class _FakeLayoutParams: def __init__(self, *a, **k): pass android_widget_mod = types.ModuleType("android.widget") for widget_name in ("LinearLayout", "TextView", "ScrollView", "FrameLayout", "Button"): cls = _java_widget_stub(widget_name) cls.VERTICAL = 1 cls.HORIZONTAL = 0 cls.LayoutParams = _FakeLayoutParams setattr(android_widget_mod, widget_name, cls) sys.modules["android.widget"] = android_widget_mod android_mod.widget = android_widget_mod org_mod = types.ModuleType("org") org_mod.__path__ = [] sys.modules["org"] = org_mod org_telegram_mod = types.ModuleType("org.telegram") org_telegram_mod.__path__ = [] sys.modules["org.telegram"] = org_telegram_mod tgnet_mod = types.ModuleType("org.telegram.tgnet") class _TLRequestStub: def __init__(self): self.peer = None self.offset_id = 0 self.offset_date = 0 self.add_offset = 0 self.limit = 0 self.max_id = 0 self.min_id = 0 self.hash = 0 self.msg_id = 0 tgnet_mod.TLRPC = type( "TLRPC", (), { "TL_messages_getHistory": staticmethod(_TLRequestStub), "TL_messages_getReplies": staticmethod(_TLRequestStub), "TL_message": staticmethod(lambda: _TLObj("TL_message")), "TL_user": staticmethod(lambda: _TLObj("TL_user")), "TL_peerUser": staticmethod(lambda: _TLObj("TL_peerUser")), "TL_peerChat": staticmethod(lambda: _TLObj("TL_peerChat")), "TL_peerChannel": staticmethod(lambda: _TLObj("TL_peerChannel")), "TL_messageReplyHeader": staticmethod(lambda: _TLObj("TL_messageReplyHeader")), "TL_emojiStatus": staticmethod(lambda: _TLObj("TL_emojiStatus")), }, ) sys.modules["org.telegram.tgnet"] = tgnet_mod org_telegram_mod.tgnet = tgnet_mod # TL_iv: instant-view block/rich-text constructors used by rich_markdown. # Every block type is the same duck-typed bag of attributes; `blocks`, # `items`, and `texts` behave like Java ArrayList (.add()). tgnet_tl_mod = types.ModuleType("org.telegram.tgnet.tl") tgnet_tl_mod.__path__ = [] _TL_IV_NAMES = ( "RichMessage textPlain textEmpty textConcat textBold textItalic textStrike " "textFixed textUnderline textUrl pageBlockParagraph pageBlockHeading1 " "pageBlockHeading2 pageBlockHeading3 pageBlockPreformatted pageBlockBlockquote " "pageBlockDivider pageBlockThinking pageBlockList pageBlockOrderedList " "pageBlockDetails TL_pageListItemText TL_pageListOrderedItemText" ).split() tgnet_tl_mod.TL_iv = type( "TL_iv", (), {name: staticmethod(_tl_iv_factory(name)) for name in _TL_IV_NAMES}, ) sys.modules["org.telegram.tgnet.tl"] = tgnet_tl_mod tgnet_mod.tl = tgnet_tl_mod messenger_mod = types.ModuleType("org.telegram.messenger") messenger_mod.AndroidUtilities = type("AndroidUtilities", (), {"dp": staticmethod(lambda v: v)}) messenger_mod.R = type("R", (), {}) messenger_mod.MessageObject = _FakeMessageObject messenger_mod.MessagesController = _FakeMessagesController messenger_mod.AnimatedEmojiDrawable = type( "AnimatedEmojiDrawable", (), {"findDocument": staticmethod(lambda account, doc_id: None)} ) sys.modules["org.telegram.messenger"] = messenger_mod org_telegram_mod.messenger = messenger_mod ui_pkg_mod = types.ModuleType("org.telegram.ui") ui_pkg_mod.__path__ = [] sys.modules["org.telegram.ui"] = ui_pkg_mod org_telegram_mod.ui = ui_pkg_mod components_mod = types.ModuleType("org.telegram.ui.Components") edit_text_cls = _java_widget_stub("EditTextBoldCursor") components_mod.EditTextBoldCursor = edit_text_cls components_mod.LayoutHelper = type( "LayoutHelper", (), { "MATCH_PARENT": -1, "WRAP_CONTENT": -2, "createLinear": staticmethod(lambda *a, **k: None), }, ) sys.modules["org.telegram.ui.Components"] = components_mod ui_pkg_mod.Components = components_mod actionbar_mod = types.ModuleType("org.telegram.ui.ActionBar") class _FakeTheme: # A handful of real key_* names used by src/ui/theme.py; getattr on # any key_* name returns the name itself (getColor below just # echoes a stable fake int derived from it). key_dialogBackground = "key_dialogBackground" key_dialogTextBlack = "key_dialogTextBlack" key_dialogTextGray3 = "key_dialogTextGray3" key_windowBackgroundWhiteHintText = "key_windowBackgroundWhiteHintText" key_windowBackgroundWhiteBlackText = "key_windowBackgroundWhiteBlackText" key_dialogInputField = "key_dialogInputField" key_dialogInputFieldActivated = "key_dialogInputFieldActivated" key_windowBackgroundWhiteBlueText = "key_windowBackgroundWhiteBlueText" @staticmethod def getColor(key): return -1 # any deterministic int; value is never asserted on actionbar_mod.Theme = _FakeTheme sys.modules["org.telegram.ui.ActionBar"] = actionbar_mod ui_pkg_mod.ActionBar = actionbar_mod requests_mod = types.ModuleType("requests") # Captures the last request body so checks can assert what was actually # sent (system prompt, JSON transcript) rather than trusting the caller. LAST_REQUEST.clear() class _FakeResponse: status_code = 200 text = "{}" def iter_lines(self): for line in ( b'data: {"choices":[{"delta":{"reasoning_content":"Thinking."}}]}', b"", b'data: {"choices":[{"delta":{"content":"Fake summary."}}]}', b"data: [DONE]", ): yield line def close(self): pass def fake_post(url, headers=None, json=None, timeout=None, stream=False): LAST_REQUEST["url"] = url LAST_REQUEST["headers"] = headers or {} LAST_REQUEST["json"] = json or {} return _FakeResponse() requests_mod.post = fake_post sys.modules["requests"] = requests_mod KEY_TO_DUNDER = { "id": "__id__", "name": "__name__", "description": "__description__", "author": "__author__", "version": "__version__", "icon": "__icon__", "app_version": "__app_version__", "sdk_version": "__sdk_version__", "requirements": "__requirements__", } def expected_dunders(meta: dict) -> Dict[str, Any]: result = {} for key, dunder in KEY_TO_DUNDER.items(): if key not in meta: continue value = meta[key] if key in ("description", "icon") and value == "": continue if key == "requirements" and value == []: continue result[dunder] = value return result def main(argv=None) -> int: argv = sys.argv[1:] if argv is None else argv if len(argv) != 1: print("usage: tools/verify_bundle.py ", file=sys.stderr) return 1 bundle_path = Path(argv[0]) if not bundle_path.is_file(): print(f"error: bundle not found at {bundle_path}", file=sys.stderr) return 1 install_fakes() source = bundle_path.read_text(encoding="utf-8") modules_before = set(sys.modules) ns = {"__name__": "chat_summaries"} exec(compile(source, str(bundle_path), "exec"), ns) new_modules = set(sys.modules) - modules_before meta = json.loads(PLUGIN_JSON.read_text(encoding="utf-8")) expected = expected_dunders(meta) mismatches = [ f"{dunder}: expected {value!r}, got {ns.get(dunder)!r}" for dunder, value in expected.items() if ns.get(dunder) != value ] check("metadata constants match plugin.json", not mismatches, "; ".join(mismatches)) tree = ast.parse(source, filename=str(bundle_path)) top_level_assigned = { target.id for node in tree.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name) } check( "metadata exposed as top-level literal assignments (AST)", set(expected) <= top_level_assigned, f"missing: {set(expected) - top_level_assigned}", ) plugin_classes = [ value for value in ns.values() if isinstance(value, type) and issubclass(value, BasePlugin) and value is not BasePlugin ] check( "exactly one BasePlugin subclass defined", len(plugin_classes) == 1, f"found {len(plugin_classes)}", ) plugin_cls = plugin_classes[0] if len(plugin_classes) == 1 else None if plugin_cls is not None: check( "plugin class __module__ matches executed namespace", plugin_cls.__module__ == ns["__name__"], f"__module__={plugin_cls.__module__!r} != {ns['__name__']!r}", ) plugin = plugin_cls() MENU_ITEMS.clear() LOG_LINES.clear() plugin.on_plugin_load() check( "on_plugin_load emits load log line", any("chat_summaries loaded" in line for line in LOG_LINES), f"log lines: {LOG_LINES}", ) check( "on_plugin_load registers Summarize and Copy diagnostics CHAT_ACTION_MENU items", len(MENU_ITEMS) == 2 and all(m.menu_type == MenuItemType.CHAT_ACTION_MENU for m in MENU_ITEMS) and {m.text for m in MENU_ITEMS} == {"Summarize", "Copy diagnostics"}, f"menu items: {[(getattr(m, 'text', None), getattr(m, 'menu_type', None)) for m in MENU_ITEMS]}", ) check( "unread_hook installs dispatchTouchEvent, onPageDownClicked, and ChatUnreadCell hooks", len(plugin.unread_hook._unhooks) == 3, f"installed hooks: {len(plugin.unread_hook._unhooks)} (expected 3)", ) settings_rows = plugin.create_settings() settings_keys = {row.key for row in settings_rows if hasattr(row, "key")} expected_keys = { "custom_base_url", "custom_api_key", "custom_model", "default_count", "default_offset", "global_prompt", "include_attachments", } check( "create_settings exposes endpoint and default settings keys", expected_keys == settings_keys, f"missing: {expected_keys - settings_keys}; unexpected: {settings_keys - expected_keys}", ) # Per-chat/topic prompt override: falls back to global, then to a # saved override once one is set for that specific (dialog, topic). dialog_id, topic_id = -100123456, 7 global_prompt_key = "global_prompt" plugin.set_setting(global_prompt_key, "Global default prompt") before_override = plugin._get_prompt_for(dialog_id, topic_id) check( "prompt falls back to global default with no override", before_override == "Global default prompt", f"got {before_override!r}", ) plugin._save_prompt_for(dialog_id, topic_id, "Chat-specific prompt") after_override = plugin._get_prompt_for(dialog_id, topic_id) check( "per-chat/topic prompt override takes precedence once saved", after_override == "Chat-specific prompt", f"got {after_override!r}", ) other_topic_prompt = plugin._get_prompt_for(dialog_id, topic_id + 1) check( "prompt override is scoped per-topic, not just per-chat", other_topic_prompt == "Global default prompt", f"got {other_topic_prompt!r}", ) # Dispatcher: OpenAI path against the faked requests module. FormattedMessage # is a private bundled module (never in sys.modules), so duck-type instead # of importing it -- the dispatcher only needs a message with these attrs. class _FakeMessage: def __init__(self, sender_name, text, id=0, sender_id=0, reply_to_id=0, reply_highlight="", attachments=None): self.sender_name = sender_name self.text = text self.id = id self.date = 1700000000 self.sender_id = sender_id self.reply_to_id = reply_to_id self.reply_highlight = reply_highlight self.attachments = list(attachments or []) fake_messages = [_FakeMessage("Alice", "hello", id=10, sender_id=111)] plugin.set_setting("custom_base_url", "https://example.test/v1") plugin.set_setting("custom_api_key", "sk-test") results = {} def on_done(answer, thinking, error): results["answer"] = answer results["error"] = error plugin.dispatcher.summarize_streaming(fake_messages, "Summarize", lambda k, t: None, on_done) check( "dispatcher streams a summary from a faked SSE response", results.get("error") is None and results.get("answer") == "Fake summary.", f"results: {results}", ) # The request must carry three separate roles, not one concatenated # blob: script-owned system prompt, the user's instructions, and the # anonymized JSON transcript. sent = LAST_REQUEST.get("json") or {} roles = [m["role"] for m in sent.get("messages", [])] contents = [m["content"] for m in sent.get("messages", [])] check( "request sends a system prompt, the user prompt, and the transcript", roles == ["system", "user", "user"] and "anonymized" in contents[0] and contents[1] == "Summarize" and contents[2].startswith('{"messages":['), f"roles={roles} third={contents[2][:60] if len(contents) > 2 else None!r}", ) check( "the system prompt documents the mention syntax the model must use", "{userN}" in contents[0] and "{msgN}" in contents[0], ) check( "streaming is requested so deltas arrive incrementally", sent.get("stream") is True, f"stream={sent.get('stream')}", ) # Endpoint is mandatory now that there is no built-in provider: an # unconfigured base URL must produce a clear error, not a crash. plugin.set_setting("custom_base_url", "") unconfigured = {} def on_unconfigured(answer, thinking, error): unconfigured["answer"] = answer unconfigured["error"] = error plugin.dispatcher.summarize_streaming( fake_messages, "Summarize", lambda k, t: None, on_unconfigured ) check( "dispatcher reports a clear error when no base URL is configured", unconfigured.get("answer") is None and "base URL" in (unconfigured.get("error") or ""), f"results: {unconfigured}", ) plugin.set_setting("custom_base_url", "https://example.test/v1") # MessageFetcher: no controller available -> graceful error, not a crash. fetch_results = {} def on_fetched(messages, error): fetch_results["messages"] = messages fetch_results["error"] = error plugin.fetcher.fetch_history(dialog_id=dialog_id, count=10, offset=0, callback=on_fetched) check( "fetch_history reports an error when no messages controller is available", fetch_results.get("error") is not None, f"fetch_results: {fetch_results}", ) # Telegram returns java.util.ArrayList, which Chaquopy exposes without # the iterator protocol: list()/for-in raise TypeError. The fetcher must # read it through size()/get(i). page_results = {} def on_page(messages, error): page_results["messages"] = messages page_results["error"] = error fake_response = _TLObj("TL_messages_messages") raw_message = _TLObj("TL_message") raw_message.id = 4242 raw_message.date = 1700000000 raw_message.message = "hello from java" raw_message.from_id = None fake_response.messages = _JavaArrayList([raw_message]) fake_response.users = _JavaArrayList() fake_response.chats = _JavaArrayList() plugin.fetcher._handle_response( fake_response, None, [], on_page, dialog_id, 10, 0, 0, 100, ) check( "fetcher reads a non-iterable Java ArrayList response via size()/get()", page_results.get("error") is None and len(page_results.get("messages") or []) == 1 and page_results["messages"][0].text == "hello from java", f"page_results={page_results}", ) # Provisional anchor, chosen before the fetch returns: with an offset # the analyzed range ends partway up the history, so the guess has to # skip that many messages instead of grabbing the newest. class _AnchorList(list): def size(self): return len(self) def get(self, index): return self[index] class _AnchorActivity: def __init__(self, ids): self.messages = _AnchorList() for mid in ids: owner_obj = _TLObj("TL_message") owner_obj.id = mid holder = types.SimpleNamespace(messageOwner=owner_obj) self.messages.append(holder) anchor_activity = _AnchorActivity([900, 800, 700, 600]) check( "with no offset the provisional anchor is the newest message", plugin._newest_visible_message_id(anchor_activity) == 900, f"got {plugin._newest_visible_message_id(anchor_activity)}", ) check( "an offset moves the provisional anchor up the history", plugin._newest_visible_message_id(anchor_activity, skip=2) == 700, f"got {plugin._newest_visible_message_id(anchor_activity, skip=2)}", ) # Injected cards carry negative ids and must never be chosen as the # anchor, or a second summary would anchor to the first one's card. mixed_anchor = _AnchorActivity([-700007, 900, 800]) check( "an existing injected card is never picked as the anchor", plugin._newest_visible_message_id(mixed_anchor) == 900, f"got {plugin._newest_visible_message_id(mixed_anchor)}", ) # ------------------------------------------------------------------ # Bundled-module checks: markdown serialization, SSE streaming, and the # SQLite store. Reached through the bundle's private registry, since # bundled modules deliberately never enter sys.modules. # ------------------------------------------------------------------ loaded = ns.get("_LOADED") or {} bundle_import = ns.get("_bundle_load") def bundled(name): if name in loaded: return loaded[name] if bundle_import is not None: try: return bundle_import(name) except Exception: return None return None rich_markdown = bundled("services.rich_markdown") if rich_markdown is None: check("services.rich_markdown is importable from the bundle", False, "module not found") else: blocks = rich_markdown.markdown_to_blocks( "# Title\n\n" "Plain **bold** and `code`.\n\n" "- one\n- two\n\n" "1. first\n2. second\n\n" "> quoted\n\n" "---\n\n" "```python\nprint(1)\n```\n" ) kinds = [b.tl_name for b in blocks] check( "markdown_to_blocks maps every markdown construct to its block type", kinds == [ "pageBlockHeading1", "pageBlockParagraph", "pageBlockList", "pageBlockOrderedList", "pageBlockBlockquote", "pageBlockDivider", "pageBlockPreformatted", ], f"got {kinds}", ) code_block = blocks[-1] check( "fenced code block keeps its language and body", code_block.language == "python" and code_block.text.text == "print(1)", f"language={code_block.language!r} text={getattr(code_block.text, 'text', None)!r}", ) check( "list blocks carry one item per bullet", len(blocks[2].items) == 2 and len(blocks[3].items) == 2, f"bullet items={len(blocks[2].items)} ordered items={len(blocks[3].items)}", ) check( "ordered list items are numbered from the markdown start value", [i.num for i in blocks[3].items] == ["1", "2"], f"nums={[i.num for i in blocks[3].items]}", ) # Inline styling must nest into a RichText tree, not be dropped. inline = rich_markdown.build_rich_text("a **b** c `d` [e](http://x)") inline_kinds = [p.tl_name for p in inline.texts] check( "inline markdown becomes bold/fixed/url RichText nodes", "textBold" in inline_kinds and "textFixed" in inline_kinds and "textUrl" in inline_kinds, f"kinds={inline_kinds}", ) # Reasoning still arriving (no answer yet) animates as a thinking # block; once the answer starts it is finished, so it collapses into a # tappable details container that can be revealed and hidden. reasoning_only = rich_markdown.build_rich_message("", thinking="reasoning", streaming=True) check( "reasoning still streaming renders as an animated thinking block", [b.tl_name for b in reasoning_only.blocks] == ["pageBlockThinking"], f"blocks={[b.tl_name for b in reasoning_only.blocks]}", ) streaming_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=True) details = streaming_rm.blocks[0] check( "finished reasoning collapses into a closed details block", details.tl_name == "pageBlockDetails" and details.open is False and details.title.text == rich_markdown.THINKING_TITLE and len(details.blocks) == 1, f"first={details.tl_name} open={getattr(details, 'open', None)}", ) # `part` drives RichMessageLayout's partial branch, which also draws a # server-backed "Show more" button; it must stay off for local summaries. final_rm = rich_markdown.build_rich_message("answer", thinking="reasoning", streaming=False) check( "part stays unset so no Show more collapse is drawn", streaming_rm.part is False and final_rm.part is False, f"streaming={streaming_rm.part} final={final_rm.part}", ) # Stage feedback: the card is injected on click, before any content # exists, so a bare status must still render as a shimmer block. status_rm = rich_markdown.build_rich_message("", status="Fetching messages…", streaming=True) check( "status-only RichMessage renders the stage as a thinking block", len(status_rm.blocks) == 1 and status_rm.blocks[0].tl_name == "pageBlockThinking", f"blocks={[b.tl_name for b in status_rm.blocks]}", ) superseded = rich_markdown.build_rich_message( "", thinking="real reasoning", status="Fetching messages…", streaming=True ) check( "real reasoning supersedes the status placeholder", len(superseded.blocks) == 1 and superseded.blocks[0].text.text == "real reasoning", f"blocks={[b.tl_name for b in superseded.blocks]}", ) answered = rich_markdown.build_rich_message( "# Answer", status="Summarizing…", streaming=True ) check( "status is dropped once answer text arrives", [b.tl_name for b in answered.blocks] == ["pageBlockHeading1"], f"blocks={[b.tl_name for b in answered.blocks]}", ) empty_rm = rich_markdown.build_rich_message("", streaming=True) check( "an empty RichMessage still carries one block so the cell renders", len(empty_rm.blocks) == 1, f"blocks={len(empty_rm.blocks)}", ) transcript_mod = bundled("services.transcript") if transcript_mod is None: check("services.transcript is importable from the bundle", False, "module not found") else: class _Msg: def __init__(self, id, sender_id, sender_name, text, reply_to_id=0, reply_highlight="", attachments=None, sender_kind="user"): self.id = id self.date = 1700000000 self.sender_id = sender_id self.sender_name = sender_name self.sender_kind = sender_kind self.text = text self.reply_to_id = reply_to_id self.reply_highlight = reply_highlight self.attachments = list(attachments or []) convo = [ _Msg(5001, 111, "Alice", "got new crocs", attachments=["randhash.png"]), _Msg(5002, 222, "Bob", "cap", reply_to_id=5001, reply_highlight="new"), _Msg(5003, 111, "Alice", "fr", reply_to_id=4000), _Msg(5004, 1001234567890, "Some Channel", "announcement", sender_kind="chat"), ] payload, mapping = transcript_mod.build_transcript(convo) parsed = json.loads(payload) check( "transcript numbers messages from 1 and senders from 1 by first appearance", [(m["id"], m["user"]) for m in parsed["messages"]] == [(1, 1), (2, 2), (3, 1), (4, 3)], f"got {[(m['id'], m['user']) for m in parsed['messages']]}", ) check( "transcript is compact JSON with no spacing", " " not in payload.replace("got new crocs", "").replace("randhash.png", ""), f"payload={payload[:120]}", ) check( "reply_to is remapped to the anonymized id and carries the highlight", parsed["messages"][1].get("reply_to") == 1 and parsed["messages"][1].get("reply_highlight") == "new", f"entry={parsed['messages'][1]}", ) check( "real Telegram ids never reach the transcript", "5001" not in payload and "111" not in payload and "4000" not in payload, f"payload={payload}", ) # Attachments are opt-in: message text is always sent, file names only # when the setting is enabled. check( "attachments are omitted unless explicitly included", "attachments" not in payload, f"payload={payload}", ) with_attachments, _ = transcript_mod.build_transcript(convo, include_attachments=True) check( "attachments are listed when included", json.loads(with_attachments)["messages"][0].get("attachments") == ["randhash.png"], f"entry={json.loads(with_attachments)['messages'][0]}", ) # A reply whose parent was not analyzed loses meaning, so the parent is # injected with a negative id marking it as outside the range. parent = _Msg(4000, 333, "Carol", "original point") ranged, ranged_map = transcript_mod.build_transcript(convo, extra_by_id={4000: parent}) ranged_entries = json.loads(ranged)["messages"] ids = [m["id"] for m in ranged_entries] check( "an out-of-range reply parent is injected with a negative id before its child", ids == [1, 2, -1, 3, 4] and ranged_entries[2]["text"] == "original point", f"ids={ids}", ) check( "the injected parent is the reply target of its child", ranged_entries[3].get("reply_to") == -1, f"entry={ranged_entries[3]}", ) check( "out-of-range parents stay resolvable back to their real id", ranged_map.real_message_id(-1) == 4000, f"mapped={ranged_map.real_message_id(-1)}", ) # The model answers with placeholders; substitution supplies the real # display name and turns citations into tappable deep links. answer = "{user1} said it broke ({msg1}, {msg2}), {user2} agreed." rendered = transcript_mod.deanonymize(answer, mapping, -1001234567890) check( "user placeholders resolve to the real display name and profile link", "[Alice](tg://user?id=111)" in rendered and "[Bob](tg://user?id=222)" in rendered, f"rendered={rendered}", ) check( "message placeholders become numbered links into this dialog", "[№2](tg://openmessage?chat_id=1234567890&message_id=5002)" in rendered, f"rendered={rendered}", ) check( "no placeholder survives substitution", "{user" not in rendered and "{msg" not in rendered, f"rendered={rendered}", ) check( "private-chat message links use user_id rather than chat_id", "user_id=777" in transcript_mod.message_link(777, 42), transcript_mod.message_link(777, 42), ) # A group or channel sender is not a user: tg://user?id= on one reports # "user not found", so chat senders need the chat link form. check( "a chat/channel sender links to the chat, not a user profile", "[Some Channel](tg://openmessage?chat_id=1234567890)" in transcript_mod.deanonymize("{user3} posted", mapping, -1), transcript_mod.deanonymize("{user3} posted", mapping, -1), ) check( "sender_link uses the user form only for real users", transcript_mod.sender_link(111, "user") == "tg://user?id=111" and transcript_mod.sender_link(1001234567890, "chat") == "tg://openmessage?chat_id=1234567890", f"user={transcript_mod.sender_link(111, 'user')} " f"chat={transcript_mod.sender_link(1001234567890, 'chat')}", ) # tg://openmessage carries no thread parameter, so inside a forum it # lands in the supergroup's global feed. The t.me/c form is the only # one that opens the right topic. check( "a topic message link targets the topic, not the supergroup feed", transcript_mod.message_link(-1001234567890, 42, 7) == "https://t.me/c/1234567890/7/42", transcript_mod.message_link(-1001234567890, 42, 7), ) check( "a group without a topic still uses the openmessage form", transcript_mod.message_link(-1001234567890, 42, 0) == "tg://openmessage?chat_id=1234567890&message_id=42", transcript_mod.message_link(-1001234567890, 42, 0), ) topic_payload, topic_mapping = transcript_mod.build_transcript(convo, topic_id=9) check( "the topic travels on the mapping so citations open in-topic", topic_mapping.topic_id == 9 and "/c/1234567890/9/5002" in transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890), transcript_mod.deanonymize("{msg2}", topic_mapping, -1001234567890), ) check( "the topic id is not leaked into the transcript payload", '"topic' not in topic_payload, f"payload={topic_payload[:80]}", ) check( "an unknown user placeholder degrades to readable text", transcript_mod.deanonymize("{user99} spoke", mapping, -1) == "someone spoke", transcript_mod.deanonymize("{user99} spoke", mapping, -1), ) check( "an unknown message placeholder is dropped rather than left dangling", transcript_mod.deanonymize("see {msg99}", mapping, -1) == "see ", transcript_mod.deanonymize("see {msg99}", mapping, -1), ) # Streaming safety: this is why placeholders replaced Markdown links. # A half-received token must not render as literal text. check( "a placeholder still being streamed is suppressed, not shown raw", transcript_mod.strip_partial_placeholder("build broke ({msg1") == "build broke (", transcript_mod.strip_partial_placeholder("build broke ({msg1"), ) check( "complete placeholders earlier in the text survive stripping", transcript_mod.strip_partial_placeholder("a {msg1} b {us") == "a {msg1} b ", transcript_mod.strip_partial_placeholder("a {msg1} b {us"), ) check( "text with no trailing placeholder is left untouched", transcript_mod.strip_partial_placeholder("all done.") == "all done.", ) # Reasoning is model-authored text too, so it carries placeholders and # must go through the same substitution -- leaving them raw would show # the reader the anonymized namespace. reasoning = "Looking at {user1} and {msg2} to decide." rendered_reasoning = transcript_mod.deanonymize(reasoning, mapping, -1001234567890) check( "reasoning text is substituted, not left with raw placeholders", "{user" not in rendered_reasoning and "{msg" not in rendered_reasoning and "Alice" in rendered_reasoning and "tg://" in rendered_reasoning, f"rendered={rendered_reasoning}", ) # Privacy boundary: only text and attachments cross the wire verbatim. # Names, ids and topic stay local, while the DB keeps the real thing. check( "sender names never reach the transcript payload", "Alice" not in payload and "Bob" not in payload and "Some Channel" not in payload, f"payload={payload}", ) check( "the mapping retains real names and ids for local substitution", mapping.user_name(1) == "Alice" and mapping.real_user_id(1) == 111 and mapping.real_message_id(2) == 5002 and mapping.sender_kind(3) == "chat", f"name={mapping.user_name(1)} id={mapping.real_user_id(1)} kind={mapping.sender_kind(3)}", ) check( "message text and attachment names are the only verbatim fields", "got new crocs" in payload and "randhash.png" in json.loads(with_attachments)["messages"][0]["attachments"], f"payload={payload[:100]}", ) streaming_mod = bundled("providers.streaming") if streaming_mod is None: check("providers.streaming is importable from the bundle", False, "module not found") else: # Reasoning fields differ per vendor; all three must be recognised. check( "reasoning_content and reasoning deltas are classified as thinking", streaming_mod._extract_delta({"choices": [{"delta": {"reasoning_content": "r"}}]}) == ("thinking", "r") and streaming_mod._extract_delta({"choices": [{"delta": {"reasoning": "r"}}]}) == ("thinking", "r"), ) check( "content deltas are classified as answer", streaming_mod._extract_delta({"choices": [{"delta": {"content": "c"}}]}) == ("answer", "c"), ) # A tag split across chunks must not leak into the answer. splitter = streaming_mod._ThinkTagSplitter() got = [] for piece in ["beforerea", "soningafter"]: got.extend(splitter.feed(piece)) got.extend(splitter.flush()) joined = [ ("thinking", "".join(t for k, t in got if k == "thinking")), ("answer", "".join(t for k, t in got if k == "answer")), ] check( "think tags split across SSE chunks are reassembled correctly", joined == [("thinking", "reasoning"), ("answer", "beforeafter")], f"got {joined}", ) sse_lines = [ b'data: {"choices":[{"delta":{"content":"hi"}}]}', b"", b"data: [DONE]", b'data: {"choices":[{"delta":{"content":"never"}}]}', ] objects = list(streaming_mod.iter_sse_objects(iter(sse_lines))) check( "SSE parsing stops at [DONE] and ignores blank lines", len(objects) == 1, f"parsed {len(objects)} objects", ) summary_db_mod = bundled("services.summary_db") if summary_db_mod is None: check("services.summary_db is importable from the bundle", False, "module not found") else: db = summary_db_mod.SummaryDb(str(Path(tempfile.mkdtemp()) / "summaries.db")) first = db.save(-100, 0, 555, "text one", "thinking one", 42) rows = db.get_for_scope(-100, 0) check( "summary_db persists text, anchor id, and message count", len(rows) == 1 and rows[0].anchor_msg_id == 555 and rows[0].text == "text one" and rows[0].thinking == "thinking one" and rows[0].msg_count == 42, f"rows={rows}", ) # Re-saving the same anchor must update in place: streaming reserves a # row first and rewrites it when the stream finishes. second = db.save(-100, 0, 555, "text two", "thinking two", 43) rows = db.get_for_scope(-100, 0) check( "re-saving the same anchor updates the existing row instead of duplicating", second == first and len(rows) == 1 and rows[0].text == "text two", f"first={first} second={second} rows={rows}", ) db.save(-100, 7, 555, "topic text", "", 1) check( "summaries are scoped per topic, not just per chat", len(db.get_for_scope(-100, 0)) == 1 and len(db.get_for_scope(-100, 7)) == 1, f"topic0={len(db.get_for_scope(-100, 0))} topic7={len(db.get_for_scope(-100, 7))}", ) check("summary_db.delete removes a row", db.delete(first) and db.get(first) is None) injector = bundled("services.rich_injector") if injector is None: check("services.rich_injector is importable from the bundle", False, "module not found") else: check( "mock sender id is positive so getUser can resolve it", injector.MOCK_SENDER_ID > 0, f"id={injector.MOCK_SENDER_ID}", ) user = injector.register_mock_sender(0) from org.telegram.messenger import MessagesController # noqa: E402 resolved = MessagesController.getInstance(0).getUser(injector.MOCK_SENDER_ID) check( "mock sender resolves from the local user cache with a name", resolved is not None and resolved.first_name == injector.MOCK_SENDER_NAME, f"resolved={resolved}", ) # Peer subtype drives isOutOwner; a wrong type renders as outgoing. check( "peer_for_dialog picks the peer subtype matching the dialog kind", injector.peer_for_dialog(12345).tl_name == "TL_peerUser" and injector.peer_for_dialog(-4242).tl_name == "TL_peerChat" and injector.peer_for_dialog(-1001234567890).tl_name == "TL_peerChannel", ) check( "channel peer id is converted out of dialog-id space", injector.peer_for_dialog(-1001234567890).channel_id == 1234567890, f"channel_id={injector.peer_for_dialog(-1001234567890).channel_id}", ) message_object = injector.build_summary_message( 0, -4242, 0, 555, injector.virtual_id_for(1, 555), "# Answer", "reasoning", True ) owner = message_object.messageOwner check( "summary message carries rich_message with the type-36 flag bit", owner.rich_message is not None and bool(owner.flags2 & injector.FLAGS2_RICH_MESSAGE) and message_object.type == 36, f"flags2={owner.flags2} type={message_object.type}", ) check( "summary message is attributed to the mock sender and not outgoing", owner.from_id.user_id == injector.MOCK_SENDER_ID and owner.out is False and message_object.isOutOwner() is False, ) check( "summary message replies to the newest analyzed message", owner.reply_to.reply_to_msg_id == 555, f"reply_to={owner.reply_to}", ) check( "virtual ids are negative and stable per (summary, anchor)", injector.virtual_id_for(1, 555) < 0 and injector.virtual_id_for(1, 555) == injector.virtual_id_for(1, 555) and injector.virtual_id_for(2, 555) != injector.virtual_id_for(1, 555), ) # Insertion order: ChatActivity.messages is newest-first, so the # summary must land at the anchor's index to sit after it on screen. class _FakeAdapter: def __init__(self): self.notified = 0 def notifyDataSetChanged(self): self.notified += 1 class _FakeCell: """Live ChatMessageCell: the row the targeted repaint should hit.""" def __init__(self, message_object): self._message_object = message_object self.rebound = 0 self.invalidated = 0 def getMessageObject(self): return self._message_object def setMessageObject(self, mo, grouped, pinned_bottom, pinned_top, first): self._message_object = mo self.rebound += 1 def invalidate(self): self.invalidated += 1 class _FakeDelegate: def __init__(self): self.forced = 0 def forceUpdate(self, cell, a, b): self.forced += 1 class _FakeListView: def __init__(self): self.cells = [] def getChildCount(self): return len(self.cells) def getChildAt(self, index): return self.cells[index] class _FakeChatActivity: def __init__(self, ids): self.messages = _JavaList() for mid in ids: owner_obj = _TLObj("TL_message") owner_obj.id = mid self.messages.add(_FakeMessageObject(0, owner_obj)) self.chatAdapter = _FakeAdapter() self.chatMessageCellDelegate = _FakeDelegate() self.chatListView = _FakeListView() self.scrolled_to = [] def getDialogId(self): return -4242 def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread): self.scrolled_to.append(msg_id) activity = _FakeChatActivity([600, 555, 500]) check( "insertion index places the summary directly after its anchor", injector.find_index_for_anchor(activity.messages, 555) == 1, f"index={injector.find_index_for_anchor(activity.messages, 555)}", ) # The anchor is the newest ANALYZED message, which with a non-zero # offset is not the newest message in the chat. The card must land # there, never at the bottom. offset_activity = _FakeChatActivity([900, 800, 700, 600, 500]) check( "with an offset the card lands at the analyzed range, not the chat bottom", injector.find_index_for_anchor(offset_activity.messages, 700) == 2, f"index={injector.find_index_for_anchor(offset_activity.messages, 700)}", ) # Anchor not loaded in this view: falling back to index 0 would put the # card below messages the summary never saw, which is the bug this # ordering fallback exists to prevent. # These exercise the id-ordering fallback itself, which is now opt-in: # callers that must place a card somewhere (reanchor) ask for it, while # the default refuses to guess a position for an unloaded anchor. check( "requested id ordering places an unloaded anchor by id", injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False) == 2, f"index={injector.find_index_for_anchor(offset_activity.messages, 750, require_anchor=False)}", ) check( "an anchor older than everything loaded lands at the top of the list", injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False) == offset_activity.messages.size(), f"index={injector.find_index_for_anchor(offset_activity.messages, 100, require_anchor=False)}", ) check( "an anchor newer than everything loaded lands at the bottom", injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False) == 0, f"index={injector.find_index_for_anchor(offset_activity.messages, 1000, require_anchor=False)}", ) # The real invariant is positional, not a fixed index: every message # newer than the anchor stays below the card, every older one above, # regardless of injected cards already sitting in the list. def neighbours(activity_obj, anchor): # require_anchor=False so the invariant is checked for unloaded # anchors too: when a position is chosen at all, it must be correct. index = injector.find_index_for_anchor( activity_obj.messages, anchor, require_anchor=False ) ids = [m.messageOwner.id for m in activity_obj.messages] real = [i for i in ids if i > 0] below = [i for i in ids[:index] if i > 0] above = [i for i in ids[index:] if i > 0] return below, above, real mixed = _FakeChatActivity([900, 800, 700]) injected_owner = _TLObj("TL_message") injected_owner.id = -800007 mixed.messages.add(1, _FakeMessageObject(0, injected_owner)) below, above, _ = neighbours(mixed, 750) check( "an injected card already in the list does not misplace the next one", all(i > 750 for i in below) and all(i < 750 for i in above), f"below={below} above={above}", ) # Same invariant across the offset cases, which is what "injected only # after the latest analyzed message" actually means. for anchor in (700, 750, 900, 100, 1000): below, above, _ = neighbours(offset_activity, anchor) check( f"no message newer than anchor {anchor} ends up below the card", all(i > anchor for i in below) and all(i <= anchor for i in above), f"anchor={anchor} below={below} above={above}", ) virtual_id = injector.virtual_id_for(9, 555) inj = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) inserted = inj.insert(status="Fetching messages…", streaming=True) check( "injection inserts one message at the anchor index and refreshes the adapter", inserted and activity.messages.size() == 4 and activity.messages.get(1).messageOwner.id == virtual_id and activity.chatAdapter.notified >= 1, f"size={activity.messages.size()} notified={activity.chatAdapter.notified}", ) # The analyzed range can end far up the history, so the card may be # injected off-screen; the chat has to move to it. The anchor is the # scroll target because it is a real message the app can resolve, and # the card sits directly above it. check( "inserting the card scrolls the chat to the analyzed range", activity.scrolled_to == [555], f"scrolled_to={activity.scrolled_to}", ) before = activity.messages.get(1) inj.update(answer_markdown="final answer", thinking="reasoning", streaming=False) after = activity.messages.get(1) check( "update swaps in a rebuilt MessageObject rather than mutating in place", activity.messages.size() == 4 and after is not before and after.messageOwner.id == virtual_id and after.type == 36 and [b.tl_name for b in after.messageOwner.rich_message.blocks] == ["pageBlockDetails", "pageBlockParagraph"], f"size={activity.messages.size()} same={after is before} " f"blocks={[b.tl_name for b in after.messageOwner.rich_message.blocks]}", ) check( "a streaming update does not scroll again and fight the user", activity.scrolled_to == [555], f"scrolled_to={activity.scrolled_to}", ) # Streaming repaints should hit the one visible cell, not rebind every # row: notifyDataSetChanged() on each delta is what made the card # flicker and lose scroll position. activity.chatListView.cells = [_FakeCell(activity.messages.get(1))] notified_before = activity.chatAdapter.notified inj.update(answer_markdown="more text", thinking="reasoning", streaming=True) check( "a streaming repaint updates the live cell instead of the whole list", activity.chatMessageCellDelegate.forced == 1 and activity.chatListView.cells[0].rebound == 1 and activity.chatAdapter.notified == notified_before, f"forced={activity.chatMessageCellDelegate.forced} " f"rebound={activity.chatListView.cells[0].rebound} " f"notified={activity.chatAdapter.notified} (was {notified_before})", ) # Off-screen card: no cell to repaint, so the adapter must be told. activity.chatListView.cells = [] notified_before = activity.chatAdapter.notified inj.update(answer_markdown="offscreen", thinking="reasoning", streaming=True) check( "an off-screen card falls back to a full adapter refresh", activity.chatAdapter.notified == notified_before + 1, f"notified={activity.chatAdapter.notified} (was {notified_before})", ) # The card is injected before the fetch, so its anchor starts # provisional and must be corrected once the real range is known. reanchor_activity = _FakeChatActivity([600, 555, 500]) provisional_id = injector.virtual_id_for(0, 600) reinj = injector.SummaryInjection(reanchor_activity, 0, -4242, 0, 600, provisional_id) reinj.insert(status="Fetching messages…", streaming=True) check( "provisional card lands at the newest message before the fetch", reanchor_activity.messages.get(0).messageOwner.id == provisional_id, f"ids={[m.messageOwner.id for m in reanchor_activity.messages]}", ) final_id = injector.virtual_id_for(11, 555) reinj.reanchor(555, final_id) ids = [m.messageOwner.id for m in reanchor_activity.messages] check( "reanchor moves the card to the real anchor and adopts its virtual id", reanchor_activity.messages.size() == 4 and ids.index(final_id) == 1 and reanchor_activity.messages.get(1).messageOwner.reply_to.reply_to_msg_id == 555, f"ids={ids}", ) check( "reanchor follows the card to the real end of the analyzed range", reanchor_activity.scrolled_to == [600, 555], f"scrolled_to={reanchor_activity.scrolled_to}", ) # Regression: a streaming update after a reanchor used to add a second # card, because the lookup missed the old id and fell through to add(). reinj.update(status="Summarizing 98 messages…", streaming=True) ids = [m.messageOwner.id for m in reanchor_activity.messages] check( "updating after a reanchor leaves exactly one summary card", reanchor_activity.messages.size() == 4 and ids.count(final_id) == 1 and provisional_id not in ids, f"ids={ids}", ) # Re-summarizing a range whose saved card the restore hook already # re-injected must claim that card, not sit beside it. collide_activity = _FakeChatActivity([600, 555, 500]) saved_id = injector.virtual_id_for(12, 555) injector.restore_saved_summaries( collide_activity, 0, -4242, 0, [summary_db_mod.StoredSummary(12, -4242, 0, 555, "previous run", "", 5, 0)], ) collide_provisional = injector.virtual_id_for(0, 600) collide = injector.SummaryInjection(collide_activity, 0, -4242, 0, 600, collide_provisional) collide.insert(status="Fetching messages…", streaming=True) collide.reanchor(555, saved_id) collide.update(status="Summarizing…", streaming=True) ids = [m.messageOwner.id for m in collide_activity.messages] check( "re-summarizing a restored range reuses its card instead of duplicating", ids.count(saved_id) == 1 and collide_provisional not in ids, f"ids={ids}", ) # Re-inserting the same virtual id must replace, never duplicate. inj2 = injector.SummaryInjection(activity, 0, -4242, 0, 555, virtual_id) inj2.insert(answer_markdown="again", thinking="", streaming=False) check( "re-injecting an existing virtual id replaces instead of duplicating", activity.messages.size() == 4, f"size={activity.messages.size()}", ) restore_activity = _FakeChatActivity([600, 555, 500]) restored = injector.restore_saved_summaries( restore_activity, 0, -4242, 0, [summary_db_mod.StoredSummary(3, -4242, 0, 555, "saved text", "", 5, 0)], ) check( "stored summaries are re-injected when a chat is reopened", restored == 1 and restore_activity.messages.size() == 4, f"restored={restored} size={restore_activity.messages.size()}", ) # Several stored summaries for one chat must each land after their own # analyzed range, not pile up at one position. multi_activity = _FakeChatActivity([900, 800, 700, 600, 500]) injector.restore_saved_summaries( multi_activity, 0, -4242, 0, [ summary_db_mod.StoredSummary(11, -4242, 0, 600, "older range", "", 5, 0), summary_db_mod.StoredSummary(12, -4242, 0, 800, "newer range", "", 5, 0), ], ) ids = [m.messageOwner.id for m in multi_activity.messages] positions = {} for position, msg_id in enumerate(ids): positions[msg_id] = position older_card = injector.virtual_id_for(11, 600) newer_card = injector.virtual_id_for(12, 800) check( "each restored summary sits directly above its own anchor", positions[older_card] == positions[600] - 1 and positions[newer_card] == positions[800] - 1, f"ids={ids}", ) check( "restoring several summaries keeps chat messages in order", [i for i in ids if i > 0] == [900, 800, 700, 600, 500], f"ids={ids}", ) # Bug: every saved summary appeared stacked in a row at the bottom of # the chat. Cards whose anchor is outside the loaded window have no # correct position, and placing them by id order alone collapsed them # together. They must be skipped until paging brings their anchor in. narrow = _FakeChatActivity([600, 590, 580]) stranded = injector.restore_saved_summaries( narrow, 0, -4242, 0, [ summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0), summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0), summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0), ], ) narrow_ids = [m.messageOwner.id for m in narrow.messages] injected = [i for i in narrow_ids if i < 0] check( "summaries whose anchor is not loaded are not injected at all", stranded == 1 and injected == [injector.virtual_id_for(23, 590)], f"dispatched={stranded} injected={injected} ids={narrow_ids}", ) check( "the one restorable summary still lands directly above its anchor", narrow_ids.index(injector.virtual_id_for(23, 590)) == narrow_ids.index(590) - 1, f"ids={narrow_ids}", ) # Once paging widens the window, the previously skipped summary has a # real anchor and must be restored on the next attempt. for older in (130, 120): owner_obj = _TLObj("TL_message") owner_obj.id = older narrow.messages.add(_FakeMessageObject(0, owner_obj)) after_paging = injector.restore_saved_summaries( narrow, 0, -4242, 0, [ summary_db_mod.StoredSummary(21, -4242, 0, 120, "ancient", "", 5, 0), summary_db_mod.StoredSummary(22, -4242, 0, 130, "old", "", 5, 0), summary_db_mod.StoredSummary(23, -4242, 0, 590, "loaded", "", 5, 0), ], ) paged_ids = [m.messageOwner.id for m in narrow.messages] check( "summaries are restored once paging loads their anchor", after_paging == 2 and paged_ids.index(injector.virtual_id_for(21, 120)) == paged_ids.index(120) - 1 and paged_ids.index(injector.virtual_id_for(22, 130)) == paged_ids.index(130) - 1, f"dispatched={after_paging} ids={paged_ids}", ) check( "re-restoring does not duplicate the summary already on screen", paged_ids.count(injector.virtual_id_for(23, 590)) == 1, f"ids={paged_ids}", ) # Bug: the card appeared after the wrong message when many messages # were included. The insert index was computed on the calling thread and # applied later on the UI thread; anything arriving in between shifted # the list and invalidated it. A fake dispatcher that mutates the list # between computing and applying reproduces that window. racing = _FakeChatActivity([700, 690, 680]) real_on_ui = injector._on_ui def _racing_on_ui(fn): owner_obj = _TLObj("TL_message") owner_obj.id = 701 racing.messages.add(0, _FakeMessageObject(0, owner_obj)) fn() injector._on_ui = _racing_on_ui try: racing_injection = injector.SummaryInjection( racing, 0, -4242, 0, 690, injector.virtual_id_for(31, 690) ) racing_injection.insert("summary body", "", streaming=False) finally: injector._on_ui = real_on_ui racing_ids = [m.messageOwner.id for m in racing.messages] card = injector.virtual_id_for(31, 690) check( "the card lands above its anchor even if the list shifts first", racing_ids.index(card) == racing_ids.index(690) - 1, f"ids={racing_ids}", ) # find_index_for_anchor must report an absent anchor rather than # guessing index 0 (chat bottom) or a nearest-by-id slot. absent = _FakeChatActivity([900, 800]) check( "an unloaded anchor is reported as absent", injector.find_index_for_anchor(absent.messages, 123) == -1, f"index={injector.find_index_for_anchor(absent.messages, 123)}", ) check( "id-ordered placement is still available when explicitly requested", injector.find_index_for_anchor(absent.messages, 123, require_anchor=False) == 2, f"index={injector.find_index_for_anchor(absent.messages, 123, require_anchor=False)}", ) check( "sys.modules gained no entry for bundled plugin module names", not ({"main", "config", "services", "providers", "hooks", "ui.summarize_dialog"} & new_modules), f"leaked: {({'main', 'config', 'services', 'providers', 'hooks', 'ui.summarize_dialog'} & new_modules)}", ) if _FAILURES: print(f"\n{len(_FAILURES)} check(s) failed: {', '.join(_FAILURES)}", file=sys.stderr) return 1 print("\nall checks passed") return 0 if __name__ == "__main__": sys.exit(main())