switchroom 0.16.29 → 0.16.46

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -72,6 +72,45 @@ export interface ObservedMessage {
72
72
  * must be silent, only the final answer should ping.
73
73
  */
74
74
  silent: boolean;
75
+ /**
76
+ * The rich-formatting entities Telegram actually attached to the
77
+ * rendered message (bold / italic / code / pre / text_link / …),
78
+ * normalized from mtcute's `Message.entities`. This is what the
79
+ * highest-fidelity UAT layer asserts on: not the markdown we SENT, but
80
+ * the entity structure Telegram PARSED and will render. Empty array for
81
+ * a plain-text message with no formatting.
82
+ *
83
+ * Empty (never undefined) when the message body could not be decoded to
84
+ * real text+entities (see `text === "\x01"` sentinel below).
85
+ */
86
+ entities: ObservedEntity[];
87
+ /**
88
+ * The `t.me/c/<internal_chat>/<msgid>` (or `t.me/<username>/<msgid>`)
89
+ * permalink to this message, for human eyeball reference in UAT output.
90
+ * `undefined` for chats that don't support message links (private DMs) —
91
+ * mtcute's `Message.link` getter throws there, so we swallow and leave it
92
+ * unset rather than fail the observation.
93
+ */
94
+ link?: string;
95
+ }
96
+
97
+ /**
98
+ * One rich-formatting entity as Telegram parsed it, flattened from
99
+ * mtcute's `MessageEntity` into the fields a UAT assertion cares about.
100
+ * `kind` is mtcute's entity kind (`bold`, `italic`, `code`, `pre`,
101
+ * `text_link`, `blockquote`, …); `text` is the exact inner substring the
102
+ * entity spans; `offset`/`length` are UTF-16 code-unit coordinates (the
103
+ * same units Telegram uses on the wire).
104
+ */
105
+ export interface ObservedEntity {
106
+ kind: string;
107
+ offset: number;
108
+ length: number;
109
+ text: string;
110
+ /** Destination for `text_link` entities; undefined otherwise. */
111
+ url?: string;
112
+ /** Language info string for `pre` fenced blocks; undefined otherwise. */
113
+ language?: string;
75
114
  }
76
115
 
