Files
chatSummaries/tools/verify_bundle.py
T
lyuksovannyyandClaude Opus 5.5 af23d2e682
Publish plugin / publish (push) Successful in 3s
Keep the chat on a far-back summary card after its anchor loads
With an offset the newest analyzed message is usually outside the loaded
window, so placement asks the chat to load it first. That path could leave the
view away from the card while offset 0 always worked:

- Placement gave up after ~3 s (25 x 120 ms) and asked for the anchor only
  once. scrollToMessageId drops the request while waitingForGetDifference, and
  a load can be superseded. It now waits ~10 s and re-requests every ~2 s.
- Loading a far anchor makes ChatActivity run its own postponed scroll, and a
  cache-then-server reload can re-scroll or replace the list. Either can land
  after the jump. Settle checks at 0.25/0.7/1.5/3 s re-jump if the card row is
  outside the visible range, and re-place the card if a reload dropped it.
  They stop as soon as the user drags the list.

Also logs each run's count/offset, the fetched id range, pre-stream failures,
load requests, placement and jumps to the diagnostics file, since bulletin-only
errors previously left no trace.

Verified on device with count 1000, offset 1000: anchor loaded on the first
retry, card placed after it and the chat jumped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:52:24 +02:00

2458 lines
97 KiB
Python
Executable File

