switchroom 0.21.9 → 0.21.11

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 (46) hide show
  1. package/dist/cli/switchroom.js +143 -27
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +3 -2
  4. package/telegram-plugin/dist/gateway/gateway.js +1152 -296
  5. package/telegram-plugin/format.ts +74 -1
  6. package/telegram-plugin/gateway/answer-route-overrides.ts +163 -0
  7. package/telegram-plugin/gateway/answer-thread-resolve.test.ts +175 -1
  8. package/telegram-plugin/gateway/answer-thread-resolve.ts +58 -6
  9. package/telegram-plugin/gateway/escalation-staleness.ts +526 -0
  10. package/telegram-plugin/gateway/gateway.ts +61 -68
  11. package/telegram-plugin/gateway/obligation-wiring.ts +91 -3
  12. package/telegram-plugin/gateway/outbound-send-path.ts +62 -1
  13. package/telegram-plugin/gateway/reply-route-log.test.ts +134 -0
  14. package/telegram-plugin/gateway/reply-route-log.ts +118 -0
  15. package/telegram-plugin/gateway/speech-capture.ts +158 -0
  16. package/telegram-plugin/gateway/stream-render.ts +1 -1
  17. package/telegram-plugin/history.ts +21 -0
  18. package/telegram-plugin/registry/subagents-bugs.test.ts +3 -3
  19. package/telegram-plugin/render/html-fold.ts +372 -0
  20. package/telegram-plugin/render/parse.ts +578 -29
  21. package/telegram-plugin/render/render.ts +14 -13
  22. package/telegram-plugin/tests/answer-route-side-effect.test.ts +111 -0
  23. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +3 -3
  24. package/telegram-plugin/tests/escalation-staleness.test.ts +1275 -0
  25. package/telegram-plugin/tests/forwarded-rich-message-coalesce.test.ts +6 -6
  26. package/telegram-plugin/tests/forwarded-rich-message.test.ts +8 -8
  27. package/telegram-plugin/tests/history.test.ts +78 -0
  28. package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +29 -1
  29. package/telegram-plugin/tests/orphaned-db-sweep.test.ts +17 -1
  30. package/telegram-plugin/tests/render/html-dialect-content-loss.test.ts +326 -0
  31. package/telegram-plugin/tests/render/html-dialect.test.ts +283 -0
  32. package/telegram-plugin/tests/render/parse.test.ts +9 -5
  33. package/telegram-plugin/tests/send-reply-golden.test.ts +290 -3
  34. package/telegram-plugin/tests/speech-capture.test.ts +296 -0
  35. package/telegram-plugin/tests/status-pin.test.ts +2 -2
  36. package/telegram-plugin/tests/subagent-handback-inbound-builder.test.ts +2 -2
  37. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +2 -2
  38. package/telegram-plugin/tests/telegram-format.test.ts +52 -0
  39. package/telegram-plugin/tests/tts-normalize.test.ts +114 -0
  40. package/telegram-plugin/tests/turn-supersede-finalizes-prior-card.test.ts +1 -1
  41. package/telegram-plugin/tests/voice-normalize-text.test.ts +89 -0
  42. package/telegram-plugin/tests/worker-origin-gap-dispatch.test.ts +1 -1
  43. package/telegram-plugin/tts-normalize.ts +47 -9
  44. package/telegram-plugin/uat/scenarios/jtbd-supergroup-reply-channel.test.ts +1 -1
  45. package/telegram-plugin/voice-normalize-text.ts +48 -9
  46. package/telegram-plugin/worker-activity-feed.ts +2 -2
