SilverElixir commited on
Commit
a46f21b
·
1 Parent(s): 8da7b15

Match Claude's activity timeline: sparkle spinner, thought-for block with sources

Browse files
docs/ARCHITECTURE.md CHANGED
@@ -40,7 +40,7 @@ This speed estimate is measured, not hardcoded. There is no table of "model X do
40
 
41
  If the streaming attempt for the head-of-route model fails before showing any text, the bot falls back to a normal (non-streaming) call further down the chain, reusing the same placeholder message rather than sending a new one.
42
 
43
- In private chats every request with a real early stage (web search, media, a link or YouTube) first gets a live status bubble (`lumen_status.py`): "Searching the web…", then "Searched the web · found N" plus "Thinking… Ns" while the model call runs. The same bubble is reused: the streamed or non-streamed answer replaces the status text, so no extra message appears. Plain text without an early stage skips the bubble and shows the same "Thinking… Ns" line once streaming starts. Status phrases are fixed pipeline facts (never model reasoning or model identity) and are localized in `lumen_lang.py`.
44
 
45
  Final answers go out via `sendRichMessage` (Bot API 10.1: real tables, headings, math) with automatic fallback to plain HTML; streaming edits always use HTML. Kill-switch: `RICH_MESSAGES_ENABLED=0` (plus restart) forces legacy HTML.
46
 
 
40
 
41
  If the streaming attempt for the head-of-route model fails before showing any text, the bot falls back to a normal (non-streaming) call further down the chain, reusing the same placeholder message rather than sending a new one.
42
 
