switchroom 0.16.23 → 0.16.27

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 (138) hide show
  1. package/dist/agent-scheduler/index.js +80 -80
  2. package/dist/auth-broker/index.js +80 -80
  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 +82 -82
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
  7. package/dist/cli/skill-validate-pretool.mjs +2936 -119
  8. package/dist/cli/switchroom.js +804 -465
  9. package/dist/host-control/main.js +169 -163
  10. package/dist/vault/approvals/kernel-server.js +82 -82
  11. package/dist/vault/broker/server.js +83 -83
  12. package/package.json +4 -4
  13. package/telegram-plugin/answer-stream.ts +20 -49
  14. package/telegram-plugin/auth-snapshot-format.ts +27 -30
  15. package/telegram-plugin/auto-fallback-fleet.ts +6 -11
  16. package/telegram-plugin/bridge/bridge.ts +1 -1
  17. package/telegram-plugin/card-format.ts +28 -25
  18. package/telegram-plugin/credits-watch.ts +5 -10
  19. package/telegram-plugin/dist/bridge/bridge.js +113 -113
  20. package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
  21. package/telegram-plugin/dist/server.js +161 -161
  22. package/telegram-plugin/draft-stream.ts +4 -4
  23. package/telegram-plugin/format.ts +427 -680
  24. package/telegram-plugin/gateway/approval-callback.ts +2 -3
  25. package/telegram-plugin/gateway/approval-card.test.ts +17 -4
  26. package/telegram-plugin/gateway/approval-card.ts +16 -6
  27. package/telegram-plugin/gateway/approvals-commands.ts +18 -24
  28. package/telegram-plugin/gateway/auth-command.ts +74 -74
  29. package/telegram-plugin/gateway/auth-line.ts +5 -15
  30. package/telegram-plugin/gateway/boot-card.ts +20 -22
  31. package/telegram-plugin/gateway/boot-version.ts +3 -2
  32. package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
  33. package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
  34. package/telegram-plugin/gateway/config-snapshot.ts +9 -9
  35. package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
  36. package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
  37. package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
  38. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  39. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  40. package/telegram-plugin/gateway/effort-command.ts +17 -17
  41. package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
  42. package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
  43. package/telegram-plugin/gateway/gateway.ts +881 -633
  44. package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
  45. package/telegram-plugin/gateway/inject-handler.ts +5 -5
  46. package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
  47. package/telegram-plugin/gateway/ipc-server.ts +39 -6
  48. package/telegram-plugin/gateway/linear-activity.ts +16 -14
  49. package/telegram-plugin/gateway/linear-setup.ts +1 -1
  50. package/telegram-plugin/gateway/model-command.ts +25 -25
  51. package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
  52. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  53. package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
  54. package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
  55. package/telegram-plugin/issues-card.ts +6 -7
  56. package/telegram-plugin/model-unavailable.ts +8 -12
  57. package/telegram-plugin/operator-events-history.ts +1 -1
  58. package/telegram-plugin/operator-events.ts +24 -28
  59. package/telegram-plugin/package.json +1 -1
  60. package/telegram-plugin/pending-work-progress.ts +36 -36
  61. package/telegram-plugin/permission-title.ts +39 -20
  62. package/telegram-plugin/pty-partial-handler.ts +5 -13
  63. package/telegram-plugin/quota-check.ts +5 -5
  64. package/telegram-plugin/quota-watch.ts +13 -18
  65. package/telegram-plugin/recent-outbound-dedup.ts +5 -5
  66. package/telegram-plugin/registry/turns-schema.ts +43 -3
  67. package/telegram-plugin/retry-api-call.ts +42 -7
  68. package/telegram-plugin/rich-send.ts +86 -0
  69. package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
  70. package/telegram-plugin/secret-detect/vault-error.ts +29 -22
  71. package/telegram-plugin/shared/bot-runtime.ts +29 -7
  72. package/telegram-plugin/silence-poke.ts +26 -69
  73. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  74. package/telegram-plugin/slot-banner-driver.ts +9 -6
  75. package/telegram-plugin/slot-banner.ts +5 -8
  76. package/telegram-plugin/status-no-truncate.ts +11 -5
  77. package/telegram-plugin/steering.ts +0 -4
  78. package/telegram-plugin/stream-controller.ts +59 -62
  79. package/telegram-plugin/stream-reply-handler.ts +49 -98
  80. package/telegram-plugin/subagent-watcher.ts +2 -2
  81. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
  82. package/telegram-plugin/tests/answer-stream.test.ts +54 -63
  83. package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
  84. package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
  85. package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
  86. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
  87. package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
  88. package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
  89. package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
  90. package/telegram-plugin/tests/bot-api.harness.ts +23 -1
  91. package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
  92. package/telegram-plugin/tests/card-format.test.ts +6 -4
  93. package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
  94. package/telegram-plugin/tests/credits-watch.test.ts +5 -5
  95. package/telegram-plugin/tests/fake-bot-api.ts +58 -4
  96. package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
  97. package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
  98. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
  99. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  100. package/telegram-plugin/tests/issues-card.test.ts +15 -12
  101. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  102. package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
  103. package/telegram-plugin/tests/model-command.test.ts +2 -2
  104. package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
  105. package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
  106. package/telegram-plugin/tests/operator-events.test.ts +7 -9
  107. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  108. package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
  109. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  110. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +88 -41
  112. package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
  113. package/telegram-plugin/tests/quota-check.test.ts +3 -3
  114. package/telegram-plugin/tests/quota-watch.test.ts +8 -4
  115. package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
  116. package/telegram-plugin/tests/silence-poke.test.ts +75 -112
  117. package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
  118. package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
  119. package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
  120. package/telegram-plugin/tests/slot-banner.test.ts +9 -6
  121. package/telegram-plugin/tests/status-accent.test.ts +29 -32
  122. package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
  123. package/telegram-plugin/tests/stream-controller.test.ts +63 -52
  124. package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
  125. package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
  126. package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
  127. package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
  128. package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
  129. package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
  130. package/telegram-plugin/tests/welcome-text.test.ts +72 -65
  131. package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
  132. package/telegram-plugin/text-voice-scrub.ts +8 -11
  133. package/telegram-plugin/tool-activity-summary.ts +29 -29
  134. package/telegram-plugin/welcome-text.ts +82 -83
  135. package/telegram-plugin/worker-activity-feed.ts +2 -3
  136. package/telegram-plugin/html-sanitize.ts +0 -244
  137. package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
  138. package/telegram-plugin/tests/parse-mode-rotation.test.ts +0 -162
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Tests for normalizeParagraphBreaks (task A of the Telegram-formatting bundle).
3
+ *
4
+ * The rich GFM render path collapses a LONE `\n` (it's a soft break), so a model
5
+ * that separates paragraphs with one newline produces a cramped wall of text.
6
+ * normalizeParagraphBreaks promotes a lone *prose* `\n` into a GFM hard break
7
+ * (` \n`) while leaving lists, tables, blockquotes, headings, code, and genuine
8
+ * `\n\n` gaps untouched. It is deliberately conservative — false negatives
9
+ * (un-promoted break) are preferred over false positives (double-spaced list).
10
+ */
11
+ import { describe, test, expect } from 'vitest'
12
+ import { normalizeParagraphBreaks } from '../format.js'
13
+
14
+ describe('normalizeParagraphBreaks', () => {
15
+ test('promotes a lone prose paragraph break (prev ends with `.`, next is prose)', () => {
16
+ const input = 'First thought ends here.\nSecond thought starts here.'
17
+ expect(normalizeParagraphBreaks(input)).toBe(
18
+ 'First thought ends here. \nSecond thought starts here.',
19
+ )
20
+ })
21
+
22
+ test('promotes after ! ? and : terminators too', () => {
23
+ expect(normalizeParagraphBreaks('Done!\nNext line here.')).toBe('Done! \nNext line here.')
24
+ expect(normalizeParagraphBreaks('Really?\nYes, really.')).toBe('Really? \nYes, really.')
25
+ expect(normalizeParagraphBreaks('Steps follow:\nDo the thing.')).toBe(
26
+ 'Steps follow: \nDo the thing.',
27
+ )
28
+ })
29
+
30
+ test('promotes when the terminator is wrapped by a closing quote or paren', () => {
31
+ expect(normalizeParagraphBreaks('He said "go."\nThen we went.')).toBe(
32
+ 'He said "go." \nThen we went.',
33
+ )
34
+ expect(normalizeParagraphBreaks('(all done.)\nMoving on now.')).toBe(
35
+ '(all done.) \nMoving on now.',
36
+ )
37
+ })
38
+
39
+ test('does NOT promote a lone mid-sentence soft wrap (prev has no terminator)', () => {
40
+ // A soft-wrapped sentence: the first line does not end in terminal
41
+ // punctuation, so we leave the break alone (false negative by design).
42
+ const input = 'this is a long sentence that wrapped\nonto a second line mid-thought'
43
+ expect(normalizeParagraphBreaks(input)).toBe(input)
44
+ })
45
+
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'
48
+ expect(normalizeParagraphBreaks(input)).toBe(input)
49
+ })
50
+
51
+ test('does NOT promote between list items (ordered or unordered)', () => {
52
+ const ul = '- alpha.\n- beta.\n- gamma.'
53
+ const ol = '1. first.\n2. second.\n3. third.'
54
+ expect(normalizeParagraphBreaks(ul)).toBe(ul)
55
+ expect(normalizeParagraphBreaks(ol)).toBe(ol)
56
+ })
57
+
58
+ test('does NOT promote indented (nested) list items', () => {
59
+ const input = '- parent.\n - child one.\n - child two.'
60
+ expect(normalizeParagraphBreaks(input)).toBe(input)
61
+ })
62
+
63
+ test('does NOT touch a markdown table', () => {
64
+ const input = '| col a | col b |\n| --- | --- |\n| r1a | r1b |\n| r2a | r2b |'
65
+ expect(normalizeParagraphBreaks(input)).toBe(input)
66
+ })
67
+
68
+ test('does NOT promote into a blockquote or heading (but DOES guarantee a block-start blank line)', () => {
69
+ // The lone `\n` is never promoted to a hard break (` \n`) when the next
70
+ // line is a blockquote / heading marker — but Step 3 DOES guarantee the
71
+ // blank line a GFM block needs to start, so prose→quote and prose→heading
72
+ // transitions get a `\n\n` gap (the block renders correctly).
73
+ expect(normalizeParagraphBreaks('As noted.\n> a quoted line')).toBe(
74
+ 'As noted.\n\n> a quoted line',
75
+ )
76
+ expect(normalizeParagraphBreaks('Intro line.\n# Heading')).toBe('Intro line.\n\n# Heading')
77
+ // No spurious hard break (` \n`) is ever introduced at the boundary.
78
+ expect(normalizeParagraphBreaks('As noted.\n> a quoted line')).not.toContain(' \n')
79
+ })
80
+
81
+ test('preserves a fenced code block verbatim (interior newlines untouched)', () => {
82
+ const input = 'Look here.\n```js\nconst a = 1;\nconst b = 2;\n```\nDone.'
83
+ const out = normalizeParagraphBreaks(input)
84
+ // The fence body is preserved exactly — no hard break injected inside it.
85
+ expect(out).toContain('```js\nconst a = 1;\nconst b = 2;\n```')
86
+ // A blank line is guaranteed BEFORE the fence open so it starts a fresh
87
+ // GFM code block instead of being glued to the preceding prose line.
88
+ expect(out).toContain('Look here.\n\n```js')
89
+ })
90
+
91
+ test('preserves inline code spans verbatim', () => {
92
+ const input = 'Run `npm test` now.\nThen check the output.'
93
+ const out = normalizeParagraphBreaks(input)
94
+ expect(out).toContain('`npm test`')
95
+ // Prose break after a sentence ending in `.` is still promoted.
96
+ expect(out).toBe('Run `npm test` now. \nThen check the output.')
97
+ })
98
+
99
+ test('preserves an existing `\\n\\n` paragraph gap (never collapses it)', () => {
100
+ const input = 'Paragraph one.\n\nParagraph two.'
101
+ expect(normalizeParagraphBreaks(input)).toBe(input)
102
+ })
103
+
104
+ test('collapses 3+ newlines down to exactly `\\n\\n`', () => {
105
+ expect(normalizeParagraphBreaks('A.\n\n\nB.')).toBe('A.\n\nB.')
106
+ expect(normalizeParagraphBreaks('A.\n\n\n\n\nB.')).toBe('A.\n\nB.')
107
+ })
108
+
109
+ test('does not promote a break adjacent to a blank line', () => {
110
+ // The newline that is part of a `\n\n` gap must stay a plain newline, not
111
+ // become a ` \n` hard break.
112
+ const input = 'Done.\n\nMore prose.'
113
+ const out = normalizeParagraphBreaks(input)
114
+ expect(out).not.toContain(' \n\n')
115
+ expect(out).toBe('Done.\n\nMore prose.')
116
+ })
117
+
118
+ test('handles a mixed body: prose promoted, list left alone, fence preserved', () => {
119
+ const input = [
120
+ 'Summary of the change.',
121
+ 'It does two things now.',
122
+ '',
123
+ '- adds a normalizer',
124
+ '- lifts the char cap',
125
+ '',
126
+ '```ts',
127
+ 'const x = 1',
128
+ '```',
129
+ ].join('\n')
130
+ const out = normalizeParagraphBreaks(input)
131
+ // The two prose lines get a hard break between them.
132
+ expect(out).toContain('Summary of the change. \nIt does two things now.')
133
+ // The list stays tight.
134
+ expect(out).toContain('- adds a normalizer\n- lifts the char cap')
135
+ // The fence is intact.
136
+ expect(out).toContain('```ts\nconst x = 1\n```')
137
+ })
138
+
139
+ test('returns single-line input unchanged (no newline to consider)', () => {
140
+ expect(normalizeParagraphBreaks('just one line, no breaks')).toBe('just one line, no breaks')
141
+ })
142
+
143
+ test('does not double-promote an already-hard break', () => {
144
+ // A break already followed by two trailing spaces should not gain more.
145
+ const input = 'Done. \nNext.'
146
+ const out = normalizeParagraphBreaks(input)
147
+ // prev line trimEnd() ends in `.`, so it promotes — but the result must not
148
+ // accumulate extra spaces beyond the single ` \n` hard break.
149
+ expect(out).toBe('Done. \nNext.')
150
+ })
151
+
152
+ test('CRLF input promotes to a hard break with NO stranded `\\r`', () => {
153
+ // A CRLF source must not leave a lone carriage return before the injected
154
+ // ` \n`. The trailing-whitespace strip includes `\r` so the result is a
155
+ // clean ` \n` hard break, not ` \r\n` or `\r \n`.
156
+ const out = normalizeParagraphBreaks('Alpha.\r\nBravo.')
157
+ expect(out).toBe('Alpha. \nBravo.')
158
+ expect(out).not.toContain('\r')
159
+ })
160
+
161
+ test('does NOT promote a break adjacent to an indented code block', () => {
162
+ // CommonMark indented code block (4+ leading spaces then non-space) is a
163
+ // block marker — a break adjacent to it must NOT be promoted to a hard break.
164
+ const input = 'Note:\n indented code'
165
+ expect(normalizeParagraphBreaks(input)).toBe(input)
166
+ })
167
+
168
+ // -------------------------------------------------------------------------
169
+ // Step 3 — block-boundary blank-line guarantee. A GFM block glued to the
170
+ // previous line by a single `\n` fails to render (a table prints as literal
171
+ // pipe text; prose after a list is absorbed as a lazy list continuation).
172
+ // These cases capture the two live-render bugs plus the regression guards
173
+ // that the tight-list / code / table-internals constraints depend on.
174
+ // -------------------------------------------------------------------------
175
+
176
+ test('inserts a blank line before a table header glued to a single-`\\n` text line (VERIFY 5)', () => {
177
+ // The live render showed the table printing as inline literal pipe text
178
+ // because only a single `\n` separated the preceding text line from the
179
+ // table's first row. A blank line must be inserted BEFORE the header, and
180
+ // header + delimiter + body rows must stay contiguous (single `\n`).
181
+ const input =
182
+ 'VERIFY 5 — GFM table\n| Name | Role |\n|---|---|\n| Ada | Engineer |\n| Bob | Designer |'
183
+ expect(normalizeParagraphBreaks(input)).toBe(
184
+ 'VERIFY 5 — GFM table\n\n| Name | Role |\n|---|---|\n| Ada | Engineer |\n| Bob | Designer |',
185
+ )
186
+ })
187
+
188
+ test('inserts a blank line before post-list prose, list stays tight, fence untouched (VERIFY 7)', () => {
189
+ // The live render showed "A prose line after the list." absorbed into the
190
+ // last bullet (GFM lazy continuation). A blank line must break it out of
191
+ // the list, while the bullets stay tight and the fence stays verbatim.
192
+ const input =
193
+ 'VERIFY 7 — mixed\n' +
194
+ 'This first prose sentence stands alone.\n' +
195
+ 'This second prose sentence should show a gap above it.\n' +
196
+ '- bullet\n' +
197
+ '- list\n' +
198
+ 'A prose line after the list.\n' +
199
+ '```\n' +
200
+ 'echo hello.\n' +
201
+ 'echo world.\n' +
202
+ '```'
203
+ const out = normalizeParagraphBreaks(input)
204
+ // The two leading prose sentences are promoted (each ends in `.`).
205
+ expect(out).toContain(
206
+ 'This first prose sentence stands alone. \nThis second prose sentence should show a gap above it.',
207
+ )
208
+ // The bullet items stay TIGHT — no blank line between same-list items.
209
+ expect(out).toContain('- bullet\n- list')
210
+ // The post-list prose is separated from the list by a blank line.
211
+ expect(out).toContain('- list\n\nA prose line after the list.')
212
+ // The fence interior is untouched and the fence is preceded by a blank line.
213
+ expect(out).toContain('```\necho hello.\necho world.\n```')
214
+ expect(out).toContain('A prose line after the list.\n\n```')
215
+ })
216
+
217
+ test('regression: a pure tight bullet list stays tight (no blank lines inserted)', () => {
218
+ const input = '- alpha\n- beta\n- gamma'
219
+ expect(normalizeParagraphBreaks(input)).toBe(input)
220
+ })
221
+
222
+ test('regression: a pure tight numbered list stays tight (no blank lines inserted)', () => {
223
+ const input = '1. first\n2. second\n3. third'
224
+ expect(normalizeParagraphBreaks(input)).toBe(input)
225
+ })
226
+
227
+ test('regression: existing `\\n\\n` paragraphs are unchanged (no extra gap)', () => {
228
+ const input = 'Para one.\n\nPara two.\n\nPara three.'
229
+ expect(normalizeParagraphBreaks(input)).toBe(input)
230
+ })
231
+
232
+ test('regression: a fenced code block with `|` pipes / `-` lines is NOT treated as a table', () => {
233
+ // The pipes and dashes live INSIDE a fence — masking must keep them out of
234
+ // the table heuristic so no spurious blank line is injected inside or
235
+ // around the code, and the interior is byte-for-byte preserved.
236
+ const input = 'Header here.\n```\n| not | a | table |\n|---|---|---|\n--- dash line\n```'
237
+ const out = normalizeParagraphBreaks(input)
238
+ // The fence interior is verbatim — pipes and dashes untouched.
239
+ expect(out).toContain('```\n| not | a | table |\n|---|---|---|\n--- dash line\n```')
240
+ // The fence open gets its block-start blank line (it's a code block, not a
241
+ // table) but nothing inside it is reflowed.
242
+ expect(out).toBe(
243
+ 'Header here.\n\n```\n| not | a | table |\n|---|---|---|\n--- dash line\n```',
244
+ )
245
+ })
246
+
247
+ test('a heading after prose gets its block-start blank line', () => {
248
+ expect(normalizeParagraphBreaks('Some intro prose.\n## Section')).toBe(
249
+ 'Some intro prose.\n\n## Section',
250
+ )
251
+ })
252
+
253
+ test('a blockquote after prose gets its block-start blank line', () => {
254
+ expect(normalizeParagraphBreaks('Some intro prose.\n> quoted wisdom')).toBe(
255
+ 'Some intro prose.\n\n> quoted wisdom',
256
+ )
257
+ })
258
+
259
+ test('does NOT split a table header from its delimiter or body rows', () => {
260
+ // A table already preceded by a blank line must keep all of its own rows
261
+ // contiguous (single `\n`) — the blank-line rule only fires BEFORE the
262
+ // header, never between header/delimiter/body.
263
+ const input = 'Intro.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |'
264
+ expect(normalizeParagraphBreaks(input)).toBe(input)
265
+ })
266
+
267
+ test('list followed by an indented continuation line stays glued (no breakout)', () => {
268
+ // A 4-space indented line under a bullet is a lazy paragraph continuation
269
+ // of that item, not breakout prose — it must NOT gain a blank line.
270
+ const input = '- first item\n continued text of the first item'
271
+ expect(normalizeParagraphBreaks(input)).toBe(input)
272
+ })
273
+ })
@@ -139,7 +139,7 @@ describe('pending-work-progress', () => {
139
139
  expect(cap.edits).toHaveLength(1)
140
140
  expect(cap.edits[0].messageId).toBe(100)
141
141
  expect(cap.edits[0].newText).toBe(
142
- "Background sleep running; awaiting completion.\n\n— still working (1m) · message me anytime, I'll keep you posted",
142
+ "Background sleep running; awaiting completion.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
143
143
  )
144
144
 
145
145
  // Tick at 3 intervals total — second edit, "3m".
@@ -148,7 +148,7 @@ describe('pending-work-progress', () => {
148
148
  await flush()
149
149
  expect(cap.edits).toHaveLength(2)
150
150
  expect(cap.edits[1].newText).toBe(
151
- "Background sleep running; awaiting completion.\n\n— still working (3m) · message me anytime, I'll keep you posted",
151
+ "Background sleep running; awaiting completion.\n\n_still working (3m) · message me anytime, I'll keep you posted_",
152
152
  )
153
153
  })
154
154
 
@@ -168,7 +168,7 @@ describe('pending-work-progress', () => {
168
168
  await flush()
169
169
  // The new edit should be based on 'worker dispatched' alone.
170
170
  expect(cap.edits[0].newText).toBe(
171
- "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
171
+ "worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
172
172
  )
173
173
  })
174
174
 
@@ -186,7 +186,7 @@ describe('pending-work-progress', () => {
186
186
  __tickForTests(cap.now)
187
187
  await flush()
188
188
  expect(cap.edits[0].newText).toBe(
189
- "worker dispatched\n\n— still working (1m) · message me anytime, I'll keep you posted",
189
+ "worker dispatched\n\n_still working (1m) · message me anytime, I'll keep you posted_",
190
190
  )
191
191
  })
192
192
 
@@ -336,17 +336,17 @@ describe('pending-work-progress', () => {
336
336
  expect(cap.edits).toHaveLength(0)
337
337
  })
338
338
 
339
- // ─── #1698 regression — preserve parse_mode on the cross-turn edit ───
340
- it("preserves the anchor's parse_mode on every edit (#1698)", async () => {
339
+ // ─── #1698 successor — preserve the anchor's send shape (literalText) on
340
+ // every cross-turn edit (#2669 single-rich-path contract) ───
341
+ it("preserves the anchor's rich shape on every edit (default literalText=false)", async () => {
341
342
  const cap = setup()
342
343
  startTurn(KEY)
343
344
  noteAsyncDispatch(KEY)
344
- // Anchor was sent through the reply tool with format='html', so
345
- // the captured text is already rendered Telegram HTML.
345
+ // Anchor was sent via the rich-markdown path (the default), so the
346
+ // captured text is raw GFM markdown and the edit re-sends it richly.
346
347
  noteOutbound(KEY, {
347
348
  messageId: 100,
348
- text: '<b>Worker back.</b> Both blockers fixed.',
349
- parseMode: 'HTML',
349
+ text: '**Worker back.** Both blockers fixed.',
350
350
  })
351
351
  cap.now = 0
352
352
  noteTurnEnd(KEY)
@@ -354,42 +354,41 @@ describe('pending-work-progress', () => {
354
354
  __tickForTests(cap.now)
355
355
  await flush()
356
356
  expect(cap.edits).toHaveLength(1)
357
- expect(cap.edits[0].parseMode).toBe('HTML')
357
+ expect(cap.edits[0].literalText).toBe(false)
358
358
  expect(cap.edits[0].newText).toBe(
359
- "<b>Worker back.</b> Both blockers fixed.\n\n— still working (1m) · message me anytime, I'll keep you posted",
359
+ "**Worker back.** Both blockers fixed.\n\n_still working (1m) · message me anytime, I'll keep you posted_",
360
360
  )
361
361
  })
362
362
 
363
- it('passes undefined parseMode through when the anchor was plain text', async () => {
363
+ it('threads literalText=true through when the anchor was a literal format:text send', async () => {
364
364
  const cap = setup()
365
365
  startTurn(KEY)
366
366
  noteAsyncDispatch(KEY)
367
- // format: 'text' path — anchor was sent without parse_mode.
367
+ // format: 'text' path — the anchor was a literal plain send.
368
368
  noteOutbound(KEY, {
369
369
  messageId: 100,
370
370
  text: 'plain text reply',
371
- parseMode: undefined,
371
+ literalText: true,
372
372
  })
373
373
  noteTurnEnd(KEY)
374
374
  cap.now = EDIT_INTERVAL_MS
375
375
  __tickForTests(cap.now)
376
376
  await flush()
377
- expect(cap.edits[0].parseMode).toBeUndefined()
377
+ expect(cap.edits[0].literalText).toBe(true)
378
378
  })
379
379
 
380
- it('defaults parseMode to undefined when caller omits it (test ergonomics)', async () => {
380
+ it('defaults literalText to false when caller omits it (rich path, test ergonomics)', async () => {
381
381
  const cap = setup()
382
382
  startTurn(KEY)
383
383
  noteAsyncDispatch(KEY)
384
- // Callsite that hasn't been updated for the new field — must not
385
- // typecheck-fail nor crash. The edit goes out parse_mode-less,
386
- // matching the pre-#1698 behaviour for legacy callers.
384
+ // Callsite that omits the field — must not typecheck-fail nor crash.
385
+ // The edit goes out on the rich path (literalText=false).
387
386
  noteOutbound(KEY, { messageId: 100, text: 'wd' })
388
387
  noteTurnEnd(KEY)
389
388
  cap.now = EDIT_INTERVAL_MS
390
389
  __tickForTests(cap.now)
391
390
  await flush()
392
- expect(cap.edits[0].parseMode).toBeUndefined()
391
+ expect(cap.edits[0].literalText).toBe(false)
393
392
  })
394
393
 
395
394
  it('multiple chats — independent state', async () => {
@@ -41,12 +41,22 @@ describe('no-repeat-on-timeout wiring', () => {
41
41
  })
42
42
 
43
43
  it('the TTL auto-deny attaches a timeout message and records the signature', () => {
44
- // Within the pending-permission sweep block.
45
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 2200)
44
+ // Within the pending-permission sweep block. Span bumped (2200 → 3200)
45
+ // when Bug 2 added per-tool TTL + the timed-out keyboard-strip to this
46
+ // block — the signature-record line now sits further down but the wiring
47
+ // is intact.
48
+ const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
46
49
  expect(sweep).toContain('timeoutDenyMessage(')
47
50
  expect(sweep).toContain('permissionTimeoutSignatures.set(')
48
51
  })
49
52
 
53
+ it('the TTL auto-deny strips the timed-out card keyboard (Bug 2)', () => {
54
+ const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
55
+ // Per-tool TTL + keyboard-strip are both wired into the sweep.
56
+ expect(sweep).toContain('ttlForTool(')
57
+ expect(sweep).toContain('stripTimedOutPermissionCards(')
58
+ })
59
+
50
60
  it('onPermissionRequest short-circuits a recent-timeout duplicate before posting a card', () => {
51
61
  const fn = slice(GATEWAY, 'onPermissionRequest(', 4000)
52
62
  const dupIdx = fn.indexOf('isRecentTimeoutDuplicate(')
@@ -13,6 +13,13 @@ import {
13
13
  timeoutDenyMessage,
14
14
  duplicateDenyMessage,
15
15
  isRecentTimeoutDuplicate,
16
+ PERMISSION_TTL_MS,
17
+ HOSTD_PERMISSION_TTL_MS,
18
+ ttlForTool,
19
+ buildTimedOutCardEdits,
20
+ isStaleTap,
21
+ STALE_TAP_NOTICE,
22
+ TIMED_OUT_FOOTER,
16
23
  } from '../gateway/permission-timeout.js'
17
24
 
18
25
  describe('permissionSignature', () => {
@@ -85,3 +92,73 @@ describe('isRecentTimeoutDuplicate', () => {
85
92
  expect(isRecentTimeoutDuplicate(m, permissionSignature('t', 'Rentals'), NOW, WINDOW)).toBe(true)
86
93
  })
87
94
  })
95
+
96
+ // ─── Bug 2 — per-tool TTL, timed-out card hygiene, stale-tap honesty ────────
97
+
98
+ describe('ttlForTool (Bug 2 fix #2)', () => {
99
+ it('gives hostd gated verbs a 30-min window', () => {
100
+ for (const tool of [
101
+ 'mcp__hostd__rollout',
102
+ 'mcp__hostd__update_apply',
103
+ 'mcp__hostd__agent_restart',
104
+ 'mcp__hostd__agent_exec',
105
+ 'mcp__hostd__config_propose_edit',
106
+ ]) {
107
+ expect(ttlForTool(tool)).toBe(HOSTD_PERMISSION_TTL_MS)
108
+ expect(ttlForTool(tool)).toBe(30 * 60_000)
109
+ }
110
+ })
111
+
112
+ it('keeps the 10-min default for non-hostd tools', () => {
113
+ expect(ttlForTool('Bash')).toBe(PERMISSION_TTL_MS)
114
+ expect(ttlForTool('Bash')).toBe(10 * 60_000)
115
+ expect(ttlForTool('mcp__perplexity__search')).toBe(PERMISSION_TTL_MS)
116
+ expect(ttlForTool('mcp__agent-config__schedule_add')).toBe(PERMISSION_TTL_MS)
117
+ expect(ttlForTool(undefined)).toBe(PERMISSION_TTL_MS)
118
+ })
119
+
120
+ it('hostd TTL is strictly longer than the default', () => {
121
+ expect(HOSTD_PERMISSION_TTL_MS).toBeGreaterThan(PERMISSION_TTL_MS)
122
+ })
123
+ })
124
+
125
+ describe('buildTimedOutCardEdits (Bug 2 fix #1 — strip stale keyboard)', () => {
126
+ it('marks every recorded card for keyboard-strip and appends the footer', () => {
127
+ const cards = [
128
+ { chatId: '111', messageId: 5 },
129
+ { chatId: '222', messageId: 9 },
130
+ ]
131
+ const edits = buildTimedOutCardEdits('🔐 **Overlord** wants to roll the fleet', cards)
132
+ expect(edits).toHaveLength(2)
133
+ for (const [i, edit] of edits.entries()) {
134
+ // The keyboard MUST be stripped — a live Approve on a dead request_id is
135
+ // exactly the bug.
136
+ expect(edit.stripKeyboard).toBe(true)
137
+ expect(edit.chatId).toBe(cards[i]!.chatId)
138
+ expect(edit.messageId).toBe(cards[i]!.messageId)
139
+ expect(edit.text).toContain('roll the fleet')
140
+ expect(edit.text).toContain('Timed out — re-request to act')
141
+ expect(edit.text.endsWith(TIMED_OUT_FOOTER)).toBe(true)
142
+ }
143
+ })
144
+
145
+ it('returns no edits when there were no recorded cards', () => {
146
+ expect(buildTimedOutCardEdits('body', [])).toEqual([])
147
+ })
148
+ })
149
+
150
+ describe('isStaleTap (Bug 2 fix #3 — do not dispatch a verdict for a dead id)', () => {
151
+ it('is stale when no pending entry exists for the tapped request_id', () => {
152
+ // hasPending=false ⇒ the reaper already auto-denied + deleted it.
153
+ expect(isStaleTap(false)).toBe(true)
154
+ })
155
+
156
+ it('is NOT stale (live id) when a pending entry still exists', () => {
157
+ expect(isStaleTap(true)).toBe(false)
158
+ })
159
+
160
+ it('exposes an honest operator notice for the stale path', () => {
161
+ expect(STALE_TAP_NOTICE).toMatch(/already resolved/i)
162
+ expect(STALE_TAP_NOTICE).toMatch(/ask again/i)
163
+ })
164
+ })