switchroom 0.16.24 → 0.16.28

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 (28) hide show
  1. package/dist/cli/switchroom.js +135 -37
  2. package/dist/host-control/main.js +13 -7
  3. package/package.json +2 -2
  4. package/telegram-plugin/answer-stream.ts +7 -6
  5. package/telegram-plugin/bridge/bridge.ts +1 -1
  6. package/telegram-plugin/dist/bridge/bridge.js +1 -1
  7. package/telegram-plugin/dist/gateway/gateway.js +255 -30
  8. package/telegram-plugin/dist/server.js +1 -1
  9. package/telegram-plugin/format.ts +335 -27
  10. package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
  11. package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
  12. package/telegram-plugin/gateway/gateway.ts +169 -12
  13. package/telegram-plugin/gateway/ipc-server.ts +11 -6
  14. package/telegram-plugin/gateway/permission-timeout.ts +76 -0
  15. package/telegram-plugin/permission-title.ts +3 -0
  16. package/telegram-plugin/retry-api-call.ts +27 -0
  17. package/telegram-plugin/rich-send.ts +29 -0
  18. package/telegram-plugin/shared/bot-runtime.ts +6 -1
  19. package/telegram-plugin/silent-reply-anchor.ts +9 -2
  20. package/telegram-plugin/status-no-truncate.ts +11 -5
  21. package/telegram-plugin/stream-reply-handler.ts +9 -1
  22. package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
  23. package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
  24. package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
  25. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
  26. package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
  27. package/telegram-plugin/tests/permission-title.test.ts +43 -0
  28. package/telegram-plugin/tests/poll-health.test.ts +64 -0
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { describe, it, expect } from "vitest";
9
9
  import { validateClientMessage } from "../gateway/ipc-server.js";
10
+ import { RICH_MESSAGE_MAX_CHARS } from "../format.js";
10
11
 
11
12
  const base = { type: "send_outbound", agentName: "clerk", chatId: "12345", text: "Daily heartbeat" };
12
13
 
@@ -38,8 +39,11 @@ describe("validateClientMessage — send_outbound", () => {
38
39
  expect(validateClientMessage({ ...base, text: undefined })).toBe(false);
39
40
  expect(validateClientMessage({ ...base, text: "" })).toBe(false);
40
41
  expect(validateClientMessage({ ...base, text: 5 })).toBe(false);
41
- expect(validateClientMessage({ ...base, text: "x".repeat(4096) })).toBe(true); // at the cap
42
- expect(validateClientMessage({ ...base, text: "x".repeat(4097) })).toBe(false); // over Telegram's limit
42
+ // send_outbound posts via sendRichMessage (rich path), so the cap is the
43
+ // rich-message wire limit (RICH_MESSAGE_MAX_CHARS, 32768) post-#2669, not
44
+ // the legacy 4096 plain-text limit.
45
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS) })).toBe(true); // at the cap
46
+ expect(validateClientMessage({ ...base, text: "x".repeat(RICH_MESSAGE_MAX_CHARS + 1) })).toBe(false); // over the cap
43
47
  });
44
48
 