77
116
  export interface ObservedButton {
@@ -142,11 +181,21 @@ export class Driver {
142
181
  await this.client.connect();
143
182
  // `connect()` opens the transport but does NOT start the updates
144
183
  // dispatch loop — that's `start()`'s job. For a returning session
145
- // (no interactive login) we have to call `startUpdatesLoop()`
146
- // ourselves, otherwise `onNewMessage` / `onEditMessage` never
147
- // fire and `observeMessages` silently waits forever. Symptom:
148
- // `expectMessage` timing out even though the bot's reply has
149
- // arrived in the chat (visible in Telegram).
184
+ // (no interactive login) we have to drive that lifecycle ourselves,
185
+ // otherwise `onNewMessage` / `onEditMessage` never fire and
186
+ // `observeMessages` silently waits forever. Symptom: `expectMessage`
187
+ // timing out even though the bot's reply has arrived in the chat
188
+ // (visible in Telegram).
189
+ //
190
+ // `notifyLoggedIn` is the lifecycle call `start()` makes from its
191
+ // login callback but a manually-imported session skips — it tells the
192
+ // updates manager the client is authorized (and seeds self into the
193
+ // peer cache). mtcute 0.30's own docs recommend calling it, or
194
+ // otherwise ensuring the user is logged in, before `startUpdatesLoop`.
195
+ // Resolve self from the imported session and notify BEFORE starting
196
+ // the loop, matching what `start()` does internally.
197
+ const me = await this.client.getMe();
198
+ await this.client.notifyLoggedIn(me.raw);
150
199
  await this.client.startUpdatesLoop();
151
200
  }
152
201
 
@@ -290,18 +339,30 @@ export class Driver {
290
339
  else queue.push(m);
291
340
  };
292
341
 
293
- const onNew = (msg: Message): void => {
294
- const observed = toObserved(msg, false);
295
- if (observed.chatId !== chatId) return;
296
- if (targetThread !== undefined && observed.threadId !== targetThread) return;
297
- dispatch(observed);
298
- };
299
- const onEdit = (msg: Message): void => {
300
- const observed = toObserved(msg, true);
342
+ // Defensive wrapper: a throw inside the listener propagates out of
343
+ // mtcute's `onNewMessage`/`onEditMessage` emitter (it does not catch
344
+ // listener errors) and drops the update entirely, so `observeMessages`
345
+ // would silently never yield it. `toObserved` is already hardened against
346
+ // the known `PeersIndex` throw, but any future getter that throws must
347
+ // NOT be allowed to swallow an observation — log and move on instead.
348
+ const dispatchObserved = (msg: Message, edited: boolean): void => {
349
+ let observed: ObservedMessage;
350
+ try {
351
+ observed = toObserved(msg, edited);
352
+ } catch (err) {
353
+ // eslint-disable-next-line no-console -- harness diagnostic
354
+ console.warn(
355
+ `[uat/driver] observeMessages: dropping unobservable message ` +
356
+ `(id=${msg.id}): ${err instanceof Error ? err.message : String(err)}`,
357
+ );
358
+ return;
359
+ }
301
360
  if (observed.chatId !== chatId) return;
302
361
  if (targetThread !== undefined && observed.threadId !== targetThread) return;
303
362
  dispatch(observed);
304
363
  };
364
+ const onNew = (msg: Message): void => dispatchObserved(msg, false);
365
+ const onEdit = (msg: Message): void => dispatchObserved(msg, true);
305
366
 
306
367
  c.onNewMessage.add(onNew);
307
368
  c.onEditMessage.add(onEdit);
@@ -838,15 +899,411 @@ export class Driver {
838
899
  }
839
900
 
840
901
  function toObserved(msg: Message, edited: boolean): ObservedMessage {
902
+ // Bot API 10.1 rich messages (`sendMessage` with `{ markdown }`) used to
903
+ // arrive as `messageMediaUnsupported` with an empty `message.message` on
904
+ // the pinned mtcute (0.27.x). With mtcute >=0.30 the message decodes to
905
+ // real text + entities, so we surface those directly. The
906
+ // `messageMediaUnsupported` sentinel path is KEPT as a defensive fallback:
907
+ // if a future Bot API / TL-layer change ever regresses the decode, the
908
+ // `\x01` sentinel still lets text-presence assertions fire (and the
909
+ // formatting scenario will flag the empty-entities case loudly rather than
910
+ // silently pass). It is no longer expected to trigger on the happy path.
911
+ const rawText = msg.text ?? "";
912
+ // Bot API 10.1 rich messages (`sendRichMessage({ markdown })`) arrive on the
913
+ // wire in a NEW TL field — `message.richMessage` — with the legacy `message`
914
+ // string left EMPTY and NO `entities`. mtcute 0.30 does not yet map that
915
+ // field onto `Message.text`/`Message.entities`, so `msg.text` is "" and the
916
+ // observation looks textless. This is the sibling case to the
917
+ // `messageMediaUnsupported` sentinel below: the visible text is on the wire,
918
+ // just in a shape the getter doesn't decode. Flatten the `richMessage`
919
+ // page-block/RichText tree ourselves into plain text + entities. (Confirmed
920
+ // via a raw-TL dump on the uat-host runner, #2744.)
921
+ const richDecoded =
922
+ rawText === "" && msg.raw._ === "message"
923
+ ? decodeRichMessage((msg.raw as { richMessage?: unknown }).richMessage)
924
+ : null;
925
+ const isRichMedia = rawText === "" && !richDecoded &&
926
+ msg.raw._ === "message" && msg.raw.media?._ === "messageMediaUnsupported";
927
+ // Resolve chat/sender ids from the RAW TL peer, not the `msg.chat` /
928
+ // `msg.sender` getters. Those getters look the peer up in the update's
929
+ // `PeersIndex` and THROW `MtArgumentError` ("peer not available in this
930
+ // index") when it's absent — which happens for the driver because it runs
931
+ // on `MemoryStorage` (empty peer cache each connect), so an incoming bot
932
+ // reply can arrive before that peer has been cached. An unguarded throw
933
+ // here propagates out of the mtcute `onNewMessage`/`onEditMessage` emitter
934
+ // (that emitter does not catch listener errors), so the WHOLE update is
935
+ // dropped and `observeMessages` never yields it — the exact "bot replied
936
+ // fast but `expectMessage` timed out" failure. `getMarkedPeerId` reads the
937
+ // raw TL peer directly and never touches the index, so it can't throw.
938
+ const chatId = getMarkedPeerId(msg.raw.peerId);
939
+ const rawFrom = msg.raw._ === "message" || msg.raw._ === "messageService"
940
+ ? msg.raw.fromId
941
+ : undefined;
942
+ const senderUserId = rawFrom
943
+ ? getMarkedPeerId(rawFrom)
944
+ : chatId; // no explicit sender ⇒ the DM peer is the sender (bot reply in a DM)
841
945
  return {
842
- chatId: msg.chat.id,
946
+ chatId,
843
947
  messageId: msg.id,
844
948
  threadId: msg.replyToMessage?.threadId ?? undefined,
845
- text: msg.text ?? "",
846
- senderUserId: msg.sender.id,
847
- fromBot: msg.sender.type === "user" && msg.sender.isBot === true,
949
+ text: isRichMedia ? "\x01" : (richDecoded ? richDecoded.text : rawText),
950
+ senderUserId,
951
+ fromBot: safeIsFromBot(msg),
848
952
  date: msg.date,
849
953
  edited,
850
954
  silent: msg.isSilent,
955
+ entities: isRichMedia
956
+ ? []
957
+ : (richDecoded ? richDecoded.entities : toObservedEntities(msg)),
958
+ link: safeMessageLink(msg),
959
+ };
960
+ }
961
+
962
+ /**
963
+ * Flatten mtcute's `Message.entities` into the `ObservedEntity[]` the UAT
964
+ * assertions consume. Pulls the entity `url` (text_link) and `language`
965
+ * (pre) out of the discriminated `params` union so scenarios can assert on
966
+ * link destinations and fenced-block languages, not just the span kind.
967
+ */
968
+ function toObservedEntities(msg: Message): ObservedEntity[] {
969
+ // Real mtcute always populates `.entities` (empty array for unformatted
970
+ // text). Guard against a message shape that lacks it (e.g. a hand-rolled
971
+ // test double) so the observation degrades to "no entities" rather than
972
+ // throwing and taking down the whole scenario.
973
+ const entities = msg.entities ?? [];
974
+ return entities.map((e): ObservedEntity => {
975
+ const params = e.params;
976
+ return {
977
+ kind: e.kind,
978
+ offset: e.offset,
979
+ length: e.length,
980
+ text: e.text,
981
+ url: params.kind === "text_link" ? params.url : undefined,
982
+ language: params.kind === "pre" ? params.language : undefined,
983
+ };
984
+ });
985
+ }
986
+
987
+ /**
988
+ * Decode a Bot API 10.1 rich message (`message.richMessage`) into flat
989
+ * `{ text, entities }`, mirroring what `Message.text` + `Message.entities`
990
+ * would carry if mtcute mapped the field. Returns `null` when the value isn't
991
+ * a `richMessage` (so callers fall back to the legacy `message` string).
992
+ *
993
+ * On the wire a `richMessage` is Telegram's Instant-View page tree: a list of
994
+ * `pageBlock`s, each carrying a `RichText` node (or, for tables/lists,
995
+ * structured children). We model the block kinds Telegram's GFM parser
996
+ * actually emits for a chat rich message, all shapes grounded against
997
+ * `@mtcute/tl@223.0.0` (the TL layer mtcute 0.30 ships):
998
+ *
999
+ * - `pageBlockParagraph` — styled inline text (`.text: RichText`).
1000
+ * - `pageBlockPreformatted` — fenced code → one `pre` entity spanning the
1001
+ * whole block, carrying `.language`.
1002
+ * - `pageBlockHeader` / — a markdown heading (`# …` / `## …`) →
1003
+ * `pageBlockSubheader` rendered as a `bold` entity over the line
1004
+ * (Telegram has no first-class heading entity;
1005
+ * GFM headings surface as bold on a phone).
1006
+ * - `pageBlockBlockquote` / — `> …` quote → a `blockquote` entity over the
1007
+ * `pageBlockPullquote` quote body (`.text`; `.caption` follows on a
1008
+ * newline, unstyled).
1009
+ * - `pageBlockList` — unordered list; `.items: PageListItem[]`
1010
+ * (`pageListItemText.text: RichText` or
1011
+ * `pageListItemBlocks.blocks: PageBlock[]`),
1012
+ * each item rendered `• ` + inline content.
1013
+ * - `pageBlockOrderedList` — ordered list; `.items: PageListOrderedItem[]`
1014
+ * (`…Text` / `…Blocks`) each carrying a `.num`
1015
+ * label rendered `<num>. ` + content.
1016
+ * - `pageBlockTable` — `.title: RichText`, `.rows: PageTableRow[]`,
1017
+ * each `.cells: PageTableCell[]` with an
1018
+ * optional `.text: RichText` (+ `.header`).
1019
+ * Cells join with " | ", rows with newlines;
1020
+ * header cells emit a `bold` entity.
1021
+ * - `pageBlockDivider` — a horizontal rule (`---`) → a literal "———"
1022
+ * line, no entity.
1023
+ *
1024
+ * NOTE (grounding): TL layer 223 has NO `textSpoiler` RichText kind. GFM
1025
+ * spoiler syntax (`||…||`) surfaces on the IV wire as `textMarked`
1026
+ * ("highlighted text") — so `textMarked` maps to the Bot API `spoiler`
1027
+ * entity. `textStrike`/`textUnderline` ARE first-class RichText kinds and
1028
+ * map to `strikethrough`/`underline` directly.
1029
+ *
1030
+ * Blocks are joined with a newline. The RichText tree is a
1031
+ * discriminated union: leaf `textPlain` carries a string; `textConcat` holds a
1032
+ * `texts[]` sequence; the styled wrappers (`textBold`/`textItalic`/`textFixed`/
1033
+ * `textUrl`/…) nest another RichText in `.text`. We flatten depth-first,
1034
+ * tracking UTF-16 code-unit offsets (JS string `.length` is already UTF-16, the
1035
+ * same units Telegram uses on the wire) and emit an `ObservedEntity` for each
1036
+ * styled span so the render-fidelity assertions see the same entity structure
1037
+ * the legacy `entities` path produces.
1038
+ */
1039
+ export function decodeRichMessage(
1040
+ rich: unknown,
1041
+ ): { text: string; entities: ObservedEntity[] } | null {
1042
+ const rm = rich as
1043
+ | { _?: string; blocks?: Array<Record<string, unknown>> }
1044
+ | undefined;
1045
+ if (!rm || rm._ !== "richMessage" || !Array.isArray(rm.blocks)) return null;
1046
+
1047
+ let text = "";
1048
+ const entities: ObservedEntity[] = [];
1049
+
1050
+ const emit = (
1051
+ kind: string,
1052
+ start: number,
1053
+ extra?: { url?: string; language?: string },
1054
+ ): void => {
1055
+ const length = text.length - start;
1056
+ if (length <= 0) return;
1057
+ entities.push({
1058
+ kind,
1059
+ offset: start,
1060
+ length,
1061
+ text: text.slice(start, start + length),
1062
+ url: extra?.url,
1063
+ language: extra?.language,
1064
+ });
1065
+ };
1066
+
1067
+ // Depth-first walk of a RichText node, appending plain text to `text` and
1068
+ // pushing an entity for each styled wrapper (mapping TL kinds to mtcute's).
1069
+ const walk = (node: unknown): void => {
1070
+ const n = node as {
1071
+ _?: string;
1072
+ text?: unknown;
1073
+ texts?: unknown[];
1074
+ url?: string;
1075
+ };
1076
+ if (!n || typeof n._ !== "string") return;
1077
+ switch (n._) {
1078
+ case "textEmpty":
1079
+ return;
1080
+ case "textPlain":
1081
+ if (typeof n.text === "string") text += n.text;
1082
+ return;
1083
+ case "textConcat":
1084
+ for (const t of n.texts ?? []) walk(t);
1085
+ return;
1086
+ case "textBold": {
1087
+ const s = text.length;
1088
+ walk(n.text);
1089
+ emit("bold", s);
1090
+ return;
1091
+ }
1092
+ case "textItalic": {
1093
+ const s = text.length;
1094
+ walk(n.text);
1095
+ emit("italic", s);
1096
+ return;
1097
+ }
1098
+ case "textUnderline": {
1099
+ const s = text.length;
1100
+ walk(n.text);
1101
+ emit("underline", s);
1102
+ return;
1103
+ }
1104
+ case "textStrike": {
1105
+ const s = text.length;
1106
+ walk(n.text);
1107
+ emit("strikethrough", s);
1108
+ return;
1109
+ }
1110
+ case "textFixed": {
1111
+ // Inline fixed-width (`code_token`) → Bot API `code` entity.
1112
+ const s = text.length;
1113
+ walk(n.text);
1114
+ emit("code", s);
1115
+ return;
1116
+ }
1117
+ case "textMarked": {
1118
+ // TL layer 223 has no `textSpoiler`; GFM `||spoiler||` rides the
1119
+ // IV "highlighted text" node (`textMarked`) → Bot API `spoiler`.
1120
+ const s = text.length;
1121
+ walk(n.text);
1122
+ emit("spoiler", s);
1123
+ return;
1124
+ }
1125
+ case "textUrl": {
1126
+ const s = text.length;
1127
+ walk(n.text);
1128
+ emit("text_link", s, { url: n.url });
1129
+ return;
1130
+ }
1131
+ case "textEmail": {
1132
+ const s = text.length;
1133
+ walk(n.text);
1134
+ emit("email", s);
1135
+ return;
1136
+ }
1137
+ default:
1138
+ // Any other wrapper (marked/sub/superscript/phone/anchor/…) — keep its
1139
+ // visible text but don't classify the span; the render assertions only
1140
+ // key off the kinds the gateway emits.
1141
+ if (n.text !== undefined) walk(n.text);
1142
+ return;
1143
+ }
851
1144
  };
1145
+
1146
+ // Emit a span that Telegram has no first-class entity for (heading, table
1147
+ // header) but which renders styled on a phone — we pick the closest Bot API
1148
+ // entity so the render-fidelity assertions can key off it.
1149
+ const emitOver = (kind: string, start: number): void => emit(kind, start);
1150
+
1151
+ // Walk one IV page-block, appending its text to `text` and pushing entities.
1152
+ // Recursive: lists/tables nest paragraph-like children.
1153
+ const walkBlock = (block: Record<string, unknown> | undefined): void => {
1154
+ if (!block || typeof block._ !== "string") return;
1155
+ switch (block._) {
1156
+ case "pageBlockPreformatted": {
1157
+ // Fenced code block → one `pre` entity spanning the whole block, with
1158
+ // the language string Telegram tagged the fence with.
1159
+ const s = text.length;
1160
+ walk(block.text);
1161
+ const length = text.length - s;
1162
+ if (length > 0) {
1163
+ entities.push({
1164
+ kind: "pre",
1165
+ offset: s,
1166
+ length,
1167
+ text: text.slice(s, s + length),
1168
+ url: undefined,
1169
+ language: (block.language as string) || undefined,
1170
+ });
1171
+ }
1172
+ return;
1173
+ }
1174
+ case "pageBlockHeader":
1175
+ case "pageBlockSubheader": {
1176
+ // GFM heading (`#`/`##`) → no first-class Telegram heading entity;
1177
+ // it renders bold on a phone, so we model it as a `bold` span.
1178
+ const s = text.length;
1179
+ walk(block.text);
1180
+ emitOver("bold", s);
1181
+ return;
1182
+ }
1183
+ case "pageBlockBlockquote":
1184
+ case "pageBlockPullquote": {
1185
+ // `> …` quote → `blockquote` entity over the quote body. `.caption`
1186
+ // (if any) follows on its own line, unstyled.
1187
+ const s = text.length;
1188
+ walk(block.text);
1189
+ emitOver("blockquote", s);
1190
+ const caption = block.caption as Record<string, unknown> | undefined;
1191
+ if (caption && caption._ && caption._ !== "textEmpty") {
1192
+ text += "\n";
1193
+ walk(caption);
1194
+ }
1195
+ return;
1196
+ }
1197
+ case "pageBlockList": {
1198
+ // Unordered list. Items are pageListItemText (RichText) or
1199
+ // pageListItemBlocks (nested page blocks); each gets a "• " bullet.
1200
+ const items = (block.items as Array<Record<string, unknown>>) ?? [];
1201
+ items.forEach((item, i) => {
1202
+ if (i > 0) text += "\n";
1203
+ text += "• ";
1204
+ if (item._ === "pageListItemText") {
1205
+ walk(item.text);
1206
+ } else if (item._ === "pageListItemBlocks") {
1207
+ const blocks = (item.blocks as Array<Record<string, unknown>>) ?? [];
1208
+ blocks.forEach((b, j) => {
1209
+ if (j > 0) text += "\n";
1210
+ walkBlock(b);
1211
+ });
1212
+ }
1213
+ });
1214
+ return;
1215
+ }
1216
+ case "pageBlockOrderedList": {
1217
+ // Ordered list. Items carry a `.num` label ("1", "2", …).
1218
+ const items = (block.items as Array<Record<string, unknown>>) ?? [];
1219
+ items.forEach((item, i) => {
1220
+ if (i > 0) text += "\n";
1221
+ text += `${(item.num as string) || String(i + 1)}. `;
1222
+ if (item._ === "pageListOrderedItemText") {
1223
+ walk(item.text);
1224
+ } else if (item._ === "pageListOrderedItemBlocks") {
1225
+ const blocks = (item.blocks as Array<Record<string, unknown>>) ?? [];
1226
+ blocks.forEach((b, j) => {
1227
+ if (j > 0) text += "\n";
1228
+ walkBlock(b);
1229
+ });
1230
+ }
1231
+ });
1232
+ return;
1233
+ }
1234
+ case "pageBlockTable": {
1235
+ // `.title` (optional) on its own line, then rows joined by newline,
1236
+ // cells within a row joined by " | ". Header cells emit a `bold` span.
1237
+ const title = block.title as Record<string, unknown> | undefined;
1238
+ let wroteTitle = false;
1239
+ if (title && title._ && title._ !== "textEmpty") {
1240
+ walk(title);
1241
+ wroteTitle = true;
1242
+ }
1243
+ const rows = (block.rows as Array<Record<string, unknown>>) ?? [];
1244
+ rows.forEach((row, ri) => {
1245
+ if (ri > 0 || wroteTitle) text += "\n";
1246
+ const cells = (row.cells as Array<Record<string, unknown>>) ?? [];
1247
+ cells.forEach((cell, ci) => {
1248
+ if (ci > 0) text += " | ";
1249
+ const s = text.length;
1250
+ if (cell.text !== undefined) walk(cell.text);
1251
+ if (cell.header) emitOver("bold", s);
1252
+ });
1253
+ });
1254
+ return;
1255
+ }
1256
+ case "pageBlockDivider": {
1257
+ // Horizontal rule (`---`). No entity; a literal rule line.
1258
+ text += "———";
1259
+ return;
1260
+ }
1261
+ default:
1262
+ // pageBlockParagraph, footer, kicker, title, subtitle, and any other
1263
+ // text-bearing block — inline walk of `.text`.
1264
+ walk(block.text);
1265
+ return;
1266
+ }
1267
+ };
1268
+
1269
+ for (const block of rm.blocks) {
1270
+ if (text.length > 0) text += "\n"; // join blocks in send order
1271
+ walkBlock(block);
1272
+ }
1273
+
1274
+ return { text, entities };
1275
+ }
1276
+
1277
+ /**
1278
+ * `Message.link` throws (`MtArgumentError`) for chats that don't support
1279
+ * message permalinks — notably private DMs, which is exactly where most
1280
+ * UAT scenarios run. Swallow that and leave the field unset rather than
1281
+ * blow up the whole observation; group/channel messages still get a real
1282
+ * `t.me/c/<chat>/<msgid>` link for human eyeball reference.
1283
+ */
1284
+ function safeMessageLink(msg: Message): string | undefined {
1285
+ try {
1286
+ return msg.link;
1287
+ } catch {
1288
+ return undefined;
1289
+ }
1290
+ }
1291
+
1292
+ /**
1293
+ * Whether the message was sent by a bot. Reads `msg.sender`, which resolves
1294
+ * the peer via the update's `PeersIndex` and THROWS `MtArgumentError` when
1295
+ * that peer isn't cached (the driver's `MemoryStorage` starts empty, so an
1296
+ * incoming reply can arrive before its sender is cached). We only need
1297
+ * `fromBot` for coarse classification, and `expectMessage`'s `from: "bot"`
1298
+ * filter keys off `senderUserId` (resolved from the raw peer above), not this
1299
+ * flag — so a swallowed lookup degrades to `false` rather than dropping the
1300
+ * whole observation. Group/DM messages with a cached sender still classify
1301
+ * correctly.
1302
+ */
1303
+ function safeIsFromBot(msg: Message): boolean {
1304
+ try {
1305
+ return msg.sender.type === "user" && msg.sender.isBot === true;
1306
+ } catch {
1307
+ return false;
1308
+ }
852
1309
  }
@@ -49,16 +49,28 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
49
49
  timeout: 60_000,
50
50
  });
