switchroom 0.16.22 → 0.16.24

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 (118) hide show
  1. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  2. package/dist/cli/skill-validate-pretool.mjs +2865 -48
  3. package/dist/cli/switchroom.js +338 -92
  4. package/dist/host-control/main.js +1 -1
  5. package/package.json +5 -5
  6. package/telegram-plugin/answer-stream.ts +13 -43
  7. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  8. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  9. package/telegram-plugin/card-format.ts +28 -25
  10. package/telegram-plugin/credits-watch.ts +5 -10
  11. package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
  12. package/telegram-plugin/draft-stream.ts +4 -4
  13. package/telegram-plugin/format.ts +101 -662
  14. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  15. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  16. package/telegram-plugin/gateway/approval-card.ts +16 -6
  17. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  18. package/telegram-plugin/gateway/auth-command.ts +74 -74
  19. package/telegram-plugin/gateway/auth-line.ts +5 -15
  20. package/telegram-plugin/gateway/boot-card.ts +20 -22
  21. package/telegram-plugin/gateway/boot-version.ts +3 -2
  22. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  23. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  24. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  25. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  26. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  27. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  28. package/telegram-plugin/gateway/effort-command.ts +17 -17
  29. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  30. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  31. package/telegram-plugin/gateway/gateway.ts +751 -624
  32. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  33. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  34. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  35. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  36. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  37. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  38. package/telegram-plugin/gateway/model-command.ts +25 -25
  39. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  40. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  41. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  42. package/telegram-plugin/issues-card.ts +6 -7
  43. package/telegram-plugin/model-unavailable.ts +8 -12
  44. package/telegram-plugin/operator-events-history.ts +1 -1
  45. package/telegram-plugin/operator-events.ts +24 -28
  46. package/telegram-plugin/package.json +1 -1
  47. package/telegram-plugin/pending-work-progress.ts +36 -36
  48. package/telegram-plugin/permission-title.ts +36 -20
  49. package/telegram-plugin/pty-partial-handler.ts +5 -13
  50. package/telegram-plugin/quota-check.ts +5 -5
  51. package/telegram-plugin/quota-watch.ts +13 -18
  52. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  53. package/telegram-plugin/registry/turns-schema.ts +44 -2
  54. package/telegram-plugin/retry-api-call.ts +15 -7
  55. package/telegram-plugin/rich-send.ts +57 -0
  56. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  57. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  58. package/telegram-plugin/shared/bot-runtime.ts +23 -6
  59. package/telegram-plugin/silence-poke.ts +26 -69
  60. package/telegram-plugin/slot-banner-driver.ts +9 -6
  61. package/telegram-plugin/slot-banner.ts +5 -8
  62. package/telegram-plugin/steering.ts +0 -4
  63. package/telegram-plugin/stream-controller.ts +59 -62
  64. package/telegram-plugin/stream-reply-handler.ts +40 -97
  65. package/telegram-plugin/subagent-watcher.ts +2 -2
  66. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  67. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  68. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  69. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  70. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  71. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  72. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  73. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  74. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  75. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  76. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  77. package/telegram-plugin/tests/card-format.test.ts +6 -4
  78. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  79. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  80. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  81. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  82. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  83. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  84. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  85. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  86. package/telegram-plugin/tests/model-command.test.ts +2 -2
  87. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  88. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  89. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  90. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  91. package/telegram-plugin/tests/permission-title.test.ts +45 -41
  92. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  93. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  94. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  95. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  96. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  97. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  98. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  99. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  100. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  101. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  102. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  103. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  104. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  105. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  106. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  107. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  108. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  109. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  110. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  111. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  112. package/telegram-plugin/text-voice-scrub.ts +8 -11
  113. package/telegram-plugin/tool-activity-summary.ts +29 -29
  114. package/telegram-plugin/welcome-text.ts +82 -83
  115. package/telegram-plugin/worker-activity-feed.ts +2 -3
  116. package/telegram-plugin/html-sanitize.ts +0 -244
  117. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  118. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -18,21 +18,21 @@ import { renderBootCard, resolvePersonaName } from '../gateway/boot-card.js'
