switchroom 0.18.17 → 0.18.19

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 (67) hide show
  1. package/dist/agent-scheduler/index.js +13 -0
  2. package/dist/auth-broker/index.js +13 -0
  3. package/dist/cli/notion-write-pretool.mjs +13 -0
  4. package/dist/cli/switchroom.js +605 -479
  5. package/dist/host-control/main.js +17 -1
  6. package/dist/vault/approvals/kernel-server.js +13 -0
  7. package/dist/vault/broker/server.js +13 -0
  8. package/package.json +1 -1
  9. package/telegram-plugin/bridge/bridge.ts +7 -1
  10. package/telegram-plugin/dist/bridge/bridge.js +26 -1
  11. package/telegram-plugin/dist/gateway/gateway.js +1544 -619
  12. package/telegram-plugin/dist/server.js +32 -1
  13. package/telegram-plugin/fleet-fallback-resume.ts +26 -3
  14. package/telegram-plugin/format.ts +137 -213
  15. package/telegram-plugin/gateway/approval-hold.ts +49 -0
  16. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +61 -18
  17. package/telegram-plugin/gateway/gateway.ts +399 -85
  18. package/telegram-plugin/gateway/linear-activity.ts +20 -4
  19. package/telegram-plugin/gateway/outbound-send-path.ts +9 -7
  20. package/telegram-plugin/gateway/premium-recovery-wiring.ts +122 -0
  21. package/telegram-plugin/gateway/session-model-file.ts +103 -0
  22. package/telegram-plugin/gateway/tier-downgrade-wiring.ts +121 -0
  23. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +14 -1
  24. package/telegram-plugin/llm-error-present.ts +474 -0
  25. package/telegram-plugin/operator-events.ts +7 -1
  26. package/telegram-plugin/permission-title.ts +172 -10
  27. package/telegram-plugin/premium-recovery.ts +101 -0
  28. package/telegram-plugin/raw-error-scrub.ts +73 -0
  29. package/telegram-plugin/retry-api-call.ts +8 -2
  30. package/telegram-plugin/send-gate-degraded.test.ts +152 -1
  31. package/telegram-plugin/send-gate-observability.test.ts +140 -0
  32. package/telegram-plugin/send-gate-observability.ts +65 -20
  33. package/telegram-plugin/send-gate.test.ts +143 -1
  34. package/telegram-plugin/send-gate.ts +212 -19
  35. package/telegram-plugin/session-tail.ts +16 -0
  36. package/telegram-plugin/shared/local-time.ts +69 -0
  37. package/telegram-plugin/stream-reply-handler.ts +5 -14
  38. package/telegram-plugin/tests/approval-hold-harness.ts +6 -6
  39. package/telegram-plugin/tests/approval-hold-outcome.test.ts +10 -2
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +61 -0
  41. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +39 -0
  42. package/telegram-plugin/tests/flood-windows-persistence.test.ts +3 -2
  43. package/telegram-plugin/tests/format-consistency.test.ts +68 -53
  44. package/telegram-plugin/tests/formatting-parse-regression.test.ts +5 -6
  45. package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
  46. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +26 -0
  47. package/telegram-plugin/tests/linear-create-issue.test.ts +30 -2
  48. package/telegram-plugin/tests/llm-error-present.test.ts +481 -0
  49. package/telegram-plugin/tests/outbound-send-path.test.ts +4 -3
  50. package/telegram-plugin/tests/paragraph-normalizer.test.ts +42 -100
  51. package/telegram-plugin/tests/permission-title.test.ts +167 -4
  52. package/telegram-plugin/tests/premium-recovery-wiring.test.ts +150 -0
  53. package/telegram-plugin/tests/premium-recovery.test.ts +165 -0
  54. package/telegram-plugin/tests/reaction-gate-routing.test.ts +6 -1
  55. package/telegram-plugin/tests/retry-api-call.test.ts +21 -0
  56. package/telegram-plugin/tests/stream-reply-handler.test.ts +9 -12
  57. package/telegram-plugin/tests/telegram-format.test.ts +86 -31
  58. package/telegram-plugin/tests/tier-downgrade-wiring.test.ts +165 -0
  59. package/telegram-plugin/tests/tier-downgrade.test.ts +141 -0
  60. package/telegram-plugin/tests/turn-flush-safety.test.ts +17 -21
  61. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +27 -1
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +5 -2
  63. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +492 -0
  64. package/telegram-plugin/tier-downgrade.ts +198 -0
  65. package/telegram-plugin/tool-activity-summary.ts +99 -0
  66. package/telegram-plugin/turn-flush-safety.ts +4 -3
  67. package/telegram-plugin/worker-activity-feed.ts +509 -409