45
49
  it("rejects a non-integer threadId", () => {
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Tests for the over-length error classification (task E of the Telegram-
3
+ * formatting bundle) and the hardSliceToCap last-resort slicer.
4
+ *
5
+ * The rich path rejects a 32769+ char body with `RICH_MESSAGE_TEXT_TOO_LONG`
6
+ * (and the legacy plain path with `MESSAGE_TOO_LONG`). That is a LENGTH error,
7
+ * not a markdown-parse error — it must be classified so the caller re-splits
8
+ * the body rather than (a) misclassifying it as a parse-reject and resending
9
+ * the same oversized payload as plain text, or (b) surfacing the raw 400.
10
+ */
11
+ import { describe, test, expect } from 'vitest'
12
+ import { GrammyError } from 'grammy'
13
+ import { isLengthError, isParseEntitiesError } from '../rich-send.js'
14
+ import { isMessageTooLongError, isHtmlParseRejectError } from '../retry-api-call.js'
15
+ import { hardSliceToCap, splitMarkdownChunks, RICH_MESSAGE_MAX_CHARS } from '../format.js'
16
+
17
+ // Build a GrammyError with a given 400 description. GrammyError's constructor
18
+ // takes (message, payload, method, parameters) in grammy 1.44.
19
+ function grammy400(description: string): GrammyError {
20
+ return new GrammyError(
21
+ `Call to 'sendRichMessage' failed! (400: ${description})`,
22
+ { ok: false, error_code: 400, description },
23
+ 'sendRichMessage',
24
+ {},
25
+ )
26
+ }
27
+
28
+ describe('length-error classification (rich-send.isLengthError)', () => {
29
+ test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
30
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
31
+ expect(isLengthError(err)).toBe(true)
32
+ })
33
+
34
+ test('legacy MESSAGE_TOO_LONG / "message is too long" are length errors', () => {
35
+ expect(isLengthError(grammy400('MESSAGE_TOO_LONG'))).toBe(true)
36
+ expect(isLengthError(grammy400('Bad Request: message is too long'))).toBe(true)
37
+ })
38
+
39
+ test('a parse-entities error is NOT a length error', () => {
40
+ expect(isLengthError(grammy400("can't parse entities: bad offset"))).toBe(false)
41
+ })
42
+
43
+ test('a length error is NOT misclassified as a parse-entities error', () => {
44
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
45
+ expect(isParseEntitiesError(err)).toBe(false)
46
+ })
47
+
48
+ test('non-GrammyError and non-400 are neither', () => {
49
+ expect(isLengthError(new Error('boom'))).toBe(false)
50
+ const err403 = new GrammyError(
51
+ 'forbidden',
52
+ { ok: false, error_code: 403, description: 'Forbidden: bot was blocked' },
53
+ 'sendRichMessage',
54
+ {},
55
+ )
56
+ expect(isLengthError(err403)).toBe(false)
57
+ })
58
+ })
59
+
60
+ describe('length-error classification (retry-api-call.isMessageTooLongError)', () => {
61
+ test('RICH_MESSAGE_TEXT_TOO_LONG is a length error', () => {
62
+ expect(isMessageTooLongError(grammy400('RICH_MESSAGE_TEXT_TOO_LONG'))).toBe(true)
63
+ })
64
+
65
+ test('a length error is NOT misclassified as an html-parse-reject', () => {
66
+ const err = grammy400('RICH_MESSAGE_TEXT_TOO_LONG')
67
+ expect(isHtmlParseRejectError(err)).toBe(false)
68
+ })
69
+
70
+ test('a parse-reject error still classifies as a parse-reject (not length)', () => {
71
+ const err = grammy400("can't parse entities: unexpected end")
72
+ expect(isHtmlParseRejectError(err)).toBe(true)
73
+ expect(isMessageTooLongError(err)).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('hardSliceToCap', () => {
78
+ test('returns the input as one piece when it already fits', () => {
79
+ expect(hardSliceToCap('short', 100)).toEqual(['short'])
80
+ })
81
+
82
+ test('slices an oversized body into pieces each <= cap', () => {
83
+ const body = 'x'.repeat(250)
84
+ const pieces = hardSliceToCap(body, 100)
85
+ expect(pieces).toHaveLength(3)
86
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(100)
87
+ expect(pieces.join('')).toBe(body)
88
+ })
89
+
90
+ test('defaults the cap to RICH_MESSAGE_MAX_CHARS', () => {
91
+ const body = 'y'.repeat(RICH_MESSAGE_MAX_CHARS + 5)
92
+ const pieces = hardSliceToCap(body)
93
+ expect(pieces).toHaveLength(2)
94
+ expect(pieces[0].length).toBe(RICH_MESSAGE_MAX_CHARS)
95
+ expect(pieces[1].length).toBe(5)
96
+ })
97
+ })
98
+
99
+ // The gateway length-error recovery (gateway.ts sendChunkResplit, ~8758) re-splits
100
+ // an oversized chunk with `splitMarkdownChunks` and falls back to `hardSliceToCap`
101
+ // when the block is indivisible. These tests pin that seam at the function level:
102
+ // a >RICH_MESSAGE_MAX_CHARS body is ALWAYS chunked into >=2 sends (each <= cap),
103
+ // never silently dropped or resent whole on a RICH_MESSAGE_TEXT_TOO_LONG reject.
104
+ describe('resplit-on-reject: oversized body chunks into >=2 sends', () => {
105
+ test('a >cap body with prose boundaries re-splits into >=2 chunks each <= cap', () => {
106
+ // A paragraph-separated body twice the cap: splitMarkdownChunks finds the
107
+ // `\n\n` boundaries and yields multiple chunks.
108
+ const para = 'x'.repeat(4000)
109
+ const body = Array.from({ length: 20 }, () => para).join('\n\n')
110
+ expect(body.length).toBeGreaterThan(RICH_MESSAGE_MAX_CHARS)
111
+ const pieces = splitMarkdownChunks(body, RICH_MESSAGE_MAX_CHARS)
112
+ expect(pieces.length).toBeGreaterThanOrEqual(2)
113
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
114
+ })
115
+
116
+ test('an indivisible >cap blob falls back through hardSliceToCap to >=2 chunks each <= cap', () => {
117
+ // A single boundary-free blob larger than the cap: splitMarkdownChunks
118
+ // cannot break it and emits it whole (length > cap), so the recovery path
119
+ // hands it to hardSliceToCap — mirroring sendChunkResplit's fallback.
120
+ const blob = 'z'.repeat(RICH_MESSAGE_MAX_CHARS + 5000)
121
+ const subPieces = splitMarkdownChunks(blob, RICH_MESSAGE_MAX_CHARS)
122
+ const pieces =
123
+ subPieces.length > 1 && subPieces.every((p) => p.length <= RICH_MESSAGE_MAX_CHARS)
124
+ ? subPieces
125
+ : hardSliceToCap(blob, RICH_MESSAGE_MAX_CHARS)
126
+ expect(pieces.length).toBeGreaterThanOrEqual(2)
127
+ for (const p of pieces) expect(p.length).toBeLessThanOrEqual(RICH_MESSAGE_MAX_CHARS)
128
+ // Nothing is dropped — the pieces reconstitute the original body.
129
+ expect(pieces.join('')).toBe(blob)
130
+ })
131
+ })
@@ -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
+ })
@@ -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
+ })
@@ -368,6 +368,49 @@ describe('formatPermissionCardBody', () => {
368
368
  })
