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
@@ -4,15 +4,16 @@ import {
4
4
  MIN_INITIAL_CHARS,
5
5
  } from '../answer-stream.js'
6
6
  import { resolveAnswerLaneConfig, ANSWER_LANE_NEVER_OPENS } from '../answer-stream-flag.js'
7
- import { markdownToHtml } from '../format.js'
8
- import { sanitizeTelegramHtml } from '../html-sanitize.js'
9
7
 
10
- /**
11
- * The exact renderer the gateway injects into createAnswerStream โ€” markdown โ†’
12
- * Telegram HTML, then HTML sanitize. Mirrors gateway.ts so the test pins the
13
- * real conversion, not a hand-rolled one.
14
- */
15
- const renderText = (text: string): string => sanitizeTelegramHtml(markdownToHtml(text))
8
+ // #2669: the answer lane no longer converts markdown โ†’ HTML. It ships the RAW
9
+ // assistant transcript markdown through its sendMessage/editMessageText
10
+ // closures, which the gateway wires to the rich-message path
11
+ // (`sendRichMessage` / `editMessageText({ markdown })`). There is no
12
+ // renderText / parse_mode anymore โ€” the body IS the raw GFM markdown.
13
+
14
+ // The rich-message wire cap the answer lane guards against (matches
15
+ // answer-stream.ts's TELEGRAM_MAX_CHARS).
16
+ const TELEGRAM_MAX_CHARS = 32768
16
17
 
17
18
  // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
18
19
 
@@ -137,27 +138,27 @@ describe('answer-stream โ€” minInitialChars threshold', () => {
137
138
  expect(sendMessage).toHaveBeenCalledWith(
138
139
  'chat1',
139
140
  'y'.repeat(500),
140
- expect.objectContaining({ parse_mode: 'HTML' }),
141
+ // #2669: no parse_mode โ€” the body ships as raw markdown via the rich path.
142
+ expect.not.objectContaining({ parse_mode: 'HTML' }),
141
143
  )
142
144
  expect(editMessageText).not.toHaveBeenCalled()
143
145
  })
144
146
  })
145
147
 