18
18
  describe('renderBootCard — quiet by default', () => {
19
19
  it('returns one-line ack with default ✅ when no probes and no restart reason', () => {
20
20
  const out = renderBootCard({ agentName: 'klanker', version: 'v0.3.0+44' })
21
- expect(out).toBe('✅ <b>klanker</b> back up · v0.3.0+44')
21
+ expect(out).toBe('✅ **klanker** back up · v0.3.0+44')
22
22
  expect(out).not.toContain('\n')
23
23
  })
24
24
 
25
25
  it('uses ✅ for planned and graceful restart reasons (return-to-service)', () => {
26
26
  for (const reason of ['planned', 'graceful'] as const) {
27
27
  const out = renderBootCard({ agentName: 'agent', version: 'v1', restartReason: reason })
28
- expect(out).toBe('✅ <b>agent</b> back up · v1')
28
+ expect(out).toBe('✅ **agent** back up · v1')
29
29
  }
30
30
  })
31
31
 
32
32
  it('uses 🆕 for fresh starts (no prior session marker — first boot)', () => {
33
33
  const out = renderBootCard({ agentName: 'a', version: 'v', restartReason: 'fresh' })
34
34
  expect(out.startsWith('🆕')).toBe(true)
35
- expect(out).toBe('🆕 <b>a</b> back up · v')
35
+ expect(out).toBe('🆕 **a** back up · v')
36
36
  })
37
37
 
38
38
  it('all-ok probes produce no extra rows — same output as no probes at all', () => {
@@ -62,16 +62,16 @@ describe('renderBootCard — degraded conditions', () => {
62
62
  restartAgeMs: 2_100,
63
63
  })
64
64
  const lines = out.split('\n')
65
- expect(lines[0]).toBe('⚠️ <b>lawgpt</b> back up · v0.3.0+44')
65
+ expect(lines[0]).toBe('⚠️ **lawgpt** back up · v0.3.0+44')
66
66
  expect(lines[1]).toBe('') // separator
67
- expect(lines[2]).toContain('⚠️ <b>Restart</b>')
67
+ expect(lines[2]).toContain('⚠️ **Restart**')
68
68
  expect(lines[2]).toContain('crash recovery')
69
69
  expect(lines[2]).toContain('2.1s ago')
70
70
  })
71
71
 
72
72
  it('crash reason without restartAgeMs omits the "ago" suffix', () => {
73
73
  const out = renderBootCard({ agentName: 'a', version: 'v1', restartReason: 'crash' })
74
- expect(out).toContain('⚠️ <b>Restart</b> crash recovery')
74
+ expect(out).toContain('⚠️ **Restart** crash recovery')
75
75
  expect(out).not.toMatch(/\d+\.\d+s ago/)
76
76
  })
77
77
 
@@ -88,13 +88,13 @@ describe('renderBootCard — degraded conditions', () => {
88
88
  crons: { status: 'ok', label: 'Crons', detail: '4 timers' },
89
89
  },
90
90
  })
91
- expect(out).toContain('🟡 <b>Account</b> token expiring · 3d')
92
- expect(out).toContain('🔴 <b>Quota</b> rate limited')
91
+ expect(out).toContain('🟡 **Account** token expiring · 3d')
92
+ expect(out).toContain('🔴 **Quota** rate limited')
93
93
  // Healthy probes must not render.
94
- expect(out).not.toContain('Agent</b>')
95
- expect(out).not.toContain('Gateway</b>')
96
- expect(out).not.toContain('Hindsight</b>')
97
- expect(out).not.toContain('Scheduler</b>')
94
+ expect(out).not.toContain('Agent**')
95
+ expect(out).not.toContain('Gateway**')
96
+ expect(out).not.toContain('Hindsight**')
97
+ expect(out).not.toContain('Scheduler**')
98
98
  })
99
99
 