369
369
  })
370
370
 
371
+ // Bug 1: the hostd rollout verb gained an optional `reason` (which the agent
372
+ // can now actually supply) and a curated MCP_TOOL_DESCRIPTIONS title, so the
373
+ // card stops rendering the generic "rollout (Hostd)" with "why: not provided".
374
+ describe('formatPermissionCardBody — hostd rollout (Bug 1)', () => {
375
+ test('renders the caller-supplied reason on the why: line', () => {
376
+ const body = formatPermissionCardBody({
377
+ toolName: 'mcp__hostd__rollout',
378
+ inputPreview: JSON.stringify({
379
+ reason: 'promote canary-green v0.16.24 to the fleet',
380
+ pin: 'v0.16.24',
381
+ }),
382
+ description: 'SAFELY roll the fleet to a pinned SEMVER version …',
383
+ agentName: 'overlord',
384
+ })
385
+ expect(body).toContain('why: _promote canary-green v0.16.24 to the fleet_')
386
+ // #2469: never the schema description.
387
+ expect(body).not.toContain('SAFELY roll the fleet')
388
+ })
389
+
390
+ test('renders the clean MCP_TOOL_DESCRIPTIONS title, not the raw tool id', () => {
391
+ const body = formatPermissionCardBody({
392
+ toolName: 'mcp__hostd__rollout',
393
+ inputPreview: JSON.stringify({
394
+ reason: 'rollback to last-good tag',
395
+ pin: 'v0.16.20',
396
+ }),
397
+ description: 'desc',
398
+ agentName: 'overlord',
399
+ })
400
+ const firstLine = body.split('\n')[0]
401
+ // Curated phrase from MCP_TOOL_DESCRIPTIONS["mcp__hostd__rollout"].
402
+ expect(firstLine).toBe('🔐 **Overlord** wants to roll the fleet to a pinned version')
403
+ expect(firstLine).not.toContain('mcp__hostd__rollout')
404
+ expect(firstLine).not.toMatch(/rollout \(Hostd\)/i)
405
+ })
406
+
407
+ test('naturalAction surfaces the curated title for the rollout verb', () => {
408
+ expect(naturalAction('mcp__hostd__rollout', undefined)).toBe(
409
+ 'roll the fleet to a pinned version',
410
+ )
411
+ })
412
+ })
413
+
371
414
  describe('describeGrant — phrased from the chosen scope', () => {
372
415
  test('MCP server wildcard → "use any <Server> tool"', () => {
373
416
  expect(describeGrant('mcp__perplexity__search', undefined, opt('mcp__perplexity__*'))).toBe(
@@ -68,6 +68,70 @@ describe("createPollHealthCheck", () => {
68
68
  hc.stop();
69
69
  });
70
70
 
71
+ it("detects stall when getMe passes but getUpdates heartbeat is stale", async () => {
72
+ // Simulates the 2026-06-30 incident: one getUpdates TimeoutError left the
73
+ // grammy runner loop frozen. getMe kept succeeding so the original
74
+ // getMe-only health check never fired. Fleet was deaf for 2 h.
75
+ // The fix: ping() also checks lastGetUpdatesHeartbeatMs; if stale, throws
76
+ // so the failure counter increments and stall recovery fires.
77
+ const onStall = vi.fn().mockResolvedValue(undefined);
78
+ let tickFn: () => void = () => {};
79
+ let lastGetUpdatesMs = Date.now() - 999_999; // very stale
80
+ const staleThresholdMs = 180_000; // 3 min (threshold × interval)
81
+
82
+ const hc = createPollHealthCheck({
83
+ ping: async () => {
84
+ // getMe succeeds (network fine):
85
+ // (no throw from network layer)
86
+ // heartbeat stale check (mirrors gateway.ts logic):
87
+ const staleMs = Date.now() - lastGetUpdatesMs;
88
+ if (staleMs > staleThresholdMs) {
89
+ throw new Error(
90
+ `getUpdates heartbeat stale: last seen ${Math.round(staleMs / 1000)}s ago — runner loop frozen`,
91
+ );
92
+ }
93
+ },
94
+ onStall,
95
+ failureThreshold: 3,
96
+ setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
97
+ clearIntervalFn: () => {},
98
+ log: () => {},
99
+ });
100
+ hc.start();
101
+ tickFn(); await Promise.resolve();
102
+ tickFn(); await Promise.resolve();
103
+ tickFn(); await Promise.resolve();
104
+ await new Promise((r) => setTimeout(r, 10));
105
+ expect(onStall).toHaveBeenCalledTimes(1);
106
+ });
107
+
108
+ it("does NOT stall when getUpdates heartbeat is fresh", async () => {
109
+ const onStall = vi.fn().mockResolvedValue(undefined);
110
+ let tickFn: () => void = () => {};
111
+ let lastGetUpdatesMs = Date.now(); // fresh
112
+ const staleThresholdMs = 180_000;
113
+
114
+ const hc = createPollHealthCheck({
115
+ ping: async () => {
116
+ const staleMs = Date.now() - lastGetUpdatesMs;
117
+ if (staleMs > staleThresholdMs) {
118
+ throw new Error("getUpdates heartbeat stale");
119
+ }
120
+ },
121
+ onStall,
122
+ failureThreshold: 3,
123
+ setIntervalFn: (fn) => { tickFn = fn; return 1 as unknown as ReturnType<typeof setInterval>; },
124
+ clearIntervalFn: () => {},
125
+ log: () => {},
126
+ });
127
+ hc.start();
128
+ for (let i = 0; i < 5; i++) {
129
+ tickFn(); await Promise.resolve();
130
+ }
131
+ expect(onStall).not.toHaveBeenCalled();
132
+ hc.stop();
133
+ });
134
+
71
135
  it("stop() cancels the interval", () => {
72
136
  const onStall = vi.fn();
73
137
  let cleared = false;