switchroom 0.16.29 → 0.16.46

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 (130) hide show
  1. package/dist/agent-scheduler/index.js +101 -84
  2. package/dist/auth-broker/index.js +100 -83
  3. package/dist/cli/autoaccept-poll.js +8 -8
  4. package/dist/cli/drive-write-pretool.mjs +10 -10
  5. package/dist/cli/notion-write-pretool.mjs +102 -85
  6. package/dist/cli/skill-validate-pretool.mjs +91 -91
  7. package/dist/cli/switchroom.js +62124 -60406
  8. package/dist/cli/ui/index.html +877 -214
  9. package/dist/host-control/main.js +1000 -256
  10. package/dist/vault/approvals/kernel-server.js +141 -121
  11. package/dist/vault/broker/server.js +163 -143
  12. package/examples/minimal.yaml +1 -1
  13. package/examples/switchroom.yaml +1 -1
  14. package/package.json +3 -2
  15. package/profiles/_shared/agent-self-service.md.hbs +7 -2
  16. package/profiles/_shared/reply-discipline.md.hbs +9 -0
  17. package/skills/switchroom-status/SKILL.md +1 -1
  18. package/telegram-plugin/auth-snapshot-format.ts +173 -67
  19. package/telegram-plugin/auto-fallback-fleet.ts +3 -6
  20. package/telegram-plugin/bridge/bridge.ts +2 -1
  21. package/telegram-plugin/card-format.ts +59 -3
  22. package/telegram-plugin/credits-watch.ts +4 -7
  23. package/telegram-plugin/dist/bridge/bridge.js +132 -114
  24. package/telegram-plugin/dist/gateway/gateway.js +4456 -1591
  25. package/telegram-plugin/dist/server.js +180 -163
  26. package/telegram-plugin/format.ts +551 -20
  27. package/telegram-plugin/gateway/approval-card.ts +7 -14
  28. package/telegram-plugin/gateway/approvals-commands.ts +6 -9
  29. package/telegram-plugin/gateway/auth-command.ts +35 -38
  30. package/telegram-plugin/gateway/boot-card.ts +7 -2
  31. package/telegram-plugin/gateway/chat-id-fallback.ts +46 -0
  32. package/telegram-plugin/gateway/config-approval-handler.ts +6 -9
  33. package/telegram-plugin/gateway/diff-preview-card.ts +3 -6
  34. package/telegram-plugin/gateway/gateway.ts +1524 -129
  35. package/telegram-plugin/gateway/ipc-protocol.ts +66 -2
  36. package/telegram-plugin/gateway/ipc-server.ts +91 -1
  37. package/telegram-plugin/gateway/linear-activity.ts +2 -5
  38. package/telegram-plugin/gateway/model-command.ts +173 -19
  39. package/telegram-plugin/gateway/obligation-turn-end.ts +27 -0
  40. package/telegram-plugin/gateway/permission-card-store.ts +104 -0
  41. package/telegram-plugin/gateway/permission-timeout.ts +25 -6
  42. package/telegram-plugin/gateway/status-pin-store.ts +302 -0
  43. package/telegram-plugin/gateway/turns-jsonl-rotate.ts +30 -0
  44. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +12 -1
  45. package/telegram-plugin/gateway/vault-grant-inbound-builders.ts +35 -0
  46. package/telegram-plugin/gateway/vault-request-access-card.ts +61 -0
  47. package/telegram-plugin/history.ts +17 -7
  48. package/telegram-plugin/hooks/tool-label-pretool.d.mts +12 -0
  49. package/telegram-plugin/hooks/tool-label-pretool.mjs +54 -16
  50. package/telegram-plugin/idle-footer.ts +2 -2
  51. package/telegram-plugin/issues-card.ts +12 -7
  52. package/telegram-plugin/model-unavailable.ts +3 -6
  53. package/telegram-plugin/operator-events.ts +4 -6
  54. package/telegram-plugin/package.json +1 -1
  55. package/telegram-plugin/quota-check.ts +2 -2
  56. package/telegram-plugin/quota-watch.ts +7 -10
  57. package/telegram-plugin/server.ts +3 -1
  58. package/telegram-plugin/session-tail.ts +47 -1
  59. package/telegram-plugin/status-pin-driver.ts +102 -0
  60. package/telegram-plugin/status-pin.ts +76 -0
  61. package/telegram-plugin/stream-reply-handler.ts +33 -2
  62. package/telegram-plugin/subagent-watcher.ts +6 -3
  63. package/telegram-plugin/tests/always-allow-grant.test.ts +34 -2
  64. package/telegram-plugin/tests/auth-command-format2.test.ts +6 -2
  65. package/telegram-plugin/tests/auth-command-vernacular.test.ts +18 -0
  66. package/telegram-plugin/tests/auth-snapshot-format.test.ts +204 -45
  67. package/telegram-plugin/tests/card-format.test.ts +79 -0
  68. package/telegram-plugin/tests/chat-id-fallback.test.ts +74 -0
  69. package/telegram-plugin/tests/claude-code-event-contract.test.ts +151 -0
  70. package/telegram-plugin/tests/codespan-escaping-golden.test.ts +166 -0
  71. package/telegram-plugin/tests/credits-watch.test.ts +18 -0
  72. package/telegram-plugin/tests/format-consistency.test.ts +223 -0
  73. package/telegram-plugin/tests/formatting-parse-regression.test.ts +272 -0
  74. package/telegram-plugin/tests/formatting-torture-set.ts +218 -0
  75. package/telegram-plugin/tests/history.test.ts +38 -2
  76. package/telegram-plugin/tests/idle-footer.test.ts +53 -9
  77. package/telegram-plugin/tests/ipc-server-validate-rollout-status.test.ts +66 -0
  78. package/telegram-plugin/tests/issues-card.test.ts +24 -0
  79. package/telegram-plugin/tests/model-command.test.ts +213 -47
  80. package/telegram-plugin/tests/obligation-turn-end.test.ts +76 -0
  81. package/telegram-plugin/tests/paragraph-normalizer.test.ts +384 -3
  82. package/telegram-plugin/tests/permission-card-single-edit.test.ts +66 -0
  83. package/telegram-plugin/tests/permission-card-store.test.ts +83 -0
  84. package/telegram-plugin/tests/permission-timeout.test.ts +37 -6
  85. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +23 -6
  86. package/telegram-plugin/tests/quota-check.test.ts +9 -2
  87. package/telegram-plugin/tests/quota-watch.test.ts +7 -7
  88. package/telegram-plugin/tests/rich-markdown-oracle.ts +469 -0
  89. package/telegram-plugin/tests/rollout-status-wiring.test.ts +81 -0
  90. package/telegram-plugin/tests/session-tail.test.ts +91 -0
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +294 -0
  92. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +210 -0
  93. package/telegram-plugin/tests/status-pin-store.test.ts +450 -0
  94. package/telegram-plugin/tests/status-pin.test.ts +202 -0
  95. package/telegram-plugin/tests/status-vocabulary-unification.test.ts +125 -0
  96. package/telegram-plugin/tests/stream-reply-handler.test.ts +39 -0
  97. package/telegram-plugin/tests/telegram-format.test.ts +121 -8
  98. package/telegram-plugin/tests/text-voice-scrub.test.ts +142 -22
  99. package/telegram-plugin/tests/tool-activity-summary.test.ts +88 -25
  100. package/telegram-plugin/tests/tts-normalize.test.ts +242 -0
  101. package/telegram-plugin/tests/turns-jsonl-rotate.test.ts +39 -0
  102. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +12 -0
  103. package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +54 -0
  104. package/telegram-plugin/tests/vault-request-access-card.test.ts +94 -0
  105. package/telegram-plugin/tests/vault-request-access-tool.test.ts +24 -0
  106. package/telegram-plugin/tests/voice-normalize-text.test.ts +256 -0
  107. package/telegram-plugin/tests/voice-ondemand.test.ts +299 -0
  108. package/telegram-plugin/tests/voice-out-one-send.test.ts +153 -0
  109. package/telegram-plugin/tests/voice-presynth.test.ts +437 -0
  110. package/telegram-plugin/tests/voice-synthesize-sidecar.test.ts +352 -0
  111. package/telegram-plugin/tests/voice-transcribe-sidecar.test.ts +332 -0
  112. package/telegram-plugin/tests/voice-transcribe.test.ts +188 -0
  113. package/telegram-plugin/tests/worker-activity-feed.test.ts +77 -10
  114. package/telegram-plugin/text-voice-scrub.ts +68 -18
  115. package/telegram-plugin/tool-activity-summary.ts +31 -116
  116. package/telegram-plugin/tts-normalize.ts +377 -0
  117. package/telegram-plugin/uat/driver.ts +474 -17
  118. package/telegram-plugin/uat/scenarios/jtbd-model-litellm-sr-dm.test.ts +34 -14
  119. package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +169 -0
  120. package/telegram-plugin/uat/scenarios/jtbd-narration-intent-dm.test.ts +134 -0
  121. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +254 -0
  122. package/telegram-plugin/uat/scenarios/jtbd-status-phase-transitions-dm.test.ts +109 -0
  123. package/telegram-plugin/uat/uat-driver.test.ts +297 -0
  124. package/telegram-plugin/voice-normalize-text.ts +340 -0
  125. package/telegram-plugin/voice-ondemand.ts +289 -0
  126. package/telegram-plugin/voice-presynth.ts +242 -0
  127. package/telegram-plugin/voice-synthesize-sidecar.ts +259 -0
  128. package/telegram-plugin/voice-synthesize.ts +128 -0
  129. package/telegram-plugin/voice-transcribe-sidecar.ts +176 -0
  130. package/telegram-plugin/worker-activity-feed.ts +22 -5