100
100
  it('orders probe rows in PROBE_KEYS canonical order regardless of object iteration', () => {
@@ -110,9 +110,9 @@ describe('renderBootCard — degraded conditions', () => {
110
110
  account: { status: 'degraded', label: 'Account', detail: 'expiring' },
111
111
  },
112
112
  })
113
- const accountIdx = out.indexOf('Account</b>')
114
- const hindsightIdx = out.indexOf('Hindsight</b>')
115
- const schedulerIdx = out.indexOf('Scheduler</b>')
113
+ const accountIdx = out.indexOf('Account**')
114
+ const hindsightIdx = out.indexOf('Hindsight**')
115
+ const schedulerIdx = out.indexOf('Scheduler**')
116
116
  expect(accountIdx).toBeGreaterThan(-1)
117
117
  expect(hindsightIdx).toBeGreaterThan(accountIdx)
118
118
  expect(schedulerIdx).toBeGreaterThan(hindsightIdx)
@@ -131,14 +131,14 @@ describe('renderBootCard — degraded conditions', () => {
131
131
  const lines = out.split('\n')
132
132
  expect(lines[0].startsWith('⚠️')).toBe(true)
133
133
  expect(lines).toContain('') // separator after ack
134
- expect(out).toContain('⚠️ <b>Restart</b>')
135
- expect(out).toContain('🟡 <b>Account</b> expiring')
134
+ expect(out).toContain('⚠️ **Restart**')
135
+ expect(out).toContain('🟡 **Account** expiring')
136
136
  })
137
137
 
138
138
  it('renders nextStep as an indented continuation line beneath a degraded row', () => {
139
139
  // Principle 1 ("If they need the docs, we've failed"): every degraded
140
140
  // probe should surface its remediation inline. Plain backticks in the
141
- // nextStep get translated to <code> spans so the command stays tap-to-
141
+ // nextStep get translated to ` spans so the command stays tap-to-
142
142
  // copy on mobile.
143
143
  const out = renderBootCard({
144
144
  agentName: 'lawgpt',
@@ -152,8 +152,8 @@ describe('renderBootCard — degraded conditions', () => {
152
152
  },
153
153
  },
154
154
  })
155
- expect(out).toContain('🟡 <b>Skills</b> 10/10 dangling')
156
- expect(out).toContain(' ↳ Run <code>switchroom agent reconcile lawgpt</code> to rebuild symlinks')
155
+ expect(out).toContain('🟡 **Skills** 10/10 dangling')
156
+ expect(out).toContain(' ↳ Run `switchroom agent reconcile lawgpt` to rebuild symlinks')
157
157
  })
158
158
 
159
159
  it('crash row carries a tail-logs next-step', () => {
@@ -163,8 +163,16 @@ describe('renderBootCard — degraded conditions', () => {
163
163
  restartReason: 'crash',
164
164
  restartAgeMs: 6_100,
165
165
  })
166
- expect(out).toContain('⚠️ <b>Restart</b> crash recovery · 6.1s ago')
167
- expect(out).toContain('↳ Tail logs: <code>journalctl --user -u switchroom-lawgpt -n 100</code>')
166
+ expect(out).toContain('⚠️ **Restart** crash recovery · 6.1s ago')
167
+ // The tail-logs command is runtime-aware: in-container (docker) vs
168
+ // host (journalctl). Assert against whichever branch the renderer
169
+ // selected so the suite is green under both bun (live container env)
170
+ // and vitest (env scrubbed) — see vitest.config.ts.
171
+ const expectedTail =
172
+ process.env.SWITCHROOM_RUNTIME === 'docker'
173
+ ? '↳ Tail logs: `docker logs --tail 100 switchroom-lawgpt`'
174
+ : '↳ Tail logs: `journalctl --user -u switchroom-lawgpt -n 100`'
175
+ expect(out).toContain(expectedTail)
168
176
  })
169
177
 
170
178
  it('crash row uses agentSlug for the systemd unit when provided', () => {
@@ -191,8 +199,9 @@ describe('renderBootCard — degraded conditions', () => {
191
199
  },
192
200
  },
193
201
  })
194
- expect(out).toContain('<code>foo &lt;bar&gt; &amp; baz</code>')
195
- expect(out).not.toContain('<bar>')
202
+ // #2669: the backtick-delimited content stays a literal code span — no
203
+ // HTML entities. < > & render verbatim inside the span.
204
+ expect(out).toContain('`foo <bar> & baz`')
196
205
  })
197
206
 
198
207
  it('unpaired backticks in nextStep fall back to plain escaped text', () => {
@@ -208,8 +217,9 @@ describe('renderBootCard — degraded conditions', () => {
208
217
  },
209
218
  },
210
219
  })
211
- expect(out).toContain('↳ Run `switchroom foo to fix')
212
- expect(out).not.toContain('<code>')
220
+ // Unpaired backtick → the whole nextStep is markdown-escaped, so the lone
221
+ // backtick is rendered as a literal `\`` and never opens a real code span.
222
+ expect(out).toContain('↳ Run \\`switchroom foo to fix')
213
223
  })
214
224
 
215
225
  it('degraded rows without nextStep render unchanged (backwards compat)', () => {
@@ -220,7 +230,7 @@ describe('renderBootCard — degraded conditions', () => {
220
230
  quota: { status: 'fail', label: 'Quota', detail: 'rate limited' },
221
231
  },
222
232
  })
223
- expect(out).toContain('🔴 <b>Quota</b> rate limited')
233
+ expect(out).toContain('🔴 **Quota** rate limited')
224
234
  expect(out).not.toContain('↳')
225
235
  })
226
236
 
@@ -234,23 +244,25 @@ describe('renderBootCard — degraded conditions', () => {
234
244
  },
235
245
  })
236
246
  expect(out).not.toContain('Account')
237
- expect(out).toContain('🔴 <b>Agent</b> service deactivating')
247
+ expect(out).toContain('🔴 **Agent** service deactivating')
238
248
  })
239
249
  })
240
250
 