#!/usr/bin/env python3
"""Offline verification harness for a built .plugin bundle. Runs without a phone.
Usage: python3 tools/verify_bundle.py <bundle.plugin>
"""
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.entities = _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 = "<rich>" 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.messenger.MessagesController": ["deleteMessages", "deleteMessages"],
"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
built: List[Any] = []
def __init__(self, activity, progress_style=0, resources_provider=None):
self.activity = activity
self._view = None
self._positive = None
self._negative = None
self.red_buttons = []
self.shown = False
FakeAlertDialogBuilder.built.append(self)
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):
self.shown = True
return self
def make_button_red(self, button_type):
self.red_buttons.append(button_type)
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")),
"TL_messageEntityHashtag": staticmethod(lambda: _TLObj("TL_messageEntityHashtag")),
},
)
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 textHashtag"
).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 run_pipeline_checks(plugin, injector, summary_db_mod, chat_activity_cls, message_cls) -> None:
"""Drives the whole Summarize pipeline: fetch -> stream -> card.
The rule under test: the chat is not touched until the endpoint has
actually started answering. Every earlier failure is a bulletin only.
"""
if plugin is None or message_cls is None:
check("pipeline checks need the plugin class", False, "plugin not loaded")
return
dialog_id = -4242
requests_mod = sys.modules["requests"]
real_post = requests_mod.post
cu_mod = sys.modules["client_utils"]
real_last_fragment = cu_mod.get_last_fragment
plugin.set_setting("custom_base_url", "https://example.test/v1")
def fresh_db():
plugin.summary_db = summary_db_mod.SummaryDb(str(Path(tempfile.mkdtemp()) / "summaries.db"))
return plugin.summary_db
def feed(messages=None, error=None):
plugin.fetcher.fetch_history = lambda d, c, o, t, cb: cb(messages, error)
def run(activity):
BULLETINS.clear()
plugin._run_summary(None, activity, dialog_id, 0, 3, 0, "Summarize")
analyzed = [
message_cls("Alice", "a", id=500, sender_id=1),
message_cls("Bob", "b", id=555, sender_id=2),
message_cls("Alice", "c", id=520, sender_id=1),
]
class _ErrorResponse:
status_code = 500
text = "upstream exploded"
def close(self):
pass
class _BreakingResponse:
status_code = 200
text = ""
def iter_lines(self):
yield b'data: {"choices":[{"delta":{"content":"Partial"}}]}'
raise IOError("connection reset")
def close(self):
pass
try:
# 1. History fetch fails: bulletin only, chat untouched.
db = fresh_db()
activity = chat_activity_cls([600, 555, 500])
feed(error="flood wait")
run(activity)
check(
"a failed history fetch shows a bulletin and leaves the chat untouched",
activity.ids() == [600, 555, 500]
and activity.chatLayoutManager.jumps == []
and activity.scrolled_to == []
and ("error", "Chat Summaries: flood wait") in BULLETINS,
f"ids={activity.ids()} bulletins={BULLETINS}",
)
feed(messages=[])
run(activity)
check(
"an empty history shows a bulletin and leaves the chat untouched",
activity.ids() == [600, 555, 500] and any("no messages" in b[1] for b in BULLETINS),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
# 2. Endpoint fails before the first chunk (HTTP error, unreachable
# host): no card, no scroll, no DB row.
feed(messages=analyzed)
requests_mod.post = lambda *a, **k: _ErrorResponse()
run(activity)
check(
"an HTTP error before the first chunk injects nothing",
activity.ids() == [600, 555, 500]
and activity.chatLayoutManager.jumps == []
and any("HTTP 500" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
def unreachable(*a, **k):
raise ConnectionError("no route to host")
requests_mod.post = unreachable
run(activity)
check(
"an unreachable endpoint injects nothing and reports the failure",
activity.ids() == [600, 555, 500]
and any("request failed" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
check(
"a failure before the first chunk leaves no row in the store",
db.stats()[0] == 0,
f"rows={db.stats()[0]}",
)
# 3. Success: the card appears right after the newest analyzed message
# (555, not the chat's newest 600), the chat jumps to it once, and the
# finished summary is tagged and persisted.
requests_mod.post = real_post
plugin.restore_hook._scope_state["done"].add(("stale",))
run(activity)
ids = activity.ids()
cards = [i for i in ids if i < 0]
card_index = ids.index(cards[0]) if cards else -1
check(
"a successful stream places exactly one card directly after the newest analyzed message",
len(cards) == 1 and card_index == ids.index(555) - 1,
f"ids={ids}",
)
check(
"the chat hard-jumps to the card exactly once",
activity.chatLayoutManager.jumps == [(2 + card_index, 300, False)],
f"jumps={activity.chatLayoutManager.jumps}",
)
final_owner = activity.messages.get(card_index).messageOwner if cards else None
check(
"the finished card is tagged #aisummaries and carries the answer",
final_owner is not None
and final_owner.message == "Fake summary.\n\n#aisummaries",
f"message={getattr(final_owner, 'message', None)!r}",
)
rows = db.get_for_scope(dialog_id, 0)
check(
"the finished summary is saved under the analyzed anchor without the tag",
len(rows) == 1 and rows[0].anchor_msg_id == 555 and rows[0].text == "Fake summary."
and cards and cards[0] == injector.virtual_id_for(rows[0].id, 555),
f"rows={rows}",
)
check(
"saving a summary invalidates the restore cache",
("stale",) not in plugin.restore_hook._scope_state["done"],
)
# 4. Re-running a failing stream over a saved range keeps the saved text.
requests_mod.post = lambda *a, **k: _BreakingResponse()
run(activity)
rows = db.get_for_scope(dialog_id, 0)
check(
"a stream that breaks mid-way does not wipe the previously saved summary",
len(rows) == 1 and rows[0].text == "Fake summary.",
f"rows={rows}",
)
check(
"a mid-stream failure reuses the existing card and reports the error on it",
len([i for i in activity.ids() if i < 0]) == 1
and "Error:" in (activity.messages.get(card_index).messageOwner.message or "")
and any("stream failed" in b[1] for b in BULLETINS if b[0] == "error"),
f"ids={activity.ids()} bulletins={BULLETINS}",
)
# A fresh range that breaks mid-way leaves no empty row behind.
broken_activity = chat_activity_cls([700, 690])
feed(messages=[message_cls("Alice", "x", id=690, sender_id=1)])
run(broken_activity)
check(
"a fresh stream that breaks mid-way removes its reserved row",
[r.anchor_msg_id for r in db.get_for_dialog(dialog_id)] == [555],
f"rows={db.get_for_dialog(dialog_id)}",
)
requests_mod.post = real_post
# 5. Deleting the card through the app's own delete path forgets it.
card_id = cards[0]
cu_mod.get_last_fragment = lambda: activity
delete_hooks = [
hook for method, hook in HOOKS_INSTALLED
if getattr(method, "class_name", "") == "org.telegram.messenger.MessagesController"
]
BULLETINS.clear()
plugin.restore_hook._scope_state["done"].add(("stale",))
param = types.SimpleNamespace(
args=[_JavaArrayList([card_id, 600]), None, None, dialog_id, False, 0]
)
for hook in delete_hooks:
hook.before_hooked_method(param)
check(
"deleting a summary card removes its row, its card, and says so once",
db.get_for_dialog(dialog_id) == []
and card_id not in activity.ids()
and BULLETINS == [("info", "Summary deleted")]
and ("stale",) not in plugin.restore_hook._scope_state["done"],
f"rows={db.get_for_dialog(dialog_id)} ids={activity.ids()} bulletins={BULLETINS}",
)
BULLETINS.clear()
for hook in delete_hooks:
hook.before_hooked_method(types.SimpleNamespace(args=[_JavaArrayList([600]), None, None, dialog_id]))
check(
"deleting ordinary messages never touches the summary store",
BULLETINS == [],
f"bulletins={BULLETINS}",
)
# 6. Clear all: confirmed through a red-buttoned alert.
db.save(dialog_id, 0, 555, "one", "", 1)
db.save(-1, 0, 7, "two", "", 1)
count, size = db.stats()
check("stats report row count and a non-zero file size", count == 2 and size > 0, f"{count} {size}")
class _Fragment:
def getParentActivity(self):
return object()
cu_mod.get_last_fragment = lambda: _Fragment()
FakeAlertDialogBuilder.built.clear()
BULLETINS.clear()
plugin._on_clear_all_click(None)
dialog = FakeAlertDialogBuilder.built[-1] if FakeAlertDialogBuilder.built else None
check(
"Clear all asks for confirmation with a red button before deleting",
dialog is not None and dialog.shown
and dialog.red_buttons == [FakeAlertDialogBuilder.BUTTON_POSITIVE]
and db.stats()[0] == 2,
f"dialog={getattr(dialog, '__dict__', None)}",
)
if dialog is not None and dialog._positive is not None:
dialog._positive(dialog, FakeAlertDialogBuilder.BUTTON_POSITIVE)
check(
"confirming Clear all wipes every summary and reports it",
db.stats()[0] == 0 and ("success", "Deleted 2 summaries") in BULLETINS,
f"rows={db.stats()[0]} bulletins={BULLETINS}",
)
finally:
requests_mod.post = real_post
cu_mod.get_last_fragment = real_last_fragment
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 <bundle.plugin>", 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)",
)
# unread_count decays while a chat is open (markDialogAsRead runs as
# messages are viewed), so pressing the unread button a second time used
# to prefill a far smaller range than the first press: 1100 then 15 then
# 75. The resolved count must not shrink while the chat stays open.
class _UnreadDialog:
def __init__(self, count):
self.unread_count = count
class _UnreadFragment:
def __init__(self, divider, ids):
self.createUnreadMessageAfterId = divider
self.messages = _JavaList()
for mid in ids:
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
unread_dialog = _UnreadDialog(1100)
cu_mod = sys.modules["client_utils"]
real_controller = cu_mod.get_messages_controller
class _UnreadController:
class dialogs_dict:
@staticmethod
def get(_dialog_id):
return unread_dialog
cu_mod.get_messages_controller = lambda: _UnreadController
try:
unread_fragment = _UnreadFragment(171000, list(range(172700, 172670, -1)))
resolved = []
for decayed in (1100, 15, 75):
unread_dialog.unread_count = decayed
resolved.append(
plugin.unread_hook._resolve_unread_count(unread_fragment, -1978105175)
)
finally:
cu_mod.get_messages_controller = real_controller
check(
"the unread prefill does not shrink as messages are marked read",
resolved == [1100, 1100, 1100],
f"resolved={resolved}",
)
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}",
)
check(
"delete_hook hooks every MessagesController.deleteMessages overload",
len(plugin.delete_hook._unhooks) == 2,
f"installed hooks: {len(plugin.delete_hook._unhooks)} (expected 2)",
)
# Storage settings: an approximate size readout and a destructive,
# confirmed "clear all" action.
storage_rows = {getattr(r, "text", None): r for r in settings_rows}
clear_row = storage_rows.get("Clear all summaries")
size_row = storage_rows.get("Saved summaries")
check(
"settings show the saved summary count and approximate size",
size_row is not None and "saved" in (size_row.subtext or "") and "B" in size_row.subtext,
f"subtext={getattr(size_row, 'subtext', None)!r}",
)
check(
"settings offer a red Clear all summaries action",
clear_row is not None and clear_row.red is True and callable(clear_row.on_click),
f"row={getattr(clear_row, '__dict__', None)}",
)
# ------------------------------------------------------------------
# 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]}",
)
plain = rich_markdown.markdown_to_plain(
"# Title\n\n**Bold** and *it* with `code` by [Alice](tg://user?id=1)\n> quote\n```\nx = 1\n```"
)
check(
"markdown_to_plain keeps the readable text and drops the markup",
plain == "Title\n\nBold and it with code by Alice\nquote\nx = 1",
f"plain={plain!r}",
)
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 <think> tag split across chunks must not leak into the answer.
splitter = streaming_mod._ThinkTagSplitter()
got = []
for piece in ["before<thi", "nk>rea", "soning</thi", "nk>after"]:
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))}",
)
reserved, created = db.reserve(-100, 0, 555, 9)
check(
"reserving an anchor that already has a summary keeps its text",
reserved == first and created is False and db.get(first).text == "text two",
f"reserved={reserved} created={created} text={db.get(first).text!r}",
)
fresh, fresh_created = db.reserve(-100, 0, 777, 9)
check(
"reserving a new anchor creates an empty row",
fresh_created is True and db.get(fresh).text == "",
f"created={fresh_created}",
)
check(
"get_for_dialog spans every topic of the chat",
sorted((r.topic_id, r.anchor_msg_id) for r in db.get_for_dialog(-100))
== [(0, 555), (0, 777), (7, 555)],
f"rows={db.get_for_dialog(-100)}",
)
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:
# ChatActivityAdapter rows before the first message (header,
# loading row): the jump must offset the list index by this.
messagesStartRow = 2
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 = []
self.stopped = 0
self.scroll_state = 0
def getScrollState(self):
return self.scroll_state
def getMeasuredHeight(self):
return 900
def stopScroll(self):
self.stopped += 1
def getChildCount(self):
return len(self.cells)
def getChildAt(self, index):
return self.cells[index]
class _FakeLayoutManager:
"""Records jumps and reports the visible row range they produce.
`hijack` simulates ChatActivity's own postponed scroll landing
right after a jump and carrying the view elsewhere.
"""
def __init__(self):
self.jumps = []
self.visible = (-1, -1)
self.hijack = 0
def scrollToPositionWithOffset(self, position, offset, bottom):
self.jumps.append((position, offset, bottom))
if self.hijack:
self.hijack -= 1
self.visible = (40, 46)
else:
self.visible = (max(0, position - 3), position + 3)
def findFirstVisibleItemPosition(self):
return self.visible[0]
def findLastVisibleItemPosition(self):
return self.visible[1]
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.chatLayoutManager = _FakeLayoutManager()
self.scrolled_to = []
# Ids scrollToMessageId will "load" into the list, mimicking
# the history request it issues for an unloaded id.
self.loadable = {}
def getDialogId(self):
return -4242
def scrollToMessageId(self, msg_id, from_id, select, load_index, force, unread):
self.scrolled_to.append(msg_id)
for mid in self.loadable.pop(msg_id, ()):
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
self.messages.add(_FakeMessageObject(0, owner_obj))
def ids(self):
return [m.messageOwner.id for m in self.messages]
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(thinking="first chunk", 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. scrollToMessageId
# only smooth-scrolls from stale cell positions right after an insert,
# so placement hard-jumps the layout manager to the card's own row.
check(
"placing the card hard-jumps the layout manager to the card's row",
activity.chatLayoutManager.jumps == [(2 + 1, 300, False)]
and activity.chatListView.stopped == 1,
f"jumps={activity.chatLayoutManager.jumps} stopped={activity.chatListView.stopped}",
)
check(
"a loaded anchor needs no history load before placement",
activity.scrolled_to == [],
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 == [] and len(activity.chatLayoutManager.jumps) == 1,
f"scrolled_to={activity.scrolled_to} jumps={activity.chatLayoutManager.jumps}",
)
# 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})",
)
# 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 = injector.SummaryInjection(collide_activity, 0, -4242, 0, 555, saved_id)
collide.insert(thinking="new run", streaming=True)
collide.update(answer_markdown="new answer", streaming=True)
ids = collide_activity.ids()
check(
"re-summarizing a restored range reuses its card instead of duplicating",
ids.count(saved_id) == 1 and len(ids) == 4,
f"ids={ids}",
)
# The anchor is the newest ANALYZED message; with an offset it can be
# outside the loaded window. The card must never fall back to the chat
# bottom: the chat is asked to load the anchor, and the card is placed
# directly after it once it arrives.
far = _FakeChatActivity([900, 800])
far.loadable[300] = [310, 300, 290]
far_card = injector.virtual_id_for(5, 300)
far_inj = injector.SummaryInjection(far, 0, -4242, 0, 300, far_card)
far_inj.insert(thinking="chunk", streaming=True)
far_ids = far.ids()
check(
"an unloaded anchor is loaded first, then the card lands right after it",
far.scrolled_to == [300]
and far_inj.placed
and far_ids.index(far_card) == far_ids.index(300) - 1,
f"scrolled_to={far.scrolled_to} ids={far_ids}",
)
check(
"the card is never parked at the bottom while its anchor loads",
far_ids[0] == 900,
f"ids={far_ids}",
)
# Loading a far anchor makes ChatActivity run its own postponed scroll,
# which can land after the jump and carry the view away from the card.
# The settle checks must bring it back.
hijacked = _FakeChatActivity([900, 800])
hijacked.loadable[300] = [310, 300, 290]
hijacked.chatLayoutManager.hijack = 1
hijacked_card = injector.virtual_id_for(7, 300)
injector.SummaryInjection(hijacked, 0, -4242, 0, 300, hijacked_card).insert(
thinking="chunk", streaming=True
)
card_row = 2 + hijacked.ids().index(hijacked_card)
first, last = hijacked.chatLayoutManager.visible
check(
"a jump overridden by the app's own scroll is redone until the card is on screen",
len(hijacked.chatLayoutManager.jumps) == 2 and first <= card_row <= last,
f"jumps={hijacked.chatLayoutManager.jumps} visible={(first, last)} row={card_row}",
)
# Once the user takes hold of the list, the plugin stops steering it.
dragged = _FakeChatActivity([600, 555, 500])
dragged.chatLayoutManager.hijack = 1
dragged.chatListView.scroll_state = 1
injector.SummaryInjection(dragged, 0, -4242, 0, 555, injector.virtual_id_for(8, 555)).insert(
thinking="chunk", streaming=True
)
check(
"no re-jump while the user is dragging the list",
len(dragged.chatLayoutManager.jumps) == 1,
f"jumps={dragged.chatLayoutManager.jumps}",
)
# A cache-then-server reload can replace the list under a fresh card.
reloaded = _FakeChatActivity([600, 555, 500])
reloaded_card = injector.virtual_id_for(9, 555)
reloaded_inj = injector.SummaryInjection(reloaded, 0, -4242, 0, 555, reloaded_card)
real_on_ui_for_reload = injector._on_ui
dropped = []
def _reloading_on_ui(fn, delay_ms=0):
if delay_ms and not dropped:
# First settle check: the chat reloads its window first.
reloaded.messages[:] = [m for m in reloaded.messages if m.messageOwner.id > 0]
dropped.append(True)
fn()
injector._on_ui = _reloading_on_ui
try:
reloaded_inj.insert(thinking="chunk", streaming=True)
finally:
injector._on_ui = real_on_ui_for_reload
reloaded_ids = reloaded.ids()
check(
"a card dropped by a list reload right after placement is put back",
dropped and reloaded_ids.count(reloaded_card) == 1
and reloaded_ids.index(reloaded_card) == reloaded_ids.index(555) - 1,
f"ids={reloaded_ids}",
)
# An anchor that never loads: no card anywhere, rather than a card
# attached to messages it did not analyze.
never = _FakeChatActivity([900, 800])
never_card = injector.virtual_id_for(6, 300)
never_inj = injector.SummaryInjection(never, 0, -4242, 0, 300, never_card)
never_inj.insert(thinking="chunk", streaming=True)
never_inj.update(answer_markdown="more", streaming=True)
check(
"an anchor that never loads places no card at a wrong position",
never_card not in never.ids() and not never_inj.placed,
f"ids={never.ids()}",
)
check(
"a far anchor that does not arrive is requested again, not just once",
len(never.scrolled_to) >= 2 and set(never.scrolled_to) == {300},
f"scrolled_to={never.scrolled_to}",
)
# ...and the card shows up once the user scrolls the anchor in.
for mid in (310, 300):
owner_obj = _TLObj("TL_message")
owner_obj.id = mid
never.messages.add(_FakeMessageObject(0, owner_obj))
never_inj.update(answer_markdown="final", streaming=False, tagged=True)
never_ids = never.ids()
check(
"a card whose anchor loads late is placed on the next render",
never_inj.placed and never_ids.index(never_card) == never_ids.index(300) - 1,
f"ids={never_ids}",
)
# Finished summaries carry the #aisummaries tag, in the card and in
# the plain-text body (with a hashtag entity), so copied text and any
# text matching see it. Streaming renders stay untagged.
tagged_mo = injector.build_summary_message(
0, -4242, 0, 555, injector.virtual_id_for(1, 555),
"**Done** see [Alice](tg://user?id=1)", "", False, tagged=True,
)
tagged_owner = tagged_mo.messageOwner
last_block = tagged_owner.rich_message.blocks[-1]
entity = tagged_owner.entities[0] if len(tagged_owner.entities) else None
tag_node = getattr(last_block, "text", None)
check(
"a finished summary ends with a native #aisummaries hashtag node",
last_block.tl_name == "pageBlockParagraph"
and getattr(tag_node, "tl_name", None) == "textHashtag"
and getattr(tag_node.text, "text", None) == "#aisummaries",
f"last={last_block.tl_name} node={getattr(tag_node, '__dict__', None)}",
)
check(
"the plain-text body is searchable text plus a hashtag entity",
tagged_owner.message == "Done see Alice\n\n#aisummaries"
and entity is not None
and entity.tl_name == "TL_messageEntityHashtag"
and entity.offset == len("Done see Alice\n\n")
and entity.length == len("#aisummaries"),
f"message={tagged_owner.message!r} entity={getattr(entity, '__dict__', None)}",
)
streaming_mo = injector.build_summary_message(
0, -4242, 0, 555, injector.virtual_id_for(1, 555), "partial", "", True
)
check(
"a streaming render is not tagged",
"#aisummaries" not in streaming_mo.messageOwner.message
and len(streaming_mo.messageOwner.entities) == 0,
f"message={streaming_mo.messageOwner.message!r}",
)
# 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)}",
)
# Every real chat interleaves date separators, which are local
# MessageObjects carrying messageOwner.id == 0 (ChatActivity builds them
# with `iput v12, ...Message->id` where v12 is 0, and gives them ids from
# a separate dateObjectsStableIds space). They must never be treated as
# the insertion point or counted as a loaded anchor.
dated = _FakeChatActivity([700, 690])
separator_owner = _TLObj("TL_messageService")
separator_owner.id = 0
dated.messages.add(1, _FakeMessageObject(0, separator_owner))
dated_index = injector.find_index_for_anchor(dated.messages, 690)
dated_ids = [m.messageOwner.id for m in dated.messages]
check(
"a date separator is not mistaken for the anchor",
dated_ids[dated_index] == 690,
f"index={dated_index} ids={dated_ids}",
)
# A separator's id 0 must not make "anchor 0" look loaded, and must not
# be offered as a position; anchor ids are always positive.
check(
"a date separator does not satisfy an anchor-loaded check",
injector.find_index_for_anchor(dated.messages, 0) == -1,
f"index={injector.find_index_for_anchor(dated.messages, 0)}",
)
# A summary anchored on a real message must still restore correctly when
# separators sit between the loaded messages.
sep_restore = _FakeChatActivity([800, 790])
sep_owner = _TLObj("TL_messageService")
sep_owner.id = 0
sep_restore.messages.add(1, _FakeMessageObject(0, sep_owner))
sep_n = injector.restore_saved_summaries(
sep_restore,
0,
-4242,
0,
[summary_db_mod.StoredSummary(41, -4242, 0, 790, "text", "", 5, 0)],
)
sep_ids = [m.messageOwner.id for m in sep_restore.messages]
sep_card = injector.virtual_id_for(41, 790)
check(
"summaries restore correctly around date separators",
sep_n == 1 and sep_ids.index(sep_card) == sep_ids.index(790) - 1,
f"dispatched={sep_n} ids={sep_ids}",
)
run_pipeline_checks(plugin if plugin_cls is not None else None, injector, summary_db_mod, _FakeChatActivity, _FakeMessage if plugin_cls is not None else None)
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())