@@ -9,7 +9,12 @@
9
9
  * (un-promoted break) are preferred over false positives (double-spaced list).
10
10
  */
11
11
  import { describe, test, expect } from 'vitest'
12
- import { normalizeParagraphBreaks } from '../format.js'
12
+ import {
13
+ normalizeParagraphBreaks,
14
+ splitCollapsedInlineBullets,
15
+ addParagraphSpacers,
16
+ PARAGRAPH_SPACER,
17
+ } from '../format.js'
13
18
 
14
19
  describe('normalizeParagraphBreaks', () => {
15
20
  test('promotes a lone prose paragraph break (prev ends with `.`, next is prose)', () => {
@@ -43,11 +48,71 @@ describe('normalizeParagraphBreaks', () => {
43
48
  expect(normalizeParagraphBreaks(input)).toBe(input)
44
49
  })
45
50
 
46
- test('does NOT promote when the next line is a list item (tight list stays tight)', () => {
47
- const input = 'Here are the steps.\n- first\n- second\n- third'
51
+ test('promotes every interior break in a vertical stat card (#2750)', () => {
52
+ // The gymbro food-log card: lines end in digits/letters (no terminator),
53
+ // but each is a standalone `label: value` stat line, so every interior
54
+ // break must become a GFM hard break instead of collapsing to one wall.
55
+ const input = 'Calories: 1800\nProtein: 120g\nCarbs: 200g'
56
+ expect(normalizeParagraphBreaks(input)).toBe(
57
+ 'Calories: 1800 \nProtein: 120g \nCarbs: 200g',
58
+ )
59
+ })
60
+
61
+ test('promotes a key-value line followed by prose (#2750)', () => {
62
+ const input = 'Status: active\nEverything is running smoothly.'
63
+ expect(normalizeParagraphBreaks(input)).toBe(
64
+ 'Status: active \nEverything is running smoothly.',
65
+ )
66
+ })
67
+
68
+ test('promotes bold-label stat cards — colon inside AND outside the emphasis (#2750)', () => {
69
+ // The fleet HOUSE style uses markdown-bold labels. Both `**Calories:** 1800`
70
+ // (colon inside the bold) and `**Calories**: 1800` (colon outside) must have
71
+ // every interior break promoted.
72
+ const inside = '**Calories:** 1800\n**Protein:** 120g\n**Carbs:** 200g'
73
+ expect(normalizeParagraphBreaks(inside)).toBe(
74
+ '**Calories:** 1800 \n**Protein:** 120g \n**Carbs:** 200g',
75
+ )
76
+ const outside = '**Calories**: 1800\n**Protein**: 120g'
77
+ expect(normalizeParagraphBreaks(outside)).toBe('**Calories**: 1800 \n**Protein**: 120g')
78
+ })
79
+
80
+ test('regression guard: a mid-sentence colon soft-wrap still does NOT promote (#2750)', () => {
81
+ // A wrapped sentence that happens to contain a colon must NOT be mistaken
82
+ // for a stat line: the next line continues the sentence in lowercase.
83
+ const input = 'He made one point: the plan was sound and\nthe timing was right too'
84
+ expect(normalizeParagraphBreaks(input)).toBe(input)
85
+ })
86
+
87
+ test('regression guard: a long colon-clause with a CAPITAL continuation does NOT promote (#2750)', () => {
88
+ // The lowercase-continuation guard alone would miss this: the wrapped clause
89
+ // continues with a capitalised proper noun. The label before the colon is a
90
+ // long multi-word run (not a short stat label), so the word/length cap keeps
91
+ // it from being mistaken for a stat line.
92
+ const input = 'The deal has one catch: the buyer wants it and\nToronto lawyers must sign off'
48
93
  expect(normalizeParagraphBreaks(input)).toBe(input)
49
94
  })
50
95
 
96
+ test('lowercase-label stat cards are an ACCEPTED false-negative (not promoted) (#2750)', () => {
97
+ // `calories: 1800\nprotein: 120g` — the lowercase-started next line is
98
+ // indistinguishable from a mid-sentence soft-wrap continuation, so the
99
+ // conservative guard leaves it alone. Documented, not fixed: models are
100
+ // steered toward capitalised / bold labels, and a false-negative (un-promoted
101
+ // break) is the module's preferred failure mode over a false-positive.
102
+ const input = 'calories: 1800\nprotein: 120g'
103
+ expect(normalizeParagraphBreaks(input)).toBe(input)
104
+ })
105
+
106
+ test('list glued to prose gains a block-boundary blank line; interior stays tight', () => {
107
+ // Uniform-block-spacing: a list start glued to prose by a single `\n`
108
+ // gets a blank line (so the spacer pass can see the transition); the
109
+ // list's own single-`\n` interior is never touched.
110
+ const input = 'Here are the steps.\n- first\n- second\n- third'
111
+ expect(normalizeParagraphBreaks(input)).toBe(
112
+ 'Here are the steps.\n\n- first\n- second\n- third',
113
+ )
114
+ })
115
+
51
116
  test('does NOT promote between list items (ordered or unordered)', () => {
52
117
  const ul = '- alpha.\n- beta.\n- gamma.'
53
118
  const ol = '1. first.\n2. second.\n3. third.'
@@ -270,4 +335,320 @@ describe('normalizeParagraphBreaks', () => {
270
335
  const input = '- first item\n continued text of the first item'
271
336
  expect(normalizeParagraphBreaks(input)).toBe(input)
272
337
  })
338
+
339
+ test('F2: prose with an interior ` | ` gets normal paragraph-break treatment (not table)', () => {
340
+ // `isMarkerLine` used to treat any line containing ` | ` as a table row and
341
+ // suppress the paragraph-break promotion. A sentence like "choose A | B" is
342
+ // prose, not a table — the lone `\n` after a terminated prose line must
343
+ // still be promoted to a GFM hard break.
344
+ const input = 'Pick one option, choose A | B here.\nThen continue below.'
345
+ expect(normalizeParagraphBreaks(input)).toBe(
346
+ 'Pick one option, choose A | B here. \nThen continue below.',
347
+ )
348
+ })
349
+
350
+ test('F2: a genuine table (header + delimiter) is still detected as structural', () => {
351
+ // The tightened check must still recognise a real GFM table: a blank line
352
+ // is inserted before the header and the rows stay contiguous.
353
+ const input = 'Here is data.\n| a | b |\n| --- | --- |\n| 1 | 2 |'
354
+ expect(normalizeParagraphBreaks(input)).toBe(
355
+ 'Here is data.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |',
356
+ )
357
+ })
358
+
359
+ test('F4: blank line inserted after a closed code fence before glued prose', () => {
360
+ // Prose glued directly onto a fence close (single `\n`) can be swallowed /
361
+ // mis-parsed. A blank line after a closed fence is always CommonMark-safe.
362
+ const input = 'Intro.\n\n```\nconst x = 1\n```\nThen the prose continues.'
363
+ const out = normalizeParagraphBreaks(input)
364
+ // The fence interior is untouched...
365
+ expect(out).toContain('```\nconst x = 1\n```')
366
+ // ...and a blank line now separates the fence close from the following prose.
367
+ expect(out).toContain('```\n\nThen the prose continues.')
368
+ })
369
+ })
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // splitCollapsedInlineBullets — stack an inline-collapsed bullet list
373
+ // ---------------------------------------------------------------------------
374
+
375
+ describe('splitCollapsedInlineBullets', () => {
376
+ test('splits the bath-bulb run-on (4 inline bullets → 4 lines)', () => {
377
+ const input =
378
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
379
+ expect(splitCollapsedInlineBullets(input)).toBe(
380
+ '• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
381
+ )
382
+ })
383
+
384
+ test('a bold segment inside one bullet survives the split', () => {
385
+ const input = '• A, clean • **Master Bath 2** (192.168.5.252), **33% loss** • C, clean'
386
+ expect(splitCollapsedInlineBullets(input)).toBe(
387
+ '• A, clean\n• **Master Bath 2** (192.168.5.252), **33% loss**\n• C, clean',
388
+ )
389
+ })
390
+
391
+ test('a <b> segment inside one bullet survives the split', () => {
392
+ const input = '• A, clean • <b>Master Bath 2</b>, <b>33% packet loss</b> • C, clean'
393
+ expect(splitCollapsedInlineBullets(input)).toBe(
394
+ '• A, clean\n• <b>Master Bath 2</b>, <b>33% packet loss</b>\n• C, clean',
395
+ )
396
+ })
397
+
398
+ test('splits on the middle-dot `·` separator too, and a `·`-led line', () => {
399
+ const input = '· one · two · three'
400
+ expect(splitCollapsedInlineBullets(input)).toBe('· one\n· two\n· three')
401
+ })
402
+
403
+ test('accepts a `-` or `*` leading marker (but only splits unicode interior bullets)', () => {
404
+ expect(splitCollapsedInlineBullets('- lead marker • two • three')).toBe(
405
+ '- lead marker\n• two\n• three',
406
+ )
407
+ expect(splitCollapsedInlineBullets('* lead marker • two')).toBe('* lead marker\n• two')
408
+ })
409
+
410
+ test('prose containing a mid-sentence `•` (line does not start with a bullet) is untouched', () => {
411
+ const input = 'The plan uses A • B notation throughout the design doc.'
412
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
413
+ })
414
+
415
+ test('does NOT split on interior `-`/`*` (hyphens, ranges, multiplication)', () => {
416
+ const input = '• range 1-3 and a * b product stay on one bullet'
417
+ // No interior unicode bullet → line is returned unchanged.
418
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
419
+ })
420
+
421
+ test('is idempotent on the bath-bulb input', () => {
422
+ const input =
423
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
424
+ const once = splitCollapsedInlineBullets(input)
425
+ const twice = splitCollapsedInlineBullets(once)
426
+ expect(twice).toBe(once)
427
+ })
428
+
429
+ test('an already-correct multi-line bullet list passes through unchanged', () => {
430
+ const input = '• Master Bath 1, clean\n• Master Bath 2, 33% loss\n• Cabinet, clean'
431
+ expect(splitCollapsedInlineBullets(input)).toBe(input)
432
+ })
433
+ })
434
+
435
+ // ---------------------------------------------------------------------------
436
+ // addParagraphSpacers — restore a VISIBLE blank line between prose paragraphs.
437
+ //
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.
444
+ // ---------------------------------------------------------------------------
445
+
446
+ describe('addParagraphSpacers', () => {
447
+ const gap = `\n\n${PARAGRAPH_SPACER}\n\n`
448
+
449
+ test('the spacer is a single non-breaking space (U+00A0), not an ASCII space', () => {
450
+ expect(PARAGRAPH_SPACER).toBe(' ')
451
+ })
452
+
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)
475
+ })
476
+
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
+ )
484
+ })
485
+
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', () => {
526
+ const input = [
527
+ 'Summary of the change.',
528
+ '',
529
+ 'It does two things.',
530
+ '',
531
+ '- adds a normalizer',
532
+ '- lifts the cap',
533
+ ].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`)
540
+ })
541
+ })
542
+
543
+ describe('normalizeParagraphBreaks — inline bullet split integration', () => {
544
+ test('splits the bath-bulb run-on as part of the full outbound pass', () => {
545
+ const input =
546
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Master Bath 3, clean • Cabinet, clean'
547
+ expect(normalizeParagraphBreaks(input)).toBe(
548
+ '• Master Bath 1, clean\n• Master Bath 2, 33% packet loss\n• Master Bath 3, clean\n• Cabinet, clean',
549
+ )
550
+ })
551
+
552
+ test('a `•` inside an inline code span is NOT split (code masked)', () => {
553
+ const input = '`• a • b • c` is a code span'
554
+ // The line does not start with a bullet marker (it starts with the masked
555
+ // code placeholder), and the bullets live inside masked code regardless.
556
+ expect(normalizeParagraphBreaks(input)).toBe(input)
557
+ })
558
+
559
+ test('a `•` inside a fenced code block is NOT split', () => {
560
+ const input = '```\n• a • b • c\n```'
561
+ expect(normalizeParagraphBreaks(input)).toBe(input)
562
+ })
563
+
564
+ test('idempotent through the full pass on the bath-bulb input', () => {
565
+ const input =
566
+ '• Master Bath 1, clean • Master Bath 2, 33% packet loss • Cabinet, clean'
567
+ const once = normalizeParagraphBreaks(input)
568
+ const twice = normalizeParagraphBreaks(once)
569
+ expect(twice).toBe(once)
570
+ })
571
+ })
572
+
573
+ /**
574
+ * Regression guard for the stray whitespace-only blank line between paragraphs
575
+ * (the "\n\n \n\n" seen in a real reply). A model (or an upstream transform)
576
+ * that authors a blank line whose only content is ASCII whitespace leaves a
577
+ * ragged / oversized gap: CommonMark discards it so it buys no visible space,
578
+ * but it reads as noise in the raw text. Step 1 of normalizeParagraphBreaks now
579
+ * 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.
583
+ */
584
+ describe('normalizeParagraphBreaks — whitespace-only blank-line collapse (Bug 2)', () => {
585
+ const NBSP = ' '
586
+
587
+ test('a lone-space blank line between paragraphs collapses to a clean `\\n\\n` (real string)', () => {
588
+ const input =
589
+ "Here's what was fixed and how it validates.\n\n \n\n" +
590
+ 'One loose end: a stray thing.'
591
+ expect(normalizeParagraphBreaks(input)).toBe(
592
+ "Here's what was fixed and how it validates.\n\n" +
593
+ 'One loose end: a stray thing.',
594
+ )
595
+ })
596
+
597
+ test('a single whitespace-only blank line (`A\\n \\nB`) collapses to `\\n\\n`', () => {
598
+ expect(normalizeParagraphBreaks('Alpha para.\n \nBravo para.')).toBe(
599
+ 'Alpha para.\n\nBravo para.',
600
+ )
601
+ expect(normalizeParagraphBreaks('Alpha para.\n\t\nBravo para.')).toBe(
602
+ 'Alpha para.\n\nBravo para.',
603
+ )
604
+ })
605
+
606
+ test('multiple stray whitespace-only lines in one gap collapse to a single `\\n\\n`', () => {
607
+ expect(normalizeParagraphBreaks('A.\n\n \n\n \n\nB.')).toBe('A.\n\nB.')
608
+ })
609
+
610
+ test('a legitimate single `\\n\\n` paragraph break is preserved exactly', () => {
611
+ expect(normalizeParagraphBreaks('Alpha para.\n\nBravo para.')).toBe(
612
+ 'Alpha para.\n\nBravo para.',
613
+ )
614
+ })
615
+
616
+ test('never EMITS a whitespace-only line between two prose paragraphs', () => {
617
+ const out = normalizeParagraphBreaks('First.\n\n \n\nSecond.')
618
+ for (const line of out.split('\n')) {
619
+ // Every line is either empty or has real (non-ASCII-whitespace) content.
620
+ expect(line === '' || /\S/.test(line) || line.includes(NBSP)).toBe(true)
621
+ }
622
+ expect(out).toBe('First.\n\nSecond.')
623
+ })
624
+
625
+ test('a blank line inside a fenced code block (masked) is NOT collapsed', () => {
626
+ const input = '```\ncode line 1\n \ncode line 2\n```'
627
+ // The whitespace-only line lives inside a masked fence and must survive.
628
+ expect(normalizeParagraphBreaks(input)).toBe(input)
629
+ })
630
+
631
+ test('trailing hard-break spaces on a CONTENT line are not eaten', () => {
632
+ // "line. \n\nNext." — the two trailing spaces are a GFM hard break on a
633
+ // content line, not a blank line; they must survive.
634
+ const input = 'Trailing spaces on line. \n\nNext.'
635
+ expect(normalizeParagraphBreaks(input)).toBe(input)
636
+ })
637
+
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)
646
+ })
647
+
648
+ test('idempotent: collapsing a stray gap twice is stable', () => {
649
+ const once = normalizeParagraphBreaks('A.\n\n \n\nB.')
650
+ const twice = normalizeParagraphBreaks(once)
651
+ expect(twice).toBe(once)
652
+ expect(once).toBe('A.\n\nB.')
653
+ })
273
654
  })
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Structural pin for card-ux fix 3 — the "Allow once" tap updates the SAME
3
+ * card instead of spawning a separate "▶️ … got it, continuing" message.
4
+ *
5
+ * What broke before: tapping Allow edited the card to "✅ Allowed once" AND
6
+ * posted a separate resume message (to EVERY resolvePermissionCardTargets()
7
+ * surface). Ken wants no split — the continuation folds into the tapped card.
8
+ *
9
+ * The turn-resume trigger (dispatchPermissionVerdict) and the glyph flip
10
+ * (resumeReactionAfterVerdict) MUST still fire; only the user-visible second
11
+ * message collapses. mtcute UAT can observe the card edit and a separate
12
+ * sendMessage, but wiring a full Grammy ctx here is heavy, so this is a
13
+ * source-structure pin over the exact callback block (mirrors
14
+ * permission-verdict-resume-guard.test.ts).
15
+ */
16
+
17
+ import { describe, it, expect } from 'vitest'
18
+ import { readFileSync } from 'node:fs'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { dirname, resolve } from 'node:path'
21
+
22
+ const __dirname = dirname(fileURLToPath(import.meta.url))
23
+ const GATEWAY_SRC = readFileSync(
24
+ resolve(__dirname, '..', 'gateway', 'gateway.ts'),
25
+ 'utf8',
26
+ )
27
+
28
+ // Isolate the single "Allow once / Deny" verdict-tap block: from the
29
+ // `labelWithResume` construction to the end of its finalizeCallback call.
30
+ function allowOnceBlock(): string {
31
+ const start = GATEWAY_SRC.indexOf('const resumeLine = process.env.SWITCHROOM_RESUME_MSG')
32
+ expect(start).toBeGreaterThan(-1)
33
+ // Cut a generous slice covering resumeLine + finalizeCallback + synthInbound.
34
+ return GATEWAY_SRC.slice(start, start + 2000)
35
+ }
36
+
37
+ describe('permission "Allow once" tap → single in-place card edit (card-ux fix 3)', () => {
38
+ it('folds the resume line into the card edit via formatPermissionResumeMessage', () => {
39
+ const block = allowOnceBlock()
40
+ expect(block).toContain('formatPermissionResumeMessage(')
41
+ // The card newText carries BOTH the verdict label and the resume line.
42
+ expect(block).toContain('const labelWithResume = resumeLine')
43
+ expect(block).toMatch(/newText:\s*baseText\s*\?\s*`\$\{baseText\}\\n\\n\$\{labelWithResume\}`/)
44
+ })
45
+
46
+ it('does NOT post a separate resume message on this path', () => {
47
+ const block = allowOnceBlock()
48
+ expect(block).not.toContain('postPermissionResumeMessage(')
49
+ })
50
+
51
+ it('still fires the turn-resume trigger and glyph flip', () => {
52
+ const block = allowOnceBlock()
53
+ expect(block).toContain('dispatchPermissionVerdict(')
54
+ expect(block).toContain('resumeReactionAfterVerdict()')
55
+ })
56
+
57
+ it('honours the SWITCHROOM_RESUME_MSG=0 kill-switch for the folded line', () => {
58
+ const block = allowOnceBlock()
59
+ expect(block).toContain("process.env.SWITCHROOM_RESUME_MSG === '0'")
60
+ })
61
+
62
+ it('carries the card-folded-resume sentinel the resume guard keys off', () => {
63
+ const block = allowOnceBlock()
64
+ expect(block).toContain('card-folded-resume')
65
+ })
66
+ })
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { tmpdir } from 'node:os'
5
+ import { createPermissionCardStore } from '../gateway/permission-card-store.js'
6
+
7
+ function makeTmpDir() {
8
+ return mkdtempSync(join(tmpdir(), 'perm-card-store-test-'))
9
+ }
10
+
11
+ describe('createPermissionCardStore', () => {
12
+ let dir: string
13
+
14
+ beforeEach(() => { dir = makeTmpDir() })
15
+ afterEach(() => { rmSync(dir, { recursive: true, force: true }) })
16
+
17
+ it('starts empty', () => {
18
+ const store = createPermissionCardStore(dir)
19
+ expect(store.loadAll()).toEqual([])
20
+ })
21
+
22
+ it('add and loadAll returns the entry', () => {
23
+ const store = createPermissionCardStore(dir)
24
+ const entry = {
25
+ requestId: 'aabcd',
26
+ chatId: '12345',
27
+ messageId: 999,
28
+ startedAt: Date.now(),
29
+ toolName: 'mcp__shell__run',
30
+ cardText: 'Run shell command',
31
+ }
32
+ store.add(entry)
33
+ expect(store.loadAll()).toEqual([entry])
34
+ })
35
+
36
+ it('add multiple entries for same requestId (different messageIds)', () => {
37
+ const store = createPermissionCardStore(dir)
38
+ store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
39
+ store.add({ requestId: 'aabcd', chatId: '222', messageId: 2, startedAt: 1000, toolName: 't', cardText: 'c' })
40
+ const all = store.loadAll()
41
+ expect(all.length).toBe(2)
42
+ })
43
+
44
+ it('does not duplicate same requestId+messageId', () => {
45
+ const store = createPermissionCardStore(dir)
46
+ const entry = { requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' }
47
+ store.add(entry)
48
+ store.add(entry)
49
+ expect(store.loadAll().length).toBe(1)
50
+ })
51
+
52
+ it('remove clears all entries for a requestId', () => {
53
+ const store = createPermissionCardStore(dir)
54
+ store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
55
+ store.add({ requestId: 'aabcd', chatId: '222', messageId: 2, startedAt: 1000, toolName: 't', cardText: 'c' })
56
+ store.add({ requestId: 'xyzab', chatId: '333', messageId: 3, startedAt: 1000, toolName: 't', cardText: 'c' })
57
+ store.remove('aabcd')
58
+ const all = store.loadAll()
59
+ expect(all.length).toBe(1)
60
+ expect(all[0].requestId).toBe('xyzab')
61
+ })
62
+
63
+ it('remove is idempotent when entry not present', () => {
64
+ const store = createPermissionCardStore(dir)
65
+ expect(() => store.remove('nonexistent')).not.toThrow()
66
+ })
67
+
68
+ it('clear empties the store', () => {
69
+ const store = createPermissionCardStore(dir)
70
+ store.add({ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c' })
71
+ store.clear()
72
+ expect(store.loadAll()).toEqual([])
73
+ })
74
+
75
+ it('survives re-instantiation (data persists across instances)', () => {
76
+ createPermissionCardStore(dir).add({
77
+ requestId: 'aabcd', chatId: '111', messageId: 1, startedAt: 1000, toolName: 't', cardText: 'c',
78
+ })
79
+ const all = createPermissionCardStore(dir).loadAll()
80
+ expect(all.length).toBe(1)
81
+ expect(all[0].requestId).toBe('aabcd')
82
+ })
83
+ })
@@ -13,7 +13,9 @@ import {
13
13
  timeoutDenyMessage,
14
14
  duplicateDenyMessage,
15
15
  isRecentTimeoutDuplicate,
16
+ approvalTtlMs,
16
17
  PERMISSION_TTL_MS,
18
+ PERMISSION_TTL_DEFAULT_MS,
17
19
  HOSTD_PERMISSION_TTL_MS,
18
20
  ttlForTool,
19
21
  buildTimedOutCardEdits,
@@ -22,6 +24,35 @@ import {
22
24
  TIMED_OUT_FOOTER,
23
25
  } from '../gateway/permission-timeout.js'
24
26
 
27
+ describe('approvalTtlMs (config-driven approval-card lifetime)', () => {
28
+ it('defaults to 60 minutes when unset', () => {
29
+ expect(approvalTtlMs({})).toBe(60 * 60_000)
30
+ expect(PERMISSION_TTL_DEFAULT_MS).toBe(60 * 60_000)
31
+ })
32
+
33
+ it('honours a configured value (threaded as SWITCHROOM_TG_APPROVAL_TIMEOUT_MS ms)', () => {
34
+ expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '1800000' })).toBe(1_800_000)
35
+ })
36
+
37
+ it('falls back to the 60-min default on blank / garbage / non-positive', () => {
38
+ const d = 60 * 60_000
39
+ expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '' })).toBe(d)
40
+ expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: 'abc' })).toBe(d)
41
+ expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '0' })).toBe(d)
42
+ expect(approvalTtlMs({ SWITCHROOM_TG_APPROVAL_TIMEOUT_MS: '-5' })).toBe(d)
43
+ })
44
+
45
+ it('the default permission-card lifetime is at least 60 min', () => {
46
+ // With no env override at module load, PERMISSION_TTL_MS is the default.
47
+ expect(PERMISSION_TTL_MS).toBeGreaterThanOrEqual(60 * 60_000)
48
+ })
49
+
50
+ it('hostd verbs never get a shorter window than the default card', () => {
51
+ expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(PERMISSION_TTL_MS)
52
+ expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(30 * 60_000)
53
+ })
54
+ })
55
+
25
56
  describe('permissionSignature', () => {
26
57
  it('is stable for the same tool + input', () => {
27
58
  expect(permissionSignature('mcp__meta_ads__set_budget', '{"id":"1","budget":1400}'))
@@ -96,7 +127,7 @@ describe('isRecentTimeoutDuplicate', () => {
96
127
  // ─── Bug 2 — per-tool TTL, timed-out card hygiene, stale-tap honesty ────────
97
128
 
98
129
  describe('ttlForTool (Bug 2 fix #2)', () => {
99
- it('gives hostd gated verbs a 30-min window', () => {
130
+ it('gives hostd gated verbs at least a 30-min window (>= the default card)', () => {
100
131
  for (const tool of [
101
132
  'mcp__hostd__rollout',
102
133
  'mcp__hostd__update_apply',
@@ -105,20 +136,20 @@ describe('ttlForTool (Bug 2 fix #2)', () => {
105
136
  'mcp__hostd__config_propose_edit',
106
137
  ]) {
107
138
  expect(ttlForTool(tool)).toBe(HOSTD_PERMISSION_TTL_MS)
108
- expect(ttlForTool(tool)).toBe(30 * 60_000)
139
+ expect(ttlForTool(tool)).toBeGreaterThanOrEqual(30 * 60_000)
109
140
  }
110
141
  })
111
142
 
112
- it('keeps the 10-min default for non-hostd tools', () => {
143
+ it('applies the config-driven default (60 min) to non-hostd tools', () => {
113
144
  expect(ttlForTool('Bash')).toBe(PERMISSION_TTL_MS)
114
- expect(ttlForTool('Bash')).toBe(10 * 60_000)
145
+ expect(ttlForTool('Bash')).toBe(60 * 60_000)
115
146
  expect(ttlForTool('mcp__perplexity__search')).toBe(PERMISSION_TTL_MS)
116
147
  expect(ttlForTool('mcp__agent-config__schedule_add')).toBe(PERMISSION_TTL_MS)
117
148
  expect(ttlForTool(undefined)).toBe(PERMISSION_TTL_MS)
118
149
  })
119
150
 
120
- it('hostd TTL is strictly longer than the default', () => {
121
- expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThan(PERMISSION_TTL_MS)
151
+ it('hostd TTL is never shorter than the default (the 60-min raise subsumes the old 30-min floor)', () => {
152
+ expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThanOrEqual(PERMISSION_TTL_MS)
122
153
  })
123
154
  })
124
155