switchroom 0.21.8 → 0.21.9

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 (29) hide show
  1. package/dist/cli/switchroom.js +100 -39
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +2 -2
  4. package/skills/switchroom-architecture/telegram.md +12 -10
  5. package/skills/switchroom-cli/SKILL.md +1 -1
  6. package/telegram-plugin/README.md +3 -1
  7. package/telegram-plugin/dist/gateway/gateway.js +151 -86
  8. package/telegram-plugin/format.ts +12 -4
  9. package/telegram-plugin/package.json +1 -1
  10. package/telegram-plugin/render/code-segments.ts +38 -4
  11. package/telegram-plugin/render/dollar-math-guard.ts +16 -1
  12. package/telegram-plugin/render/ir.ts +53 -3
  13. package/telegram-plugin/render/parse.ts +73 -14
  14. package/telegram-plugin/render/render.ts +53 -15
  15. package/telegram-plugin/render/unsupported-token-guard.ts +45 -80
  16. package/telegram-plugin/rich-send.ts +22 -7
  17. package/telegram-plugin/shared/bot-runtime.ts +3 -2
  18. package/telegram-plugin/telegraph.ts +6 -4
  19. package/telegram-plugin/tests/grammy-rich-message-types.test.ts +199 -0
  20. package/telegram-plugin/tests/render/dollar-math-guard.test.ts +43 -0
  21. package/telegram-plugin/tests/render/guard-composition.test.ts +102 -0
  22. package/telegram-plugin/tests/render/parse.test.ts +30 -5
  23. package/telegram-plugin/tests/render/render.test.ts +9 -4
  24. package/telegram-plugin/tests/render/rich-render.test.ts +46 -5
  25. package/telegram-plugin/tests/render/tg-entity.test.ts +242 -0
  26. package/telegram-plugin/tests/render/unsupported-token-guard.test.ts +66 -66
  27. package/telegram-plugin/tests/sent-text-capture.test.ts +3 -3
  28. package/telegram-plugin/tests/telegraph.test.ts +1 -1
  29. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +17 -8