241
- describe('renderBootCard — HTML escaping', () => {
242
- it('escapes special chars in agent name', () => {
251
+ describe('renderBootCard — markdown escaping (#2669)', () => {
252
+ it('passes < > literally in agent name; escapes emphasis specials', () => {
253
+ // < > are literal in rich markdown; an underscore would be escaped.
243
254
  const out = renderBootCard({ agentName: 'foo<bar>', version: 'v1' })
244
- expect(out).toContain('foo&lt;bar&gt;')
245
- expect(out).not.toContain('foo<bar>')
255
+ expect(out).toContain('foo<bar>')
256
+ const out2 = renderBootCard({ agentName: 'foo_bar', version: 'v1' })
257
+ expect(out2).toContain('foo\\_bar')
246
258
  })
247
259
 
248
- it('escapes special chars in version', () => {
260
+ it('passes < > literally in version', () => {
249
261
  const out = renderBootCard({ agentName: 'a', version: 'v<1>' })
250
- expect(out).toContain('v&lt;1&gt;')
262
+ expect(out).toContain('v<1>')
251
263
  })
252
264
 
253
- it('escapes special chars in probe detail', () => {
265
+ it('passes < > literally in probe detail', () => {
254
266
  const out = renderBootCard({
255
267
  agentName: 'a',
256
268
  version: 'v',
@@ -258,8 +270,7 @@ describe('renderBootCard — HTML escaping', () => {
258
270
  quota: { status: 'fail', label: 'Quota', detail: 'rate <limited>' },
259
271
  },
260
272
  })
261
- expect(out).toContain('rate &lt;limited&gt;')
262
- expect(out).not.toContain('rate <limited>')
273
+ expect(out).toContain('rate <limited>')
263
274
  })
264
275
  })
265
276
 
@@ -303,8 +314,8 @@ describe('resolvePersonaName — persona name over slug (#169)', () => {
303
314
  const displayName = resolvePersonaName('finn', fakeCfg)
304
315
  const out = renderBootCard({ agentName: displayName, version: 'v0.3.0+50' })
305
316
  // User sees "Finn", not the slug "finn"
306
- expect(out).toBe('✅ <b>Finn</b> back up · v0.3.0+50')
307
- expect(out).not.toContain('<b>finn</b>')
317
+ expect(out).toBe('✅ **Finn** back up · v0.3.0+50')
318
+ expect(out).not.toContain('**finn**')
308
319
  })
309
320
  })
310
321
 
@@ -322,8 +333,8 @@ describe('renderBootCard — resolved / snooze rendering', () => {
322
333
  },
323
334
  resolvedRows: ['hindsight'],
324
335
  })
325
- expect(out).toContain('✅ <b>Hindsight</b> resolved')
326
- expect(out).toContain('🔴 <b>Broker</b> socket missing')
336
+ expect(out).toContain('✅ **Hindsight** resolved')
337
+ expect(out).toContain('🔴 **Broker** socket missing')
327
338
  // Resolved appears BEFORE Broker.
328
339
  expect(out.indexOf('Hindsight')).toBeLessThan(out.indexOf('Broker'))
329
340
  })
@@ -351,7 +362,7 @@ describe('renderBootCard — resolved / snooze rendering', () => {
351
362
  },
352
363
  snoozeRows: ['broker'],
353
364
  })
354
- expect(out).toBe('✅ <b>k</b> back up · v0.1')
365
+ expect(out).toBe('✅ **k** back up · v0.1')
355
366
  })
356
367
 
357
368
  it('resolvedRows alone (no probes degraded) renders the resolved row beneath the ack', () => {
@@ -360,8 +371,8 @@ describe('renderBootCard — resolved / snooze rendering', () => {
360
371
  version: 'v',
361
372
  resolvedRows: ['skills', 'broker'],
362
373
  })
363
- expect(out).toContain('✅ <b>Skills</b> resolved')
364
- expect(out).toContain('✅ <b>Broker</b> resolved')
374
+ expect(out).toContain('✅ **Skills** resolved')
375
+ expect(out).toContain('✅ **Broker** resolved')
365
376
  })
366
377
  })
367
378
 