@@ -140,7 +140,7 @@ describe('wiring: message:rich_message dispatches through the coalescing path',
140
140
  // through `handleInboundCoalesced`.
141
141
  // ---------------------------------------------------------------------------
142
142
 
143
- const CHAT_ID = -1004223464247
143
+ const CHAT_ID = -1004444444444
144
144
  const USER_ID = 777
145
145
 
146
146
  function makeBotInfo() {
@@ -159,13 +159,13 @@ function makeBotInfo() {
159
159
 
160
160
  /** Forwarded rich (bot) message, no top-level text/caption — the live shape. */
161
161
  function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
162
- const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
162
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Sidekick', username: 'example_sidekick_bot' }
163
163
  return {
164
164
  update_id,
165
165
  message: {
166
166
  message_id: update_id,
167
- chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
168
- from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
167
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
168
+ from: { id: USER_ID, is_bot: false, first_name: 'Alice' },
169
169
  date: 1784837003,
170
170
  forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
171
171
  forward_from: originUser,
@@ -180,8 +180,8 @@ function makeTextUpdate(update_id: number, text: string): Update {
180
180
  update_id,
181
181
  message: {
182
182
  message_id: update_id,
183
- chat: { id: CHAT_ID, type: 'supergroup', title: 'ProductOS', is_forum: true },
184
- from: { id: USER_ID, is_bot: false, first_name: 'Ken' },
183
+ chat: { id: CHAT_ID, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
184
+ from: { id: USER_ID, is_bot: false, first_name: 'Alice' },
185
185
  date: 1784837004,
186
186
  text,
187
187
  },
@@ -85,13 +85,13 @@ function buildHarness() {
85
85
  * forward_origin (modern) + legacy forward_from/forward_date siblings +
86
86
  * rich_message content, NO top-level text/caption. */
87
87
  function makeForwardedRichUpdate(update_id: number, blocks: unknown[]): Update {
88
- const originUser = { id: 8500000001, is_bot: true, first_name: 'Klanker', username: 'meken_klanker_bot' }
88
+ const originUser = { id: 8500000001, is_bot: true, first_name: 'Sidekick', username: 'example_sidekick_bot' }
89
89
  return {
90
90
  update_id,
91
91
  message: {
92
92
  message_id: 944,
93
- chat: { id: -1004223464247, type: 'supergroup', title: 'ProductOS', is_forum: true },
94
- from: { id: 777, is_bot: false, first_name: 'Ken' },
93
+ chat: { id: -1004444444444, type: 'supergroup', title: 'Example Supergroup', is_forum: true },
94
+ from: { id: 777, is_bot: false, first_name: 'Alice' },
95
95
  date: 1784837003,
96
96
  forward_origin: { type: 'user', date: 1784830000, sender_user: originUser },
97
97
  forward_from: originUser,
@@ -142,12 +142,12 @@ describe('forwarded rich (bot) message — the row-944 regression oracle', () =>
142
142
  (update as unknown as { message: { forward_origin: never } }).message.forward_origin,
143
143
  )
144
144
  const meta = buildForwardOriginMeta([origin!])
145
- expect(meta.forwarded_from).toBe('Klanker (@meken_klanker_bot)')
145
+ expect(meta.forwarded_from).toBe('Sidekick (@example_sidekick_bot)')
146
146
  expect(meta.forwarded_from_type).toBe('user')
147
147
  expect(meta.forwarded_date).toBeDefined()
148
148
  // Trusted-lane separation (#3162): the delivered BODY carries no
149
149
  // provenance strings — those live only in the channel attrs.
150
- expect(delivered[0].text).not.toContain('Klanker')
150
+ expect(delivered[0].text).not.toContain('Sidekick')
151
151
  })
152
152
 
153
153
  it('a rich message with genuinely no extractable text still yields an honest turn', async () => {
@@ -175,7 +175,7 @@ describe('forwarded rich (bot) message — the row-944 regression oracle', () =>
175
175
  message: {
176
176
  message_id: 950,
177
177
  chat: { id: 777, type: 'private' },
178
- from: { id: 777, is_bot: false, first_name: 'Ken' },
178
+ from: { id: 777, is_bot: false, first_name: 'Alice' },
179
179
  date: 1784837100,
180
180
  rich_message: { blocks: [{ type: 'heading', size: 2, text: 'Release plan' }, { type: 'paragraph', text: 'Ship Friday.' }] },
181
181
  },
@@ -196,14 +196,14 @@ describe('working shapes stay unchanged (no regression on existing forwards)', (
196
196
  msg.forward_origin = {
197
197
  type: 'user',
198
198
  date: 1_700_000_000,
199
- sender_user: { id: 424242, is_bot: false, first_name: 'Ken', last_name: 'Thompson' },
199
+ sender_user: { id: 424242, is_bot: false, first_name: 'Alice', last_name: 'Example' },
200
200
  }
201
201
  await bot.handleUpdate(update)
202
202
 
203
203
  expect(delivered).toHaveLength(1)
204
204
  expect(delivered[0]).toMatchObject({ via: 'text', text: 'here is the brief I forwarded' })
205
205
  const meta = buildForwardOriginMeta([parseForwardOrigin(msg.forward_origin as never)!])
206
- expect(meta.forwarded_from).toBe('Ken Thompson')
206
+ expect(meta.forwarded_from).toBe('Alice Example')
207
207
  expect(meta.forwarded_date).toBeDefined()
208
208
  })
209
209
 
@@ -546,6 +546,84 @@ describe('hasOutboundDeliveredSince', () => {
546
546
  expect(hasOutboundDeliveredSince('-100', openedAt, 6, 1)).toBe(false)
547
547
  })
548
548
  })
549
+
550
+ // #4681 — the optional UPPER bound. The obligation escalate branch's reroute
551
+ // fallback is licensed by an `EXPLICIT_OVERRIDDEN` record, which is evidence
552
+ // about ONE instant; an open-ended forward query lets unrelated traffic in the
553
+ // routed topic, arriving arbitrarily later, read as that record's answer and
554
+ // close a genuinely unanswered obligation in silence.
555
+ describe('untilMs parameter (the reroute fallback upper bound)', () => {
556
+ it('omitted, the query stays open-ended forward (pre-existing behaviour)', () => {
557
+ const openedAt = 1_000_000 * 1000
558
+ recordOutbound({
559
+ chat_id: '-100',
560
+ thread_id: null,
561
+ message_ids: [10],
562
+ texts: [SUBSTANTIVE],
563
+ ts: 1_090_000, // 90_000s later
564
+ })
565
+ expect(hasOutboundDeliveredSince('-100', openedAt)).toBe(true)
566
+ })
567
+
568
+ it('excludes a row delivered AFTER the bound', () => {
569
+ const openedAt = 1_000_000 * 1000
570
+ recordOutbound({
571
+ chat_id: '-100',
572
+ thread_id: null,
573
+ message_ids: [10],
574
+ texts: [SUBSTANTIVE],
575
+ ts: 1_000_090, // 90s after the lower bound
576
+ })
577
+ // Inside an 18.5s window it cannot be this record's answer.
578
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 18_500)).toBe(
579
+ false,
580
+ )
581
+ })
582
+
583
+ it('includes a row inside the bound, and the boundary second itself', () => {
584
+ const openedAt = 1_000_000 * 1000
585
+ recordOutbound({
586
+ chat_id: '-100',
587
+ thread_id: null,
588
+ message_ids: [10],
589
+ texts: [SUBSTANTIVE],
590
+ ts: 1_000_018, // 18s after — inside an 18.5s window
591
+ })
592
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 18_500)).toBe(
593
+ true,
594
+ )
595
+ // The bound is CEILed to whole seconds, so a row written in the second the
596
+ // bound falls inside is admitted — rounding never drops a real answer.
597
+ expect(hasOutboundDeliveredSince('-100', openedAt, undefined, 200, openedAt + 17_600)).toBe(
598
+ true,
599
+ )
600
+ })
601
+
602
+ it('composes with the thread filter (parameter binding order)', () => {
603
+ // The `ts <= ?` placeholder is appended BEFORE `thread_id = ?`; if the two
604
+ // params were pushed in the wrong order the thread id would land in the
605
+ // timestamp comparison and this would silently misbehave.
606
+ const openedAt = 1_000_000 * 1000
607
+ recordOutbound({
608
+ chat_id: '-100',
609
+ thread_id: 5,
610
+ message_ids: [10],
611
+ texts: [SUBSTANTIVE],
612
+ ts: 1_000_010,
613
+ })
614
+ recordOutbound({
615
+ chat_id: '-100',
616
+ thread_id: 6,
617
+ message_ids: [11],
618
+ texts: [SUBSTANTIVE],
619
+ ts: 1_000_090,
620
+ })
621
+ const until = openedAt + 18_500
622
+ expect(hasOutboundDeliveredSince('-100', openedAt, 5, 200, until)).toBe(true)
623
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6, 200, until)).toBe(false) // too late
624
+ expect(hasOutboundDeliveredSince('-100', openedAt, 6, 200)).toBe(true) // unbounded finds it
625
+ })
626
+ })
549
627
  })
550
628
 
551
629
  // Review finding H5 regression: the original PRIMARY KEY (chat_id, thread_id, message_id) does
@@ -26,7 +26,12 @@ const gatewaySrc =
26
26
  // P7 PR-10 (#2996): the InboundMessage envelope assembly (origin_turn_id,
27
27
  // topic_scope, meta lane) moved verbatim into gateway/inbound-router.ts
28
28
  // (buildInboundEnvelope) — include it in the scraped corpus.
29
- readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8')
29
+ readFileSync(resolve(__dirname, '..', 'gateway', 'inbound-router.ts'), 'utf-8') +
30
+ '\n' +
31
+ // The reply-route telemetry line (via/RECOVERED/UNROUTED/MISROUTE_RISK/
32
+ // CROSS_CHAT_ANCHOR_DROPPED) moved verbatim out of resolveAnswerThreadWithLog
33
+ // into the pure formatReplyRouteLog (#2996 line ratchet) — scrape it too.
34
+ readFileSync(resolve(__dirname, '..', 'gateway', 'reply-route-log.ts'), 'utf-8')
30
35
  // #2996 P4-A: the enqueue handler (turn ctor: `const turnId = deriveTurnId`,
31
36
  // `rememberRecentTurn(next)`, `promoteQueuedStatus`) moved VERBATIM into
32
37
  // stream-render.ts with handleSessionEvent. Enqueue-seam assertions span both.
@@ -147,6 +152,29 @@ describe('framework-owned origin recovery (determinism residual, 2026-06-05)', (
147
152
  expect(gatewaySrc).toMatch(/via === 'quoted' \? ' QUOTED\(framework-origin\)' : ''/)
148
153
  })
149
154
 
155
+ it('a thread anchor from ANOTHER chat is dropped before it can be sent (cross-chat guard)', () => {
156
+ // The routing input carries the target + anchor chat ids, so the pure
157
+ // resolver can refuse a foreign anchor (Telegram 400 "message thread not
158
+ // found"). Behaviour is asserted in answer-thread-resolve.test.ts; this
159
+ // pins the WIRING so a refactor cannot silently stop passing them.
160
+ //
161
+ // NOT A SAFETY NET — read before relying on it. Like its ~20 siblings in
162
+ // this file, this is a source-text CALL-SITE RATCHET: it greps gateway.ts
163
+ // for the argument spellings. Mutating `isCrossChatAnchor` to
164
+ // `return false` leaves every assertion here GREEN (verified), because the
165
+ // call site is still spelled the same way. The behavioural oracles are
166
+ // `answer-thread-resolve.test.ts` (the pure predicate + precedence),
167
+ // `reply-route-log.test.ts` (the telemetry), and the
168
+ // `SWITCHROOM_TURN_ORIGIN_ROUTING=0` block in `send-reply-golden.test.ts`
169
+ // (the legacy branch, at the wire). Those are what fail on a broken guard.
170
+ const fn = gatewaySrc.split('function resolveAnswerThreadWithLog')[1]?.split('\nfunction ')[0] ?? ''
171
+ expect(fn).toMatch(/targetChatId: chatId/)
172
+ expect(fn).toMatch(/originChatId: originTurn\?\.sessionChatId/)
173
+ expect(fn).toMatch(/liveChatId: liveTurn\?\.sessionChatId/)
174
+ // And the dropped anchor is observable, with both chat ids on the line.
175
+ expect(gatewaySrc).toMatch(/CROSS_CHAT_ANCHOR_DROPPED\(target=/)
176
+ })
177
+
150
178
  it('the kill switch defaults ON and is independent of TURN_ORIGIN_ROUTING', () => {
151
179
  expect(gatewaySrc).toMatch(/SWITCHROOM_FRAMEWORK_ORIGIN_ROUTING !== '0'/)
152
180
  })
@@ -106,7 +106,13 @@ import {
106
106
  runOrphanedDbSweepTick,
107
107
  startOrphanedDbSweep,
108
108
  } from '../gateway/orphaned-db-sweep.js'
109
- import { GATEWAY_SIGNATURES } from '../../src/fleet-health/detect.js'
109
+ import { GATEWAY_SIGNATURES, detectGatewayFindings } from '../../src/fleet-health/detect.js'
110
+ import { mapSignal } from '../../src/fleet-health/mapping.js'
111
+
112
+ /** Severity the fleet-health ledger would book for a real sweep log. */
113
+ function ledgerSeverities(log: string): number[] {
114
+ return detectGatewayFindings('uat', log).findings.map((f) => mapSignal(f.signal).severity)
115
+ }
110
116
 
111
117
  /**
112
118
  * `/proc/self/fd` is Linux-only. Without this guard every detection test would
@@ -294,6 +300,13 @@ describe.skipIf(!onLinux)('orphaned-db-sweep — history.db recovery', () => {
294
300
  // alarm has no consumer.
295
301
  expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
296
302
 
303
+ // #4680 — but this tick RECOVERED: the reopen landed in the same tick and
304
+ // the writer self-check proved the handle durable. The ledger must book
305
+ // that as the informational lane, not a severity-3 silent-loss incident.
306
+ // Asserted against the REAL sweep output, so a wording change in either the
307
+ // emitter or the detector fails here instead of drifting silently.
308
+ expect(ledgerSeverities(log)).toEqual([1])
309
+
297
310
  // 7. THE FIX, half one: the deleted-inode handles are actually GONE. A
298
311
  // close that leaves un-finalized statements behind leaves them open
299
312
  // (measured), so this is the assertion that pins the hard close.
@@ -439,6 +452,9 @@ describe.skipIf(!onLinux)('orphaned-db-sweep — registry.db and unowned lanes',
439
452
  expect(log).toContain('registry.db')
440
453
  expect(log).toContain('RESTART')
441
454
  expect(GATEWAY_SIGNATURES['orphaned-db-handle'].test(log)).toBe(true)
455
+ // #4680 — the registry lane has NO in-process recovery, so it stays the
456
+ // severity-3 alarm. This is the half of the split that must not be lost.
457
+ expect(ledgerSeverities(log)).toEqual([3])
442
458
 
443
459
  // Detection ONLY: the raw handle is left alone, so its consumers (the
444
460
  // by-value `turnsDb` captures in gateway.ts) are never handed a closed DB.
@@ -0,0 +1,326 @@
1
+ // Outcome tests for the content-loss regressions found in the raw-HTML
2
+ // dialect fold (#4691, merged as fa9018a7, unreleased).
3
+ //
4
+ // 29 of the 37 tests here fail on fa9018a7 — measured by running this exact
5
+ // file against that revision, not asserted. The other 8 pass on both and are
6
+ // guards: they pin behaviour the fix must not disturb. (An earlier revision of
7
+ // this header claimed "every assertion fails on fa9018a7"; at 31 tests the
8
+ // true figure was 23.) Five of the 29 — the `<pre>` fence-width cases — also
9
+ // fail on 6c120cee, the first fix's own merge commit, which emitted a
10
+ // hardcoded three-backtick fence.
11
+ //
12
+ // They drive the real `renderOutbound` (`parse` -> `renderSafe`), i.e. exactly
13
+ // what the live send path calls, so they assert the WIRE OUTCOME rather than
14
+ // an internal code path.
15
+ //
16
+ // The through-line: fa9018a7's degrade bucket DELETED any token it could not
17
+ // fold or pass through. That is safe for markup but catastrophic for prose —
18
+ // `<service>/<key>`, `<agent>` and "the `<b>` tag" are pervasive in this
19
+ // project's own agent output, and they were silently vanishing from replies.
20
+ // The discriminator is now MATCHING, not the tag name: a balanced pair is
21
+ // markup (drop it, keep the content), an unmatched marker is prose (keep it,
22
+ // HTML-entity-escaped so the wire cannot read it as a tag).
23
+
24
+ import { describe, it, expect } from "vitest";
25
+ import { renderOutbound } from "../../render/rich-render.js";
26
+ import { parse } from "../../render/parse.js";
27
+ import { escapeLinkHref } from "../../format.js";
28
+
29
+ const render = (src: string): string => renderOutbound(src).text;
30
+
31
+ describe("BLOCKER: angle-bracket placeholders in prose survive (#4691 regression)", () => {
32
+ it("keeps a `<service>/<key>` convention line intact", () => {
33
+ // fa9018a7: "Use the / convention."
34
+ expect(render("Use the <service>/<key> convention.")).toBe(
35
+ "Use the &lt;service&gt;/&lt;key&gt; convention.",
36
+ );
37
+ });
38
+
39
+ it("keeps a trailing `<key>` placeholder on a command line", () => {
40
+ // fa9018a7: "run switchroom vault get "
41
+ expect(render("run switchroom vault get <key>")).toBe(
42
+ "run switchroom vault get &lt;key&gt;",
43
+ );
44
+ });
45
+
46
+ it("keeps an `<agent>` path segment", () => {
47
+ // fa9018a7: "logs live at /host-home/.switchroom/logs//"
48
+ expect(render("logs live at /host-home/.switchroom/logs/<agent>/")).toBe(
49
+ "logs live at /host-home/.switchroom/logs/&lt;agent&gt;/",
50
+ );
51
+ });
52
+
53
+ it("keeps a tag NAMED in prose when nothing closes it", () => {
54
+ // fa9018a7: "The tag is bold and is italic"
55
+ expect(render("The <b> tag is bold and <i> is italic")).toBe(
56
+ "The &lt;b&gt; tag is bold and &lt;i&gt; is italic",
57
+ );
58
+ });
59
+
60
+ it("never emits a bare `<` for a token it kept as prose", () => {
61
+ // The escape is what makes keeping the token safe: Rich HTML documents
62
+ // `&lt;`/`&gt;`/`&amp;` as supported named entities, so this cannot trip
63
+ // `unsupported start tag` the way a bare `<service>` would.
64
+ const out = render("Use <service>/<key> and <agent>.");
65
+ expect(out).not.toMatch(/<[A-Za-z/]/);
66
+ expect(out).toBe("Use &lt;service&gt;/&lt;key&gt; and &lt;agent&gt;.");
67
+ });
68
+
69
+ it("does not double-escape when its own output is re-parsed", () => {
70
+ // Re-parsing decodes `&lt;` back to a text-node `<`, which never reaches
71
+ // the fold at all (the documented residual in html-fold.ts's header), so
72
+ // the second pass is NOT byte-idempotent. What must never happen is a
73
+ // compounding `&amp;lt;` that the reader actually sees.
74
+ const once = render("Use the <service>/<key> convention.");
75
+ expect(once).toBe("Use the &lt;service&gt;/&lt;key&gt; convention.");
76
+ expect(render(once)).not.toContain("&amp;");
77
+ });
78
+
79
+ it("still DROPS the markup of a balanced unknown pair (content kept)", () => {
80
+ // The other half of the discriminator: a matched pair really is markup.
81
+ expect(render("<marquee>scrolling</marquee>")).toBe("scrolling");
82
+ expect(render(`<span data-x="1">t</span>`)).toBe("t");
83
+ });
84
+ });
85
+
86
+ describe("MAJOR 1: `<pre>` folds to a fenced block, not a broken code span", () => {
87
+ it("renders `<pre><code>` as a real fence", () => {
88
+ // fa9018a7: "`const a = 1;\nconst b = 2;`" — a backtick span wrapping a
89
+ // newline, which Telegram will not parse, so the reader sees literal
90
+ // backticks around broken text.
91
+ expect(render("<pre><code>const a = 1;\nconst b = 2;</code></pre>")).toBe(
92
+ "```\nconst a = 1;\nconst b = 2;\n```",
93
+ );
94
+ });
95
+
96
+ it("never emits a code span containing a newline", () => {
97
+ const out = render("<pre><code>a\nb</code></pre>");
98
+ expect(out).not.toMatch(/(^|[^`])`[^`]*\n[^`]*`/);
99
+ });
100
+
101
+ it("carries the `class=\"language-…\"` hint into the fence info string", () => {
102
+ expect(
103
+ render(`<pre><code class="language-python">print(1)</code></pre>`),
104
+ ).toBe("```python\nprint(1)\n```");
105
+ });
106
+
107
+ it("handles a bare `<pre>` with no inner `<code>`", () => {
108
+ expect(render("<pre>one\ntwo</pre>")).toBe("```\none\ntwo\n```");
109
+ });
110
+
111
+ it("leaves no stray leading or trailing newline around the fence", () => {
112
+ // The layout newlines a `<pre>` block carries are not content.
113
+ expect(render("<pre>\nbody\n</pre>")).toBe("```\nbody\n```");
114
+ expect(render("<pre>\nbody\n</pre>")).not.toMatch(/^\n|\n$/);
115
+ });
116
+
117
+ it("degrades to an inline code span where a fence cannot survive", () => {
118
+ // A fence inside a `#` line or a table cell would end the block/row.
119
+ expect(render("# a <pre>x</pre> b")).toBe("# a `x` b");
120
+ expect(render("| h |\n| --- |\n| <pre>x</pre> |")).toContain("| `x` |");
121
+ });
122
+
123
+ // ── Regression: the fold's fence must be WIDER than its own body ────────
124
+ // `buildPreBlock` emitted a hardcoded three-backtick delimiter as a `raw`
125
+ // node (verbatim wire passthrough — nothing downstream widens it). A `<pre>`
126
+ // whose body carried its own ``` therefore shipped THREE delimiters: the
127
+ // wire opens at #1, closes at #2, reads the remainder as prose, and #3 opens
128
+ // a fence that never terminates -> `can't find end of Pre entity` 400 and a
129
+ // whole-message plain-text resend. `fa9018a7` escaped the backticks instead
130
+ // — cosmetically worse, but wire-correct; this restores wire-correctness
131
+ // using the same width rule `renderCodeBlock` already applies.
132
+ it("widens the fence past a three-backtick run in the body", () => {
133
+ expect(render("<pre>\na\n```\nb\n</pre>")).toBe("````\na\n```\nb\n````");
134
+ });
135
+
136
+ it("widens the fence past a longer run than three", () => {
137
+ expect(render("<pre>\na\n````\nb\n</pre>")).toBe("`````\na\n````\nb\n`````");
138
+ expect(render("<pre>\na\n`````\nb\n</pre>")).toBe(
139
+ "``````\na\n`````\nb\n``````",
140
+ );
141
+ });
142
+
143
+ it("widens past a run that shares a line with other text", () => {
144
+ expect(render("<pre><code>x ``` y</code></pre>")).toBe(
145
+ "````\nx ``` y\n````",
146
+ );
147
+ });
148
+
149
+ it("still emits a plain three-backtick fence when the body has no run", () => {
150
+ // The widening must not inflate the common case.
151
+ expect(render("<pre>\nplain body\n</pre>")).toBe("```\nplain body\n```");
152
+ expect(render("<pre>\na ` b `` c\n</pre>")).toBe("```\na ` b `` c\n```");
153
+ });
154
+
155
+ it("keeps the language hint attached to the widened delimiter", () => {
156
+ expect(
157
+ render('<pre><code class="language-md">see ```\n</code></pre>'),
158
+ ).toBe("````md\nsee ```\n````");
159
+ });
160
+
161
+ it("emits a terminating fence for every inner backtick run length", () => {
162
+ // Outcome, not shape: whatever the body carries, the delimiter run must
163
+ // appear on exactly TWO lines (open and close) and be strictly longer than
164
+ // every run inside, so the wire can neither close it early nor leave it
165
+ // open. This is the property whose violation produced the 400.
166
+ for (const inner of ["```", "````", "`````", "` `` ```", "a```b", "```` x"]) {
167
+ const src = `<pre>\nhead\n${inner}\ntail\n</pre>`;
168
+ const out = render(src);
169
+ const lines = out.split("\n");
170
+ const delim = lines[0];
171
+ expect(delim, `open delimiter for ${JSON.stringify(inner)}`).toMatch(
172
+ /^`{3,}$/,
173
+ );
174
+ expect(lines[lines.length - 1], `close delimiter for ${inner}`).toBe(
175
+ delim,
176
+ );
177
+ // Every backtick run in the BODY is strictly shorter than the delimiter.
178
+ const body = lines.slice(1, -1).join("\n");
179
+ for (const run of body.match(/`+/g) ?? []) {
180
+ expect(
181
+ run.length,
182
+ `body run ${JSON.stringify(run)} vs delimiter ${delim}`,
183
+ ).toBeLessThan(delim.length);
184
+ }
185
+ }
186
+ });
187
+ });
188
+
189
+ describe("MAJOR 2: structural tags do not glue their content together", () => {
190
+ it("separates list items instead of emitting `onetwo`", () => {
191
+ expect(render("<ul><li>one</li><li>two</li></ul>")).toBe("one \ntwo");
192
+ });
193
+
194
+ it("separates paragraphs instead of emitting `para onepara two`", () => {
195
+ expect(render("<p>para one</p><p>para two</p>")).toBe(
196
+ "para one \npara two",
197
+ );
198
+ });
199
+
200
+ it("separates table cells", () => {
201
+ expect(render("<table><tr><td>a</td><td>b</td></tr></table>")).toBe(
202
+ "a \nb",
203
+ );
204
+ });
205
+
206
+ it("leaves no separator hanging at the edges of the block", () => {
207
+ const out = render("<div>only</div>");
208
+ expect(out).toBe("only");
209
+ });
210
+
211
+ it("still renders a markup-only block to nothing", () => {
212
+ // Regression guard for the separator: an empty structural block must not
213
+ // become a whitespace-only paragraph and a spurious `\n\n`.
214
+ expect(render("a\n\n<div></div>\n\nb")).toBe("a\n\nb");
215
+ expect(render("a\n\n<!-- c -->\n\nb")).toBe("a\n\nb");
216
+ });
217
+
218
+ it("uses a space, not a newline, inside a table cell", () => {
219
+ // A newline in a cell ends the row.
220
+ const out = render("| h |\n| --- |\n| <p>x</p><p>y</p> |");
221
+ expect(out).toContain("| x y |");
222
+ });
223
+ });
224
+
225
+ describe("MAJOR 3: the passthrough bucket is balance-checked", () => {
226
+ it("does not ship an unmatched `<u>` raw", () => {
227
+ // fa9018a7 emitted this verbatim -> `unclosed start tag` 400 -> the
228
+ // fallback resends the WHOLE message as plain text. One stray marker in
229
+ // relayed content was a denial-of-formatting on the agent's reply.
230
+ expect(render("hello <u>world")).toBe("hello &lt;u&gt;world");
231
+ });
232
+
233
+ it("does not ship a stray close marker raw", () => {
234
+ expect(render("</details>")).toBe("&lt;/details&gt;");
235
+ });
236
+
237
+ it("keeps the balanced inner pair and escapes only the unmatched outer", () => {
238
+ expect(render("<u><u>x</u>")).toBe("&lt;u&gt;<u>x</u>");
239
+ });
240
+
241
+ it("still passes a BALANCED allowlist tag through raw", () => {
242
+ for (const src of [
243
+ "<u>under</u>",
244
+ "H<sub>2</sub>O",
245
+ "x<sup>2</sup>",
246
+ "<aside><cite>note</cite></aside>",
247
+ ]) {
248
+ expect(render(src)).toBe(src);
249
+ }
250
+ });
251
+
252
+ it("passes a `<details>` block through even though it spans mdast blocks", () => {
253
+ // The balance check MUST be document-level: micromark splits this into
254
+ // three separate `html` nodes, so a per-node check would wrongly call the
255
+ // open and close markers unmatched and escape them.
256
+ const src = "<details open><summary>S</summary>\n\nbody\n\n</details>";
257
+ expect(render(src)).toBe(src);
258
+ });
259
+
260
+ it("does not let a tag inside a code fence balance a prose marker", () => {
261
+ // Tags in a fence are not markup and must not satisfy anything outside it.
262
+ const out = render("```\n</u>\n```\n\nhello <u>world");
263
+ expect(out).toContain("```\n</u>\n```");
264
+ expect(out).toContain("hello &lt;u&gt;world");
265
+ });
266
+ });
267
+
268
+ describe("MAJOR 4: the fence comment's claimed safety property (pinned)", () => {
269
+ it("PINS the lazy-continuation counterexample the comment gets wrong", () => {
270
+ // The `**>` rewrite tracks TOP-LEVEL fences only, so the `> ``` ` opener
271
+ // here is invisible to it and the column-0 `**> lazy` line is rewritten,
272
+ // eating the `**`. CommonMark forbids lazy continuation into fenced code,
273
+ // so that line should CLOSE the blockquote and stay literal.
274
+ //
275
+ // This is PRE-EXISTING and identical before #4691 — NOT a regression — and
276
+ // fixing it needs real container tracking, not a wider fence regex. Pinned
277
+ // so any future container-aware rewrite has to decide it deliberately;
278
+ // `FENCE_DELIM_RE`'s doc comment now describes this instead of claiming
279
+ // the case cannot happen.
280
+ expect(render("> ```\n**> lazy\n> ```")).toBe("> ```\n> lazy\n> ```");
281
+ });
282
+ });
283
+
284
+ describe("nits: breaks, nested links, and href hygiene", () => {
285
+ it("does not break a heading block with `<br>`", () => {
286
+ // fa9018a7: "# a \nb" — the hard break ends the heading and orphans `b`.
287
+ expect(render("# a<br>b")).toBe("# a b");
288
+ expect(parse("# a<br>b").blocks).toHaveLength(1);
289
+ });
290
+
291
+ it("keeps a `<br>` a real break in ordinary prose", () => {
292
+ expect(render("one<br>two")).toBe("one \ntwo");
293
+ // Two explicit breaks are the author's, not synthesized separators — they
294
+ // must not be collapsed by the separator normalizer.
295
+ expect(render("a<br><br>b")).toBe("a \n \nb");
296
+ });
297
+
298
+ it("does not silently drop a nested `<a>`'s href", () => {
299
+ // fa9018a7: "[out in](https://o.example)" — the inner URL vanished.
300
+ const out = render(
301
+ '<a href="https://o.example">out <a href="https://i.example">in</a></a>',
302
+ );
303
+ expect(out).toContain("i.example");
304
+ });
305
+
306
+ it("percent-encodes whitespace in a link destination", () => {
307
+ // A bare destination ends at the first whitespace character, so a raw
308
+ // space turned the rest of the URL into a title and a raw newline
309
+ // terminated the link outright.
310
+ expect(render('<a href="https://ex.com/a b">x</a>')).toBe(
311
+ "[x](https://ex.com/a%20b)",
312
+ );
313
+ expect(render('<a href="https://ex.com/a\nb">x</a>')).toBe(
314
+ "[x](https://ex.com/a%0Ab)",
315
+ );
316
+ });
317
+
318
+ it("escapeLinkHref still balances parens and is a no-op for clean URLs", () => {
319
+ expect(escapeLinkHref("https://e.com/a_(b)")).toBe(
320
+ "https://e.com/a_\\(b\\)",
321
+ );
322
+ expect(escapeLinkHref("https://e.com/plain?a=1&b=2")).toBe(
323
+ "https://e.com/plain?a=1&b=2",
324
+ );
325
+ });
326
+ });