43
+ In private chats every request with a real early stage (web search, media, a link or YouTube) first gets a live status bubble (`lumen_status.py`) styled after Claude: an animated sparkle (Claude Code's spinner frames) plus "Searching the web", then a checkmarked "Searched the web · found N" and a "Thinking… Ns" timer while the model call runs. The same bubble is reused for the answer. At the final edit a collapsed `Thought for Ns` blockquote (with the completed steps and search source domains) is prepended above the answer, mirroring Claude's collapsible reasoning/activity block; Telegram collapses it by default. Plain text without an early stage skips the early bubble and gets the same collapsed `Thought for Ns` block once streaming finishes. Long answers split across several Telegram messages skip the block: it would not fit the per-message limit. Status phrases are fixed pipeline facts (never model reasoning or model identity) and are localized in `lumen_lang.py`.
44
 
45
  Final answers go out via `sendRichMessage` (Bot API 10.1: real tables, headings, math) with automatic fallback to plain HTML; streaming edits always use HTML. Kill-switch: `RICH_MESSAGES_ENABLED=0` (plus restart) forces legacy HTML.
46
 
lumen_lang.py CHANGED
@@ -866,6 +866,14 @@ STRINGS: dict[str, dict[str, str]] = {
866
  "ru": "Пробую ещё раз",
867
  "uk": "Пробую ще раз",
868
  },
 
 
 
 
 
 
 
 
869
  # ── Кнопки-уточнения: служебные реплики ──
870
  "pick_suffix": {
871
  "be": "\n\n(або проста напішы тэкстам)",
@@ -1003,6 +1011,7 @@ PACK_PT: dict[str, str] = {
1003
  "status_listening": "Ouvindo.",
1004
  "progress_searching": "Pesquisando na internet",
1005
  "progress_search_done": "Pesquisa na internet · encontrei {count}",
 
1006
  "progress_search_empty": "A pesquisa não encontrou nada",
1007
  "progress_thinking": "Pensando… {sec}s",
1008
  "progress_reading_link": "Lendo o link",
@@ -1111,6 +1120,7 @@ PACK_AR: dict[str, str] = {
1111
  "status_listening": "أستمع.",
1112
  "progress_searching": "أبحث في الإنترنت",
1113
  "progress_search_done": "بحث في الإنترنت · وجدت {count}",
 
1114
  "progress_search_empty": "لم يجد البحث شيئًا",
1115
  "progress_thinking": "أفكر… {sec} ث",
1116
  "progress_reading_link": "أقرأ الرابط",
@@ -1219,6 +1229,7 @@ PACK_TR: dict[str, str] = {
1219
  "status_listening": "Dinliyorum.",
1220
  "progress_searching": "İnternette arıyorum",
1221
  "progress_search_done": "İnternet araması · {count} bulundu",
 
1222
  "progress_search_empty": "Arama hiçbir şey bulamadı",
1223
  "progress_thinking": "Düşünüyorum… {sec} sn",
1224
  "progress_reading_link": "Bağlantıyı okuyorum",
@@ -1327,6 +1338,7 @@ PACK_DE: dict[str, str] = {
1327
  "status_listening": "Ich höre zu.",
1328
  "progress_searching": "Ich suche im Internet",
1329
  "progress_search_done": "Internetsuche · {count} gefunden",
 
1330
  "progress_search_empty": "Die Suche ergab nichts",
1331
  "progress_thinking": "Ich denke nach… {sec} s",
1332
  "progress_reading_link": "Ich lese den Link",
@@ -1435,6 +1447,7 @@ PACK_FR: dict[str, str] = {
1435
  "status_listening": "J'écoute.",
1436
  "progress_searching": "Je cherche sur internet",
1437
  "progress_search_done": "Recherche web · {count} trouvés",
 
1438
  "progress_search_empty": "La recherche n'a rien donné",
1439
  "progress_thinking": "Je réfléchis… {sec} s",
1440
  "progress_reading_link": "Je lis le lien",
@@ -1543,6 +1556,7 @@ PACK_IT: dict[str, str] = {
1543
  "status_listening": "Ti ascolto.",
1544
  "progress_searching": "Cerco su internet",
1545
  "progress_search_done": "Ricerca web · {count} trovati",
 
1546
  "progress_search_empty": "La ricerca non ha trovato nulla",
1547
  "progress_thinking": "Sto pensando… {sec} s",
1548
  "progress_reading_link": "Sto leggendo il link",
@@ -1651,6 +1665,7 @@ PACK_HI: dict[str, str] = {
1651
  "status_listening": "सुन रहा हूं।",
1652
  "progress_searching": "इंटरनेट पर खोज रहा हूँ",
1653
  "progress_search_done": "वेब खोज · {count} मिले",
 
1654
  "progress_search_empty": "खोज में कुछ नहीं मिला",
1655
  "progress_thinking": "सोच रहा हूँ… {sec} से",
1656
  "progress_reading_link": "लिंक पढ़ रहा हूँ",
@@ -1759,6 +1774,7 @@ PACK_ID: dict[str, str] = {
1759
  "status_listening": "Mendengarkan.",
1760
  "progress_searching": "Mencari di internet",
1761
  "progress_search_done": "Pencarian web · {count} ditemukan",
 
1762
  "progress_search_empty": "Pencarian tidak menemukan apa pun",
1763
  "progress_thinking": "Berpikir… {sec} dtk",
1764
  "progress_reading_link": "Membaca tautan",
@@ -1867,6 +1883,7 @@ PACK_MS: dict[str, str] = {
1867
  "status_listening": "Mendengar.",
1868
  "progress_searching": "Mencari di internet",
1869
  "progress_search_done": "Carian web · {count} ditemui",
 
1870
  "progress_search_empty": "Carian tidak menemui apa-apa",
1871
  "progress_thinking": "Berfikir… {sec} saat",
1872
  "progress_reading_link": "Membaca pautan",
@@ -1975,6 +1992,7 @@ PACK_VI: dict[str, str] = {
1975
  "status_listening": "Đang nghe.",
1976
  "progress_searching": "Đang tìm trên internet",
1977
  "progress_search_done": "Tìm kiếm web · tìm thấy {count}",
 
1978
  "progress_search_empty": "Tìm kiếm không có kết quả",
1979
  "progress_thinking": "Đang suy nghĩ… {sec} giây",
1980
  "progress_reading_link": "Đang đọc liên kết",
@@ -2083,6 +2101,7 @@ PACK_TH: dict[str, str] = {
2083
  "status_listening": "กำลังฟัง",
2084
  "progress_searching": "กำลังค้นหาในอินเทอร์เน็ต",
2085
  "progress_search_done": "ค้นหาเว็บ · พบ {count}",
 
2086
  "progress_search_empty": "ค้นหาไม่พบอะไร",
2087
  "progress_thinking": "กำลังคิด… {sec} วิ",
2088
  "progress_reading_link": "กำลังอ่านลิงก์",
@@ -2191,6 +2210,7 @@ PACK_FA: dict[str, str] = {
2191
  "status_listening": "گوش می‌دهم.",
2192
  "progress_searching": "در اینترنت جستجو می‌کنم",
2193
  "progress_search_done": "جستجوی وب · {count} یافت شد",
 
2194
  "progress_search_empty": "جستجو چیزی پیدا نکرد",
2195
  "progress_thinking": "در حال فکر کردن… {sec} ث",
2196
  "progress_reading_link": "در حال خواندن پیوند",
@@ -2299,6 +2319,7 @@ PACK_UR: dict[str, str] = {
2299
  "status_listening": "سن رہا ہوں۔",
2300
  "progress_searching": "انٹرنیٹ پر تلاش کر رہا ہوں",
2301
  "progress_search_done": "ویب تلاش · {count} ملے",
 
2302
  "progress_search_empty": "تلاش میں کچھ نہیں ملا",
2303
  "progress_thinking": "سوچ رہا ہوں… {sec} س",
2304
  "progress_reading_link": "لنک پڑھ رہا ہوں",
@@ -2407,6 +2428,7 @@ PACK_UZ: dict[str, str] = {
2407
  "status_listening": "Eshityapman.",
2408
  "progress_searching": "Internetda qidiryapman",
2409
  "progress_search_done": "Veb qidiruv · {count} topildi",
 
2410
  "progress_search_empty": "Qidiruv hech narsa topmadi",
2411
  "progress_thinking": "O'ylayapman… {sec} s",
2412
  "progress_reading_link": "Havolani o'qiyapman",
@@ -2515,6 +2537,7 @@ PACK_BN: dict[str, str] = {
2515
  "status_listening": "শুনছি।",
2516
  "progress_searching": "ইন্টারনেটে খুঁজছি",
2517
  "progress_search_done": "ওয়েব অনুসন্ধান · {count}টি পাওয়া গেছে",
 
2518
  "progress_search_empty": "অনুসন্ধানে কিছু পাওয়া যায়নি",
2519
  "progress_thinking": "ভাবছি… {sec} সেকেন্ড",
2520
  "progress_reading_link": "লিংক পড়ছি",
@@ -2623,6 +2646,7 @@ PACK_FIL: dict[str, str] = {
2623
  "status_listening": "Nakikinig.",
2624
  "progress_searching": "Naghahanap sa internet",
2625
  "progress_search_done": "Web search · {count} ang nakita",
 
2626
  "progress_search_empty": "Walang nahanap ang paghahanap",
2627
  "progress_thinking": "Nag-iisip… {sec} seg",
2628
  "progress_reading_link": "Binabasa ang link",
@@ -2731,6 +2755,7 @@ PACK_PL: dict[str, str] = {
2731
  "status_listening": "Słucham.",
2732
  "progress_searching": "Szukam w internecie",
2733
  "progress_search_done": "Wyszukiwanie w sieci · znaleziono {count}",
 
2734
  "progress_search_empty": "Wyszukiwanie nic nie dało",
2735
  "progress_thinking": "Myślę… {sec} s",
2736
  "progress_reading_link": "Czytam link",
@@ -2839,6 +2864,7 @@ PACK_NL: dict[str, str] = {
2839
  "status_listening": "Ik luister.",
2840
  "progress_searching": "Ik zoek op internet",
2841
  "progress_search_done": "Webzoekopdracht · {count} gevonden",
 
2842
  "progress_search_empty": "De zoekopdracht leverde niets op",
2843
  "progress_thinking": "Ik denk na… {sec} s",
2844
  "progress_reading_link": "Ik lees de link",
@@ -2947,6 +2973,7 @@ PACK_ZU: dict[str, str] = {
2947
  "status_listening": "Ngilalele.",
2948
  "progress_searching": "Ngisesha kuyi-inthanethi",
2949
  "progress_search_done": "Ukusesha kuwebhu · kutholwe {count}",
 
2950
  "progress_search_empty": "Ukusesha akutholanga lutho",
2951
  "progress_thinking": "Ngicabanga… {sec} s",
2952
  "progress_reading_link": "Ngifunda isixhumanisi",
 
866
  "ru": "Пробую ещё раз",
867
  "uk": "Пробую ще раз",
868
  },
869
+ "progress_thought_for": {
870
+ "be": "Думаў {sec} с",
871
+ "en": "Thought for {sec}s",
872
+ "es": "Pensé durante {sec} s",
873
+ "kk": "Ойлауға {sec} с кетті",
874
+ "ru": "Думал {sec} с",
875
+ "uk": "Думав {sec} с",
876
+ },
877
  # ── Кнопки-уточнения: служебные реплики ──
878
  "pick_suffix": {
879
  "be": "\n\n(або проста напішы тэкстам)",
 
1011
  "status_listening": "Ouvindo.",
1012
  "progress_searching": "Pesquisando na internet",
1013
  "progress_search_done": "Pesquisa na internet · encontrei {count}",
1014
+ "progress_thought_for": "Pensei por {sec}s",
1015
  "progress_search_empty": "A pesquisa não encontrou nada",
1016
  "progress_thinking": "Pensando… {sec}s",
1017
  "progress_reading_link": "Lendo o link",
 
1120
  "status_listening": "أستمع.",
1121
  "progress_searching": "أبحث في الإنترنت",
1122
  "progress_search_done": "بحث في الإنترنت · وجدت {count}",
1123
+ "progress_thought_for": "فكرت لمدة {sec} ث",
1124
  "progress_search_empty": "لم يجد البحث شيئًا",
1125
  "progress_thinking": "أفكر… {sec} ث",
1126
  "progress_reading_link": "أقرأ الرابط",
 
1229
  "status_listening": "Dinliyorum.",
1230
  "progress_searching": "İnternette arıyorum",
1231
  "progress_search_done": "İnternet araması · {count} bulundu",
1232
+ "progress_thought_for": "{sec} sn düşündüm",
1233
  "progress_search_empty": "Arama hiçbir şey bulamadı",
1234
  "progress_thinking": "Düşünüyorum… {sec} sn",
1235
  "progress_reading_link": "Bağlantıyı okuyorum",
 
1338
  "status_listening": "Ich höre zu.",
1339
  "progress_searching": "Ich suche im Internet",
1340
  "progress_search_done": "Internetsuche · {count} gefunden",
1341
+ "progress_thought_for": "{sec} s nachgedacht",
1342
  "progress_search_empty": "Die Suche ergab nichts",
1343
  "progress_thinking": "Ich denke nach… {sec} s",
1344
  "progress_reading_link": "Ich lese den Link",
 
1447
  "status_listening": "J'écoute.",
1448
  "progress_searching": "Je cherche sur internet",
1449
  "progress_search_done": "Recherche web · {count} trouvés",
1450
+ "progress_thought_for": "Réflexion pendant {sec} s",
1451
  "progress_search_empty": "La recherche n'a rien donné",
1452
  "progress_thinking": "Je réfléchis… {sec} s",
1453
  "progress_reading_link": "Je lis le lien",
 
1556
  "status_listening": "Ti ascolto.",
1557
  "progress_searching": "Cerco su internet",
1558
  "progress_search_done": "Ricerca web · {count} trovati",
1559
+ "progress_thought_for": "Ho pensato per {sec} s",
1560
  "progress_search_empty": "La ricerca non ha trovato nulla",
1561
  "progress_thinking": "Sto pensando… {sec} s",
1562
  "progress_reading_link": "Sto leggendo il link",
 
1665
  "status_listening": "सुन रहा हूं।",
1666
  "progress_searching": "इंटरनेट पर खोज रहा हूँ",
1667
  "progress_search_done": "वेब खोज · {count} मिले",
1668
+ "progress_thought_for": "{sec} सेकंड सोचा",
1669
  "progress_search_empty": "खोज में कुछ नहीं मिला",
1670
  "progress_thinking": "सोच रहा हूँ… {sec} से",
1671
  "progress_reading_link": "लिंक पढ़ रहा हूँ",
 
1774
  "status_listening": "Mendengarkan.",
1775
  "progress_searching": "Mencari di internet",
1776
  "progress_search_done": "Pencarian web · {count} ditemukan",
1777
+ "progress_thought_for": "Berpikir {sec} dtk",
1778
  "progress_search_empty": "Pencarian tidak menemukan apa pun",
1779
  "progress_thinking": "Berpikir… {sec} dtk",
1780
  "progress_reading_link": "Membaca tautan",
 
1883
  "status_listening": "Mendengar.",
1884
  "progress_searching": "Mencari di internet",
1885
  "progress_search_done": "Carian web · {count} ditemui",
1886
+ "progress_thought_for": "Berfikir {sec} saat",
1887
  "progress_search_empty": "Carian tidak menemui apa-apa",
1888
  "progress_thinking": "Berfikir… {sec} saat",
1889
  "progress_reading_link": "Membaca pautan",
 
1992
  "status_listening": "Đang nghe.",
1993
  "progress_searching": "Đang tìm trên internet",
1994
  "progress_search_done": "Tìm kiếm web · tìm thấy {count}",
1995
+ "progress_thought_for": "Đã suy nghĩ {sec} giây",
1996
  "progress_search_empty": "Tìm kiếm không có kết quả",
1997
  "progress_thinking": "Đang suy nghĩ… {sec} giây",
1998
  "progress_reading_link": "Đang đọc liên kết",
 
2101
  "status_listening": "กำลังฟัง",
2102
  "progress_searching": "กำลังค้นหาในอินเทอร์เน็ต",
2103
  "progress_search_done": "ค้นหาเว็บ · พบ {count}",
2104
+ "progress_thought_for": "คิดเป็นเวลา {sec} วิ",
2105
  "progress_search_empty": "ค้นหาไม่พบอะไร",
2106
  "progress_thinking": "กำลังคิด… {sec} วิ",
2107
  "progress_reading_link": "กำลังอ่านลิงก์",
 
2210
  "status_listening": "گوش می‌دهم.",
2211
  "progress_searching": "در اینترنت جستجو می‌کنم",
2212
  "progress_search_done": "جستجوی وب · {count} یافت شد",
2213
+ "progress_thought_for": "{sec} ثانیه فکر کردم",
2214
  "progress_search_empty": "جستجو چیزی پیدا نکرد",
2215
  "progress_thinking": "در حال فکر کردن… {sec} ث",
2216
  "progress_reading_link": "در حال خواندن پیوند",
 
2319
  "status_listening": "سن رہا ہوں۔",
2320
  "progress_searching": "انٹرنیٹ پر تلاش کر رہا ہوں",
2321
  "progress_search_done": "ویب تلاش · {count} ملے",
2322
+ "progress_thought_for": "{sec} سیکنڈ سوچا",
2323
  "progress_search_empty": "تلاش میں کچھ نہیں ملا",
2324
  "progress_thinking": "سوچ رہا ہوں… {sec} س",
2325
  "progress_reading_link": "لنک پڑھ رہا ہوں",
 
2428
  "status_listening": "Eshityapman.",
2429
  "progress_searching": "Internetda qidiryapman",
2430
  "progress_search_done": "Veb qidiruv · {count} topildi",
2431
+ "progress_thought_for": "{sec} soniya o'yladim",
2432
  "progress_search_empty": "Qidiruv hech narsa topmadi",
2433
  "progress_thinking": "O'ylayapman… {sec} s",
2434
  "progress_reading_link": "Havolani o'qiyapman",
 
2537
  "status_listening": "শুনছি।",
2538
  "progress_searching": "ইন্টারনেটে খুঁজছি",
2539
  "progress_search_done": "ওয়েব অনুসন্ধান · {count}টি পাওয়া গেছে",
2540
+ "progress_thought_for": "{sec} সেকেন্ড ভেবেছি",
2541
  "progress_search_empty": "অনুসন্ধানে কিছু পাওয়া যায়নি",
2542
  "progress_thinking": "ভাবছি… {sec} সেকেন্ড",
2543
  "progress_reading_link": "লিংক পড়ছি",
 
2646
  "status_listening": "Nakikinig.",
2647
  "progress_searching": "Naghahanap sa internet",
2648
  "progress_search_done": "Web search · {count} ang nakita",
2649
+ "progress_thought_for": "Nag-isip ng {sec} seg",
2650
  "progress_search_empty": "Walang nahanap ang paghahanap",
2651
  "progress_thinking": "Nag-iisip… {sec} seg",
2652
  "progress_reading_link": "Binabasa ang link",
 
2755
  "status_listening": "Słucham.",
2756
  "progress_searching": "Szukam w internecie",
2757
  "progress_search_done": "Wyszukiwanie w sieci · znaleziono {count}",
2758
+ "progress_thought_for": "Myślałem przez {sec} s",
2759
  "progress_search_empty": "Wyszukiwanie nic nie dało",
2760
  "progress_thinking": "Myślę… {sec} s",
2761
  "progress_reading_link": "Czytam link",
 
2864
  "status_listening": "Ik luister.",
2865
  "progress_searching": "Ik zoek op internet",
2866
  "progress_search_done": "Webzoekopdracht · {count} gevonden",
2867
+ "progress_thought_for": "Ik dacht {sec} s na",
2868
  "progress_search_empty": "De zoekopdracht leverde niets op",
2869
  "progress_thinking": "Ik denk na… {sec} s",
2870
  "progress_reading_link": "Ik lees de link",
 
2973
  "status_listening": "Ngilalele.",
2974
  "progress_searching": "Ngisesha kuyi-inthanethi",
2975
  "progress_search_done": "Ukusesha kuwebhu · kutholwe {count}",
2976
+ "progress_thought_for": "Ngicabange imizuzwana engu-{sec}",
2977
  "progress_search_empty": "Ukusesha akutholanga lutho",
2978
  "progress_thinking": "Ngicabanga… {sec} s",
2979
  "progress_reading_link": "Ngifunda isixhumanisi",
lumen_message_core.py CHANGED
@@ -14,8 +14,9 @@ from datetime import datetime
14
  from typing import Any
15
  from zoneinfo import ZoneInfo
16
 
17
- from aiogram.enums import ChatType
18
  from aiogram.types import Message, Update
 
19
 
20
  from lumen_message_parse import (
21
  DRAW_TRIGGER_PREFIXES,
@@ -782,6 +783,7 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
782
  if status is not None:
783
  if snippets:
784
  status.add_done("progress_search_done", count=len(snippets))
 
785
  else:
786
  status.add_done("progress_search_empty")
787
  await status.set_phase("progress_thinking", timed=True)
@@ -875,8 +877,17 @@ async def _handle_message_core(message: Message, extra_media: list[tuple[bytes,
875
  ans = note_text + "\n\n" + ans
876
  if reply_already_sent and status is not None:
877
  # Ответ уже в статус-пузыре (стрим или переиспользование) —
878
- # пометку дописываем туда же, иначе она потеряется.
879
- await bot._edit_message_quietly(status.message, ans)
 
 
 
 
 
 
 
 
 
880
  if not reply_already_sent:
881
  await bot._safe_reply(message, ans)
882
  # Частичный стрим тоже «успешен» для вызывающего, но его пометка
 
14
  from typing import Any
15
  from zoneinfo import ZoneInfo
16
 
17
+ from aiogram.enums import ChatType, ParseMode
18
  from aiogram.types import Message, Update
19
+ from urllib.parse import urlparse
20
 
21
  from lumen_message_parse import (
22
  DRAW_TRIGGER_PREFIXES,
 
783
  if status is not None:
784
  if snippets:
785
  status.add_done("progress_search_done", count=len(snippets))
786
+ status.set_sources([urlparse(item.get("url") or "").netloc for item in snippets])
787
  else:
788
  status.add_done("progress_search_empty")
789
  await status.set_phase("progress_thinking", timed=True)
 
877
  ans = note_text + "\n\n" + ans
878
  if reply_already_sent and status is not None:
879
  # Ответ уже в статус-пузыре (стрим или переиспользование) —
880
+ # пометку дописываем туда же вместе со свёрнутым блоком активности.
881
+ html_text, plain_text = status.compose_answer(ans)
882
+ if html_text is not None:
883
+ edited = await bot._tg_call(
884
+ status.message.edit_text, html_text,
885
+ parse_mode=ParseMode.HTML, call_timeout=15.0,
886
+ )
887
+ if edited is None:
888
+ await bot._tg_call(status.message.edit_text, plain_text, parse_mode=None, call_timeout=15.0)
889
+ else:
890
+ await bot._edit_message_quietly(status.message, ans)
891
  if not reply_already_sent:
892
  await bot._safe_reply(message, ans)
893
  # Частичный стрим тоже «успешен» для вызывающего, но его пометка
lumen_routes.py CHANGED
@@ -16,6 +16,7 @@ from datetime import date, datetime
16
  from typing import Any, Callable
17
 
18
  import aiohttp
 
19
  from aiogram.types import Message
20
  from google.genai import types
21
 
@@ -1101,13 +1102,29 @@ async def _run_route(
1101
  _note_answered("openrouter", None)
1102
 
1103
  if status is not None:
1104
- # Останавливаем анимацию статуса до записи ответа, иначе тикер
1105
- # перезапишет готовый текст.
1106
- await status.stop()
1107
  if reusable_placeholder is not None:
1108
  # Готовый ответ — прямо в плейсхолдер (если влезает в одно сообщение), иначе обычным путём с разбиением.
1109
  fits_one_message = len(_split_text_chunks(ans, bot.TG_MAX_LEN)) == 1
1110
- reused = fits_one_message and await bot._edit_message_quietly(reusable_placeholder, ans)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1111
  if not reused:
1112
  await bot._delete_message_quietly(reusable_placeholder)
1113
  reusable_placeholder = None
 
16
  from typing import Any, Callable
17
 
18
  import aiohttp
19
+ from aiogram.enums import ParseMode
20
  from aiogram.types import Message
21
  from google.genai import types
22
 
 
1102
  _note_answered("openrouter", None)
1103
 
1104
  if status is not None:
1105
+ # Фиксируем длительность ожидания и останавливаем анимацию до
1106
+ # записи ответа, иначе тикер перезапишет готовый текст.
1107
+ await status.finish()
1108
  if reusable_placeholder is not None:
1109
  # Готовый ответ — прямо в плейсхолдер (если влезает в одно сообщение), иначе обычным путём с разбиением.
1110
  fits_one_message = len(_split_text_chunks(ans, bot.TG_MAX_LEN)) == 1
1111
+ reused = False
1112
+ if fits_one_message:
1113
+ html_text, plain_text = status.compose_answer(ans) if status is not None else (None, ans)
1114
+ if html_text is not None:
1115
+ edited = await bot._tg_call(
1116
+ reusable_placeholder.edit_text, html_text,
1117
+ parse_mode=ParseMode.HTML, call_timeout=15.0,
1118
+ )
1119
+ reused = edited is not None
1120
+ if not reused:
1121
+ edited = await bot._tg_call(
1122
+ reusable_placeholder.edit_text, plain_text,
1123
+ parse_mode=None, call_timeout=15.0,
1124
+ )
1125
+ reused = edited is not None
1126
+ else:
1127
+ reused = await bot._edit_message_quietly(reusable_placeholder, ans)
1128
  if not reused:
1129
  await bot._delete_message_quietly(reusable_placeholder)
1130
  reusable_placeholder = None
lumen_status.py CHANGED
@@ -1,35 +1,47 @@
1
- """lumen_status.py — живой статус-плейсхолдер: этапы обработки запроса в одном сообщении (поиск, источники, таймер ожидания)."""
2
  from __future__ import annotations
3
 
4
  import asyncio
5
  import contextlib
6
- import logging
7
  import time
8
 
9
  from aiogram.types import Message
10
 
 
11
  from lumen_lang import t as _lang_t
12
 
13
- log = logging.getLogger("bot")
14
-
15
- _PROGRESS_FRAMES = (".", "..", "…")
16
  # Страховка от утечки тикера, если обработчик упал между созданием статуса и
17
  # маршрутом: дольше этого статус сам себя останавливает.
18
  _STATUS_MAX_LIFETIME_SEC = 300.0
19
 
20
 
 
 
 
 
 
 
 
 
 
 
21
  class StatusMessage:
22
- """Сообщение-статус: завершённые этапы + текущая фраза с анимацией или таймером.
23
  Все правки глотают ошибки: удалённое сообщение не должно ломать ответ."""
24
 
25
  def __init__(self, message: Message, lang: str):
26
  self.message = message
27
  self.lang = lang
28
  self._done: list[str] = []
 
29
  self._phrase_key = ""
30
  self._timed = False
31
  self._phase_started = time.monotonic()
32
  self._created = time.monotonic()
 
33
  self._task: asyncio.Task | None = None
34
  self._stopped = False
35
 
@@ -50,8 +62,12 @@ class StatusMessage:
50
  return status
51
 
52
  def add_done(self, key: str, **kwargs: object) -> None:
53
- """Завершённый этап отдельной строкой над текущей фразой."""
54
- self._done.append(_lang_t(self.lang, key, **kwargs))
 
 
 
 
55
 
56
  async def set_phase(self, key: str, *, timed: bool = False) -> None:
57
  """Смена текущей фразы с немедленной правкой, не дожидаясь тика."""
@@ -60,18 +76,49 @@ class StatusMessage:
60
  with contextlib.suppress(Exception):
61
  await bot._tg_call(self.message.edit_text, self._render(0), parse_mode=None, call_timeout=15.0)
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  def _set_phrase(self, key: str, *, timed: bool) -> None:
64
  self._phrase_key = key
65
  self._timed = timed
66
  self._phase_started = time.monotonic()
67
 
68
  def _render(self, frame_idx: int) -> str:
 
69
  lines = list(self._done)
70
  if self._timed:
71
  elapsed = max(0, int(time.monotonic() - self._phase_started))
72
- lines.append(_lang_t(self.lang, self._phrase_key, sec=elapsed))
73
  else:
74
- lines.append(_lang_t(self.lang, self._phrase_key) + _PROGRESS_FRAMES[frame_idx % len(_PROGRESS_FRAMES)])
75
  return "\n".join(lines)
76
 
77
  async def _tick(self) -> None:
 
1
+ """lumen_status.py — живой статус-плейсхолдер в духе Claude: анимированная искра, этапы, «Думал N с» и свёрнутый блок активности."""
2
  from __future__ import annotations
3
 
4
  import asyncio
5
  import contextlib
6
+ import html
7
  import time
8
 
9
  from aiogram.types import Message
10
 
11
+ from lumen_formatting import _is_table_separator, _md_to_html
12
  from lumen_lang import t as _lang_t
13
 
14
+ # Искры как в Claude Code: кадры спиннера, а не точки.
15
+ _SPINNER_FRAMES = ("·", "✢", "✳", "✶", "✻", "✽")
 
16
  # Страховка от утечки тикера, если обработчик упал между созданием статуса и
17
  # маршрутом: дольше этого статус сам себя останавливает.
18
  _STATUS_MAX_LIFETIME_SEC = 300.0
19
 
20
 
21
+ def thought_block(lang: str, sec: int, answer_md: str) -> tuple[str | None, str]:
22
+ """Свёрнутый блок «✻ Thought for Ns» над ответом: (HTML, простой текст).
23
+ Таблицы возвращают None — их показываем обычным rich-путём без блока."""
24
+ if any(_is_table_separator(line) for line in answer_md.splitlines()):
25
+ return None, answer_md
26
+ line = "✻ " + _lang_t(lang, "progress_thought_for", sec=max(0, int(sec)))
27
+ html_block = "<blockquote expandable>" + html.escape(line) + "</blockquote>\n\n" + _md_to_html(answer_md)
28
+ return html_block, line + "\n\n" + answer_md
29
+
30
+
31
  class StatusMessage:
32
+ """Сообщение-статус: завершённые этапы + текущая фраза с искрой и таймером.
33
  Все правки глотают ошибки: удалённое сообщение не должно ломать ответ."""
34
 
35
  def __init__(self, message: Message, lang: str):
36
  self.message = message
37
  self.lang = lang
38
  self._done: list[str] = []
39
+ self._sources: list[str] = []
40
  self._phrase_key = ""
41
  self._timed = False
42
  self._phase_started = time.monotonic()
43
  self._created = time.monotonic()
44
+ self._thought_seconds: int | None = None
45
  self._task: asyncio.Task | None = None
46
  self._stopped = False
47
 
 
62
  return status
63
 
64
  def add_done(self, key: str, **kwargs: object) -> None:
65
+ """Завершённый этап отдельной строкой с галочкой, как в трейсе Claude."""
66
+ self._done.append("✓ " + _lang_t(self.lang, key, **kwargs))
67
+
68
+ def set_sources(self, hosts: list[str]) -> None:
69
+ """Домены найденных источников для раскрытого блока активности."""
70
+ self._sources = [h for h in hosts if h][:5]
71
 
72
  async def set_phase(self, key: str, *, timed: bool = False) -> None:
73
  """Смена текущей фразы с немедленной правкой, не дожидаясь тика."""
 
76
  with contextlib.suppress(Exception):
77
  await bot._tg_call(self.message.edit_text, self._render(0), parse_mode=None, call_timeout=15.0)
78
 
79
+ async def finish(self) -> None:
80
+ """Фиксирует длительность ожидания и останавливает анимацию."""
81
+ if self._thought_seconds is None:
82
+ self._thought_seconds = max(0, int(time.monotonic() - self._phase_started))
83
+ await self.stop()
84
+
85
+ def activity_prefix(self) -> tuple[str, str]:
86
+ """(HTML-блок для ответа, тот же блок простым текстом); пусто — активности нет."""
87
+ thought = self._thought_seconds if self._thought_seconds is not None else max(
88
+ 0, int(time.monotonic() - self._phase_started))
89
+ lines = ["✻ " + _lang_t(self.lang, "progress_thought_for", sec=thought)]
90
+ lines.extend(self._done)
91
+ if self._phrase_key and not self._timed and self._phrase_key != "progress_thinking":
92
+ lines.append("✓ " + _lang_t(self.lang, self._phrase_key))
93
+ if self._sources:
94
+ lines.extend("· " + host for host in self._sources)
95
+ plain = "\n".join(lines)
96
+ html_block = "<blockquote expandable>" + "\n".join(html.escape(line) for line in lines) + "</blockquote>"
97
+ return html_block, plain
98
+
99
+ def compose_answer(self, answer_md: str) -> tuple[str | None, str]:
100
+ """Ответ с свёрнутым блоком активности: (HTML для parse_mode=HTML, простой текст).
101
+ Таблицы отдаём обычным путём (rich-сообщение): цитата сломала бы их разметку."""
102
+ if any(_is_table_separator(line) for line in answer_md.splitlines()):
103
+ return None, answer_md
104
+ html_block, plain_block = self.activity_prefix()
105
+ if not html_block:
106
+ return None, answer_md
107
+ return html_block + "\n\n" + _md_to_html(answer_md), plain_block + "\n\n" + answer_md
108
+
109
  def _set_phrase(self, key: str, *, timed: bool) -> None:
110
  self._phrase_key = key
111
  self._timed = timed
112
  self._phase_started = time.monotonic()
113
 
114
  def _render(self, frame_idx: int) -> str:
115
+ spark = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
116
  lines = list(self._done)
117
  if self._timed:
118
  elapsed = max(0, int(time.monotonic() - self._phase_started))
119
+ lines.append(spark + " " + _lang_t(self.lang, self._phrase_key, sec=elapsed))
120
  else:
121
+ lines.append(spark + " " + _lang_t(self.lang, self._phrase_key))
122
  return "\n".join(lines)
123
 
124
  async def _tick(self) -> None:
lumen_streaming.py CHANGED
@@ -10,11 +10,12 @@ import logging
10
  import time
11
  from typing import Any
12
  import aiohttp
13
- from aiogram.enums import ParseMode
14
  from aiogram.types import Message
15
 
16
  from lumen_formatting import _md_to_html, _split_text_chunks
17
  from lumen_lang import t as _lang_t
 
18
  from lumen_message_parse import _history_user_text
19
  from lumen_model_speed import (
20
  speed_key as _model_speed_key,
@@ -50,13 +51,16 @@ async def _tick_waiting_dots(placeholder: Message) -> None:
50
  await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
51
  chat = getattr(placeholder, "chat", None)
52
  lang = bot._chat_lang(getattr(chat, "id", None))
 
53
  while True:
54
  elapsed = max(0, int(time.monotonic() - started))
 
55
  with contextlib.suppress(Exception):
56
  await bot._tg_call(
57
- placeholder.edit_text, _lang_t(lang, "progress_thinking", sec=elapsed),
58
  parse_mode=None, call_timeout=15.0,
59
  )
 
60
  await bot._dots_sleep(bot._DOTS_TICK_SEC)
61
  except asyncio.CancelledError:
62
  raise
@@ -84,8 +88,9 @@ async def _pieces_with_waiting_feedback(piece_agen, placeholder: Message, *, fir
84
  with contextlib.suppress(asyncio.CancelledError):
85
  await dots_task
86
  if status is not None:
87
- # Первый кусок пришёл: текст сейчас заменит статус, анимацию гасим.
88
- await status.stop()
 
89
  yield first
90
  async for piece in piece_agen:
91
  yield piece
@@ -268,7 +273,7 @@ async def _run_streaming_reply(
268
  placeholder = status.message
269
  else:
270
  placeholder = await bot._tg_call(
271
- message.reply, bot._t(message.chat.id, "progress_thinking", sec=0),
272
  parse_mode=None, call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
273
  )
274
  if placeholder is None:
@@ -417,6 +422,28 @@ async def _run_streaming_reply(
417
  # досылаем новым сообщением (_send_text внутри режет на части сам),
418
  # иначе пользователь его не увидит.
419
  await bot._safe_reply(message, full_text.strip())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
420
  else:
421
  # HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
422
  await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
 
10
  import time
11
  from typing import Any
12
  import aiohttp
13
+ from aiogram.enums import ChatType, ParseMode
14
  from aiogram.types import Message
15
 
16
  from lumen_formatting import _md_to_html, _split_text_chunks
17
  from lumen_lang import t as _lang_t
18
+ from lumen_status import _SPINNER_FRAMES, thought_block
19
  from lumen_message_parse import _history_user_text
20
  from lumen_model_speed import (
21
  speed_key as _model_speed_key,
 
51
  await bot._dots_sleep(bot._DOTS_START_AFTER_SEC)
52
  chat = getattr(placeholder, "chat", None)
53
  lang = bot._chat_lang(getattr(chat, "id", None))
54
+ frame_idx = 0
55
  while True:
56
  elapsed = max(0, int(time.monotonic() - started))
57
+ spark = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)]
58
  with contextlib.suppress(Exception):
59
  await bot._tg_call(
60
+ placeholder.edit_text, spark + " " + _lang_t(lang, "progress_thinking", sec=elapsed),
61
  parse_mode=None, call_timeout=15.0,
62
  )
63
+ frame_idx += 1
64
  await bot._dots_sleep(bot._DOTS_TICK_SEC)
65
  except asyncio.CancelledError:
66
  raise
 
88
  with contextlib.suppress(asyncio.CancelledError):
89
  await dots_task
90
  if status is not None:
91
+ # Первый кусок пришёл: фиксируем «Думал N с» и гасим анимацию —
92
+ # текст сейчас заменит статус.
93
+ await status.finish()
94
  yield first
95
  async for piece in piece_agen:
96
  yield piece
 
273
  placeholder = status.message
274
  else:
275
  placeholder = await bot._tg_call(
276
+ message.reply, _SPINNER_FRAMES[0] + " " + bot._t(message.chat.id, "progress_thinking", sec=0),
277
  parse_mode=None, call_timeout=bot.TELEGRAM_REQUEST_TIMEOUT,
278
  )
279
  if placeholder is None:
 
422
  # досылаем новым сообщением (_send_text внутри режет на части сам),
423
  # иначе пользователь его не увидит.
424
  await bot._safe_reply(message, full_text.strip())
425
+ elif len(final_chunks) == 1 and (
426
+ status is not None or getattr(getattr(message, "chat", None), "type", None) == ChatType.PRIVATE
427
+ ):
428
+ # Одно сообщение: сверху добавляем свёрнутый блок активности,
429
+ # как «Thought for Ns» в Claude. Длинный ответ не трогаем: заголовок
430
+ # не влез бы в лимит первого сообщения. Без раннего статуса (обычный
431
+ # текст) строим минимальный блок по времени до первого куска.
432
+ if status is not None:
433
+ html_text, plain_text = status.compose_answer(final_text)
434
+ else:
435
+ thought_sec = int(max(0.0, (first_piece_ts or overall_start_ts) - overall_start_ts))
436
+ html_text, plain_text = thought_block(bot._chat_lang(message.chat.id), thought_sec, final_text)
437
+ if html_text is None:
438
+ # Таблицы и прочий rich-контент: обычный путь без блока активности.
439
+ await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
440
+ else:
441
+ edited = await bot._tg_call(
442
+ sent_messages[-1].edit_text, html_text,
443
+ parse_mode=ParseMode.HTML, call_timeout=15.0,
444
+ )
445
+ if edited is None:
446
+ await bot._tg_call(sent_messages[-1].edit_text, plain_text, parse_mode=None, call_timeout=15.0)
447
  else:
448
  # HTML->plain fallback — внутри _edit_message_quietly (раньше дублировался здесь вручную).
449
  await bot._edit_message_quietly(sent_messages[-1], final_text, call_timeout=15.0)
tests/test_bot_search.py CHANGED
@@ -117,12 +117,16 @@ class _FakeStatus:
117
  self.message = object()
118
  self.done = []
119
  self.phases = []
 
120
  self.stopped = False
121
  self.deleted = False
122
 
123
  def add_done(self, key, **kwargs):
124
  self.done.append((key, kwargs))
125
 
 
 
 
126
  async def set_phase(self, key, *, timed=False):
127
  self.phases.append((key, timed))
128
 
 
117
  self.message = object()
118
  self.done = []
119
  self.phases = []
120
+ self.sources = []
121
  self.stopped = False
122
  self.deleted = False
123
 
124
  def add_done(self, key, **kwargs):
125
  self.done.append((key, kwargs))
126
 
127
+ def set_sources(self, hosts):
128
+ self.sources = list(hosts)
129
+
130
  async def set_phase(self, key, *, timed=False):
131
  self.phases.append((key, timed))
132
 
tests/test_bot_streaming.py CHANGED
@@ -712,7 +712,9 @@ def test_waiting_dots_cycles_frames_then_stops_on_cancel(monkeypatch):
712
  assert calls[0] == bot._DOTS_START_AFTER_SEC
713
  assert calls[1:] == [bot._DOTS_TICK_SEC] * 3
714
  assert len(placeholder.edits) == 3
715
- assert all(text.startswith("Thinking") for text, _ in placeholder.edits)
 
 
716
  # Код языка не должен утечь в chat_state строковым ключом (прод 28.10.2026).
717
  assert "en" not in bot.chat_state
718
 
@@ -738,6 +740,30 @@ def test_streaming_fast_path_shows_no_dots_frames():
738
  bot.chat_state.pop(chat_id, None)
739
 
740
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741
  def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
742
  # Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
743
  chat_id = 999320
@@ -745,10 +771,14 @@ def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
745
  class _FakeStatus:
746
  def __init__(self):
747
  self.message = _FakeSentMessage()
748
- self.stopped = False
 
 
 
749
 
750
- async def stop(self):
751
- self.stopped = True
 
752
 
753
  async def pieces():
754
  yield "Привет"
@@ -762,8 +792,10 @@ def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
762
  ))
763
  assert answer == "Привет"
764
  assert incoming.sent == [] # второй пузырь не отправлялся
765
- assert status.stopped is True
766
- assert status.message.edits[-1][0] == "Привет"
 
 
767
  finally:
768
  bot.chat_state.pop(chat_id, None)
769
 
 
712
  assert calls[0] == bot._DOTS_START_AFTER_SEC
713
  assert calls[1:] == [bot._DOTS_TICK_SEC] * 3
714
  assert len(placeholder.edits) == 3
715
+ import lumen_status
716
+ assert all(text[0] in lumen_status._SPINNER_FRAMES and "Thinking" in text
717
+ for text, _ in placeholder.edits)
718
  # Код языка не должен утечь в chat_state строковым ключом (прод 28.10.2026).
719
  assert "en" not in bot.chat_state
720
 
 
740
  bot.chat_state.pop(chat_id, None)
741
 
742
 
743
+ def test_streaming_plain_text_adds_collapsed_thought_block():
744
+ # Даже без раннего статуса обычный ответ получает свёрнутый «Thought for Ns»,
745
+ # как в Claude: вопрос не ждал bubble, но финальный вид — с трейсом.
746
+ chat_id = 999321
747
+
748
+ async def pieces():
749
+ yield "Привет"
750
+
751
+ incoming = _FakeIncomingMessage(chat_id)
752
+ try:
753
+ answer, _ = asyncio.run(bot._run_streaming_reply(
754
+ chat_id, "Привет!", incoming, provider="openrouter", model_id="y:free",
755
+ piece_agen=pieces(),
756
+ ))
757
+ assert answer == "Привет"
758
+ final_text, parse_mode = incoming.sent[0].edits[-1]
759
+ assert parse_mode == bot.ParseMode.HTML
760
+ assert final_text.startswith("<blockquote expandable>")
761
+ assert "Thought for" in final_text
762
+ assert "Привет" in final_text
763
+ finally:
764
+ bot.chat_state.pop(chat_id, None)
765
+
766
+
767
  def test_streaming_reuses_status_bubble_instead_of_sending_new_placeholder():
768
  # Живой статус уже в чате: стрим пишет ответ в него, второго «…» нет.
769
  chat_id = 999320
 
771
  class _FakeStatus:
772
  def __init__(self):
773
  self.message = _FakeSentMessage()
774
+ self.finished = False
775
+
776
+ async def finish(self):
777
+ self.finished = True
778
 
779
+ def compose_answer(self, answer_md):
780
+ return ("<blockquote expandable>✻ Thought for 1s</blockquote>\n\n" + answer_md,
781
+ "✻ Thought for 1s\n\n" + answer_md)
782
 
783
  async def pieces():
784
  yield "Привет"
 
792
  ))
793
  assert answer == "Привет"
794
  assert incoming.sent == [] # второй пузырь не отправлялся
795
+ assert status.finished is True
796
+ final_edit = status.message.edits[-1][0]
797
+ assert "Привет" in final_edit
798
+ assert "Thought for" in final_edit
799
  finally:
800
  bot.chat_state.pop(chat_id, None)
801
 
tests/test_lumen_status.py CHANGED
@@ -27,16 +27,24 @@ def test_status_message_renders_done_lines_and_timer(monkeypatch):
27
  status = await StatusMessage.start(incoming, "en", "progress_searching")
28
  assert status is not None
29
  status.add_done("progress_search_done", count=5)
 
30
  await status.set_phase("progress_thinking", timed=True)
31
- await status.stop()
32
  return status
33
 
34
  status = asyncio.run(scenario())
35
  sent = incoming.sent[0]
36
  assert status.message is sent
37
  text = sent.edits[-1][0]
38
- assert "Searched the web · found 5" in text
39
  assert "Thinking… 0s" in text
 
 
 
 
 
 
 
40
  # Язык статуса — это код языка, а не chat_id: строка "en" не должна
41
  # попасть в chat_state как ключ (прод 28.10.2026).
42
  assert "en" not in bot.chat_state
 
27
  status = await StatusMessage.start(incoming, "en", "progress_searching")
28
  assert status is not None
29
  status.add_done("progress_search_done", count=5)
30
+ status.set_sources(["example.com"])
31
  await status.set_phase("progress_thinking", timed=True)
32
+ await status.finish()
33
  return status
34
 
35
  status = asyncio.run(scenario())
36
  sent = incoming.sent[0]
37
  assert status.message is sent
38
  text = sent.edits[-1][0]
39
+ assert "✓ Searched the web · found 5" in text
40
  assert "Thinking… 0s" in text
41
+ html_block, plain_block = status.activity_prefix()
42
+ assert html_block.startswith("<blockquote expandable>") and html_block.endswith("</blockquote>")
43
+ assert "Thought for 0s" in plain_block
44
+ assert "✓ Searched the web · found 5" in plain_block
45
+ assert "· example.com" in plain_block
46
+ # Таблицы показываются rich-сообщением: блок-цитата вернула бы None.
47
+ assert status.compose_answer("| a | b |\n|---|---|\n| 1 | 2 |")[0] is None
48
  # Язык статуса — это код языка, а не chat_id: строка "en" не должна
49
  # попасть в chat_state как ключ (прод 28.10.2026).
50
  assert "en" not in bot.chat_state