@@ -12,8 +12,6 @@ import { describe, test, expect } from 'vitest'
12
12
  import {
13
13
  normalizeParagraphBreaks,
14
14
  splitCollapsedInlineBullets,
15
- addParagraphSpacers,
16
- PARAGRAPH_SPACER,
17
15
  } from '../format.js'
18
16
 
19
17
  describe('normalizeParagraphBreaks', () => {
@@ -433,96 +431,44 @@ describe('splitCollapsedInlineBullets', () => {
433
431
  })
434
432
 
435
433
  // ---------------------------------------------------------------------------
436
- // addParagraphSpacers — restore a VISIBLE blank line between prose paragraphs.
434
+ // Paragraph gap spacing — plain \n\n, no NBSP spacer (#2669 follow-up).
437
435
  //
438
- // The rich GFM renderer (post-#2669) collapses a `\n\n` paragraph gap TIGHT, so
439
- // multi-paragraph replies render jammed together — the operator-confirmed
440
- // regression vs the old HTML path (`parse_mode:"HTML"`, `\n\n` → real blank
441
- // line). addParagraphSpacers wedges a U+00A0 spacer paragraph into each prose
442
- // `\n\n` gap so the rich renderer shows a visible empty line. Conservative:
443
- // only between two prose blocks, never adjacent to list/table/code/quote/heading.
436
+ // The NBSP paragraph-spacer (addParagraphSpacers / PARAGRAPH_SPACER) was
437
+ // REMOVED: its premise — that the Bot API 10.1 rich GFM renderer collapses a
438
+ // `\n\n` gap TIGHT — is false for the live renderer, which shows a `\n\n` gap
439
+ // as one normal blank line. The spacer therefore injected a spurious SECOND
440
+ // blank line (`\n\n \n\n`) between every paragraph fleet-wide. Paragraph
441
+ // spacing now relies on the plain `\n\n` that normalizeParagraphBreaks already
442
+ // guarantees. These tests pin: multi-paragraph output has exactly one blank
443
+ // line per gap, and no U+00A0 anywhere.
444
444
  // ---------------------------------------------------------------------------
445
445
 
446
- describe('addParagraphSpacers', () => {
447
- const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
446
+ describe('paragraph gap spacing — single blank line, no NBSP', () => {
447
+ const NBSP = String.fromCharCode(0xa0)
448
448
 
449
- test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
450
- expect(PARAGRAPH_SPACER).toBe(' ')
449
+ test('a two-paragraph prose body has exactly one blank-line gap and no NBSP', () => {
450
+ const out = normalizeParagraphBreaks('Paragraph one.\n\nParagraph two.')
451
+ expect(out).toBe('Paragraph one.\n\nParagraph two.')
452
+ expect(out).not.toContain(NBSP)
453
+ // Exactly one empty line between the two content lines.
454
+ expect(out.split('\n')).toEqual(['Paragraph one.', '', 'Paragraph two.'])
451
455
  })
452
456
 
453
- test('inserts a visible spacer paragraph into a prose `\\n\\n` gap', () => {
454
- expect(addParagraphSpacers('Paragraph one.\n\nParagraph two.')).toBe(
455
- `Paragraph one.${gap}Paragraph two.`,
456
- )
457
- })
458
-
459
- test('spaces every gap in a three-paragraph body', () => {
460
- expect(addParagraphSpacers('One.\n\nTwo.\n\nThree.')).toBe(
461
- `One.${gap}Two.${gap}Three.`,
462
- )
463
- })
464
-
465
- test('is idempotent — a spaced gap is not doubled on a second pass', () => {
466
- const once = addParagraphSpacers('Alpha.\n\nBravo.')
467
- expect(addParagraphSpacers(once)).toBe(once)
468
- })
469
-
470
- test('leaves a single-`\\n` separation alone (only `\\n\\n` gaps are spaced)', () => {
471
- // normalizeParagraphBreaks owns lone-break promotion; this pass only adds a
472
- // visible spacer where a genuine blank-line gap already exists.
473
- const input = 'Line one.\nLine two.'
474
- expect(addParagraphSpacers(input)).toBe(input)
457
+ test('a three-paragraph body keeps single-blank-line gaps, no NBSP', () => {
458
+ const out = normalizeParagraphBreaks('One.\n\nTwo.\n\nThree.')
459
+ expect(out).toBe('One.\n\nTwo.\n\nThree.')
460
+ expect(out).not.toContain(NBSP)
461
+ // No gap is a DOUBLE blank line (`\n\n\n`), which the old spacer produced.
462
+ expect(out).not.toMatch(/\n\n\n/)
475
463
  })
476
464
 
477
- test('spaces prose↔list transitions (uniform block spacing), list interior tight', () => {
478
- expect(addParagraphSpacers('Here are the steps.\n\n- first\n- second')).toBe(
479
- `Here are the steps.${gap}- first\n- second`,
480
- )
481
- expect(addParagraphSpacers('- first\n- second\n\nClosing prose after the list.')).toBe(
482
- `- first\n- second${gap}Closing prose after the list.`,
483
- )
465
+ test('prose->list transition is a single `\n\n` boundary, list interior tight', () => {
466
+ const out = normalizeParagraphBreaks('Here are the steps.\n\n- first\n- second')
467
+ expect(out).toBe('Here are the steps.\n\n- first\n- second')
468
+ expect(out).not.toContain(NBSP)
484
469
  })
485
470
 
486
- test('spaces a prose→table boundary; table rows stay contiguous', () => {
487
- const table = '| a | b |\n| --- | --- |\n| 1 | 2 |'
488
- expect(addParagraphSpacers(`Intro.\n\n${table}`)).toBe(`Intro.${gap}${table}`)
489
- })
490
-
491
- test('spaces prose↔fence boundaries; fence interior untouched', () => {
492
- const input = 'Look here.\n\n```js\nconst a = 1;\n```\n\nDone.'
493
- expect(addParagraphSpacers(input)).toBe(
494
- `Look here.${gap}\`\`\`js\nconst a = 1;\n\`\`\`${gap}Done.`,
495
- )
496
- })
497
-
498
- test('spaces heading→anything and blockquote boundaries', () => {
499
- expect(addParagraphSpacers('Intro prose.\n\n## Section\n\nBody prose.')).toBe(
500
- `Intro prose.${gap}## Section${gap}Body prose.`,
501
- )
502
- expect(addParagraphSpacers('Said.\n\n> a quote\n\nAfter.')).toBe(
503
- `Said.${gap}> a quote${gap}After.`,
504
- )
505
- })
506
-
507
- test('does NOT space between items of the same loose list / same-kind blocks', () => {
508
- const looseList = '- first\n\n- second\n\n- third'
509
- expect(addParagraphSpacers(looseList)).toBe(looseList)
510
- const quotes = '> one\n\n> two'
511
- expect(addParagraphSpacers(quotes)).toBe(quotes)
512
- })
513
-
514
- test('never reaches inside a fenced block (interior blank line untouched)', () => {
515
- const input = 'Before.\n\n```\nline 1\n\nline 2\n```\n\nAfter.'
516
- const out = addParagraphSpacers(input)
517
- // The fence interior — including its own blank line — is byte-for-byte intact.
518
- expect(out).toContain('```\nline 1\n\nline 2\n```')
519
- })
520
-
521
- test('a body with no paragraph gap is returned unchanged', () => {
522
- expect(addParagraphSpacers('just one paragraph, no gap')).toBe('just one paragraph, no gap')
523
- })
524
-
525
- test('composes after normalizeParagraphBreaks: prose gap spaced, list left tight', () => {
471
+ test('composed prose+list body: one blank line per gap, no NBSP, no double gap', () => {
526
472
  const input = [
527
473
  'Summary of the change.',
528
474
  '',
@@ -531,12 +477,11 @@ describe('addParagraphSpacers', () => {
531
477
  '- adds a normalizer',
532
478
  '- lifts the cap',
533
479
  ].join('\n')
534
- const out = addParagraphSpacers(normalizeParagraphBreaks(input))
535
- // The two prose paragraphs gain a visible spacer between them.
536
- expect(out).toContain(`Summary of the change.${gap}It does two things.`)
537
- // The prose→list transition gains a spacer too (uniform block spacing),
538
- // but the list INTERIOR stays tight.
539
- expect(out).toContain(`It does two things.${gap}- adds a normalizer\n- lifts the cap`)
480
+ const out = normalizeParagraphBreaks(input)
481
+ expect(out).toContain('Summary of the change.\n\nIt does two things.')
482
+ expect(out).toContain('It does two things.\n\n- adds a normalizer\n- lifts the cap')
483
+ expect(out).not.toContain(NBSP)
484
+ expect(out).not.toMatch(/\n\n\n/)
540
485
  })
541
486
  })
542
487
 
@@ -577,12 +522,12 @@ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
577
522
  * ragged / oversized gap: CommonMark discards it so it buys no visible space,
578
523
  * but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
579
524
  * collapses any run of blank lines — including whitespace-only interior lines —
580
- * to exactly one clean `\n\n`, WITHOUT ever eating the deliberate U+00A0
581
- * paragraph spacer that addParagraphSpacers (#2692) adds later for a visible
582
- * gap on the rich-message path.
525
+ * to exactly one clean `\n\n`. The collapse is deliberately ASCII-only, so a
526
+ * genuine user-typed U+00A0 line survives (the conservative choice; there is no
527
+ * longer an NBSP paragraph spacer to protect — that pass was removed).
583
528
  */
584
529
  describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
585
- const NBSP = ' '
530
+ const NBSP = String.fromCharCode(0xa0)
586
531
 
587
532
  test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
588
533
  const input =
@@ -635,14 +580,11 @@ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug
635
580
  expect(normalizeParagraphBreaks(input)).toBe(input)
636
581
  })
637
582
 
638
- test('the deliberate U+00A0 spacer from addParagraphSpacers is preserved (no #2692 regression)', () => {
639
- const normalized = normalizeParagraphBreaks('Alpha para.\n\nBravo para.')
640
- const spaced = addParagraphSpacers(normalized)
641
- // addParagraphSpacers wedges a U+00A0-only line to force a visible gap.
642
- expect(spaced).toContain('\n\n' + PARAGRAPH_SPACER + '\n\n')
643
- expect(spaced.split('\n')).toContain(NBSP)
644
- // And re-running the normalizer must NOT eat that intentional spacer.
645
- expect(normalizeParagraphBreaks(spaced)).toBe(spaced)
583
+ test('a genuine user-typed U+00A0-only line survives (ASCII-only collapse)', () => {
584
+ // The blank-line collapse is deliberately ASCII-only, so a non-breaking
585
+ // space a user actually typed on its own line is not silently eaten.
586
+ const input = 'Alpha para.\n' + NBSP + '\nBravo para.'
587
+ expect(normalizeParagraphBreaks(input)).toBe(input)
646
588
  })
647
589
 
648
590
  test('idempotent: collapsing a stray gap twice is stable', () => {
@@ -156,25 +156,188 @@ describe('formatPermissionCardBody', () => {
156
156
  expect(body).toContain('why: _listing temp files_')
157
157
  })
158
158
 
159
- test('shows "not provided" when no caller reason is present (never the description)', () => {
159
+ // #3167: no caller reason → an honest synthesized `context:` line built
160
+ // from the tool's salient input, NEVER a bare "why: not provided" and never
161
+ // the static schema description. The distinct `context:` label keeps the
162
+ // agent's omission of a rationale visible.
163
+ test('synthesizes a context line when no caller reason is present (never "not provided" / the description)', () => {
160
164
  const body = formatPermissionCardBody({
161
165
  toolName: 'Bash',
162
166
  inputPreview: JSON.stringify({ command: 'ls /tmp' }),
163
167
  description: 'Run a shell command on the host.',
164
168
  agentName: 'gymbro',
165
169
  })
166
- expect(body).toContain('why: _not provided_')
170
+ expect(body).not.toContain('not provided')
167
171
  expect(body).not.toContain('Run a shell command')
172
+ expect(body).toContain('context: _command: ls /tmp_')
168
173
  })
169
174
 
170
- test('shows "not provided" when caller reason is whitespace only', () => {
175
+ test('synthesizes context when the caller reason is whitespace only', () => {
171
176
  const body = formatPermissionCardBody({
172
177
  toolName: 'Bash',
173
178
  inputPreview: JSON.stringify({ command: 'ls /tmp', reason: ' \n ' }),
174
179
  description: 'Run a shell command.',
175
180
  agentName: 'gymbro',
176
181
  })
177
- expect(body).toContain('why: _not provided_')
182
+ expect(body).not.toContain('not provided')
183
+ expect(body).toContain('context: _command: ls /tmp_')
184
+ })
185
+
186
+ // #3167 root case: the `reply` tool (and react/edit_message/…) carries NO
187
+ // `reason` argument, so its cards used to render a contentless
188
+ // "🔐 Clerk wants to reply / why: not provided". Now the reply text is
189
+ // synthesized onto a `context:` line so the operator has something to judge.
190
+ test('reply (no reason arg) synthesizes the reply text as context, not "not provided"', () => {
191
+ const body = formatPermissionCardBody({
192
+ toolName: 'mcp__switchroom-telegram__reply',
193
+ inputPreview: JSON.stringify({
194
+ chat_id: '12345',
195
+ text: "On it — pulling yesterday's GitHub activity now.",
196
+ format: 'html',
197
+ disable_notification: true,
198
+ }),
199
+ description: 'Reply on Telegram.',
200
+ agentName: 'clerk',
201
+ })
202
+ const lines = body.split('\n')
203
+ expect(lines[0]).toBe('🔐 **Clerk** wants to reply')
204
+ expect(body).not.toContain('not provided')
205
+ // Salient field surfaced (truncated); id/routing/formatting noise stripped.
206
+ expect(body).toContain('context: _text: On it — pulling')
207
+ expect(body).toMatch(/context: _text: On it — pulling[^_]*…_/)
208
+ expect(body).not.toContain('chat_id')
209
+ expect(body).not.toContain('disable_notification')
210
+ expect(body).not.toContain('format')
211
+ })
212
+
213
+ test('a reason ON a reply card still renders as why:, not context: (#3167)', () => {
214
+ const body = formatPermissionCardBody({
215
+ toolName: 'mcp__switchroom-telegram__reply',
216
+ inputPreview: JSON.stringify({
217
+ chat_id: '12345',
218
+ text: 'done',
219
+ reason: 'answering the operator’s status question',
220
+ }),
221
+ description: 'Reply on Telegram.',
222
+ agentName: 'clerk',
223
+ })
224
+ expect(body).toContain('why: _answering the operator’s status question_')
225
+ expect(body).not.toContain('context:')
226
+ })
227
+
228
+ test('synthesized context redacts secrets in the salient input (#3167)', () => {
229
+ const fakeToken = 'sk-ant-' + 'api03-' + 'B'.repeat(48)
230
+ const body = formatPermissionCardBody({
231
+ toolName: 'mcp__switchroom-telegram__reply',
232
+ inputPreview: JSON.stringify({ chat_id: '1', text: `here is the key: ${fakeToken}` }),
233
+ description: 'Reply on Telegram.',
234
+ agentName: 'clerk',
235
+ })
236
+ expect(body).toContain('context:')
237
+ expect(body).not.toContain(fakeToken)
238
+ })
239
+
240
+ test('falls back to the natural action when the input exposes nothing salient (#3167)', () => {
241
+ const body = formatPermissionCardBody({
242
+ toolName: 'ExitPlanMode',
243
+ inputPreview: undefined,
244
+ description: 'Exit plan mode.',
245
+ agentName: 'clerk',
246
+ })
247
+ expect(body).not.toContain('not provided')
248
+ expect(body).toContain('context: _exit plan mode_')
249
+ })
250
+
251
+ // #3167 review (HIGH/MEDIUM): the salient-value redactor must catch secrets
252
+ // the bare-value path missed. redact() excludes generic_high_entropy and the
253
+ // contextual kv detectors need a `key=value` shape — so a prefixless token
254
+ // under a credential-shaped key leaked verbatim. These FAIL pre-fix.
255
+ test('hard-masks a prefixless high-entropy value under a `token` key (#3167)', () => {
256
+ // No sk-/ghp-/JWT prefix — shape detection alone cannot catch it; only the
257
+ // key-name signal ("token") does.
258
+ const bare = 'Zx8Kq2Lm9Rn4Tv6Wb1Yc3Hd5Jf7Ug0Pe'
259
+ const body = formatPermissionCardBody({
260
+ toolName: 'mcp__acme__do',
261
+ inputPreview: JSON.stringify({ token: bare, note: 'ping' }),
262
+ description: 'do a thing',
263
+ agentName: 'clerk',
264
+ })
265
+ expect(body).not.toContain(bare)
266
+ // `token` value is hard-masked; the benign `note` still surfaces.
267
+ expect(body).toContain('token:')
268
+ expect(body).toContain('REDACTED')
269
+ expect(body).toContain('note: ping')
270
+ })
271
+
272
+ test('hard-masks a 40-hex secret under a `secret`-shaped key (#3167)', () => {
273
+ const hex = 'a3f1'.repeat(10) // 40 hex chars, no prefix
274
+ const body = formatPermissionCardBody({
275
+ toolName: 'mcp__acme__do',
276
+ inputPreview: JSON.stringify({ webhook_secret: hex }),
277
+ description: 'do a thing',
278
+ agentName: 'clerk',
279
+ })
280
+ expect(body).not.toContain(hex)
281
+ expect(body).toContain('REDACTED')
282
+ })
283
+
284
+ test('masks a non-http DSN credential embedded in free text (redactUrls misses it) (#3167)', () => {
285
+ // Under a benign key (`text`) so the hard-mask key path does NOT fire —
286
+ // this exercises the NON_HTTP_DSN_RE scheme coverage specifically.
287
+ const dsn = 'postgres://user:S3cretPass99@db.host:5432/app'
288
+ const body = formatPermissionCardBody({
289
+ toolName: 'mcp__switchroom-telegram__reply',
290
+ inputPreview: JSON.stringify({ chat_id: '1', text: `connect via ${dsn} then run` }),
291
+ description: 'Reply on Telegram.',
292
+ agentName: 'clerk',
293
+ })
294
+ expect(body).not.toContain('S3cretPass99')
295
+ expect(body).not.toContain(dsn)
296
+ expect(body).toContain('REDACTED')
297
+ })
298
+
299
+ test('hard-masks a DATABASE_URL under a url-shaped key (#3167)', () => {
300
+ const dsn = 'postgres://admin:hunter2pass@10.0.0.5/prod'
301
+ const body = formatPermissionCardBody({
302
+ toolName: 'mcp__acme__migrate',
303
+ inputPreview: JSON.stringify({ database_url: dsn }),
304
+ description: 'run a migration',
305
+ agentName: 'clerk',
306
+ })
307
+ expect(body).not.toContain('hunter2pass')
308
+ expect(body).not.toContain(dsn)
309
+ expect(body).toContain('REDACTED')
310
+ })
311
+
312
+ // #3167 review (LOW-2): acceptance asks for "tool + summarized input +
313
+ // originating turn" — surface a compact origin-turn reference when present.
314
+ test('appends a compact originating-turn reference when origin_turn_id is present (#3167)', () => {
315
+ const body = formatPermissionCardBody({
316
+ toolName: 'mcp__switchroom-telegram__reply',
317
+ inputPreview: JSON.stringify({ chat_id: '1', text: 'hi', origin_turn_id: 'turn-abcdef123456' }),
318
+ description: 'Reply on Telegram.',
319
+ agentName: 'clerk',
320
+ })
321
+ expect(body).toContain('· turn …ef123456')
322
+ // The raw routing id is NOT dumped as its own kv pair.
323
+ expect(body).not.toContain('origin_turn_id:')
324
+ })
325
+
326
+ // #3167 review (LOW-3): raw commands/text now reach the context line, so a
327
+ // markdown metachar in the value must be escaped or it can spoof/break the
328
+ // card's own `_italic_` / `code` formatting.
329
+ test('escapes markdown metachars in the synthesized context value (#3167)', () => {
330
+ const body = formatPermissionCardBody({
331
+ toolName: 'Bash',
332
+ inputPreview: JSON.stringify({ command: 'echo _a_ *b* `c`' }),
333
+ description: 'run a shell command',
334
+ agentName: 'clerk',
335
+ })
336
+ // The metachars are backslash-escaped so they can't open emphasis/code.
337
+ expect(body).toContain('\\_a\\_')
338
+ expect(body).toContain('\\*b\\*')
339
+ expect(body).toContain('\\`c\\`')
340
+ expect(body).not.toContain('echo _a_ *b* `c`')
178
341
  })
179
342
 
180
343
  test('drops the agent prefix when agentName is null (early-boot edge)', () => {
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Integration test for the premium-recovery ping GLUE
3
+ * (`runPremiumRecoveryPing`, premium-recovery-wiring.ts) — the never-storm
4
+ * orchestration the pure `decidePremiumRecovery` / `renderPremiumRecoveryPing`
5
+ * tests do NOT cover. This is the P0 at-most-once guarantee (claim-gate → clear-
6
+ * marker-before-send → per-chat fan-out) that was inline in the gateway and so
7
+ * had ZERO coverage; the same untested-glue gap the tier-downgrade extraction
8
+ * closed for the downgrade path.
9
+ *
10
+ * It drives the real runner with injected seams and PINS:
11
+ * - clear-BEFORE-send ordering (never-storm) + exactly one send per chat;
12
+ * - claim !granted → marker cleared, NO send;
13
+ * - decision.fire === false → no clear, no claim, no send;
14
+ * - no marker / no agent dir → no-op;
15
+ * - the button `callback_data` is EXACTLY `mdl:alias:<premium>` (a future edit
16
+ * can't silently break the session-scoped switch-back path).
17
+ * These are outcome assertions that FAIL if the guard logic is reordered/dropped.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import {
22
+ runPremiumRecoveryPing,
23
+ type PremiumRecoveryPingDeps,
24
+ type PremiumRecoveryMarkerView,
25
+ type PremiumRecoveryKeyboard,
26
+ } from '../gateway/premium-recovery-wiring.js'
27
+ import type { PremiumRecoveryPing } from '../premium-recovery.js'
28
+
29
+ interface Recorder {
30
+ /** Ordered event log — pins clear-before-send. */
31
+ events: string[]
32
+ clears: number
33
+ claims: string[]
34
+ sends: Array<{ chatId: string; ping: PremiumRecoveryPing; keyboard: PremiumRecoveryKeyboard }>
35
+ decides: number
36
+ logs: string[]
37
+ }
38
+
39
+ function makeDeps(
40
+ over: {
41
+ agentDir?: string | null
42
+ marker?: PremiumRecoveryMarkerView | null
43
+ fire?: boolean
44
+ granted?: boolean
45
+ fallbackChats?: string[]
46
+ } = {},
47
+ ): { deps: PremiumRecoveryPingDeps; rec: Recorder } {
48
+ const rec: Recorder = { events: [], clears: 0, claims: [], sends: [], decides: 0, logs: [] }
49
+ const deps: PremiumRecoveryPingDeps = {
50
+ getAgentDir: () => (over.agentDir === undefined ? '/tmp/agent' : over.agentDir),
51
+ readMarker: () =>
52
+ over.marker === undefined
53
+ ? { premiumModel: 'fable', chats: ['111', '222'] }
54
+ : over.marker,
55
+ clearMarker: () => {
56
+ rec.clears += 1
57
+ rec.events.push('clear')
58
+ },
59
+ getAgent: () => 'klanker',
60
+ decide: () => {
61
+ rec.decides += 1
62
+ return over.fire === undefined ? true : over.fire
63
+ },
64
+ claimNotification: async (key) => {
65
+ rec.claims.push(key)
66
+ return over.granted === undefined ? true : over.granted
67
+ },
68
+ fallbackChats: () => over.fallbackChats ?? ['999'],
69
+ sendToChat: (chatId, ping, keyboard) => {
70
+ rec.sends.push({ chatId, ping, keyboard })
71
+ rec.events.push(`send:${chatId}`)
72
+ },
73
+ log: (msg) => rec.logs.push(msg),
74
+ }
75
+ return { deps, rec }
76
+ }
77
+
78
+ describe('runPremiumRecoveryPing — never-storm at-most-once ordering', () => {
79
+ it('fire + granted: marker cleared BEFORE any send, exactly one send per chat', async () => {
80
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111', '222'] } })
81
+ await runPremiumRecoveryPing(deps)
82
+ expect(rec.clears).toBe(1)
83
+ // One send per recorded chat, in order, no duplicates.
84
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['111', '222'])
85
+ // The clear MUST precede the first send (a double-send is unrecoverable).
86
+ const clearIdx = rec.events.indexOf('clear')
87
+ const firstSendIdx = rec.events.findIndex((e) => e.startsWith('send:'))
88
+ expect(clearIdx).toBeGreaterThanOrEqual(0)
89
+ expect(firstSendIdx).toBeGreaterThanOrEqual(0)
90
+ expect(clearIdx).toBeLessThan(firstSendIdx)
91
+ // The fleet claim was keyed on agent + the dropped premium token.
92
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
93
+ expect(rec.logs).toHaveLength(1)
94
+ })
95
+
96
+ it('claim NOT granted: marker cleared (no lingering re-attempt), NO send', async () => {
97
+ const { deps, rec } = makeDeps({ granted: false })
98
+ await runPremiumRecoveryPing(deps)
99
+ expect(rec.claims).toEqual(['premium-recovery:klanker:fable'])
100
+ expect(rec.clears).toBe(1)
101
+ expect(rec.sends).toHaveLength(0)
102
+ })
103
+
104
+ it('decision.fire === false: NO clear, NO claim, NO send (marker survives for a later tick)', async () => {
105
+ const { deps, rec } = makeDeps({ fire: false })
106
+ await runPremiumRecoveryPing(deps)
107
+ expect(rec.decides).toBe(1)
108
+ expect(rec.clears).toBe(0)
109
+ expect(rec.claims).toHaveLength(0)
110
+ expect(rec.sends).toHaveLength(0)
111
+ })
112
+
113
+ it('no marker present: no-op — no decide, no claim, no clear, no send', async () => {
114
+ const { deps, rec } = makeDeps({ marker: null })
115
+ await runPremiumRecoveryPing(deps)
116
+ expect(rec.decides).toBe(0)
117
+ expect(rec.clears).toBe(0)
118
+ expect(rec.claims).toHaveLength(0)
119
+ expect(rec.sends).toHaveLength(0)
120
+ })
121
+
122
+ it('no agent dir: immediate no-op (marker is never even read)', async () => {
123
+ const { deps, rec } = makeDeps({ agentDir: null })
124
+ await runPremiumRecoveryPing(deps)
125
+ expect(rec.decides).toBe(0)
126
+ expect(rec.clears).toBe(0)
127
+ expect(rec.sends).toHaveLength(0)
128
+ })
129
+
130
+ it('marker with no chats falls back to the access allowFrom chats', async () => {
131
+ const { deps, rec } = makeDeps({
132
+ marker: { premiumModel: 'fable', chats: [] },
133
+ fallbackChats: ['999'],
134
+ })
135
+ await runPremiumRecoveryPing(deps)
136
+ expect(rec.sends.map((s) => s.chatId)).toEqual(['999'])
137
+ })
138
+ })
139
+
140
+ describe('runPremiumRecoveryPing — session-scoped switch-back button', () => {
141
+ it('the button callback_data is EXACTLY mdl:alias:<premium> (routes the model-menu apply path)', async () => {
142
+ const { deps, rec } = makeDeps({ marker: { premiumModel: 'fable', chats: ['111'] } })
143
+ await runPremiumRecoveryPing(deps)
144
+ const button = rec.sends[0].keyboard.inline_keyboard[0][0]
145
+ // Pin the exact wire format — a change to MODEL_CALLBACK_ALIAS or the button
146
+ // construction that broke the session-scoped `/model` apply path must fail here.
147
+ expect(button.callback_data).toBe('mdl:alias:fable')
148
+ expect(button.text).toBe('Switch to fable')
149
+ })
150
+ })