146
- describe('answer-stream โ€” markdown โ†’ HTML conversion (renderText)', () => {
147
- // Regression for the answer-stream-raw-markdown bug: this lane historically
148
- // shipped the RAW assistant transcript under parse_mode:'HTML', so `**bold**`
149
- // arrived as literal asterisks while every other lane converted. The fix
150
- // injects the same renderText the other lanes use; these tests assert the
151
- // wire payload is converted, not raw.
148
+ describe('answer-stream โ€” raw GFM markdown passthrough (#2669)', () => {
149
+ // The answer lane historically shipped the RAW assistant transcript under
150
+ // parse_mode:'HTML', so `**bold**` arrived as literal asterisks. Post-#2669
151
+ // there is ONE rich-markdown path: the lane ships the raw markdown VERBATIM
152
+ // (the gateway wraps it in `sendRichMessage` / `editMessageText({ markdown })`),
153
+ // so `**bold**` reaches Telegram intact and renders as bold there.
152
154
 
153
- it('converts **bold** to <b>bold</b> on the opening sendMessage', async () => {
155
+ it('ships raw markdown verbatim on the opening sendMessage (no HTML conversion)', async () => {
154
156
  const sendMessage = makeSendMessage()
155
157
  const editMessageText = makeEditMessageText()
156
158
  const stream = createAnswerStream({
157
159
  chatId: 'chat1',
158
160
  minInitialChars: 10,
159
161
  throttleMs: 250,
160
- renderText,
161
162
  sendMessage,
162
163
  editMessageText,
163
164
  })
@@ -167,33 +168,27 @@ describe('answer-stream โ€” markdown โ†’ HTML conversion (renderText)', () => {
167
168
 
168
169
  expect(sendMessage).toHaveBeenCalledTimes(1)
169
170
  const sentText = sendMessage.mock.calls[0][1] as string
170
- expect(sentText).toContain('<b>bold</b>')
171
- expect(sentText).not.toContain('**bold**')
172
- expect(sendMessage).toHaveBeenCalledWith(
173
- 'chat1',
174
- expect.stringContaining('<b>bold</b>'),
175
- expect.objectContaining({ parse_mode: 'HTML' }),
176
- )
171
+ // Raw markdown passes through unchanged โ€” no <b> conversion.
172
+ expect(sentText).toContain('**bold**')
173
+ expect(sentText).not.toContain('<b>bold</b>')
174
+ expect(sendMessage.mock.calls[0][2]).not.toHaveProperty('parse_mode')
177
175
  })
178
176
 
179
- it('converts **bold** to <b>bold</b> on a follow-up editMessageText', async () => {
177
+ it('ships raw markdown verbatim on a follow-up editMessageText', async () => {
180
178
  const sendMessage = makeSendMessage()
181
179
  const editMessageText = makeEditMessageText()
182
180
  const stream = createAnswerStream({
183
181
  chatId: 'chat1',
184
182
  minInitialChars: 10,
185
183
  throttleMs: 250,
186
- renderText,
187
184
  sendMessage,
188
185
  editMessageText,
189
186
  })
190
187
 
191
- // First update opens the message.
192
188
  stream.update('first chunk of the answer arriving')
193
189
  await flushMicrotasks()
194
190
  expect(sendMessage).toHaveBeenCalledTimes(1)
195
191
 
196
- // Second update (past the throttle window) edits in place with markdown.
197
192
  vi.advanceTimersByTime(300)
198
193
  stream.update('first chunk of the answer arriving with **bold** added')
199
194
  vi.advanceTimersByTime(300)
@@ -201,54 +196,31 @@ describe('answer-stream โ€” markdown โ†’ HTML conversion (renderText)', () => {
201
196
 
202
197
  expect(editMessageText).toHaveBeenCalled()
203
198
  const editText = editMessageText.mock.calls.at(-1)![2] as string
204
- expect(editText).toContain('<b>bold</b>')
205
- expect(editText).not.toContain('**bold**')
199
+ expect(editText).toContain('**bold**')
200
+ expect(editText).not.toContain('<b>bold</b>')
206
201
  })
207
202
 
208
- it('converts **bold** to <b>bold</b> on materialize', async () => {
203
+ it('ships raw markdown verbatim on materialize', async () => {
209
204
  const sendMessage = makeSendMessage()
210
205
  const editMessageText = makeEditMessageText()
211
206
  const stream = createAnswerStream({
212
207
  chatId: 'chat1',
213
208
  minInitialChars: 10,
214
209
  throttleMs: 250,
215
- renderText,
216
210
  sendMessage,
217
211
  editMessageText,
218
212
  })
219
213
 
220
214
  stream.update('The final answer is **definitely** forty-two.')
221
215
  await flushMicrotasks()
222
- // Opening send already converted; capture how many sends precede materialize.
223
216
  const sendsBeforeMaterialize = sendMessage.mock.calls.length
224
217
 
225
218
  const id = await stream.materialize()
226
219
  expect(typeof id).toBe('number')
227
- // materialize always sends a fresh message for the push notification.
228
220
  expect(sendMessage.mock.calls.length).toBe(sendsBeforeMaterialize + 1)
229
221
  const sentText = sendMessage.mock.calls.at(-1)![1] as string
230
- expect(sentText).toContain('<b>definitely</b>')
231
- expect(sentText).not.toContain('**definitely**')
232
- })
233
-
234
- it('sends raw text verbatim when no renderText is injected (back-compat)', async () => {
235
- const sendMessage = makeSendMessage()
236
- const editMessageText = makeEditMessageText()
237
- const stream = createAnswerStream({
238
- chatId: 'chat1',
239
- minInitialChars: 10,
240
- throttleMs: 250,
241
- sendMessage,
242
- editMessageText,
243
- })
244
-
245
- stream.update('Here is some **bold** narration text for the user.')
246
- await flushMicrotasks()
247
-
248
- expect(sendMessage).toHaveBeenCalledTimes(1)
249
- const sentText = sendMessage.mock.calls[0][1] as string
250
- // No renderer โ†’ unchanged (preserves prior behaviour for unwired callers).
251
- expect(sentText).toContain('**bold**')
222
+ expect(sentText).toContain('**definitely**')
223
+ expect(sentText).not.toContain('<b>definitely</b>')
252
224
  })
253
225
  })
254
226
 
@@ -467,7 +439,7 @@ describe('answer-stream โ€” empty / whitespace-only text is a no-op', () => {
467
439
  })
468
440
 
469
441
  describe('answer-stream โ€” maxChars guard', () => {
470
- it('does not send when text exceeds 4096 chars', async () => {
442
+ it('does not send when text exceeds the rich-message cap (#2669)', async () => {
471
443
  const sendMessage = makeSendMessage()
472
444
  const editMessageText = makeEditMessageText()
473
445
  const stream = createAnswerStream({
@@ -478,14 +450,32 @@ describe('answer-stream โ€” maxChars guard', () => {
478
450
  editMessageText,
479
451
  })
480
452
 
481
- // 4097 chars exceeds Telegram's 4096 limit
482
- stream.update('z'.repeat(4097))
453
+ // One char over the 32768 rich-message limit.
454
+ stream.update('z'.repeat(TELEGRAM_MAX_CHARS + 1))
483
455
  vi.advanceTimersByTime(1000)
484
456
  await flushMicrotasks()
485
457
 
486
458
  expect(sendMessage).not.toHaveBeenCalled()
487
459
  expect(editMessageText).not.toHaveBeenCalled()
488
460
  })
461
+
462
+ it('DOES send a body that fits under the 32768 cap (was rejected under the old 4096)', async () => {
463
+ const sendMessage = makeSendMessage()
464
+ const editMessageText = makeEditMessageText()
465
+ const stream = createAnswerStream({
466
+ chatId: 'chat1',
467
+ minInitialChars: 0,
468
+ throttleMs: 250,
469
+ sendMessage,
470
+ editMessageText,
471
+ })
472
+
473
+ // 8000 chars โ€” over the legacy 4096 cap but well under the rich cap.
474
+ stream.update('z'.repeat(8000))
475
+ await flushMicrotasks()
476
+
477
+ expect(sendMessage).toHaveBeenCalledTimes(1)
478
+ })
489
479
  })
490
480
 
491
481
  describe('answer-stream โ€” onSuperseded callback', () => {
@@ -532,7 +522,7 @@ describe('answer-stream โ€” onSuperseded callback', () => {
532
522
  })
533
523
 
534
524
  describe('answer-stream โ€” materialize() max-chars guard', () => {
535
- it('does not send when buffered text exceeds 4096 chars', async () => {
525
+ it('does not send when buffered text exceeds the rich-message cap (#2669)', async () => {
536
526
  const sendMessage = makeSendMessage()
537
527
  const editMessageText = makeEditMessageText()
538
528
  const warn = vi.fn()
@@ -550,13 +540,14 @@ describe('answer-stream โ€” materialize() max-chars guard', () => {
550
540
  await flushMicrotasks()
551
541
  const sendsAfterStreaming = sendMessage.mock.calls.length
552
542
 
553
- stream.update('b'.repeat(4097))
543
+ // One char over the 32768 rich cap.
544
+ stream.update('b'.repeat(TELEGRAM_MAX_CHARS + 1))
554
545
  const result = await stream.materialize()
555
546
 
556
547
  expect(result).toBeUndefined()
557
548
  expect(sendMessage.mock.calls.length).toBe(sendsAfterStreaming)
558
549
  expect(warn).toHaveBeenCalled()
559
- expect(warn.mock.calls.some((c) => /4096|exceeds/i.test(String(c[0])))).toBe(true)
550
+ expect(warn.mock.calls.some((c) => /32768|exceeds/i.test(String(c[0])))).toBe(true)
560
551
  })
561
552
  })
562
553
 
@@ -800,7 +791,7 @@ describe('answer-stream โ€” onMetric callback (#203)', () => {
800
791
  onMetric,
801
792
  })
802
793
 
803
- stream.update('x'.repeat(4097))
794
+ stream.update('x'.repeat(TELEGRAM_MAX_CHARS + 1))
804
795
  onMetric.mockClear()
805
796
  const id = await stream.materialize()
806
797
 
@@ -77,7 +77,7 @@ function mockClient(over: Partial<AuthBrokerClient> = {}): AuthBrokerClient {
77
77
  describe('renderShowText โ€” Format 2 vs legacy', () => {
78
78
  it('falls back to legacy ASCII table when no liveQuotas given', () => {
79
79
  const out = renderShowText(FIXTURE_STATE, NOW_MS);
80
- expect(out).toContain('<b>Auth โ€” fleet snapshot</b>');
80
+ expect(out).toContain('**Auth โ€” fleet snapshot**');
81
81
  expect(out).toContain('ACCOUNT');
82
82
  expect(out).toContain('STATUS');
83
83
  expect(out).toContain('EXPIRES');
@@ -91,10 +91,10 @@ describe('renderShowText โ€” Format 2 vs legacy', () => {
91
91
  tz: 'UTC',
92
92
  liveProbedAtMs: NOW_MS,
93
93
  });
94
- expect(out).toContain('๐Ÿ”‹ <b>Auth โ€” fleet status</b>');
94
+ expect(out).toContain('๐Ÿ”‹ **Auth โ€” fleet status**');
95
95
  expect(out).toContain('Recommendation:');
96
- expect(out).toContain('๐Ÿ”ด <b>BLOCKED</b>');
97
- expect(out).toContain('๐ŸŸข <b>HEALTHY</b>');
96
+ expect(out).toContain('๐Ÿ”ด **BLOCKED**');
97
+ expect(out).toContain('๐ŸŸข **HEALTHY**');
98
98
  // Legacy ASCII column headers should be absent
99
99
  expect(out).not.toContain('ACCOUNT STATUS');
100
100
  });
@@ -511,10 +511,11 @@ describe('handleAuthCommand โ€” help text lists every verb', () => {
511
511
  { agentName: 'x', isAdmin: true, client },
512
512
  )
513
513
  const text = reply.text
514
- // Verbs (all variants). The help is HTML; <code> wraps each verb.
514
+ // Verbs (all variants). The help is GFM markdown (#2669); each verb is
515
+ // wrapped in a `code span`, so `<agent>` renders literally โ€” no HTML escape.
515
516
  for (const fragment of [
516
517
  '/auth show',
517
- '/auth show &lt;agent&gt;',
518
+ '/auth show <agent>',
518
519
  '/auth list',
519
520
  '/auth use',
520
521
  '/auth rotate',
@@ -194,16 +194,16 @@ describe('renderAuthSnapshotFormat2', () => {
194
194
  it('renders three health-grouped sections (BLOCKED first, then HEALTHY)', () => {
195
195
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
196
196
  // Headers present
197
- expect(out).toContain('๐Ÿ”‹ <b>Auth โ€” fleet status</b>');
198
- expect(out).toContain('๐Ÿ”ด <b>BLOCKED</b> (1)');
199
- expect(out).toContain('๐ŸŸข <b>HEALTHY</b> (2)');
197
+ expect(out).toContain('๐Ÿ”‹ **Auth โ€” fleet status**');
198
+ expect(out).toContain('๐Ÿ”ด **BLOCKED** (1)');
199
+ expect(out).toContain('๐ŸŸข **HEALTHY** (2)');
200
200
  // Order: BLOCKED before HEALTHY
201
201
  expect(out.indexOf('๐Ÿ”ด')).toBeLessThan(out.indexOf('๐ŸŸข'));
202
202
  });
203
203
 
204
204
  it('marks the active account with โ—', () => {
205
205
  const out = renderAuthSnapshotFormat2(fixtureSnaps, { now: NOW, tz: 'UTC' });
206
- expect(out).toMatch(/โ—\s*<code>you@example\.com<\/code>/);
206
+ expect(out).toMatch(/โ—\s*`you@example\.com`/);
207
207
  });
208
208
 
209
209
  it('shows "back โ€ฆ" for blocked accounts with binding-window word', () => {
@@ -253,7 +253,7 @@ describe('renderAuthSnapshotFormat2', () => {
253
253
  const out = renderAuthSnapshotFormat2(errSnaps, { now: NOW });
254
254
  expect(out).toContain('quota probe failed');
255
255
  expect(out).toContain('HTTP 401');
256
- expect(out).toContain('โšช <b>UNKNOWN</b>');
256
+ expect(out).toContain('โšช **UNKNOWN**');
257
257
  });
258
258
 
259
259
  it('renders refresh stamp when liveProbedAtMs given', () => {
@@ -261,7 +261,7 @@ describe('renderAuthSnapshotFormat2', () => {
261
261
  now: NOW,
262
262
  liveProbedAtMs: NOW.getTime() - 12_000,
263
263
  });
264
- expect(out).toMatch(/<i>Live ยท refreshed \d+s ago<\/i>/);
264
+ expect(out).toMatch(/_Live ยท refreshed \d+s ago_/);
265
265
  });
266
266
 
267
267
  it('#2495 Change 2 โ€” renders "โš  cached Nm ago" (NOT a live stamp) on staleCachedAtMs', () => {
@@ -269,7 +269,7 @@ describe('renderAuthSnapshotFormat2', () => {
269
269
  now: NOW,
270
270
  staleCachedAtMs: NOW.getTime() - 3 * 60_000, // 3 min old cache
271
271
  });
272
- expect(out).toMatch(/<i>โš  cached 3m ago<\/i>/);
272
+ expect(out).toMatch(/_โš  cached 3m ago_/);
273
273
  // Crucially: no false live stamp.
274
274
  expect(out).not.toContain('Live ยท refreshed');
275
275
  });
@@ -302,7 +302,7 @@ describe('renderAuthSnapshotFormat2', () => {
302
302
  expect(out).not.toContain('you@example.com');
303
303
  // Masked fakes present (pool order from a clean cache: blocked-first
304
304
  // render order means bob is masked first, then alice/you in healthy).
305
- expect(out).toMatch(/<code>[^@<]+@example\.com<\/code>/);
305
+ expect(out).toMatch(/`[^@<]+@example\.com`/);
306
306
  });
307
307
 
308
308
  it('WITH demo, the recommendation footer masks the active label', () => {
@@ -381,7 +381,7 @@ describe('renderFallbackAnnouncement', () => {
381
381
  now: NOW,
382
382
  tz: 'UTC',
383
383
  });
384
- expect(out).toContain('Triggered by: agent <b>carrie</b>');
384
+ expect(out).toContain('Triggered by: agent **carrie**');
385
385
  expect(out).toMatch(/ken@x.*recovers.*in 4h 57m/);
386
386
  });
387
387
 
@@ -419,7 +419,7 @@ describe('renderFallbackAnnouncement', () => {
419
419
  now: NOW,
420
420
  tz: 'UTC',
421
421
  });
422
- expect(out).toContain('๐Ÿ”ด <b>All accounts blocked');
422
+ expect(out).toContain('๐Ÿ”ด **All accounts blocked');
423
423
  expect(out).toMatch(/ken@x recovers.*in 4h 57m/);
424
424
  expect(out).toContain('/auth add');
425
425
  });
@@ -461,7 +461,7 @@ describe('renderFallbackAnnouncement', () => {
461
461
  now: NOW,
462
462
  tz: 'UTC',
463
463
  });
464
- expect(out).toContain('๐Ÿ”ด <b>All accounts blocked');
464
+ expect(out).toContain('๐Ÿ”ด **All accounts blocked');
465
465
  // Every account is listed (not just the trigger).
466
466
  expect(out).toContain('ken@x');
467
467
  expect(out).toContain('you@x');
@@ -474,7 +474,7 @@ describe('renderFallbackAnnouncement', () => {
474
474
  expect(out).toMatch(/quota exhausted/);
475
475
  // The earliest recovery across the fleet (carol, 5h reset at 03:00Z = ~2h)
476
476
  // is called out explicitly.
477
- expect(out).toMatch(/Earliest recovery:\s*<code>carol@x<\/code>/);
477
+ expect(out).toMatch(/Earliest recovery:\s*`carol@x`/);
478
478
  expect(out).toContain('/auth add');
479
479
  });
480
480
 
@@ -488,7 +488,7 @@ describe('renderFallbackAnnouncement', () => {
488
488
  now: NOW,
489
489
  tz: 'UTC',
490
490
  });
491
- expect(out).toContain('๐Ÿ”ด <b>All accounts blocked');
491
+ expect(out).toContain('๐Ÿ”ด **All accounts blocked');
492
492
  expect(out).toMatch(/ken@x recovers.*in 4h 57m/);
493
493
  expect(out).not.toContain('Earliest recovery:');
494
494
  });
@@ -866,10 +866,11 @@ describe('#2494 โ€” renderAuthSnapshotFormat2 row rendering (out_of_credits demo
866
866
  { now: NOW },
867
867
  );
868
868
  // Must be in HEALTHY group, not BLOCKED
869
- expect(out).toContain('๐ŸŸข <b>HEALTHY</b>');
870
- expect(out).not.toContain('๐Ÿ”ด <b>BLOCKED</b>');
871
- // Must have informational annotation
872
- expect(out).toContain('overage off (out_of_credits) โ€” serving from quota');
869
+ expect(out).toContain('๐ŸŸข **HEALTHY**');
870
+ expect(out).not.toContain('๐Ÿ”ด **BLOCKED**');
871
+ // Must have informational annotation. #2669: the reason is markdown-
872
+ // escaped, so the underscores in "out_of_credits" render as "out\_of\_credits".
873
+ expect(out).toContain('overage off (out\\_of\\_credits) โ€” serving from quota');
873
874
  // Must NOT have old blocked framing
874
875
  expect(out).not.toContain('billing disabled');
875
876
  expect(out).not.toContain("won't recover until billing is fixed");
@@ -913,7 +914,7 @@ describe('#2494 โ€” renderAuthSnapshotFormat2 row rendering (out_of_credits demo
913
914
  [snap({ label: 'org@x', isActive: false, quota: quota({ fiveHourUtilizationPct: 85, overageDisabledReason: 'org_level_disabled' }) })],
914
915
  { now: NOW },
915
916
  );
916
- expect(out).toContain('๐ŸŸก <b>THROTTLING</b>');
917
+ expect(out).toContain('๐ŸŸก **THROTTLING**');
917
918
  expect(out).not.toContain('overage off');
918
919
  });
919
920
 
@@ -78,7 +78,7 @@ describe('runFleetAutoFallback', () => {
78
78
  expect(out.oldLabel).toBe('ken@x');
79
79
  expect(out.newLabel).toBe('you@x');
80
80
  expect(out.announcement).toContain('5-hour limit on ken@x');
81
- expect(out.announcement).toContain('Triggered by: agent <b>carrie</b>');
81
+ expect(out.announcement).toContain('Triggered by: agent **carrie**');
82
82
  expect(out.announcement).toContain('plenty of headroom');
83
83
  }
84
84
  });
@@ -248,20 +248,22 @@ import { renderFallbackFailureNotice } from "../auto-fallback-fleet.js";
248
248
 
249
249
  describe("renderFallbackFailureNotice", () => {
250
250
  it("names the trigger agent, the reason, and the manual recovery verbs", () => {
251
- const html = renderFallbackFailureNotice("marko", "auth-broker unreachable (no client).");
252
- expect(html).toContain("Auto-failover could not run");
253
- expect(html).toContain("<b>marko</b>");
254
- expect(html).toContain("auth-broker unreachable");
255
- expect(html).toContain("/auth use");
256
- expect(html).toContain("/auth</code>");
251
+ const out = renderFallbackFailureNotice("marko", "auth-broker unreachable (no client).");
252
+ expect(out).toContain("Auto-failover could not run");
253
+ expect(out).toContain("**marko**");
254
+ expect(out).toContain("auth-broker unreachable");
255
+ expect(out).toContain("/auth use");
256
+ expect(out).toContain("`/auth`");
257
257
  });
258
258
 
259
- it("escapes HTML in the error reason (broker errors can contain angle brackets)", () => {
260
- const html = renderFallbackFailureNotice("a<b", 'request <probe-quota> failed & "timed out"');
261
- expect(html).toContain("a&lt;b");
262
- expect(html).toContain("&lt;probe-quota&gt;");
263
- expect(html).toContain("&amp;");
264
- expect(html).not.toMatch(/<probe-quota>/);
259
+ it("passes < > & literally in the error reason (markdown, #2669)", () => {
260
+ // Broker errors can contain angle brackets โ€” they are literal in rich
261
+ // markdown and cannot inject formatting; emphasis specials are escaped.
262
+ const out = renderFallbackFailureNotice("a<b", 'request <probe-quota> failed & a_b');
263
+ expect(out).toContain("a<b");
264
+ expect(out).toContain("<probe-quota>");
265
+ expect(out).toContain("&");
266
+ expect(out).toContain("a\\_b");
265
267
  });
266
268
  });
267
269
 
@@ -32,6 +32,16 @@ const NOW = 1_700_000_000_000
32
32
  const VERSION = 'v0.8.0+106'
33
33
  const AGENT = 'test-harness'
34
34
 
35
+ // The crash row's tail-logs command is runtime-aware: in-container agents
36
+ // (SWITCHROOM_RUNTIME=docker) get `docker logs โ€ฆ`, everything else gets
37
+ // `journalctl โ€ฆ`. The bun-native test set inherits the live container env,
38
+ // while vitest scrubs it โ€” so assert against whichever branch the renderer
39
+ // actually selected rather than pinning one shape (#2669).
40
+ const TAIL_CMD = (slug: string): string =>
41
+ process.env.SWITCHROOM_RUNTIME === 'docker'
42
+ ? `\`docker logs --tail 100 switchroom-${slug}\``
43
+ : `\`journalctl --user -u switchroom-${slug} -n 100\``
44
+
35
45
  function rec(offsetMs: number) {
36
46
  return { ts: NOW - offsetMs }
37
47
  }
@@ -57,7 +67,7 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
57
67
  version: VERSION,
58
68
  restartReason: reason,
59
69
  })
60
- expect(card).toMatchInlineSnapshot(`"โœ… <b>test-harness</b> back up ยท v0.8.0+106"`)
70
+ expect(card).toMatchInlineSnapshot(`"โœ… **test-harness** back up ยท v0.8.0+106"`)
61
71
  // Negative assertions on the failure surface CC-8 worries about:
62
72
  expect(card).not.toContain('crash recovery')
63
73
  expect(card).not.toContain('journalctl')
@@ -83,10 +93,10 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
83
93
  restartReason: reason,
84
94
  restartAgeMs: 3_400,
85
95
  })
86
- expect(card).toContain('โš ๏ธ <b>test-harness</b> back up')
87
- expect(card).toContain('โš ๏ธ <b>Restart</b> crash recovery ยท 3.4s ago')
96
+ expect(card).toContain('โš ๏ธ **test-harness** back up')
97
+ expect(card).toContain('โš ๏ธ **Restart** crash recovery ยท 3.4s ago')
88
98
  expect(card).toContain('Tail logs:')
89
- expect(card).toContain('<code>journalctl --user -u switchroom-test-harness -n 100</code>')
99
+ expect(card).toContain(TAIL_CMD('test-harness'))
90
100
  })
91
101
 
92
102
  // โ”€โ”€โ”€ canonical crash: no marker at all โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@@ -104,7 +114,7 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
104
114
  restartReason: reason,
105
115
  restartAgeMs: 12_000,
106
116
  })
107
- expect(card).toContain('โš ๏ธ <b>test-harness</b> back up')
117
+ expect(card).toContain('โš ๏ธ **test-harness** back up')
108
118
  expect(card).toContain('crash recovery ยท 12.0s ago')
109
119
  expect(card).toContain('Tail logs:')
110
120
  })
@@ -143,7 +153,7 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
143
153
  version: VERSION,
144
154
  restartReason: reason,
145
155
  })
146
- expect(card).toMatchInlineSnapshot(`"โœ… <b>test-harness</b> back up ยท v0.8.0+106"`)
156
+ expect(card).toMatchInlineSnapshot(`"โœ… **test-harness** back up ยท v0.8.0+106"`)
147
157
  expect(card).not.toContain('crash recovery')
148
158
  })
149
159
 
@@ -161,15 +171,17 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
161
171
  version: VERSION,
162
172
  restartReason: reason,
163
173
  })
164
- expect(card).toMatchInlineSnapshot(`"๐Ÿ†• <b>test-harness</b> back up ยท v0.8.0+106"`)
174
+ expect(card).toMatchInlineSnapshot(`"๐Ÿ†• **test-harness** back up ยท v0.8.0+106"`)
165
175
  expect(card).not.toContain('crash recovery')
166
176
  })
167
177
 
168
- // โ”€โ”€โ”€ slug override path: agentSlug used in journalctl, not agentName โ”€โ”€โ”€
169
- it('crash card uses agentSlug (not agentName) in the journalctl command', () => {
170
- // The journalctl row is the user's actionable next step โ€” if the
178
+ // โ”€โ”€โ”€ slug override path: agentSlug used in tail-logs cmd, not agentName โ”€โ”€โ”€
179
+ it('crash card uses agentSlug (not agentName) in the tail-logs command', () => {
180
+ // The tail-logs row is the user's actionable next step โ€” if the
171
181
  // slug ever drifts (capitalization, special chars), the copy-paste
172
- // command stops working. Pin the slug pathway explicitly.
182
+ // command stops working. Pin the slug pathway explicitly. The command
183
+ // shape (docker vs journalctl) is runtime-determined; the slug must be
184
+ // the lowercase systemd slug regardless.
173
185
  const card = renderBootCard({
174
186
  agentName: 'Test Harness Display',
175
187
  agentSlug: 'test-harness',
@@ -177,6 +189,9 @@ describe('boot-card: reason โ†’ user-visible render (CC-8)', () => {
177
189
  restartReason: 'crash',
178
190
  restartAgeMs: 800,
179
191
  })
180
- expect(card).toContain('<code>journalctl --user -u switchroom-test-harness -n 100</code>')
192
+ expect(card).toContain(TAIL_CMD('test-harness'))
193
+ // And never the display name in the unit target.
194
+ expect(card).not.toContain('Test Harness Display -n')
195
+ expect(card).not.toContain('switchroom-Test Harness Display')
181
196
  })
182
197
  })