@@ -0,0 +1,242 @@
1
+ // Telegram inline `tg:` entities in mdast IMAGE position survive the whole
2
+ // outbound pipeline.
3
+ //
4
+ // Bot API grammar (https://core.telegram.org/bots/api, "Rich Markdown style"):
5
+ //
6
+ // ![](tg://emoji?id=5368324170671202286) custom emoji
7
+ // ![22:45 tomorrow](tg://time?unix=1647531900&format=wDT) date_time
8
+ //
9
+ // (the `date_time` MessageEntity landed in Bot API 9.5, 2026-03-01; the
10
+ // rich-message `RichTextDateTime` class in 10.1, 2026-06-11 — Bot API
11
+ // changelog.) grammy 1.44.0 speaks 10.1, so the wire has accepted this since
12
+ // #2669 — but the RENDER path escaped the brackets to literal text, so the
13
+ // syntax was dead on the streamed send path.
14
+ //
15
+ // Two DISTINCT outbound surfaces are pinned here, because they do not share a
16
+ // pipeline:
17
+ // - the STREAMED path (stream-controller.ts:272) → `renderOutboundChunks` →
18
+ // `parse → renderSafe`. This is the surface the escaping bug lived on.
19
+ // - the REPLY-TOOL path (outbound-send-path.ts) → `computeReplyChunks` →
20
+ // `richMessage()`, which NEVER runs the renderer — only the
21
+ // accidental-formatting guards. It was already correct; these tests pin it
22
+ // so a future guard can't silently start eating the syntax.
23
+
24
+ import { describe, it, expect } from "vitest";
25
+ import { parse } from "../../render/parse.js";
26
+ import { renderSafe, SUPPORTED_INLINE } from "../../render/render.js";
27
+ import { renderOutboundChunks } from "../../render/rich-render.js";
28
+ import { guardAccidentalFormatting, richMessage } from "../../rich-send.js";
29
+ import { computeReplyChunks } from "../../gateway/outbound-send-path.js";
30
+ import { splitMarkdownChunks, RICH_MESSAGE_MAX_CHARS } from "../../format.js";
31
+ import type { Inline } from "../../render/ir.js";
32
+
33
+ /** The doc's own date_time example, verbatim. */
34
+ const DATE_TIME = "![22:45 tomorrow](tg://time?unix=1647531900&format=wDT)";
35
+ /** The doc's own custom-emoji example, verbatim (empty label). */
36
+ const EMOJI = "![](tg://emoji?id=5368324170671202286)";
37
+
38
+ /** parse → renderSafe, the streamed path's core. */
39
+ const roundTrip = (md: string): string => renderSafe(parse(md), md).text;
40
+
41
+ /** Flatten every inline node in the rendered IR of `md`. */
42
+ function inlines(md: string): Inline[] {
43
+ const out: Inline[] = [];
44
+ const walk = (n: Inline): void => {
45
+ out.push(n);
46
+ for (const c of (n as { children?: Inline[] }).children ?? []) walk(c);
47
+ };
48
+ for (const b of parse(md).blocks) {
49
+ for (const n of (b as { children?: Inline[] }).children ?? []) walk(n);
50
+ }
51
+ return out;
52
+ }
53
+
54
+ describe("tg:// inline entities: parse → renderSafe round-trip", () => {
55
+ // THE bug this PR fixes: at HEAD the image node demoted to `plain` and
56
+ // `escapeMarkdown` backslash-escaped `[`, `]` and `=`, shipping
57
+ // `!\[22:45 tomorrow\](tg://time?unix\=…&format\=wDT)` — literal text, no
58
+ // entity. Byte-exact preservation is the whole contract.
59
+ it("preserves a date_time entity byte-exact", () => {
60
+ const body = `Meeting at ${DATE_TIME} sharp.`;
61
+ expect(roundTrip(body)).toBe(body);
62
+ });
63
+
64
+ it("preserves the empty-label custom-emoji entity byte-exact", () => {
65
+ expect(roundTrip(`hi ${EMOJI} there`)).toBe(`hi ${EMOJI} there`);
66
+ });
67
+
68
+ it("preserves a date_time entity with no `format` parameter", () => {
69
+ const body = "![22:45 tomorrow](tg://time?unix=1647531900)";
70
+ expect(roundTrip(body)).toBe(body);
71
+ });
72
+
73
+ it("folds the entity into a `tg-entity` IR node, not `plain`", () => {
74
+ const node = inlines(`x ${DATE_TIME}`).find((n) => n.type === "tg-entity");
75
+ expect(node).toMatchObject({
76
+ type: "tg-entity",
77
+ label: "22:45 tomorrow",
78
+ href: "tg://time?unix=1647531900&format=wDT",
79
+ });
80
+ });
81
+
82
+ it("survives inside a blockquote, a heading, a list item and a table cell", () => {
83
+ for (const body of [
84
+ `> when: ${DATE_TIME}`,
85
+ `## Due ${DATE_TIME}`,
86
+ `- due ${DATE_TIME}`,
87
+ `| when | who |\n| --- | --- |\n| ${DATE_TIME} | me |`,
88
+ ]) {
89
+ expect(roundTrip(body)).toContain(DATE_TIME);
90
+ }
91
+ });
92
+
93
+ it("matches the `tg:` href case-insensitively (URL schemes are)", () => {
94
+ const body = "![t](TG://Time?unix=1647531900)";
95
+ // Folded as an entity, and the author's ORIGINAL bytes are re-emitted —
96
+ // the parser never rewrites the href.
97
+ expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(true);
98
+ expect(roundTrip(body)).toBe(body);
99
+ });
100
+
101
+ it("lists `tg-entity` in the renderer's construct allowlist", () => {
102
+ expect(SUPPORTED_INLINE).toContain("tg-entity");
103
+ });
104
+ });
105
+
106
+ describe("tg:// inline entities: label escaping (no bracket breakout)", () => {
107
+ // A model-authored label carrying `]` would close the label early and
108
+ // smuggle raw bracket syntax past the renderer if the label were re-emitted
109
+ // undecorated. It is prose, so it gets `escapeMarkdown` exactly like a
110
+ // `plain` node.
111
+ it("escapes brackets in the label instead of letting them close it early", () => {
112
+ const out = roundTrip("![see [22:45]](tg://time?unix=1&format=t)");
113
+ expect(out).toBe("![see \\[22:45\\]](tg://time?unix=1&format=t)");
114
+ // No BARE `]` before the destination — the only unescaped one closes the label.
115
+ expect(out.slice(0, out.indexOf("](")).includes("\\]")).toBe(true);
116
+ });
117
+
118
+ it("escapes emphasis delimiters in the label", () => {
119
+ expect(roundTrip("![a*b_c~d](tg://time?unix=1&format=t)")).toBe(
120
+ "![a\\*b\\_c\\~d](tg://time?unix=1&format=t)",
121
+ );
122
+ });
123
+
124
+ it("collapses a soft line break in the label so the construct stays on one line", () => {
125
+ const out = roundTrip("![22:45\ntomorrow](tg://time?unix=1&format=t)");
126
+ expect(out).toBe("![22:45 tomorrow](tg://time?unix=1&format=t)");
127
+ expect(out).not.toContain("\n");
128
+ });
129
+ });
130
+
131
+ describe("tg:// inline entities: no regression of the image demotion", () => {
132
+ // Deliberate, unchanged: an http(s) `![](…)` is a Telegram MEDIA block —
133
+ // "Media can be specified only as a separate block" (Rich Markdown style) —
134
+ // not an inline entity, and switchroom emits no media blocks. It keeps
135
+ // degrading to escaped literal text.
136
+ it("still degrades an http(s) image link to escaped plain text", () => {
137
+ expect(roundTrip("![alt](https://x.example/y.png)")).toBe(
138
+ "!\\[alt\\](https://x.example/y.png)",
139
+ );
140
+ });
141
+
142
+ it("still degrades an UNDOCUMENTED tg:// image href to escaped plain text", () => {
143
+ // The allowlist is deliberate: only `tg://time` and `tg://emoji` are
144
+ // documented in image position, so anything else stays literal rather than
145
+ // shipping syntax Telegram may parse-reject.
146
+ expect(roundTrip("![t](tg://photo?id=1)")).toBe("!\\[t\\](tg://photo?id\\=1)");
147
+ expect(roundTrip("![t](tg://user?id=1)")).toBe("!\\[t\\](tg://user?id\\=1)");
148
+ });
149
+
150
+ it("leaves an ordinary `[label](tg://user?id=…)` mention link alone", () => {
151
+ const body = "ping [Ken](tg://user?id=123456789)";
152
+ expect(roundTrip(body)).toBe(body);
153
+ });
154
+ });
155
+
156
+ describe("tg:// inline entities: code context stays literal", () => {
157
+ it("keeps the syntax verbatim inside an inline code span", () => {
158
+ const body = "use `![22:45](tg://time?unix=1&format=t)` for that";
159
+ expect(roundTrip(body)).toBe(body);
160
+ // and it is a `code` node, NOT a folded entity
161
+ expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(false);
162
+ });
163
+
164
+ it("keeps the syntax verbatim inside a fenced code block", () => {
165
+ const body = "```\n![22:45](tg://time?unix=1&format=t)\n```";
166
+ expect(roundTrip(body)).toBe(body);
167
+ expect(inlines(body).some((n) => n.type === "tg-entity")).toBe(false);
168
+ });
169
+ });
170
+
171
+ describe("tg:// inline entities: the accidental-formatting guards are a no-op", () => {
172
+ // `guardAccidentalFormatting` is the universal seam (installed as a grammy
173
+ // API transformer in shared/bot-runtime.ts), so it runs over EVERY outbound
174
+ // body — rendered or hand-built card alike. Its documented trigger set
175
+ // (`_ * > . ~ == || $ #`) does not intersect this syntax, but nothing
176
+ // asserted that until now.
177
+ const bodies = [
178
+ `Meeting at ${DATE_TIME} sharp.`,
179
+ `${DATE_TIME} at the very start of the line`,
180
+ EMOJI,
181
+ `- due ${DATE_TIME}\n- and ${DATE_TIME}`,
182
+ `> when: ${DATE_TIME}`,
183
+ `| when |\n| --- |\n| ${DATE_TIME} |`,
184
+ "![see \\[22:45\\]](tg://time?unix=1&format=t)",
185
+ ];
186
+
187
+ it("leaves every tg:// entity body byte-identical", () => {
188
+ for (const body of bodies) expect(guardAccidentalFormatting(body)).toBe(body);
189
+ });
190
+
191
+ it("is idempotent over the rendered form too", () => {
192
+ for (const body of bodies) {
193
+ const rendered = roundTrip(body);
194
+ expect(guardAccidentalFormatting(rendered)).toBe(rendered);
195
+ }
196
+ });
197
+ });
198
+
199
+ describe("tg:// inline entities: live outbound seams", () => {
200
+ it("survives the STREAMED seam (renderOutboundChunks)", () => {
201
+ const body = `Standup starts ${DATE_TIME}.`;
202
+ const pieces = renderOutboundChunks(body);
203
+ expect(pieces).toHaveLength(1);
204
+ expect(pieces[0].mode).toBe("markdown");
205
+ expect(pieces[0].text).toBe(body);
206
+ });
207
+
208
+ it("survives the REPLY-TOOL seam (computeReplyChunks → richMessage)", () => {
209
+ // This surface never touches the renderer — it is guards-only. Pinned so a
210
+ // future guard cannot start escaping the syntax unnoticed.
211
+ const body = `Standup starts ${DATE_TIME}.`;
212
+ const chunks = computeReplyChunks({
213
+ effectiveText: body,
214
+ literalText: false,
215
+ limit: RICH_MESSAGE_MAX_CHARS,
216
+ chunkMode: "length",
217
+ });
218
+ expect(chunks.map((c) => richMessage(c).markdown).join("")).toContain(DATE_TIME);
219
+ });
220
+ });
221
+
222
+ describe("tg:// inline entities: chunk boundaries never bisect the construct", () => {
223
+ // `INLINE_SPAN_PATTERNS`' link pattern used to start at the `[`, leaving the
224
+ // leading `!` outside the protected span: a cut inside the construct
225
+ // retreated only to the `[`, stranding `!` on the previous chunk and
226
+ // silently demoting a date_time entity to an ordinary link.
227
+ it("keeps a cap-straddling date_time entity whole in one chunk", () => {
228
+ const body = `${"x".repeat(80)} ${DATE_TIME}`;
229
+ const chunks = splitMarkdownChunks(body, 110);
230
+ expect(chunks.length).toBeGreaterThan(1);
231
+ // Exactly one chunk carries the entity, whole.
232
+ expect(chunks.filter((c) => c.includes(DATE_TIME))).toHaveLength(1);
233
+ // And no chunk ends on the stranded `!`.
234
+ for (const c of chunks) expect(c.endsWith("!")).toBe(false);
235
+ });
236
+
237
+ it("still keeps an ordinary link whole (unchanged behaviour)", () => {
238
+ const link = "[a fairly long link label here](https://example.com/some/path)";
239
+ const chunks = splitMarkdownChunks(`${"x".repeat(80)} ${link}`, 110);
240
+ expect(chunks.filter((c) => c.includes(link))).toHaveLength(1);
241
+ });
242
+ });
@@ -3,44 +3,61 @@ import { guardUnsupportedTokens } from "../../render/unsupported-token-guard.js"
3
3
  import { richMessage } from "../../rich-send.js";