51
51
 
52
- // ── 1. Section headers ──────────────────────────────────────────
52
+ // ── 1. Main page → External page navigation ────────────────────
53
+ // Nested-page design: the main page carries the Claude buttons + a
54
+ // "🌐 External models ▸" button; sr-* models and the billed-separately
55
+ // header live on the second page, reached by pressing mdl:page:ext.
56
+ const mainKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
57
+ const mainFlat = (mainKb ?? []).flat().filter((b) => b.callbackData);
58
+ const extOpen = mainFlat.find((b) => b.callbackData === "mdl:page:ext");
59
+ expect(extOpen, "🌐 External models ▸ open button on the main page").toBeDefined();
60
+ expect(menu.text).toContain("Max/Pro subscription");
61
+
62
+ // Open the external page and re-read the keyboard.
63
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:page:ext");
64
+ await new Promise((r) => setTimeout(r, 2_000));
65
+ const extMsg = await sc.driver.getMessage(sc.botUserId, menu.messageId);
66
+ expect(extMsg?.text ?? "", "external page body").toContain("billed separately");
53
67
  const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
54
68
  const flat = (kb ?? []).flat().filter((b) => b.callbackData);
55
69
 
56
- const claudeHeader = flat.find(
57
- (b) => b.text.includes("Claude") && b.text.includes("subscription") && b.callbackData === "mdl:h",
58
- );
59
- const openrouterHeader = flat.find(
60
- (b) => b.text.includes("OpenRouter") && b.callbackData === "mdl:h",
70
+ const externalHeader = flat.find(
71
+ (b) => b.text.includes("External") && b.callbackData === "mdl:h",
61
72
  );
73
+ const backButton = flat.find((b) => b.callbackData === "mdl:page:main");
62
74
  // Prefer deepseek-v3 (non-thinking, consistently fast) for the E2E test.
63
75
  // gemini-2.5-flash may run in thinking mode via OpenRouter (5+ min latency),
64
76
  // reasoning models (deepseek-r1, o1, o3) also take 2-5 min and hit the
@@ -70,14 +82,12 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
70
82
  flat.find((b) => b.callbackData?.startsWith("mdl:sr:"));
71
83
 
72
84
  if (!srButton) {
73
- console.log("No sr-* buttons in menu — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
85
+ console.log("No sr-* buttons on the external page — agent not LiteLLM-enabled or no sr-* models registered. Skipping.");
74
86
  return;
75
87
  }
76
88
 
77
- expect(claudeHeader, "Claude (Max / Pro subscription) header row").toBeDefined();
78
- expect(openrouterHeader, "OpenRouter / external header row").toBeDefined();
79
- expect(menu.text).toContain("Max/Pro subscription");
80
- expect(menu.text).toContain("OpenRouter");
89
+ expect(externalHeader, "External (billed separately) header row").toBeDefined();
90
+ expect(backButton, " Back button on the external page").toBeDefined();
81
91
 
82
92
  // ── 2. sr-* switch ─────────────────────────────────────────────
83
93
  const spendBefore = await getLiteLLMSpendForAgent(AGENT);
@@ -146,15 +156,25 @@ describe("uat: /model sr-* LiteLLM routing — section headers + session switch
146
156
  from: "bot",
147
157
  timeout: 90_000,
148
158
  });
159
+ // The header row now lives on the external page — navigate there first.
160
+ const mainKb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
161
+ const extOpen = (mainKb ?? []).flat().find((b) => b.callbackData === "mdl:page:ext");
162
+ if (!extOpen) {
163
+ console.log("No External page — agent not LiteLLM-enabled. Skipping.");
164
+ return;
165
+ }
166
+ await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:page:ext");
167
+ await new Promise((r) => setTimeout(r, 2_000));
168
+ const extMsg = await sc.driver.getMessage(sc.botUserId, menu.messageId);
149
169
  const kb = await sc.driver.getKeyboard(sc.botUserId, menu.messageId);
150
170
  const flat = (kb ?? []).flat();
151
171
  const headerBtn = flat.find((b) => b.callbackData === "mdl:h");
152
172
  if (!headerBtn) {
153
- console.log("No header row — agent not LiteLLM-enabled. Skipping.");
173
+ console.log("No header row on external page — agent not LiteLLM-enabled. Skipping.");
154
174
  return;
155
175
  }
156
- // Pressing the header should NOT change the menu text
157
- const textBefore = menu.text;
176
+ // Pressing the header should NOT change the (external-page) menu text
177
+ const textBefore = extMsg?.text ?? "";
158
178
  await sc.driver.pressButton(sc.botUserId, menu.messageId, "mdl:h");
159
179
  await new Promise((r) => setTimeout(r, 3_000));
160
180
  const after = await sc.driver.getMessage(sc.botUserId, menu.messageId);