@@ -370,13 +381,13 @@ describe('renderBootCard — resolved / snooze rendering', () => {
370
381
  describe('renderBootCard — configChanges rows', () => {
371
382
  it('silent when configChanges is absent', () => {
372
383
  const out = renderBootCard({ agentName: 'k', version: 'v' })
373
- expect(out).toBe('✅ <b>k</b> back up · v')
384
+ expect(out).toBe('✅ **k** back up · v')
374
385
  expect(out).not.toContain('Config')
375
386
  })
376
387
 
377
388
  it('silent when configChanges is empty array', () => {
378
389
  const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
379
- expect(out).toBe('✅ <b>k</b> back up · v')
390
+ expect(out).toBe('✅ **k** back up · v')
380
391
  expect(out).not.toContain('Config')
381
392
  })
382
393
 
@@ -386,7 +397,7 @@ describe('renderBootCard — configChanges rows', () => {
386
397
  version: 'v',
387
398
  configChanges: [{ field: 'model', from: 'claude-opus-4', to: 'claude-sonnet-4-5' }],
388
399
  })
389
- expect(out).toContain('⚙️ <b>Config</b>')
400
+ expect(out).toContain('⚙️ **Config**')
390
401
  expect(out).toContain('claude-opus-4')
391
402
  expect(out).toContain('claude-sonnet-4-5')
392
403
  expect(out).toContain('→')
@@ -454,6 +465,6 @@ describe('renderBootCard — configChanges rows', () => {
454
465
  it('bare ack still returned when configChanges fires but produces only empty rows (defensive)', () => {
455
466
  // Edge: empty configChanges array passed → should not produce rows.
456
467
  const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
457
- expect(out).toBe('✅ <b>k</b> back up · v')
468
+ expect(out).toBe('✅ **k** back up · v')
458
469
  })
459
470
  })
@@ -26,11 +26,29 @@ export interface MockBotApi {
26
26
  opts?: Record<string, unknown>,
27
27
  ) => Promise<{ message_id: number }>
28
28
  >
29
+ /**
30
+ * Bot API 10.1 rich-message send (#2669). Receives the
31
+ * `InputRichMessage` ({ markdown }) as the 2nd arg, mirroring grammy
32
+ * 1.44's `sendRichMessage`. Tests assert on
33
+ * `sendRichMessage.mock.calls[i][1].markdown` for the raw GFM body.
34
+ */
35
+ sendRichMessage: MockInstance<
36
+ (
37
+ chat_id: string,
38
+ rich_message: { markdown: string },
39
+ opts?: Record<string, unknown>,
40
+ ) => Promise<{ message_id: number }>
41
+ >
42
+ /**
43
+ * editMessageText's 3rd arg is `string | { markdown: string }` — a
44
+ * plain string on the literal path, an `InputRichMessage` on the rich
45
+ * path (#2669).
46
+ */
29
47
  editMessageText: MockInstance<
30
48
  (
31
49
  chat_id: string,
32
50
  message_id: number,
33
- text: string,
51
+ text: string | { markdown: string },
34
52
  opts?: Record<string, unknown>,
35
53
  ) => Promise<unknown>
36
54
  >
@@ -65,6 +83,7 @@ export function createMockBot(startMessageId = 500): MockBot {
65
83
 
66
84
  const api: MockBotApi = {
67
85
  sendMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
86
+ sendRichMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
68
87
  editMessageText: vi.fn(async () => undefined),
69
88
  deleteMessage: vi.fn(async () => true as const),
70
89
  setMessageReaction: vi.fn(async () => true as const),
@@ -100,6 +119,9 @@ export function installBotResetHook(bot: MockBot): void {
100
119
  bot.api.sendMessage.mockImplementation(async () => ({
101
120
  message_id: bot.nextMessageId++,
102
121
  }))
122
+ bot.api.sendRichMessage.mockImplementation(async () => ({
123
+ message_id: bot.nextMessageId++,
124
+ }))
103
125
  bot.api.editMessageText.mockImplementation(async () => undefined)
104
126
  bot.api.deleteMessage.mockImplementation(async () => true as const)
105
127
  bot.api.setMessageReaction.mockImplementation(async () => true as const)
@@ -44,21 +44,16 @@ afterEach(() => {
44
44
 
45
45
  // ─── escapeHtmlForTg ─────────────────────────────────────────────────────
46
46
 
47
- describe('escapeHtmlForTg', () => {
48
- it('escapes ampersands', () => {
49
- expect(escapeHtmlForTg('a & b')).toBe('a &amp; b')
47
+ describe('escapeHtmlForTg (now a markdown escaper, kept under the legacy name, #2669)', () => {
48
+ it('leaves & < > literal (they are not markdown specials)', () => {
49
+ expect(escapeHtmlForTg('a & b')).toBe('a & b')
50
+ expect(escapeHtmlForTg('<script>')).toBe('<script>')
51
+ expect(escapeHtmlForTg('a > b')).toBe('a > b')
52
+ expect(escapeHtmlForTg('<a & b>')).toBe('<a & b>')
50
53
  })
51
54
 
52
- it('escapes less-than', () => {
53
- expect(escapeHtmlForTg('<script>')).toBe('&lt;script&gt;')
54
- })
55
-
56
- it('escapes greater-than', () => {
57
- expect(escapeHtmlForTg('a > b')).toBe('a &gt; b')
58
- })
59
-
60
- it('escapes all three in one string', () => {
61
- expect(escapeHtmlForTg('<a & b>')).toBe('&lt;a &amp; b&gt;')
55
+ it('escapes the inline-markdown specials (\\ ` * _ ~ = [ ] |)', () => {
56
+ expect(escapeHtmlForTg('a *b* _c_')).toBe('a \\*b\\* \\_c\\_')
62
57
  })
63
58
 
64
59
  it('returns plain text unchanged', () => {
@@ -68,13 +63,23 @@ describe('escapeHtmlForTg', () => {
68
63
 
69
64
  // ─── preBlock ────────────────────────────────────────────────────────────
70
65
 
71
- describe('preBlock', () => {
72
- it('wraps text in pre tags', () => {
73
- expect(preBlock('hello')).toBe('<pre>hello</pre>')
66
+ describe('preBlock (now a fenced code block, #2669)', () => {
67
+ it('wraps text in a triple-backtick fence', () => {
68
+ expect(preBlock('hello')).toBe('```\nhello\n```')
69
+ })
70
+
71
+ it('keeps content literal inside the fence (no HTML escaping needed)', () => {
72
+ // Inside a fenced code block everything is verbatim; HTML/markdown is inert.
73
+ expect(preBlock('<b>bold</b>')).toBe('```\n<b>bold</b>\n```')
74
74
  })
75
75
 
76
- it('escapes HTML inside the block', () => {
77
- expect(preBlock('<b>bold</b>')).toBe('<pre>&lt;b&gt;bold&lt;/b&gt;</pre>')
76
+ it('defuses a fence-closing ``` sequence embedded in the content', () => {
77
+ const out = preBlock('a ``` b')
78
+ // The embedded ``` is broken with a zero-width space so it cannot close
79
+ // the fence early.
80
+ expect(out.startsWith('```\n')).toBe(true)
81
+ expect(out.endsWith('\n```')).toBe(true)
82
+ expect(out).not.toContain('a ``` b')
78
83
  })
79
84
  })
80
85
 
@@ -1,11 +1,11 @@
1
1
  import { describe, it, expect } from 'vitest'
2
2
  import {
3
3
  cleanWorkerResultParagraph,
4
- escapeHtml,
5
4
  formatDuration,
6
5
  stripMarkdown,
7
6
  truncate,
8
7
  } from '../card-format.js'
8
+ import { escapeMarkdown } from '../format.js'
9
9
 
10
10
  describe('stripMarkdown', () => {
11
11
  it('strips paired bold and emphasis', () => {
@@ -85,9 +85,11 @@ describe('formatDuration', () => {
85
85
  })
86
86
  })
87
87
 
88
- describe('escapeHtml / truncate', () => {
89
- it('escapes the three HTML-significant characters', () => {
90
- expect(escapeHtml('a <b> & c')).toBe('a &lt;b&gt; &amp; c')
88
+ describe('escapeMarkdown / truncate', () => {
89
+ it('escapes the inline-markdown specials, leaving < > & literal (#2669)', () => {
90
+ // The HTML escaper is gone; escapeMarkdown escapes \ ` * _ ~ = [ ] | only.
91
+ expect(escapeMarkdown('a <b> & c')).toBe('a <b> & c')
92
+ expect(escapeMarkdown('a *b* _c_')).toBe('a \\*b\\* \\_c\\_')
91
93
  })
92
94
  it('truncates with an ellipsis', () => {
93
95
  expect(truncate('abcdef', 4)).toBe('abc…')
@@ -285,7 +285,7 @@ describe('renderConfigChangeDim', () => {
285
285
  for (const field of fields) {
286
286
  const row = renderConfigChangeDim({ field, from: 'a', to: 'b' })
287
287
  expect(row).toContain('⚙️')
288
- expect(row).toContain('<b>Config</b>')
288
+ expect(row).toContain('**Config**')
289
289
  }
290
290
  })
291
291
  })
@@ -103,7 +103,7 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
103
103
  if (d.kind !== "notify") return;
104
104
  expect(d.transition).toBe("entered");
105
105
  expect(d.message).toContain("out of pre-paid credits");
106
- expect(d.message).toContain("<b>lawgpt</b>");
106
+ expect(d.message).toContain("**lawgpt**");
107
107
  expect(d.newState.lastNotifiedReason).toBe("out_of_credits");
108
108
  expect(d.newState.lastNotifiedAt).toBe(NOW);
109
109
  });
@@ -175,9 +175,9 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
175
175
  expect(d.kind).toBe("skip");
176
176
  });
177
177
 
178
- it("escapes HTML in the agent name (defensive)", () => {
178
+ it("markdown-escapes emphasis specials in the agent name; < > stay literal (#2669)", () => {
179
179
  const d = evaluateCreditState({
180
- agentName: "<evil>",
180
+ agentName: "ev_il",
181
181
  currentReason: "out_of_credits",
182
182
  prev: HEALTHY,
183
183
  now: NOW,
@@ -185,8 +185,8 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
185
185
  });
186
186
  expect(d.kind).toBe("notify");
187
187
  if (d.kind !== "notify") return;
188
- expect(d.message).toContain("&lt;evil&gt;");
189
- expect(d.message).not.toContain("<evil>");
188
+ // The underscore is escaped so it can't open an italic run inside **…**.
189
+ expect(d.message).toContain("ev\\_il");
190
190
  });
191
191
  });
192
192
 
@@ -27,10 +27,21 @@ export interface SentMessage {
27
27
  readonly message_id: number
28
28
  readonly chat_id: string
29
29
  readonly text: string
30
+ /**
31
+ * True when the message landed via the Bot API 10.1 rich path
32
+ * (`sendRichMessage`) rather than plain `sendMessage` (#2669). `text`
33
+ * holds the raw GFM markdown body in both cases (for a rich send it is
34
+ * `rich_message.markdown`), so existing text assertions keep working;
35
+ * tests that care about the path assert on this flag.
36
+ */
37
+ readonly rich?: boolean
30
38
  readonly parse_mode?: string
31
39
  readonly reply_to_message_id?: number
40
+ readonly reply_parameters?: { message_id: number; quote?: { text: string; position: number } }
41
+ readonly reply_markup?: unknown
32
42
  readonly message_thread_id?: number
33
43
  readonly disable_notification?: boolean
44
+ readonly link_preview_options?: unknown
34
45
  }
35
46
 
36
47
  export interface PinnedRef {
@@ -184,6 +195,7 @@ export const errors = {
184
195
 
185
196
  export interface FakeBotApi {
186
197
  sendMessage: ReturnType<typeof vi.fn>
198
+ sendRichMessage: ReturnType<typeof vi.fn>
187
199
  editMessageText: ReturnType<typeof vi.fn>
188
200
  deleteMessage: ReturnType<typeof vi.fn>
189
201
  setMessageReaction: ReturnType<typeof vi.fn>
@@ -367,27 +379,69 @@ export function createFakeBotApi(opts: CreateFakeBotApiOpts = {}): FakeBot {
367
379
  text,
368
380
  parse_mode: opts?.parse_mode as string | undefined,
369
381
  reply_to_message_id: opts?.reply_to_message_id as number | undefined,
382
+ reply_parameters: opts?.reply_parameters as SentMessage['reply_parameters'],
383
+ reply_markup: opts?.reply_markup,
370
384
  message_thread_id: opts?.message_thread_id as number | undefined,
371
385
  disable_notification: opts?.disable_notification as boolean | undefined,
386
+ link_preview_options: opts?.link_preview_options,
372
387
  }
373
388
  sent.push(record)
374
389
  currentText.set(message_id, text)
375
390
  return { message_id, chat: { id: chat_id }, date: Math.floor(Date.now() / 1000), text }
376
391
  }),
377
392
 
393
+ // Bot API 10.1 rich-message send (#2669). Records the raw GFM markdown
394
+ // (`rich_message.markdown`) into `text` so existing text/state
395
+ // assertions keep working, flags the record `rich: true`, and captures
396
+ // reply_markup / reply_parameters / message_thread_id the same way
397
+ // sendMessage does. sendRichMessage does NOT accept link_preview_options
398
+ // on the wire — the controller strips it before calling — so it is not
399
+ // recorded here.
400
+ sendRichMessage: vi.fn(
401
+ async (chat_id: string, rich_message: { markdown: string }, opts?: Record<string, unknown>) => {
402
+ await applyEntryGates('sendRichMessage', chat_id)
403
+ const markdown = rich_message?.markdown ?? ''
404
+ const message_id = nextMessageId++
405
+ const record: SentMessage = {
406
+ message_id,
407
+ chat_id,
408
+ text: markdown,
409
+ rich: true,
410
+ reply_to_message_id: opts?.reply_to_message_id as number | undefined,
411
+ reply_parameters: opts?.reply_parameters as SentMessage['reply_parameters'],
412
+ reply_markup: opts?.reply_markup,
413
+ message_thread_id: opts?.message_thread_id as number | undefined,
414
+ disable_notification: opts?.disable_notification as boolean | undefined,
415
+ }
416
+ sent.push(record)
417
+ currentText.set(message_id, markdown)
418
+ return { message_id, chat: { id: chat_id }, date: Math.floor(Date.now() / 1000), text: markdown }
419
+ },
420
+ ),
421
+
378
422
  editMessageText: vi.fn(
379
- async (chat_id: string, message_id: number, text: string, opts?: Record<string, unknown>) => {
423
+ async (
424
+ chat_id: string,
425
+ message_id: number,
426
+ text: string | { markdown: string },
427
+ opts?: Record<string, unknown>,
428
+ ) => {
380
429
  await applyEntryGates('editMessageText', chat_id)
381
- checkParseMode('editMessageText', text, opts?.parse_mode as string | undefined)
430
+ // #2669: the rich path passes `{ markdown }`; the literal path
431
+ // passes a plain string. Normalize to the body string for the
432
+ // not-modified / currentText bookkeeping.
433
+ const isRich = typeof text === 'object' && text != null
434
+ const body = isRich ? (text as { markdown: string }).markdown : (text as string)
435
+ if (!isRich) checkParseMode('editMessageText', body, opts?.parse_mode as string | undefined)
382
436
  if (deleted.has(message_id) || !currentText.has(message_id)) {
383
437
  // Simulate Telegram's real 400 when the target is gone.
384
438
  throw errors.messageToEditNotFound()
385
439
  }
386
440
  const prev = currentText.get(message_id)
387
- if (prev === text) {
441
+ if (prev === body) {
388
442
  throw errors.notModified()
389
443
  }
390
- currentText.set(message_id, text)
444
+ currentText.set(message_id, body)
391
445
  return true
392
446
  },
393
447
  ),
@@ -15,7 +15,7 @@ import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboa
15
15
 
16
16
  interface Capture {
17
17
  acks: Array<{ text?: string; show_alert?: boolean }>
18
- edits: Array<{ text: string; opts: Record<string, unknown> }>
18
+ edits: Array<{ text: string | { markdown: string }; opts: Record<string, unknown> }>
19
19
  ackThrows?: Error
20
20
  editThrows?: Error
21
21
  }
@@ -57,25 +57,27 @@ describe('finalizeCallback — three-invariant contract', () => {
57
57
  expect(cap.acks[0]?.show_alert).toBe(true)
58
58
  })
59
59
 
60
- it('invariant 2: strips reply_markup AND edits the body in one atomic call', async () => {
60
+ it('invariant 2: strips reply_markup AND rich-edits the body in one atomic call', async () => {
61
61
  const cap: Capture = { acks: [], edits: [] }
62
62
  await finalizeCallback(mkCtx(cap), {
63
63
  ackText: 'Approved',
64
- newText: '✓ Approved\n\nGrant minted at 22:38 UTC',
65
- parseMode: 'HTML',
64
+ newText: '**✓ Approved**\n\nGrant minted at 22:38 UTC',
66
65
  })
67
66
  expect(cap.edits).toHaveLength(1)
68
- expect(cap.edits[0]?.text).toBe('✓ Approved\n\nGrant minted at 22:38 UTC')
67
+ // Default (non-literal) path wraps the body in the rich-message
68
+ // { markdown } object — raw GFM markdown, no parse_mode (#2669).
69
+ expect(cap.edits[0]?.text).toEqual({ markdown: '**✓ Approved**\n\nGrant minted at 22:38 UTC' })
69
70
  expect(cap.edits[0]?.opts.reply_markup).toEqual({ inline_keyboard: [] })
70
- expect(cap.edits[0]?.opts.parse_mode).toBe('HTML')
71
+ expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
71
72
  // link_preview_options disabled by default — keeps the edited
72
73
  // status line from rendering a stale preview card.
73
74
  expect(cap.edits[0]?.opts.link_preview_options).toEqual({ is_disabled: true })
74
75
  })
75
76
 
76
- it('invariant 2: omits parse_mode when not specified (plain text)', async () => {
77
+ it('invariant 2: literalText edits a plain string (no rich wrapper, no parse_mode)', async () => {
77
78
  const cap: Capture = { acks: [], edits: [] }
78
- await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain' })
79
+ await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true })
80
+ expect(cap.edits[0]?.text).toBe('plain')
79
81
  expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
80
82
  })
81
83
 
@@ -170,7 +172,7 @@ describe('finalizeCallback — three-invariant contract', () => {
170
172
  log: (l) => logs.push(l),
171
173
  })
172
174
  expect(cap.edits).toHaveLength(1)
173
- expect(cap.edits[0]?.text).toBe('edited body')
175
+ expect(cap.edits[0]?.text).toEqual({ markdown: 'edited body' })
174
176
  expect(synthFired).toBe(true)
175
177
  // ack fire-and-forget — its catch fires asynchronously; give it a tick
176
178
  // so the log assertion is stable across runs.
@@ -91,7 +91,7 @@ describe('end-to-end render shape under ack-first', () => {
91
91
  )
92
92
  expect(html).not.toBeNull()
93
93
  // newest child is the live, bold current step
94
- expect(html).toContain('<b>→ ranking by resolution</b>')
94
+ expect(html).toContain('**→ ranking by resolution**')
95
95
  // an earlier child is present as a done/italic step
96
96
  expect(html).toContain('checking license on 4 candidates')
97
97
  })