4
4
 
5
5
  describe("guardUnsupportedTokens — deterministic send-time repair", () => {
6
- it("folds <details><summary> into a Telegram expandable blockquote", () => {
6
+ // ── Natively-supported constructs pass through BYTE-IDENTICAL ────────────
7
+ // Wire-verified 2026-08-13: raw sendRichMessage probes showed `<details>`,
8
+ // footnotes, `<sub>`/`<sup>`/`<u>`, `<aside>`, `tg://time` and task lists
9
+ // all parse into real typed nodes on Telegram's rich markdown path. An
10
+ // earlier revision of this guard "repaired" `<details>` into a `**> `
11
+ // expandable blockquote (MarkdownV2-only syntax that renders as LITERAL
12
+ // `**>` text on this path) and deleted footnote markers — both conversions
13
+ // destroyed supported constructs and are deleted. These tests would FAIL on
14
+ // the old guard.
15
+ it("passes a <details><summary> block through untouched (native construct)", () => {
7
16
  const input =
8
- "Here is the trace:\n<details><summary>Stack trace</summary>\nline 1\nline 2\n</details>\ndone";
9
- const out = guardUnsupportedTokens(input);
10
- // Anti-tautology: the raw HTML tags MUST be gone from the wire body.
11
- expect(out).not.toContain("<details>");
12
- expect(out).not.toContain("</details>");
13
- expect(out).not.toContain("<summary>");
14
- // Summary becomes the expandable-blockquote first line (`**> ` marker).
15
- expect(out).toContain("**> Stack trace");
16
- // Body lines become plain `> ` continuation lines.
17
- expect(out).toContain("> line 1");
18
- expect(out).toContain("> line 2");
17
+ "Here is the trace:\n<details open><summary>Stack trace</summary>\n\nline 1\nline 2\n\n</details>\ndone";
18
+ expect(guardUnsupportedTokens(input)).toBe(input);
19
19
  });
20
20
 
21
- it("folds a <details> without a <summary> into an expandable blockquote", () => {
22
- const out = guardUnsupportedTokens("<details>hidden body text</details>");
23
- expect(out).not.toContain("<details");
24
- expect(out).toContain("**> hidden body text");
21
+ it("passes a <details> without a <summary> through untouched", () => {
22
+ const input = "<details>hidden body text</details>";
23
+ expect(guardUnsupportedTokens(input)).toBe(input);
25
24
  });
26
25
 
27
- it("strips caret highlight / superscript pairs to their inner text", () => {
28
- expect(guardUnsupportedTokens("energy is x^2^ joules")).toBe(
29
- "energy is x2 joules",
30
- );
31
- expect(guardUnsupportedTokens("a ^highlighted^ word")).toBe(
32
- "a highlighted word",
26
+ it("never converts <details> into the retired `**>` marker", () => {
27
+ const out = guardUnsupportedTokens(
28
+ "<details><summary>More</summary>\nbody\n</details>",
33
29
  );
30
+ expect(out).not.toContain("**>");
31
+ expect(out).toContain("<details>");
34
32
  });
35
33
 
36
- it("removes footnote reference markers but keeps definition lines", () => {
34
+ it("keeps footnote reference markers AND definition lines (native construct)", () => {
37
35
  expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
38
- "see the note here",
36
+ "see the note[^1] here",
39
37
  );
40
- // A `[^1]:` definition line is left intact (the negative lookahead).
41
38
  expect(guardUnsupportedTokens("[^1]: the definition")).toBe(
42
39
  "[^1]: the definition",
43
40
  );
41
+ // Alphanumeric ids too — the pair renders as real footnote machinery.
42
+ expect(guardUnsupportedTokens("claim[^note] holds\n\n[^note]: body")).toBe(
43
+ "claim[^note] holds\n\n[^note]: body",
44
+ );
45
+ });
46
+
47
+ it("passes <sub>/<sup>/<u>/<aside> HTML tags through untouched", () => {
48
+ const input =
49
+ "H<sub>2</sub>O and x<sup>2</sup> and <u>under</u>\n<aside>pull\n<cite>credit</cite></aside>";
50
+ expect(guardUnsupportedTokens(input)).toBe(input);
51
+ });
52
+
53
+ // ── The one genuinely-unsupported token: caret pairs ─────────────────────
54
+ it("strips caret highlight / superscript pairs to their inner text", () => {
55
+ expect(guardUnsupportedTokens("energy is x^2^ joules")).toBe(
56
+ "energy is x2 joules",
57
+ );
58
+ expect(guardUnsupportedTokens("a ^highlighted^ word")).toBe(
59
+ "a highlighted word",
60
+ );
44
61
  });
45
62
 
46
63
  it("leaves unpaired carets scattered across prose intact (no interior space)", () => {
@@ -71,27 +88,18 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
71
88
  expect(guardUnsupportedTokens("x^2^ metres")).toBe("x2 metres");
72
89
  });
73
90
 
74
- it("repairs alphanumeric footnote markers, not just numeric", () => {
75
- // POLISH-4: widened from digits-only so `[^note]`/`[^ref]` no longer ship
76
- // as literal bracket noise. Each would survive UNTOUCHED on origin/main.
77
- expect(guardUnsupportedTokens("see the note[^note] here")).toBe(
78
- "see the note here",
79
- );
80
- expect(guardUnsupportedTokens("as shown[^ref] above")).toBe(
81
- "as shown above",
82
- );
83
- expect(guardUnsupportedTokens("point[^fn1] made")).toBe("point made");
84
- // A single-letter id is a footnote too.
85
- expect(guardUnsupportedTokens("claim[^a] holds")).toBe("claim holds");
86
- // Definition lines with an alphanumeric id are still left intact (`(?!:)`).
87
- expect(guardUnsupportedTokens("[^note]: the definition")).toBe(
88
- "[^note]: the definition",
89
- );
91
+ it("never strips carets inside a $…$ math span (protected segment)", () => {
92
+ // `$x^2y^2$` contains an alphanumeric-only caret pair (`^2y^`) that the
93
+ // caret regex WOULD strip in bare prose — but the compact math span is a
94
+ // protected segment (code-segments.ts), so the wire bytes survive intact
95
+ // for Telegram to typeset as a mathematical_expression node.
96
+ const math = "inline $x^2y^2$ done";
97
+ expect(guardUnsupportedTokens(math)).toBe(math);
90
98
  });
91
99
 
92
100
  it("leaves regex-literal negated char classes intact", () => {
93
- // Real negated char classes contain punctuation / ranges / escapes, so the
94
- // alphanumeric-only id requirement excludes them — these stay verbatim.
101
+ // The guard no longer touches `[^…]` at all (footnotes are supported),
102
+ // so every negated-char-class shape survives verbatim.
95
103
  expect(guardUnsupportedTokens("use [^/] to match")).toBe(
96
104
  "use [^/] to match",
97
105
  );
@@ -99,23 +107,14 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
99
107
  "strip [^a-z] chars",
100
108
  );
101
109
  expect(guardUnsupportedTokens('match [^"] here')).toBe('match [^"] here');
102
- // Accepted tradeoff (task POLISH-4): a PURE-alphanumeric negated class in
103
- // BARE prose like `array[^index]` is now treated as a footnote and stripped.
104
- // This is rare — regex/index literals in real prose live in code spans,
105
- // which splitProtectedSegments masks (see code-span test below).
106
- expect(guardUnsupportedTokens("array[^index] lookup")).toBe("array lookup");
107
- // …but inside a code span the same literal is untouched.
110
+ expect(guardUnsupportedTokens("array[^index] lookup")).toBe(
111
+ "array[^index] lookup",
112
+ );
108
113
  expect(guardUnsupportedTokens("`array[^index]` lookup")).toBe(
109
114
  "`array[^index]` lookup",
110
115
  );
111
116
  });
112
117
 
113
- it("repairs a real numeric footnote marker", () => {
114
- expect(guardUnsupportedTokens("see the note[^1] here")).toBe(
115
- "see the note here",
116
- );
117
- });
118
-
119
118
  it("is a strict no-op for clean markdown (no target tokens)", () => {
120
119
  const clean =
121
120
  "**Answer:** the `config.yaml` file. See [docs](https://example.com/x).";
@@ -139,18 +138,19 @@ describe("guardUnsupportedTokens — deterministic send-time repair", () => {
139
138
  expect(guardUnsupportedTokens(once)).toBe(once);
140
139
  });
141
140
 
142
- it("OUTCOME: the composed richMessage wire body has unsupported tokens repaired", () => {
143
- // End-to-end through the real send-path composition. This is the
144
- // anti-tautology anchor: without guardUnsupportedTokens wired into
145
- // guardAccidentalFormatting, the raw `<details>` / `^` / `[^1]` tokens would
146
- // reach the wire and this assertion would FAIL.
141
+ it("OUTCOME: the composed richMessage wire body preserves supported constructs and repairs carets", () => {
142
+ // End-to-end through the real send-path composition (the FULL
143
+ // guardAccidentalFormatting pipeline). This is the anti-tautology anchor:
144
+ // on the pre-fix pipeline, `<details>` was folded into the unsupported
145
+ // `**>` marker and `[^1]` was deleted — every assertion below would FAIL.
147
146
  const { markdown } = richMessage(
148
- "note[^1]\n<details><summary>More</summary>\ndetail line\n</details>\nx^2^",
147
+ "note[^1]\n<details><summary>More</summary>\ndetail line\n</details>\nx^2^\n\n[^1]: the note",
149
148
  );
150
- expect(markdown).not.toContain("<details>");
151
- expect(markdown).not.toContain("[^1]");
152
- expect(markdown).toContain("**> More");
153
- expect(markdown).toContain("> detail line");
154
- expect(markdown).toContain("x2");
149
+ expect(markdown).toContain("<details><summary>More</summary>");
150
+ expect(markdown).toContain("</details>");
151
+ expect(markdown).toContain("note[^1]");
152
+ expect(markdown).toContain("[^1]: the note");
153
+ expect(markdown).not.toContain("**>");
154
+ expect(markdown).toContain("x2"); // caret pair repaired
155
155
  });
156
156
  });
@@ -43,9 +43,9 @@ const CHAT = 5550001
43
43
  /**
44
44
  * The response Telegram actually returns for `sendRichMessage` — the body lives
45
45
  * in `rich_message.blocks`; `text` and `caption` are ABSENT. Verified against
46
- * `@grammyjs/types` 3.28.0 (the version `grammy@^1.44` resolves)
47
- * `message.d.ts:94` (`RichMessageMessage = CommonMessage &
48
- * MsgWith<"rich_message">`) and `:180` (`rich_message?: RichMessage`), and
46
+ * `@grammyjs/types` 4.0.0 (the version `grammy@^1.45` resolves)
47
+ * `message.d.ts:98` (`RichMessageMessage = CommonMessage &
48
+ * MsgWith<"rich_message">`) and `:184` (`rich_message?: RichMessage`), and
49
49
  * against the Bot API reference: `sendRichMessage` "On success, the sent
50
50
  * Message is returned", `Message.rich_message: RichMessage` "Optional. Message
51
51
  * is a rich formatted message", `RichMessage.blocks` "Content of the message".
@@ -165,7 +165,7 @@ describe('markdownToTelegraphNodes — block elements', () => {
165
165
  })
166
166
 
167
167
  it('folds an expandable `**>` quote into one blockquote, marker stripped', () => {
168
- // The Bot API 10.1 expandable-blockquote syntax: `**>` opener on the first
168
+ // The LEGACY switchroom expandable-quote encoding: `**>` opener on the first
169
169
  // line, `>` continuation lines. Telegra.ph has no collapsible-blockquote
170
170
  // tag, so it degrades to a normal blockquote — but the `**>` marker MUST be
171
171
  // recognised. Before the fix the `**>` line became a paragraph with literal
@@ -271,13 +271,16 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
271
271
  );
272
272
 
273
273
  // ---------------------------------------------------------------------------
274
- // Rich-render wiring proof — expandable / collapsible content round-trip.
274
+ // Rich-render wiring proof — legacy `**> ` quote repair round-trip.
275
275
  //
276
276
  // This is the piece the unit suite CANNOT prove: that a REAL markdown reply
277
- // carrying the Bot API 10.1 expandable-blockquote marker (`**> `) flows
277
+ // carrying the LEGACY switchroom expandable-quote marker (`**> `) flows
278
278
  // through the live send path — parse.ts (marker -> IR `expandable: true`) ->
279
- // render.ts (IR -> `**> ` markdown) -> renderSafe -> sendRichMessage — and
280
- // that Telegram actually parses it back to a blockquote entity on the wire.
279
+ // render.ts (IR -> plain `> ` markdown; `**>` is MarkdownV2-only and renders
280
+ // as literal text on the rich path, wire-proved 2026-08-13, so the renderer
281
+ // no longer emits it) -> renderSafe -> sendRichMessage — and that Telegram
282
+ // parses the repaired output back to a blockquote entity with NO literal
283
+ // `**>` glyphs reaching the reader.
281
284
  //
282
285
  // Runs ONLY when the rich-render flag is on in this process (see
283
286
  // RICH_RENDER_ON) AND the driver creds are present; self-skips green
@@ -299,7 +302,7 @@ const EXPANDABLE_SAMPLE = [
299
302
  "uat: expandable/collapsible content round-trips through the live renderer",
300
303
  () => {
301
304
  it(
302
- "a `**>` reply parses -> renders -> sends -> decodes as a blockquote entity",
305
+ "a legacy `**>` reply is repaired to a plain quote and decodes as a blockquote entity",
303
306
  async () => {
304
307
  const sc = await spinUp({ agent: AGENT });
305
308
  try {
@@ -318,9 +321,9 @@ const EXPANDABLE_SAMPLE = [
318
321
  ).not.toBe("\x01");
319
322
  expect(reply.text).toContain(EXP_MARKER);
320
323
 
321
- // The collapsible construct must survive as a real blockquote entity
322
- // on the wire — proof the parse->render->send path produced valid
323
- // Bot API 10.1 markdown, not stray `**>` literal text.
324
+ // The legacy quote must survive as a real blockquote entity on the
325
+ // wire — proof the parse->render->send path repaired the `**>` line
326
+ // into the quote instead of shipping stray literal text.
324
327
  const present = kindsPresent(reply);
325
328
  expect(
326
329
  [...present],
@@ -331,6 +334,12 @@ const EXPANDABLE_SAMPLE = [
331
334
  // The quoted body text must round-trip (content never lost).
332
335
  expect(reply.text).toContain("first hidden line");
333
336
 
337
+ // And the retired marker must never reach the reader as literal
338
+ // glyphs — the exact failure mode of the pre-repair renderer
339
+ // (Telegram rendered the `**> ` opener as a literal `**>` paragraph
340
+ // while the `> ` continuation lines formed a detached quote).
341
+ expect(reply.text).not.toContain("**>");
342
+
334
343
  console.info(
335
344
  "[uat] expandable round-trip entity structure: " +
336
345
  JSON.stringify(