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
@@ -75,12 +75,12 @@ describe('renderWorkerActivity', () => {
75
75
 
76
76
  it('renders the native header + running status + step feed', () => {
77
77
  const out = renderWorkerActivity(view())
78
- expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
78
+ expect(out).toContain('🛠 **Worker** · _research competitors_')
79
79
  // Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
80
- expect(out).toContain('<i>10s · 3 tools</i>')
80
+ expect(out).toContain('_10s · 3 tools_')
81
81
  expect(out).toContain('3 tools')
82
82
  // No narrativeLines → the latestSummary surfaces as the newest `→` step.
83
- expect(out).toContain('<b>→ scanning vendor pages</b>')
83
+ expect(out).toContain('**→ scanning vendor pages**')
84
84
  // The old tool/arg chrome is gone.
85
85
  expect(out).not.toContain('⚡')
86
86
  expect(out).not.toContain('<code>')
@@ -88,7 +88,7 @@ describe('renderWorkerActivity', () => {
88
88
 
89
89
  it('shows a "starting…" line when no step has run yet', () => {
90
90
  const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
91
- expect(out).toContain('🛠 <b>Worker</b>')
91
+ expect(out).toContain('🛠 **Worker**')
92
92
  expect(out).toContain('starting…')
93
93
  expect(out).not.toContain('→')
94
94
  })
@@ -109,22 +109,22 @@ describe('renderWorkerActivity', () => {
109
109
  const out = renderWorkerActivity(
110
110
  view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
111
111
  )
112
- expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
112
+ expect(out).toContain('🛠 **Worker** · _research competitors_')
113
113
  // Unified done header: "done · N tools · <elapsed>".
114
- expect(out).toContain('<i>done · 5 tools · ')
114
+ expect(out).toContain('_done · 5 tools · ')
115
115
  expect(out).toContain('─────')
116
- expect(out).toContain('✅ <i>PR #21 opened</i>')
116
+ expect(out).toContain('✅ _PR #21 opened_')
117
117
  // latestSummary is the RESULT on the finished path, never also a step.
118
- expect(out).not.toContain('<i>✓ PR #21 opened</i>')
118
+ expect(out).not.toContain('_✓ PR #21 opened_')
119
119
  })
120
120
 
121
121
  it('renders a failed terminal recap', () => {
122
122
  const out = renderWorkerActivity(view({ state: 'failed', latestSummary: 'blew up' }))
123
123
  // The header word reflects the failure (`failed · …`) AND the ⚠️ result
124
124
  // block carries the detail — the two signals are complementary.
125
- expect(out).toContain('<i>failed · ')
126
- expect(out).not.toContain('<i>done · ')
127
- expect(out).toContain('⚠️ <i>blew up</i>')
125
+ expect(out).toContain('_failed · ')
126
+ expect(out).not.toContain('_done · ')
127
+ expect(out).toContain('⚠️ _blew up_')
128
128
  })
129
129
 
130
130
  it('a failed worker with EMPTY result is never byte-identical to a done worker (regression: #2553 failure-honesty)', () => {
@@ -136,15 +136,15 @@ describe('renderWorkerActivity', () => {
136
136
  // (b) The failed render carries a visible failure marker even with no
137
137
  // result block (the `failed` header word and/or the ⚠️ emoji).
138
138
  expect(failed.includes('failed') || failed.includes('⚠️')).toBe(true)
139
- expect(failed).toContain('<i>failed · ')
139
+ expect(failed).toContain('_failed · ')
140
140
  // The done render must NOT read as failed.
141
- expect(done).toContain('<i>done · ')
141
+ expect(done).toContain('_done · ')
142
142
  expect(done).not.toContain('failed')
143
143
  })
144
144
 
145
145
  it('omits the rule + result line when the terminal result is empty', () => {
146
146
  const out = renderWorkerActivity(view({ state: 'done', latestSummary: ' ' }))
147
- expect(out).toContain('<i>done · ')
147
+ expect(out).toContain('_done · ')
148
148
  expect(out).not.toContain('─────')
149
149
  })
150
150
 
@@ -155,9 +155,9 @@ describe('renderWorkerActivity', () => {
155
155
  narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
156
156
  }),
157
157
  )
158
- expect(out).toContain('<i>✓ read the brief</i>')
159
- expect(out).toContain('<i>✓ scanned vendor A</i>')
160
- expect(out).toContain('<b>→ scanned vendor B</b>')
158
+ expect(out).toContain('_✓ read the brief_')
159
+ expect(out).toContain('_✓ scanned vendor A_')
160
+ expect(out).toContain('**→ scanned vendor B**')
161
161
  // The single-line latestSummary fallback is NOT used when a block is present.
162
162
  expect(out).not.toContain('newest only')
163
163
  expect(stepCount(out)).toBe(3)
@@ -165,14 +165,14 @@ describe('renderWorkerActivity', () => {
165
165
 
166
166
  it('falls back to latestSummary when narrativeLines is empty', () => {
167
167
  const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
168
- expect(out).toContain('<b>→ one line</b>')
168
+ expect(out).toContain('**→ one line**')
169
169
  expect(stepCount(out)).toBe(1)
170
170
  })
171
171
 
172
172
  it('drops blank narrative lines from the feed', () => {
173
173
  const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
174
- expect(out).toContain('<i>✓ kept</i>')
175
- expect(out).toContain('<b>→ also kept</b>')
174
+ expect(out).toContain('_✓ kept_')
175
+ expect(out).toContain('**→ also kept**')
176
176
  expect(stepCount(out)).toBe(2)
177
177
  })
178
178
 
@@ -180,33 +180,39 @@ describe('renderWorkerActivity', () => {
180
180
  const total = STATUS_ROLLING_LINES + 3
181
181
  const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
182
182
  const out = renderWorkerActivity(view({ narrativeLines: lines }))
183
- expect(out).toContain(`<i>✓ +${total - STATUS_ROLLING_LINES} earlier…</i>`)
183
+ expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
184
184
  expect(out).not.toContain('step 1<')
185
185
  const firstVisible = total - STATUS_ROLLING_LINES + 1
186
- expect(out).toContain(`<i>✓ step ${firstVisible}</i>`)
187
- expect(out).toContain(`<b>→ step ${total}</b>`)
186
+ expect(out).toContain(`_✓ step ${firstVisible}_`)
187
+ expect(out).toContain(`**→ step ${total}**`)
188
188
  // STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
189
189
  expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
190
190
  })
191
191
 
192
- it('strips Markdown markup from narrative + description + result', () => {
192
+ it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
193
+ // Post-#2669 the card itself is rich markdown — the header is **Worker**
194
+ // and steps are _✓ …_ / **→ …**. The per-line pipeline still runs
195
+ // stripMarkdown over the user-supplied CONTENT so a model-authored
196
+ // `**full**` / `` `git push` `` doesn't inject extra emphasis, but the
197
+ // card's own wrapper markup is expected.
193
198
  const running = renderWorkerActivity(
194
199
  view({
195
200
  description: '**Build** the `sync`',
196
201
  narrativeLines: ['- ran the **full** suite', '`git push`'],
197
202
  }),
198
203
  )
199
- expect(running).toContain('🛠 <b>Worker</b> · <i>Build the sync</i>')
204
+ expect(running).toContain('🛠 **Worker** · _Build the sync_')
200
205
  expect(running).toContain('ran the full suite')
201
206
  expect(running).toContain('git push')
202
- expect(running).not.toContain('**')
207
+ // The CONTENT bold/code markers were stripped — no `**full**`, no backticks.
208
+ expect(running).not.toContain('**full**')
203
209
  expect(running).not.toContain('`')
204
210
 
205
211
  const done = renderWorkerActivity(
206
212
  view({ state: 'done', latestSummary: '## Done\n\n**PR #21** opened\n\n---\n`merged`' }),
207
213
  )
208
214
  expect(done).toContain('Done PR #21 opened merged')
209
- expect(done).not.toContain('**')
215
+ expect(done).not.toContain('**PR #21**')
210
216
  expect(done).not.toContain('`')
211
217
  // The card's own divider is the box-drawing rule, never a raw `---`.
212
218
  expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
@@ -227,17 +233,19 @@ describe('renderWorkerActivity', () => {
227
233
  expect(out).toContain('Done. Summary Fixed the bug where a bad password logged you out')
228
234
  })
229
235
 
230
- it('escapes HTML inside narrative lines', () => {
231
- const out = renderWorkerActivity(view({ narrativeLines: ['a <b>x</b> & y'] }))
232
- expect(out).toContain('a &lt;b&gt;x&lt;/b&gt; &amp; y')
236
+ it('markdown-escapes emphasis specials inside narrative lines; passes < > & through (#2669)', () => {
237
+ // The per-line pipeline strips paired markdown then escapes survivors.
238
+ // `<`, `>`, `&` are literal in rich markdown; an intra-word `_` is escaped.
239
+ const out = renderWorkerActivity(view({ narrativeLines: ['a <tag> & b_c y'] }))
240
+ expect(out).toContain('a <tag> & b\\_c y')
233
241
  })
234
242
 
235
- it('escapes HTML in description and summary', () => {
243
+ it('markdown-escapes specials in description; passes < > & through', () => {
236
244
  const out = renderWorkerActivity(
237
- view({ description: 'a <b>bold</b> task', latestSummary: 'a > b' }),
245
+ view({ description: 'a <tag> task b_c', latestSummary: 'a > b' }),
238
246
  )
239
- expect(out).toContain('a &lt;b&gt;bold&lt;/b&gt; task')
240
- expect(out).toContain('a &gt; b')
247
+ expect(out).toContain('a <tag> task b\\_c')
248
+ expect(out).toContain('a > b')
241
249
  })
242
250
  })
243
251
 
@@ -261,7 +269,10 @@ describe('createWorkerActivityFeed', () => {
261
269
  await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
262
270
  expect(bot.sent).toHaveLength(1)
263
271
  expect(bot.sent[0].chatId).toBe('chat')
264
- expect(bot.sent[0].opts?.parse_mode).toBe('HTML')
272
+ // #2669: the worker feed body is raw GFM markdown sent through the rich
273
+ // path (the gateway wires sendMessage → sendRichMessage). No parse_mode.
274
+ expect(bot.sent[0].opts?.parse_mode).toBeUndefined()
275
+ expect(bot.sent[0].text).toContain('🛠 **Worker**')
265
276
  expect(feed.has('w1')).toBe(true)
266
277
  })
267
278
 
@@ -303,7 +314,7 @@ describe('createWorkerActivityFeed', () => {
303
314
  clock = 10_500 // well within the throttle window
304
315
  await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
305
316
  expect(bot.edits).toHaveLength(1)
306
- expect(bot.edits[0].text).toContain('<i>done · 5 tools · ')
317
+ expect(bot.edits[0].text).toContain('_done · 5 tools · ')
307
318
  // finish forgets the worker.
308
319
  expect(feed.has('w1')).toBe(false)
309
320
  expect(feed.size).toBe(0)
@@ -387,7 +398,7 @@ describe('createWorkerActivityFeed', () => {
387
398
 
388
399
  await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
389
400
  expect(bot.sent).toHaveLength(1)
390
- expect(bot.sent[0].text).toContain('<b>→ read the brief</b>')
401
+ expect(bot.sent[0].text).toContain('**→ read the brief**')
391
402
 
392
403
  clock = 11_000
393
404
  await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
@@ -395,9 +406,9 @@ describe('createWorkerActivityFeed', () => {
395
406
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
396
407
 
397
408
  const last = bot.edits.at(-1)!
398
- expect(last.text).toContain('<i>✓ read the brief</i>')
399
- expect(last.text).toContain('<i>✓ scanned vendor A</i>')
400
- expect(last.text).toContain('<b>→ scanned vendor B</b>')
409
+ expect(last.text).toContain('_✓ read the brief_')
410
+ expect(last.text).toContain('_✓ scanned vendor A_')
411
+ expect(last.text).toContain('**→ scanned vendor B**')
401
412
  expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
402
413
  })
403
414
 
@@ -451,9 +462,9 @@ describe('createWorkerActivityFeed', () => {
451
462
  clock = 13_000
452
463
  await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
453
464
  const last = bot.edits.at(-1)!
454
- expect(last.text).toContain('<i>✓ line A</i>')
455
- expect(last.text).toContain('<i>✓ line B</i>')
456
- expect(last.text).toContain('<b>→ line C</b>')
465
+ expect(last.text).toContain('_✓ line A_')
466
+ expect(last.text).toContain('_✓ line B_')
467
+ expect(last.text).toContain('**→ line C**')
457
468
  })
458
469
 
459
470
  it('forwards threadId as message_thread_id on send', async () => {
@@ -543,8 +554,8 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
543
554
  expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
544
555
  }
545
556
  // Overflow header now appears on the worker surface too.
546
- expect(out).toContain(`<i>✓ +${12 - STATUS_ROLLING_LINES} earlier…</i>`)
547
- expect(out).toContain('<b>→ stp-012</b>')
557
+ expect(out).toContain(`_✓ +${12 - STATUS_ROLLING_LINES} earlier…_`)
558
+ expect(out).toContain('**→ stp-012**')
548
559
  })
549
560
 
550
561
  it('STATUS_LINE_MAX=200: a 250-char line is clipped to 200 with a trailing …', () => {
@@ -601,7 +612,7 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
601
612
  // render never sees overflow — no "+N earlier…" marker on the manager path
602
613
  // (it surfaces only on direct renderWorkerActivity calls with >5 lines).
603
614
  expect(last.text).not.toContain('earlier…')
604
- expect(last.text).toContain('<b>→ ln-012</b>')
615
+ expect(last.text).toContain('**→ ln-012**')
605
616
  })
606
617
  })
607
618
 
@@ -632,7 +643,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
632
643
  await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
633
644
  // Drain the chain.
634
645
  await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
635
- const edit1 = bot.edits.find((e) => /· \d+s<\/b>/.test(e.text))
646
+ const edit1 = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
636
647
  expect(edit1).toBeDefined()
637
648
  // The suffix reflects the LIVE elapsed (now - dispatchAt), not the stale view.
638
649
  expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
@@ -731,32 +742,23 @@ describe('createWorkerActivityFeed — heartbeat', () => {
731
742
  // and the step vanish from the rendered output.
732
743
 
733
744
  /**
734
- * Cheap valid-HTML checker: balanced <b>/<i> tags and no dangling/partial entity.
735
- * Checks for partial entities (e.g. `&am`, `&l`, `&amp` without trailing `;`)
736
- * as produced by naive slicing of already-escaped HTML at an entity boundary.
745
+ * Cheap valid-rich-markdown checker (post-#2669): the `**…**` wrappers the
746
+ * worker renderer emits must be balanced, and no escape backslash may be left
747
+ * dangling at the very end (it would swallow the following close marker). This
748
+ * is the markdown analogue of the old "no partial HTML entity at a slice
749
+ * boundary" check — a naive RAW-slice that split a `\*` escape would surface
750
+ * here as an odd trailing-backslash count.
737
751
  */
738
- function isValidWorkerHtml(s: string): boolean {
739
- const bOpen = (s.match(/<b>/g) ?? []).length
740
- const bClose = (s.match(/<\/b>/g) ?? []).length
741
- const iOpen = (s.match(/<i>/g) ?? []).length
742
- const iClose = (s.match(/<\/i>/g) ?? []).length
743
- if (bOpen !== bClose || iOpen !== iClose) return false
744
- // Check every `&` occurrence: the run of letters after it must end with `;`.
745
- // A partial entity like `&am`, `&l`, or `&amp` (no ;) would fail this.
746
- // We scan every `&` manually so there's no regex backtracking ambiguity.
747
- for (let i = 0; i < s.length; i++) {
748
- if (s[i] !== '&') continue
749
- let j = i + 1
750
- while (j < s.length && s[j] >= 'a' && s[j] <= 'z') j++
751
- // j is now at the character after the letter run.
752
- // If there were letters and the next char isn't ';', it's a broken entity.
753
- if (j > i + 1 && (j >= s.length || s[j] !== ';')) return false
754
- }
752
+ function isValidWorkerMarkdown(s: string): boolean {
753
+ const bold = (s.match(/\*\*/g) ?? []).length
754
+ if (bold % 2 !== 0) return false
755
+ const trailing = /(\\*)$/.exec(s)?.[1].length ?? 0
756
+ if (trailing % 2 === 1) return false
755
757
  return true
756
758
  }
757
759
 
758
760
  describe('extreme-edge: single oversized narrative line (no-truncate ON)', () => {
759
- it('no-truncate ON: one ~4100-char step is shown (truncated) not discarded, output ≤ budget and valid HTML', async () => {
761
+ it('no-truncate ON: one ~4100-char step is shown (truncated) not discarded, output ≤ budget and valid markdown', async () => {
760
762
  const bot = makeFakeBot()
761
763
  let clock = 10_000
762
764
  const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
@@ -780,10 +782,10 @@ describe('extreme-edge: single oversized narrative line (no-truncate ON)', () =>
780
782
  expect(out.length).toBeLessThanOrEqual(4096)
781
783
 
782
784
  // Valid HTML: balanced tags and no partial entity.
783
- expect(isValidWorkerHtml(out)).toBe(true)
785
+ expect(isValidWorkerMarkdown(out)).toBe(true)
784
786
  })
785
787
 
786
- it('no-truncate ON: one ~4100-char step via renderWorkerActivity directly → ≤ budget and valid HTML', () => {
788
+ it('no-truncate ON: one ~4100-char step via renderWorkerActivity directly → ≤ budget and valid markdown', () => {
787
789
  const base = 'compile && link && package && ship: action=deploy env=<prod> flag=1 '
788
790
  const hugeStep = base.repeat(65)
789
791
  expect(hugeStep.length).toBeGreaterThan(4000)
@@ -795,104 +797,84 @@ describe('extreme-edge: single oversized narrative line (no-truncate ON)', () =>
795
797
  expect(hasBullet).toBe(true)
796
798
 
797
799
  expect(out.length).toBeLessThanOrEqual(4096)
798
- expect(isValidWorkerHtml(out)).toBe(true)
800
+ expect(isValidWorkerMarkdown(out)).toBe(true)
799
801
  })
800
802
  })
801
803
 
802
- // ─── Bug 2: _fitWorkerBodyToCharBudget must not slice already-escaped HTML ───
803
- //
804
- // Before the fix, the extreme fallback in _fitWorkerBodyToCharBudget sliced
805
- // directly into the already-HTML-escaped newest line string. If the slice
806
- // boundary landed inside an HTML entity (&amp;, &lt;, &gt;), the output
807
- // contained a broken entity fragment (&am, &l, &amp without ;), which
808
- // Telegram Bot API rejects with HTTP 400 on parse_mode:'HTML'.
804
+ // ─── Bug 2: fitCardToBudget must not slice already-escaped markdown ──────────
809
805
  //
810
- // The fix mirrors _fitToCharBudget (tool-activity-summary.ts): truncate RAW
811
- // content first, then escape, then wrap, re-checking post-escape because
812
- // escaping can expand the string (&→&amp; etc.).
813
- //
814
- // These tests place entity characters (&, <, >) at positions that, under the
815
- // old naive slice, would produce exactly the broken fragments the issue
816
- // identified (&am, &l). They assert the output is valid HTML and within budget.
817
-
818
- describe('Bug 2 (#2506): _fitWorkerBodyToCharBudget does not split HTML entities', () => {
806
+ // Post-#2669 the wire format is GFM markdown. The same class of bug applies:
807
+ // the extreme budget fallback must truncate the RAW newest line first, THEN
808
+ // escape, THEN wrap — never slice an already-escaped string. A naive slice
809
+ // that landed right after an escape backslash (`\` from a `\*` escape) would
810
+ // leave a dangling backslash that swallows the bold close marker, producing
811
+ // `**→ …\**` → unbalanced. These tests place markdown specials at the slice
812
+ // boundary and assert the output stays valid (balanced ** + no dangling \).
813
+
814
+ describe('Bug 2 (#2506): fitCardToBudget does not split a markdown escape', () => {
819
815
  /**
820
- * Build a narrative line that, after HTML-escaping, has the entity boundary
821
- * at a precise position so a naive `escaped.slice(3, 3 + N)` would split it.
822
- *
823
- * Strategy: fill with 'x' characters up to a budget, then append an entity
824
- * character so the entity starts right where the slice would land.
816
+ * Build a narrative line whose markdown-special character lands at roughly
817
+ * the budget boundary so a naive escaped-string slice would cut a `\*`
818
+ * escape in half.
825
819
  */
826
- function buildEntityBoundaryLine(entityChar: string, charBudget: number): string {
827
- // The fitter computes sliceAt ≈ charBudget - tagOverhead - closingTag.length.
828
- // After the "→ " prefix (2 chars) and <b>…</b> wrapper (7 chars total overhead
829
- // in the old code), the inner slice window is roughly charBudget - 9.
830
- // Place the entity character so it lands at the very start of where the
831
- // naive slice would begin — i.e., fill with (charBudget - 9) 'x's then '&'.
820
+ function buildEscapeBoundaryLine(special: string, charBudget: number): string {
832
821
  const fillLen = Math.max(0, charBudget - 9)
833
- return 'x'.repeat(fillLen) + entityChar + 'y'.repeat(50)
822
+ return 'x'.repeat(fillLen) + special + 'y'.repeat(50)
834
823
  }
835
824
 
836
- it('& at entity boundary: output is valid HTML (no &am, &amp without ; etc.)', () => {
837
- // A line that places '&' right at the slice boundary so naive cut → &am
838
- const line = buildEntityBoundaryLine('&', 4096)
839
- expect(line.length).toBeGreaterThan(100) // sanity: line is substantial
825
+ it("'*' at the budget boundary: output is valid markdown (balanced **, no dangling \\)", () => {
826
+ const line = buildEscapeBoundaryLine('*', 4096)
827
+ expect(line.length).toBeGreaterThan(100)
840
828
 
841
829
  const out = renderWorkerActivity(view({ narrativeLines: [line] }))
842
830
 
843
831
  expect(out.length).toBeLessThanOrEqual(4096)
844
- expect(isValidWorkerHtml(out)).toBe(true)
845
- // No partial entity fragments that the old code produced.
846
- expect(out).not.toMatch(/&amp$/) // incomplete &amp; at end
847
- expect(out).not.toMatch(/&am[^p]/) // &am followed by non-p (e.g. &amy)
848
- expect(out).not.toMatch(/&[lg]t?[^;]/) // &l, &lt without semicolon
832
+ expect(isValidWorkerMarkdown(out)).toBe(true)
833
+ expect(out).not.toMatch(/\\$/) // no dangling escape backslash at the end
849
834
  })
850
835
 
851
- it('< at entity boundary: output is valid HTML (no &l, &lt without ; etc.)', () => {
852
- const line = buildEntityBoundaryLine('<', 4096)
836
+ it("'_' at the budget boundary: output is valid markdown", () => {
837
+ const line = buildEscapeBoundaryLine('_', 4096)
853
838
 
854
839
  const out = renderWorkerActivity(view({ narrativeLines: [line] }))
855
840
 
856
841
  expect(out.length).toBeLessThanOrEqual(4096)
857
- expect(isValidWorkerHtml(out)).toBe(true)
858
- expect(out).not.toMatch(/&lt$/) // incomplete &lt; at end
859
- expect(out).not.toMatch(/&l[^t;]/) // &l followed by non-t
842
+ expect(isValidWorkerMarkdown(out)).toBe(true)
843
+ expect(out).not.toMatch(/\\$/)
860
844
  })
861
845
 
862
- it('> at entity boundary: output is valid HTML (no &g, &gt without ; etc.)', () => {
863
- const line = buildEntityBoundaryLine('>', 4096)
846
+ it("'`' (code span) at the budget boundary: output is valid markdown", () => {
847
+ const line = buildEscapeBoundaryLine('`', 4096)
864
848
 
865
849
  const out = renderWorkerActivity(view({ narrativeLines: [line] }))
866
850
 
867
851
  expect(out.length).toBeLessThanOrEqual(4096)
868
- expect(isValidWorkerHtml(out)).toBe(true)
869
- expect(out).not.toMatch(/&gt$/) // incomplete &gt; at end
870
- expect(out).not.toMatch(/&g[^t;]/) // &g followed by non-t
852
+ expect(isValidWorkerMarkdown(out)).toBe(true)
853
+ expect(out).not.toMatch(/\\$/)
871
854
  })
872
855
 
873
- it('entity-dense line (& < > interleaved): output ≤ budget and valid HTML', () => {
874
- // Mix entity characters throughout so any slice position is dangerous.
875
- const chunk = '&' + 'x'.repeat(3) + '<' + 'y'.repeat(3) + '>' + 'z'.repeat(3)
856
+ it('special-dense line (* _ ` ~ interleaved): output ≤ budget and valid markdown', () => {
857
+ // Mix markdown specials throughout so any slice position is dangerous.
858
+ const chunk = '*' + 'x'.repeat(3) + '_' + 'y'.repeat(3) + '`' + 'z'.repeat(3)
876
859
  const line = chunk.repeat(500) // ~10k chars raw → well over budget after escape
877
860
  expect(line.length).toBeGreaterThan(4000)
878
861
 
879
862
  const out = renderWorkerActivity(view({ narrativeLines: [line] }))
880
863
 
881
864
  expect(out.length).toBeLessThanOrEqual(4096)
882
- expect(isValidWorkerHtml(out)).toBe(true)
865
+ expect(isValidWorkerMarkdown(out)).toBe(true)
866
+ expect(out).not.toMatch(/\\$/)
883
867
  })
884
868
 
885
- it('single & alone in the line: valid HTML and within budget', () => {
886
- // Regression: a one-char entity line is a degenerate case the while-loop
887
- // must handle without infinite-looping or returning empty content.
888
- // Build a huge line: filler xs then '&' then more xs
889
- const line = 'x'.repeat(3000) + '&' + 'x'.repeat(1000)
869
+ it('single special alone in a huge line: valid markdown and within budget', () => {
870
+ const line = 'x'.repeat(3000) + '*' + 'x'.repeat(1000)
890
871
  expect(line.length).toBeGreaterThan(4000)
891
872
 
892
873
  const out = renderWorkerActivity(view({ narrativeLines: [line] }))
893
874
 
894
875
  expect(out.length).toBeLessThanOrEqual(4096)
895
- expect(isValidWorkerHtml(out)).toBe(true)
876
+ expect(isValidWorkerMarkdown(out)).toBe(true)
877
+ expect(out).not.toMatch(/\\$/)
896
878
  })
897
879
  })
898
880
 
@@ -913,14 +895,14 @@ describe('header row + rolling overflow survive in the unified worker render', (
913
895
  )
914
896
  const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
915
897
 
916
- expect(out).toContain('🛠 <b>Worker</b>')
898
+ expect(out).toContain('🛠 **Worker**')
917
899
  // Unified running status line.
918
- expect(out).toContain('<i>10s · 7 tools</i>')
900
+ expect(out).toContain('_10s · 7 tools_')
919
901
  expect(out).toContain('7 tools')
920
902
  // Every rendered line was clipped to STATUS_LINE_MAX → output well within budget.
921
903
  expect(out.length).toBeLessThanOrEqual(4096)
922
904
  // Newest bullet is the live → step.
923
- expect(out).toContain('<b>→ final short step</b>')
905
+ expect(out).toContain('**→ final short step**')
924
906
  })
925
907
 
926
908
  it('a "+N earlier…" rolling marker appears when more than STATUS_ROLLING_LINES lines render', () => {
@@ -929,11 +911,11 @@ describe('header row + rolling overflow survive in the unified worker render', (
929
911
 
930
912
  expect(out.length).toBeLessThanOrEqual(4096)
931
913
  expect(out).toContain('earlier…')
932
- expect(out).toContain('🛠 <b>Worker</b>')
933
- expect(out).toContain('<i>10s · ')
914
+ expect(out).toContain('🛠 **Worker**')
915
+ expect(out).toContain('_10s · ')
934
916
  })
935
917
 
936
- it('back-compat path (latestSummary only) still shows a step bullet, clipped + valid HTML', () => {
918
+ it('back-compat path (latestSummary only) still shows a step bullet, clipped + valid markdown', () => {
937
919
  const hugeSummary = 'deploy service && run migrations && verify health checks '.repeat(80)
938
920
  expect(hugeSummary.length).toBeGreaterThan(4000)
939
921
 
@@ -944,8 +926,8 @@ describe('header row + rolling overflow survive in the unified worker render', (
944
926
  expect(out.length).toBeLessThanOrEqual(4096)
945
927
  const hasBullet = out.includes('→') || out.includes('✓')
946
928
  expect(hasBullet).toBe(true)
947
- expect(out).toContain('🛠 <b>Worker</b>')
948
- expect(out).toContain('<i>10s · ')
949
- expect(isValidWorkerHtml(out)).toBe(true)
929
+ expect(out).toContain('🛠 **Worker**')
930
+ expect(out).toContain('_10s · ')
931
+ expect(isValidWorkerMarkdown(out)).toBe(true)
950
932
  })
951
933
  })
@@ -34,22 +34,19 @@
34
34
  * (smoking gun, delve, etc.). Substituting those mid-clause risks
35
35
  * semantic loss, so they stay with the prompt-side voice guidance.
36
36
  *
37
- * Pipeline integration. Apply BEFORE markdownToHtml so the scrub
38
- * runs on the original model text, not on rendered HTML where
39
- * the dash might already be tag-escaped or live inside a parked
40
- * code-block placeholder. Apply BEFORE outboundDedup.check so
41
- * dedup keys see the post-scrub content (same text from a retry
42
- * collapses cleanly).
37
+ * Pipeline integration. Apply on the original model text BEFORE it is
38
+ * handed to the rich-message path, so the scrub runs on the raw markdown
39
+ * (not on a partly-rendered body). Apply BEFORE outboundDedup.check so
40
+ * dedup keys see the post-scrub content (same text from a retry collapses
41
+ * cleanly).
43
42
  *
44
43
  * Code-region awareness. The scrubber MUST preserve dashes inside:
45
44
  * - fenced code blocks: ```lang\n...\n```
46
45
  * - inline code: `...`
47
- * - explicit Telegram HTML code tags: <code>...</code>, <pre>...</pre>
48
46
  * - URLs (rare to contain em-dashes, but technically valid IDN)
49
- * The strategy is to park each protected region with a sentinel,
50
- * scrub the rest, then restore. Mirrors the well-trodden
51
- * markdownToHtml() codeBlocks/inlineCode placeholder pattern at
52
- * format.ts:254-272.
47
+ * The strategy is to park each protected region with a sentinel, scrub the
48
+ * rest, then restore — the scrub is fully self-contained (it does its own
49
+ * code-region parking; it does not depend on any render pass).
53
50
  *
54
51
  * Kill switch. `SWITCHROOM_DISABLE_VOICE_SCRUB=1` returns the input
55
52
  * unchanged and reports zero replacements. Same shape every other