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.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/autoaccept-poll.js +8 -8
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +82 -82
- package/dist/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2936 -119
- package/dist/cli/switchroom.js +804 -465
- package/dist/host-control/main.js +169 -163
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +4 -4
- package/telegram-plugin/answer-stream.ts +20 -49
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/bridge/bridge.ts +1 -1
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/bridge/bridge.js +113 -113
- package/telegram-plugin/dist/gateway/gateway.js +2085 -2102
- package/telegram-plugin/dist/server.js +161 -161
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +427 -680
- package/telegram-plugin/gateway/approval-callback.ts +2 -3
- package/telegram-plugin/gateway/approval-card.test.ts +17 -4
- package/telegram-plugin/gateway/approval-card.ts +16 -6
- package/telegram-plugin/gateway/approvals-commands.ts +18 -24
- package/telegram-plugin/gateway/auth-command.ts +74 -74
- package/telegram-plugin/gateway/auth-line.ts +5 -15
- package/telegram-plugin/gateway/boot-card.ts +20 -22
- package/telegram-plugin/gateway/boot-version.ts +3 -2
- package/telegram-plugin/gateway/config-approval-handler.test.ts +35 -33
- package/telegram-plugin/gateway/config-approval-handler.ts +24 -24
- package/telegram-plugin/gateway/config-snapshot.ts +9 -9
- package/telegram-plugin/gateway/diff-preview-card.test.ts +8 -8
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -5
- package/telegram-plugin/gateway/disconnect-flush.ts +0 -4
- package/telegram-plugin/gateway/drive-write-approval.test.ts +10 -10
- package/telegram-plugin/gateway/drive-write-approval.ts +14 -8
- package/telegram-plugin/gateway/effort-command.ts +17 -17
- package/telegram-plugin/gateway/folder-picker-handler.test.ts +8 -2
- package/telegram-plugin/gateway/folder-picker-handler.ts +3 -4
- package/telegram-plugin/gateway/gateway.ts +881 -633
- package/telegram-plugin/gateway/inject-handler.test.ts +15 -13
- package/telegram-plugin/gateway/inject-handler.ts +5 -5
- package/telegram-plugin/gateway/ipc-protocol.ts +33 -1
- package/telegram-plugin/gateway/ipc-server.ts +39 -6
- package/telegram-plugin/gateway/linear-activity.ts +16 -14
- package/telegram-plugin/gateway/linear-setup.ts +1 -1
- package/telegram-plugin/gateway/model-command.ts +25 -25
- package/telegram-plugin/gateway/oversize-card-body.ts +6 -7
- package/telegram-plugin/gateway/permission-timeout.ts +76 -0
- package/telegram-plugin/gateway/skill-proposal-card.ts +167 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +19 -13
- package/telegram-plugin/issues-card.ts +6 -7
- package/telegram-plugin/model-unavailable.ts +8 -12
- package/telegram-plugin/operator-events-history.ts +1 -1
- package/telegram-plugin/operator-events.ts +24 -28
- package/telegram-plugin/package.json +1 -1
- package/telegram-plugin/pending-work-progress.ts +36 -36
- package/telegram-plugin/permission-title.ts +39 -20
- package/telegram-plugin/pty-partial-handler.ts +5 -13
- package/telegram-plugin/quota-check.ts +5 -5
- package/telegram-plugin/quota-watch.ts +13 -18
- package/telegram-plugin/recent-outbound-dedup.ts +5 -5
- package/telegram-plugin/registry/turns-schema.ts +43 -3
- package/telegram-plugin/retry-api-call.ts +42 -7
- package/telegram-plugin/rich-send.ts +86 -0
- package/telegram-plugin/secret-detect/vault-error.test.ts +6 -6
- package/telegram-plugin/secret-detect/vault-error.ts +29 -22
- package/telegram-plugin/shared/bot-runtime.ts +29 -7
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/silent-reply-anchor.ts +9 -2
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/status-no-truncate.ts +11 -5
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +49 -98
- package/telegram-plugin/subagent-watcher.ts +2 -2
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +5 -2
- package/telegram-plugin/tests/answer-stream.test.ts +54 -63
- package/telegram-plugin/tests/auth-command-format2.test.ts +4 -4
- package/telegram-plugin/tests/auth-command-vernacular.test.ts +3 -2
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +19 -18
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +15 -13
- package/telegram-plugin/tests/boot-card-reason-to-render.test.ts +27 -12
- package/telegram-plugin/tests/boot-card-render.test.ts +59 -48
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bot-api.harness.ts +23 -1
- package/telegram-plugin/tests/bot-runtime.test.ts +23 -18
- package/telegram-plugin/tests/card-format.test.ts +6 -4
- package/telegram-plugin/tests/config-snapshot.test.ts +1 -1
- package/telegram-plugin/tests/credits-watch.test.ts +5 -5
- package/telegram-plugin/tests/fake-bot-api.ts +58 -4
- package/telegram-plugin/tests/finalize-callback.test.ts +11 -9
- package/telegram-plugin/tests/foreground-nesting.test.ts +1 -1
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +3 -13
- package/telegram-plugin/tests/ipc-server-validate-send-outbound.test.ts +6 -2
- package/telegram-plugin/tests/issues-card.test.ts +15 -12
- package/telegram-plugin/tests/length-error-classify.test.ts +131 -0
- package/telegram-plugin/tests/linear-agent-activity.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +2 -2
- package/telegram-plugin/tests/model-unavailable.test.ts +13 -13
- package/telegram-plugin/tests/multi-turn-continuity.test.ts +6 -10
- package/telegram-plugin/tests/operator-events.test.ts +7 -9
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +273 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +12 -2
- package/telegram-plugin/tests/permission-timeout.test.ts +77 -0
- package/telegram-plugin/tests/permission-title.test.ts +88 -41
- package/telegram-plugin/tests/pty-partial-handler.test.ts +8 -8
- package/telegram-plugin/tests/quota-check.test.ts +3 -3
- package/telegram-plugin/tests/quota-watch.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +4 -3
- package/telegram-plugin/tests/silence-poke.test.ts +75 -112
- package/telegram-plugin/tests/single-mode-stream-reply.test.ts +137 -0
- package/telegram-plugin/tests/skill-proposal-card.test.ts +103 -0
- package/telegram-plugin/tests/slot-banner-driver.e2e.test.ts +36 -24
- package/telegram-plugin/tests/slot-banner.test.ts +9 -6
- package/telegram-plugin/tests/status-accent.test.ts +29 -32
- package/telegram-plugin/tests/{stream-controller-html-fallback.test.ts → stream-controller-parse-fallback.test.ts} +40 -42
- package/telegram-plugin/tests/stream-controller.test.ts +63 -52
- package/telegram-plugin/tests/stream-reply-error-paths.test.ts +43 -38
- package/telegram-plugin/tests/stream-reply-handler.test.ts +122 -249
- package/telegram-plugin/tests/streaming-e2e.test.ts +35 -30
- package/telegram-plugin/tests/streaming-orchestration.test.ts +29 -28
- package/telegram-plugin/tests/telegram-format.test.ts +120 -1083
- package/telegram-plugin/tests/tool-activity-summary.test.ts +144 -145
- package/telegram-plugin/tests/welcome-text.test.ts +72 -65
- package/telegram-plugin/tests/worker-activity-feed.test.ts +119 -137
- package/telegram-plugin/text-voice-scrub.ts +8 -11
- package/telegram-plugin/tool-activity-summary.ts +29 -29
- package/telegram-plugin/welcome-text.ts +82 -83
- package/telegram-plugin/worker-activity-feed.ts +2 -3
- package/telegram-plugin/html-sanitize.ts +0 -244
- package/telegram-plugin/tests/html-sanitize.test.ts +0 -146
- 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\
|
|
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\
|
|
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\
|
|
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\
|
|
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
|
|
340
|
-
|
|
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
|
|
345
|
-
//
|
|
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: '
|
|
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].
|
|
357
|
+
expect(cap.edits[0].literalText).toBe(false)
|
|
358
358
|
expect(cap.edits[0].newText).toBe(
|
|
359
|
-
"
|
|
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('
|
|
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
|
|
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
|
-
|
|
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].
|
|
377
|
+
expect(cap.edits[0].literalText).toBe(true)
|
|
378
378
|
})
|
|
379
379
|
|
|
380
|
-
it('defaults
|
|
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
|
|
385
|
-
//
|
|
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].
|
|
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
|
-
|
|
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
|
+
})
|