switchroom 0.16.22 → 0.16.24
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/cli/self-improve-apply-guard-pretool.mjs +6 -0
- package/dist/cli/skill-validate-pretool.mjs +2865 -48
- package/dist/cli/switchroom.js +338 -92
- package/dist/host-control/main.js +1 -1
- package/package.json +5 -5
- package/telegram-plugin/answer-stream.ts +13 -43
- package/telegram-plugin/auth-snapshot-format.ts +27 -30
- package/telegram-plugin/auto-fallback-fleet.ts +6 -11
- package/telegram-plugin/card-format.ts +28 -25
- package/telegram-plugin/credits-watch.ts +5 -10
- package/telegram-plugin/dist/gateway/gateway.js +1670 -1892
- package/telegram-plugin/draft-stream.ts +4 -4
- package/telegram-plugin/format.ts +101 -662
- 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/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 +751 -624
- 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 +28 -0
- 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/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 +36 -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 +44 -2
- package/telegram-plugin/retry-api-call.ts +15 -7
- package/telegram-plugin/rich-send.ts +57 -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 +23 -6
- package/telegram-plugin/silence-poke.ts +26 -69
- package/telegram-plugin/slot-banner-driver.ts +9 -6
- package/telegram-plugin/slot-banner.ts +5 -8
- package/telegram-plugin/steering.ts +0 -4
- package/telegram-plugin/stream-controller.ts +59 -62
- package/telegram-plugin/stream-reply-handler.ts +40 -97
- 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/issues-card.test.ts +15 -12
- 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/pending-work-progress.test.ts +20 -21
- package/telegram-plugin/tests/permission-title.test.ts +45 -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
|
@@ -18,21 +18,21 @@ import { renderBootCard, resolvePersonaName } from '../gateway/boot-card.js'
|
|
|
18
18
|
describe('renderBootCard — quiet by default', () => {
|
|
19
19
|
it('returns one-line ack with default ✅ when no probes and no restart reason', () => {
|
|
20
20
|
const out = renderBootCard({ agentName: 'klanker', version: 'v0.3.0+44' })
|
|
21
|
-
expect(out).toBe('✅
|
|
21
|
+
expect(out).toBe('✅ **klanker** back up · v0.3.0+44')
|
|
22
22
|
expect(out).not.toContain('\n')
|
|
23
23
|
})
|
|
24
24
|
|
|
25
25
|
it('uses ✅ for planned and graceful restart reasons (return-to-service)', () => {
|
|
26
26
|
for (const reason of ['planned', 'graceful'] as const) {
|
|
27
27
|
const out = renderBootCard({ agentName: 'agent', version: 'v1', restartReason: reason })
|
|
28
|
-
expect(out).toBe('✅
|
|
28
|
+
expect(out).toBe('✅ **agent** back up · v1')
|
|
29
29
|
}
|
|
30
30
|
})
|
|
31
31
|
|
|
32
32
|
it('uses 🆕 for fresh starts (no prior session marker — first boot)', () => {
|
|
33
33
|
const out = renderBootCard({ agentName: 'a', version: 'v', restartReason: 'fresh' })
|
|
34
34
|
expect(out.startsWith('🆕')).toBe(true)
|
|
35
|
-
expect(out).toBe('🆕
|
|
35
|
+
expect(out).toBe('🆕 **a** back up · v')
|
|
36
36
|
})
|
|
37
37
|
|
|
38
38
|
it('all-ok probes produce no extra rows — same output as no probes at all', () => {
|
|
@@ -62,16 +62,16 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
62
62
|
restartAgeMs: 2_100,
|
|
63
63
|
})
|
|
64
64
|
const lines = out.split('\n')
|
|
65
|
-
expect(lines[0]).toBe('⚠️
|
|
65
|
+
expect(lines[0]).toBe('⚠️ **lawgpt** back up · v0.3.0+44')
|
|
66
66
|
expect(lines[1]).toBe('') // separator
|
|
67
|
-
expect(lines[2]).toContain('⚠️
|
|
67
|
+
expect(lines[2]).toContain('⚠️ **Restart**')
|
|
68
68
|
expect(lines[2]).toContain('crash recovery')
|
|
69
69
|
expect(lines[2]).toContain('2.1s ago')
|
|
70
70
|
})
|
|
71
71
|
|
|
72
72
|
it('crash reason without restartAgeMs omits the "ago" suffix', () => {
|
|
73
73
|
const out = renderBootCard({ agentName: 'a', version: 'v1', restartReason: 'crash' })
|
|
74
|
-
expect(out).toContain('⚠️
|
|
74
|
+
expect(out).toContain('⚠️ **Restart** crash recovery')
|
|
75
75
|
expect(out).not.toMatch(/\d+\.\d+s ago/)
|
|
76
76
|
})
|
|
77
77
|
|
|
@@ -88,13 +88,13 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
88
88
|
crons: { status: 'ok', label: 'Crons', detail: '4 timers' },
|
|
89
89
|
},
|
|
90
90
|
})
|
|
91
|
-
expect(out).toContain('🟡
|
|
92
|
-
expect(out).toContain('🔴
|
|
91
|
+
expect(out).toContain('🟡 **Account** token expiring · 3d')
|
|
92
|
+
expect(out).toContain('🔴 **Quota** rate limited')
|
|
93
93
|
// Healthy probes must not render.
|
|
94
|
-
expect(out).not.toContain('Agent
|
|
95
|
-
expect(out).not.toContain('Gateway
|
|
96
|
-
expect(out).not.toContain('Hindsight
|
|
97
|
-
expect(out).not.toContain('Scheduler
|
|
94
|
+
expect(out).not.toContain('Agent**')
|
|
95
|
+
expect(out).not.toContain('Gateway**')
|
|
96
|
+
expect(out).not.toContain('Hindsight**')
|
|
97
|
+
expect(out).not.toContain('Scheduler**')
|
|
98
98
|
})
|
|
99
99
|
|
|
100
100
|
it('orders probe rows in PROBE_KEYS canonical order regardless of object iteration', () => {
|
|
@@ -110,9 +110,9 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
110
110
|
account: { status: 'degraded', label: 'Account', detail: 'expiring' },
|
|
111
111
|
},
|
|
112
112
|
})
|
|
113
|
-
const accountIdx = out.indexOf('Account
|
|
114
|
-
const hindsightIdx = out.indexOf('Hindsight
|
|
115
|
-
const schedulerIdx = out.indexOf('Scheduler
|
|
113
|
+
const accountIdx = out.indexOf('Account**')
|
|
114
|
+
const hindsightIdx = out.indexOf('Hindsight**')
|
|
115
|
+
const schedulerIdx = out.indexOf('Scheduler**')
|
|
116
116
|
expect(accountIdx).toBeGreaterThan(-1)
|
|
117
117
|
expect(hindsightIdx).toBeGreaterThan(accountIdx)
|
|
118
118
|
expect(schedulerIdx).toBeGreaterThan(hindsightIdx)
|
|
@@ -131,14 +131,14 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
131
131
|
const lines = out.split('\n')
|
|
132
132
|
expect(lines[0].startsWith('⚠️')).toBe(true)
|
|
133
133
|
expect(lines).toContain('') // separator after ack
|
|
134
|
-
expect(out).toContain('⚠️
|
|
135
|
-
expect(out).toContain('🟡
|
|
134
|
+
expect(out).toContain('⚠️ **Restart**')
|
|
135
|
+
expect(out).toContain('🟡 **Account** expiring')
|
|
136
136
|
})
|
|
137
137
|
|
|
138
138
|
it('renders nextStep as an indented continuation line beneath a degraded row', () => {
|
|
139
139
|
// Principle 1 ("If they need the docs, we've failed"): every degraded
|
|
140
140
|
// probe should surface its remediation inline. Plain backticks in the
|
|
141
|
-
// nextStep get translated to
|
|
141
|
+
// nextStep get translated to ` spans so the command stays tap-to-
|
|
142
142
|
// copy on mobile.
|
|
143
143
|
const out = renderBootCard({
|
|
144
144
|
agentName: 'lawgpt',
|
|
@@ -152,8 +152,8 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
152
152
|
},
|
|
153
153
|
},
|
|
154
154
|
})
|
|
155
|
-
expect(out).toContain('🟡
|
|
156
|
-
expect(out).toContain(' ↳ Run
|
|
155
|
+
expect(out).toContain('🟡 **Skills** 10/10 dangling')
|
|
156
|
+
expect(out).toContain(' ↳ Run `switchroom agent reconcile lawgpt` to rebuild symlinks')
|
|
157
157
|
})
|
|
158
158
|
|
|
159
159
|
it('crash row carries a tail-logs next-step', () => {
|
|
@@ -163,8 +163,16 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
163
163
|
restartReason: 'crash',
|
|
164
164
|
restartAgeMs: 6_100,
|
|
165
165
|
})
|
|
166
|
-
expect(out).toContain('⚠️
|
|
167
|
-
|
|
166
|
+
expect(out).toContain('⚠️ **Restart** crash recovery · 6.1s ago')
|
|
167
|
+
// The tail-logs command is runtime-aware: in-container (docker) vs
|
|
168
|
+
// host (journalctl). Assert against whichever branch the renderer
|
|
169
|
+
// selected so the suite is green under both bun (live container env)
|
|
170
|
+
// and vitest (env scrubbed) — see vitest.config.ts.
|
|
171
|
+
const expectedTail =
|
|
172
|
+
process.env.SWITCHROOM_RUNTIME === 'docker'
|
|
173
|
+
? '↳ Tail logs: `docker logs --tail 100 switchroom-lawgpt`'
|
|
174
|
+
: '↳ Tail logs: `journalctl --user -u switchroom-lawgpt -n 100`'
|
|
175
|
+
expect(out).toContain(expectedTail)
|
|
168
176
|
})
|
|
169
177
|
|
|
170
178
|
it('crash row uses agentSlug for the systemd unit when provided', () => {
|
|
@@ -191,8 +199,9 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
191
199
|
},
|
|
192
200
|
},
|
|
193
201
|
})
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
// #2669: the backtick-delimited content stays a literal code span — no
|
|
203
|
+
// HTML entities. < > & render verbatim inside the span.
|
|
204
|
+
expect(out).toContain('`foo <bar> & baz`')
|
|
196
205
|
})
|
|
197
206
|
|
|
198
207
|
it('unpaired backticks in nextStep fall back to plain escaped text', () => {
|
|
@@ -208,8 +217,9 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
208
217
|
},
|
|
209
218
|
},
|
|
210
219
|
})
|
|
211
|
-
|
|
212
|
-
|
|
220
|
+
// Unpaired backtick → the whole nextStep is markdown-escaped, so the lone
|
|
221
|
+
// backtick is rendered as a literal `\`` and never opens a real code span.
|
|
222
|
+
expect(out).toContain('↳ Run \\`switchroom foo to fix')
|
|
213
223
|
})
|
|
214
224
|
|
|
215
225
|
it('degraded rows without nextStep render unchanged (backwards compat)', () => {
|
|
@@ -220,7 +230,7 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
220
230
|
quota: { status: 'fail', label: 'Quota', detail: 'rate limited' },
|
|
221
231
|
},
|
|
222
232
|
})
|
|
223
|
-
expect(out).toContain('🔴
|
|
233
|
+
expect(out).toContain('🔴 **Quota** rate limited')
|
|
224
234
|
expect(out).not.toContain('↳')
|
|
225
235
|
})
|
|
226
236
|
|
|
@@ -234,23 +244,25 @@ describe('renderBootCard — degraded conditions', () => {
|
|
|
234
244
|
},
|
|
235
245
|
})
|
|
236
246
|
expect(out).not.toContain('Account')
|
|
237
|
-
expect(out).toContain('🔴
|
|
247
|
+
expect(out).toContain('🔴 **Agent** service deactivating')
|
|
238
248
|
})
|
|
239
249
|
})
|
|
240
250
|
|
|
241
|
-
describe('renderBootCard —
|
|
242
|
-
it('
|
|
251
|
+
describe('renderBootCard — markdown escaping (#2669)', () => {
|
|
252
|
+
it('passes < > literally in agent name; escapes emphasis specials', () => {
|
|
253
|
+
// < > are literal in rich markdown; an underscore would be escaped.
|
|
243
254
|
const out = renderBootCard({ agentName: 'foo<bar>', version: 'v1' })
|
|
244
|
-
expect(out).toContain('foo
|
|
245
|
-
|
|
255
|
+
expect(out).toContain('foo<bar>')
|
|
256
|
+
const out2 = renderBootCard({ agentName: 'foo_bar', version: 'v1' })
|
|
257
|
+
expect(out2).toContain('foo\\_bar')
|
|
246
258
|
})
|
|
247
259
|
|
|
248
|
-
it('
|
|
260
|
+
it('passes < > literally in version', () => {
|
|
249
261
|
const out = renderBootCard({ agentName: 'a', version: 'v<1>' })
|
|
250
|
-
expect(out).toContain('v
|
|
262
|
+
expect(out).toContain('v<1>')
|
|
251
263
|
})
|
|
252
264
|
|
|
253
|
-
it('
|
|
265
|
+
it('passes < > literally in probe detail', () => {
|
|
254
266
|
const out = renderBootCard({
|
|
255
267
|
agentName: 'a',
|
|
256
268
|
version: 'v',
|
|
@@ -258,8 +270,7 @@ describe('renderBootCard — HTML escaping', () => {
|
|
|
258
270
|
quota: { status: 'fail', label: 'Quota', detail: 'rate <limited>' },
|
|
259
271
|
},
|
|
260
272
|
})
|
|
261
|
-
expect(out).toContain('rate
|
|
262
|
-
expect(out).not.toContain('rate <limited>')
|
|
273
|
+
expect(out).toContain('rate <limited>')
|
|
263
274
|
})
|
|
264
275
|
})
|
|
265
276
|
|
|
@@ -303,8 +314,8 @@ describe('resolvePersonaName — persona name over slug (#169)', () => {
|
|
|
303
314
|
const displayName = resolvePersonaName('finn', fakeCfg)
|
|
304
315
|
const out = renderBootCard({ agentName: displayName, version: 'v0.3.0+50' })
|
|
305
316
|
// User sees "Finn", not the slug "finn"
|
|
306
|
-
expect(out).toBe('✅
|
|
307
|
-
expect(out).not.toContain('
|
|
317
|
+
expect(out).toBe('✅ **Finn** back up · v0.3.0+50')
|
|
318
|
+
expect(out).not.toContain('**finn**')
|
|
308
319
|
})
|
|
309
320
|
})
|
|
310
321
|
|
|
@@ -322,8 +333,8 @@ describe('renderBootCard — resolved / snooze rendering', () => {
|
|
|
322
333
|
},
|
|
323
334
|
resolvedRows: ['hindsight'],
|
|
324
335
|
})
|
|
325
|
-
expect(out).toContain('✅
|
|
326
|
-
expect(out).toContain('🔴
|
|
336
|
+
expect(out).toContain('✅ **Hindsight** resolved')
|
|
337
|
+
expect(out).toContain('🔴 **Broker** socket missing')
|
|
327
338
|
// Resolved appears BEFORE Broker.
|
|
328
339
|
expect(out.indexOf('Hindsight')).toBeLessThan(out.indexOf('Broker'))
|
|
329
340
|
})
|
|
@@ -351,7 +362,7 @@ describe('renderBootCard — resolved / snooze rendering', () => {
|
|
|
351
362
|
},
|
|
352
363
|
snoozeRows: ['broker'],
|
|
353
364
|
})
|
|
354
|
-
expect(out).toBe('✅
|
|
365
|
+
expect(out).toBe('✅ **k** back up · v0.1')
|
|
355
366
|
})
|
|
356
367
|
|
|
357
368
|
it('resolvedRows alone (no probes degraded) renders the resolved row beneath the ack', () => {
|
|
@@ -360,8 +371,8 @@ describe('renderBootCard — resolved / snooze rendering', () => {
|
|
|
360
371
|
version: 'v',
|
|
361
372
|
resolvedRows: ['skills', 'broker'],
|
|
362
373
|
})
|
|
363
|
-
expect(out).toContain('✅
|
|
364
|
-
expect(out).toContain('✅
|
|
374
|
+
expect(out).toContain('✅ **Skills** resolved')
|
|
375
|
+
expect(out).toContain('✅ **Broker** resolved')
|
|
365
376
|
})
|
|
366
377
|
})
|
|
367
378
|
|
|
@@ -370,13 +381,13 @@ describe('renderBootCard — resolved / snooze rendering', () => {
|
|
|
370
381
|
describe('renderBootCard — configChanges rows', () => {
|
|
371
382
|
it('silent when configChanges is absent', () => {
|
|
372
383
|
const out = renderBootCard({ agentName: 'k', version: 'v' })
|
|
373
|
-
expect(out).toBe('✅
|
|
384
|
+
expect(out).toBe('✅ **k** back up · v')
|
|
374
385
|
expect(out).not.toContain('Config')
|
|
375
386
|
})
|
|
376
387
|
|
|
377
388
|
it('silent when configChanges is empty array', () => {
|
|
378
389
|
const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
|
|
379
|
-
expect(out).toBe('✅
|
|
390
|
+
expect(out).toBe('✅ **k** back up · v')
|
|
380
391
|
expect(out).not.toContain('Config')
|
|
381
392
|
})
|
|
382
393
|
|
|
@@ -386,7 +397,7 @@ describe('renderBootCard — configChanges rows', () => {
|
|
|
386
397
|
version: 'v',
|
|
387
398
|
configChanges: [{ field: 'model', from: 'claude-opus-4', to: 'claude-sonnet-4-5' }],
|
|
388
399
|
})
|
|
389
|
-
expect(out).toContain('⚙️
|
|
400
|
+
expect(out).toContain('⚙️ **Config**')
|
|
390
401
|
expect(out).toContain('claude-opus-4')
|
|
391
402
|
expect(out).toContain('claude-sonnet-4-5')
|
|
392
403
|
expect(out).toContain('→')
|
|
@@ -454,6 +465,6 @@ describe('renderBootCard — configChanges rows', () => {
|
|
|
454
465
|
it('bare ack still returned when configChanges fires but produces only empty rows (defensive)', () => {
|
|
455
466
|
// Edge: empty configChanges array passed → should not produce rows.
|
|
456
467
|
const out = renderBootCard({ agentName: 'k', version: 'v', configChanges: [] })
|
|
457
|
-
expect(out).toBe('✅
|
|
468
|
+
expect(out).toBe('✅ **k** back up · v')
|
|
458
469
|
})
|
|
459
470
|
})
|
|
Binary file
|
|
@@ -26,11 +26,29 @@ export interface MockBotApi {
|
|
|
26
26
|
opts?: Record<string, unknown>,
|
|
27
27
|
) => Promise<{ message_id: number }>
|
|
28
28
|
>
|
|
29
|
+
/**
|
|
30
|
+
* Bot API 10.1 rich-message send (#2669). Receives the
|
|
31
|
+
* `InputRichMessage` ({ markdown }) as the 2nd arg, mirroring grammy
|
|
32
|
+
* 1.44's `sendRichMessage`. Tests assert on
|
|
33
|
+
* `sendRichMessage.mock.calls[i][1].markdown` for the raw GFM body.
|
|
34
|
+
*/
|
|
35
|
+
sendRichMessage: MockInstance<
|
|
36
|
+
(
|
|
37
|
+
chat_id: string,
|
|
38
|
+
rich_message: { markdown: string },
|
|
39
|
+
opts?: Record<string, unknown>,
|
|
40
|
+
) => Promise<{ message_id: number }>
|
|
41
|
+
>
|
|
42
|
+
/**
|
|
43
|
+
* editMessageText's 3rd arg is `string | { markdown: string }` — a
|
|
44
|
+
* plain string on the literal path, an `InputRichMessage` on the rich
|
|
45
|
+
* path (#2669).
|
|
46
|
+
*/
|
|
29
47
|
editMessageText: MockInstance<
|
|
30
48
|
(
|
|
31
49
|
chat_id: string,
|
|
32
50
|
message_id: number,
|
|
33
|
-
text: string,
|
|
51
|
+
text: string | { markdown: string },
|
|
34
52
|
opts?: Record<string, unknown>,
|
|
35
53
|
) => Promise<unknown>
|
|
36
54
|
>
|
|
@@ -65,6 +83,7 @@ export function createMockBot(startMessageId = 500): MockBot {
|
|
|
65
83
|
|
|
66
84
|
const api: MockBotApi = {
|
|
67
85
|
sendMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
|
|
86
|
+
sendRichMessage: vi.fn(async () => ({ message_id: state.nextMessageId++ })),
|
|
68
87
|
editMessageText: vi.fn(async () => undefined),
|
|
69
88
|
deleteMessage: vi.fn(async () => true as const),
|
|
70
89
|
setMessageReaction: vi.fn(async () => true as const),
|
|
@@ -100,6 +119,9 @@ export function installBotResetHook(bot: MockBot): void {
|
|
|
100
119
|
bot.api.sendMessage.mockImplementation(async () => ({
|
|
101
120
|
message_id: bot.nextMessageId++,
|
|
102
121
|
}))
|
|
122
|
+
bot.api.sendRichMessage.mockImplementation(async () => ({
|
|
123
|
+
message_id: bot.nextMessageId++,
|
|
124
|
+
}))
|
|
103
125
|
bot.api.editMessageText.mockImplementation(async () => undefined)
|
|
104
126
|
bot.api.deleteMessage.mockImplementation(async () => true as const)
|
|
105
127
|
bot.api.setMessageReaction.mockImplementation(async () => true as const)
|
|
@@ -44,21 +44,16 @@ afterEach(() => {
|
|
|
44
44
|
|
|
45
45
|
// ─── escapeHtmlForTg ─────────────────────────────────────────────────────
|
|
46
46
|
|
|
47
|
-
describe('escapeHtmlForTg', () => {
|
|
48
|
-
it('
|
|
49
|
-
expect(escapeHtmlForTg('a & b')).toBe('a &
|
|
47
|
+
describe('escapeHtmlForTg (now a markdown escaper, kept under the legacy name, #2669)', () => {
|
|
48
|
+
it('leaves & < > literal (they are not markdown specials)', () => {
|
|
49
|
+
expect(escapeHtmlForTg('a & b')).toBe('a & b')
|
|
50
|
+
expect(escapeHtmlForTg('<script>')).toBe('<script>')
|
|
51
|
+
expect(escapeHtmlForTg('a > b')).toBe('a > b')
|
|
52
|
+
expect(escapeHtmlForTg('<a & b>')).toBe('<a & b>')
|
|
50
53
|
})
|
|
51
54
|
|
|
52
|
-
it('escapes
|
|
53
|
-
expect(escapeHtmlForTg('
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('escapes greater-than', () => {
|
|
57
|
-
expect(escapeHtmlForTg('a > b')).toBe('a > b')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('escapes all three in one string', () => {
|
|
61
|
-
expect(escapeHtmlForTg('<a & b>')).toBe('<a & b>')
|
|
55
|
+
it('escapes the inline-markdown specials (\\ ` * _ ~ = [ ] |)', () => {
|
|
56
|
+
expect(escapeHtmlForTg('a *b* _c_')).toBe('a \\*b\\* \\_c\\_')
|
|
62
57
|
})
|
|
63
58
|
|
|
64
59
|
it('returns plain text unchanged', () => {
|
|
@@ -68,13 +63,23 @@ describe('escapeHtmlForTg', () => {
|
|
|
68
63
|
|
|
69
64
|
// ─── preBlock ────────────────────────────────────────────────────────────
|
|
70
65
|
|
|
71
|
-
describe('preBlock', () => {
|
|
72
|
-
it('wraps text in
|
|
73
|
-
expect(preBlock('hello')).toBe('
|
|
66
|
+
describe('preBlock (now a fenced code block, #2669)', () => {
|
|
67
|
+
it('wraps text in a triple-backtick fence', () => {
|
|
68
|
+
expect(preBlock('hello')).toBe('```\nhello\n```')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps content literal inside the fence (no HTML escaping needed)', () => {
|
|
72
|
+
// Inside a fenced code block everything is verbatim; HTML/markdown is inert.
|
|
73
|
+
expect(preBlock('<b>bold</b>')).toBe('```\n<b>bold</b>\n```')
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
-
it('
|
|
77
|
-
|
|
76
|
+
it('defuses a fence-closing ``` sequence embedded in the content', () => {
|
|
77
|
+
const out = preBlock('a ``` b')
|
|
78
|
+
// The embedded ``` is broken with a zero-width space so it cannot close
|
|
79
|
+
// the fence early.
|
|
80
|
+
expect(out.startsWith('```\n')).toBe(true)
|
|
81
|
+
expect(out.endsWith('\n```')).toBe(true)
|
|
82
|
+
expect(out).not.toContain('a ``` b')
|
|
78
83
|
})
|
|
79
84
|
})
|
|
80
85
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest'
|
|
2
2
|
import {
|
|
3
3
|
cleanWorkerResultParagraph,
|
|
4
|
-
escapeHtml,
|
|
5
4
|
formatDuration,
|
|
6
5
|
stripMarkdown,
|
|
7
6
|
truncate,
|
|
8
7
|
} from '../card-format.js'
|
|
8
|
+
import { escapeMarkdown } from '../format.js'
|
|
9
9
|
|
|
10
10
|
describe('stripMarkdown', () => {
|
|
11
11
|
it('strips paired bold and emphasis', () => {
|
|
@@ -85,9 +85,11 @@ describe('formatDuration', () => {
|
|
|
85
85
|
})
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
describe('
|
|
89
|
-
it('escapes the
|
|
90
|
-
|
|
88
|
+
describe('escapeMarkdown / truncate', () => {
|
|
89
|
+
it('escapes the inline-markdown specials, leaving < > & literal (#2669)', () => {
|
|
90
|
+
// The HTML escaper is gone; escapeMarkdown escapes \ ` * _ ~ = [ ] | only.
|
|
91
|
+
expect(escapeMarkdown('a <b> & c')).toBe('a <b> & c')
|
|
92
|
+
expect(escapeMarkdown('a *b* _c_')).toBe('a \\*b\\* \\_c\\_')
|
|
91
93
|
})
|
|
92
94
|
it('truncates with an ellipsis', () => {
|
|
93
95
|
expect(truncate('abcdef', 4)).toBe('abc…')
|
|
@@ -285,7 +285,7 @@ describe('renderConfigChangeDim', () => {
|
|
|
285
285
|
for (const field of fields) {
|
|
286
286
|
const row = renderConfigChangeDim({ field, from: 'a', to: 'b' })
|
|
287
287
|
expect(row).toContain('⚙️')
|
|
288
|
-
expect(row).toContain('
|
|
288
|
+
expect(row).toContain('**Config**')
|
|
289
289
|
}
|
|
290
290
|
})
|
|
291
291
|
})
|
|
@@ -103,7 +103,7 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
|
|
|
103
103
|
if (d.kind !== "notify") return;
|
|
104
104
|
expect(d.transition).toBe("entered");
|
|
105
105
|
expect(d.message).toContain("out of pre-paid credits");
|
|
106
|
-
expect(d.message).toContain("
|
|
106
|
+
expect(d.message).toContain("**lawgpt**");
|
|
107
107
|
expect(d.newState.lastNotifiedReason).toBe("out_of_credits");
|
|
108
108
|
expect(d.newState.lastNotifiedAt).toBe(NOW);
|
|
109
109
|
});
|
|
@@ -175,9 +175,9 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
|
|
|
175
175
|
expect(d.kind).toBe("skip");
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("escapes
|
|
178
|
+
it("markdown-escapes emphasis specials in the agent name; < > stay literal (#2669)", () => {
|
|
179
179
|
const d = evaluateCreditState({
|
|
180
|
-
agentName: "
|
|
180
|
+
agentName: "ev_il",
|
|
181
181
|
currentReason: "out_of_credits",
|
|
182
182
|
prev: HEALTHY,
|
|
183
183
|
now: NOW,
|
|
@@ -185,8 +185,8 @@ describe("evaluateCreditState — transition decisions (machinery, explicit fata
|
|
|
185
185
|
});
|
|
186
186
|
expect(d.kind).toBe("notify");
|
|
187
187
|
if (d.kind !== "notify") return;
|
|
188
|
-
|
|
189
|
-
expect(d.message).
|
|
188
|
+
// The underscore is escaped so it can't open an italic run inside **…**.
|
|
189
|
+
expect(d.message).toContain("ev\\_il");
|
|
190
190
|
});
|
|
191
191
|
});
|
|
192
192
|
|
|
@@ -27,10 +27,21 @@ export interface SentMessage {
|
|
|
27
27
|
readonly message_id: number
|
|
28
28
|
readonly chat_id: string
|
|
29
29
|
readonly text: string
|
|
30
|
+
/**
|
|
31
|
+
* True when the message landed via the Bot API 10.1 rich path
|
|
32
|
+
* (`sendRichMessage`) rather than plain `sendMessage` (#2669). `text`
|
|
33
|
+
* holds the raw GFM markdown body in both cases (for a rich send it is
|
|
34
|
+
* `rich_message.markdown`), so existing text assertions keep working;
|
|
35
|
+
* tests that care about the path assert on this flag.
|
|
36
|
+
*/
|
|
37
|
+
readonly rich?: boolean
|
|
30
38
|
readonly parse_mode?: string
|
|
31
39
|
readonly reply_to_message_id?: number
|
|
40
|
+
readonly reply_parameters?: { message_id: number; quote?: { text: string; position: number } }
|
|
41
|
+
readonly reply_markup?: unknown
|
|
32
42
|
readonly message_thread_id?: number
|
|
33
43
|
readonly disable_notification?: boolean
|
|
44
|
+
readonly link_preview_options?: unknown
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
export interface PinnedRef {
|
|
@@ -184,6 +195,7 @@ export const errors = {
|
|
|
184
195
|
|
|
185
196
|
export interface FakeBotApi {
|
|
186
197
|
sendMessage: ReturnType<typeof vi.fn>
|
|
198
|
+
sendRichMessage: ReturnType<typeof vi.fn>
|
|
187
199
|
editMessageText: ReturnType<typeof vi.fn>
|
|
188
200
|
deleteMessage: ReturnType<typeof vi.fn>
|
|
189
201
|
setMessageReaction: ReturnType<typeof vi.fn>
|
|
@@ -367,27 +379,69 @@ export function createFakeBotApi(opts: CreateFakeBotApiOpts = {}): FakeBot {
|
|
|
367
379
|
text,
|
|
368
380
|
parse_mode: opts?.parse_mode as string | undefined,
|
|
369
381
|
reply_to_message_id: opts?.reply_to_message_id as number | undefined,
|
|
382
|
+
reply_parameters: opts?.reply_parameters as SentMessage['reply_parameters'],
|
|
383
|
+
reply_markup: opts?.reply_markup,
|
|
370
384
|
message_thread_id: opts?.message_thread_id as number | undefined,
|
|
371
385
|
disable_notification: opts?.disable_notification as boolean | undefined,
|
|
386
|
+
link_preview_options: opts?.link_preview_options,
|
|
372
387
|
}
|
|
373
388
|
sent.push(record)
|
|
374
389
|
currentText.set(message_id, text)
|
|
375
390
|
return { message_id, chat: { id: chat_id }, date: Math.floor(Date.now() / 1000), text }
|
|
376
391
|
}),
|
|
377
392
|
|
|
393
|
+
// Bot API 10.1 rich-message send (#2669). Records the raw GFM markdown
|
|
394
|
+
// (`rich_message.markdown`) into `text` so existing text/state
|
|
395
|
+
// assertions keep working, flags the record `rich: true`, and captures
|
|
396
|
+
// reply_markup / reply_parameters / message_thread_id the same way
|
|
397
|
+
// sendMessage does. sendRichMessage does NOT accept link_preview_options
|
|
398
|
+
// on the wire — the controller strips it before calling — so it is not
|
|
399
|
+
// recorded here.
|
|
400
|
+
sendRichMessage: vi.fn(
|
|
401
|
+
async (chat_id: string, rich_message: { markdown: string }, opts?: Record<string, unknown>) => {
|
|
402
|
+
await applyEntryGates('sendRichMessage', chat_id)
|
|
403
|
+
const markdown = rich_message?.markdown ?? ''
|
|
404
|
+
const message_id = nextMessageId++
|
|
405
|
+
const record: SentMessage = {
|
|
406
|
+
message_id,
|
|
407
|
+
chat_id,
|
|
408
|
+
text: markdown,
|
|
409
|
+
rich: true,
|
|
410
|
+
reply_to_message_id: opts?.reply_to_message_id as number | undefined,
|
|
411
|
+
reply_parameters: opts?.reply_parameters as SentMessage['reply_parameters'],
|
|
412
|
+
reply_markup: opts?.reply_markup,
|
|
413
|
+
message_thread_id: opts?.message_thread_id as number | undefined,
|
|
414
|
+
disable_notification: opts?.disable_notification as boolean | undefined,
|
|
415
|
+
}
|
|
416
|
+
sent.push(record)
|
|
417
|
+
currentText.set(message_id, markdown)
|
|
418
|
+
return { message_id, chat: { id: chat_id }, date: Math.floor(Date.now() / 1000), text: markdown }
|
|
419
|
+
},
|
|
420
|
+
),
|
|
421
|
+
|
|
378
422
|
editMessageText: vi.fn(
|
|
379
|
-
async (
|
|
423
|
+
async (
|
|
424
|
+
chat_id: string,
|
|
425
|
+
message_id: number,
|
|
426
|
+
text: string | { markdown: string },
|
|
427
|
+
opts?: Record<string, unknown>,
|
|
428
|
+
) => {
|
|
380
429
|
await applyEntryGates('editMessageText', chat_id)
|
|
381
|
-
|
|
430
|
+
// #2669: the rich path passes `{ markdown }`; the literal path
|
|
431
|
+
// passes a plain string. Normalize to the body string for the
|
|
432
|
+
// not-modified / currentText bookkeeping.
|
|
433
|
+
const isRich = typeof text === 'object' && text != null
|
|
434
|
+
const body = isRich ? (text as { markdown: string }).markdown : (text as string)
|
|
435
|
+
if (!isRich) checkParseMode('editMessageText', body, opts?.parse_mode as string | undefined)
|
|
382
436
|
if (deleted.has(message_id) || !currentText.has(message_id)) {
|
|
383
437
|
// Simulate Telegram's real 400 when the target is gone.
|
|
384
438
|
throw errors.messageToEditNotFound()
|
|
385
439
|
}
|
|
386
440
|
const prev = currentText.get(message_id)
|
|
387
|
-
if (prev ===
|
|
441
|
+
if (prev === body) {
|
|
388
442
|
throw errors.notModified()
|
|
389
443
|
}
|
|
390
|
-
currentText.set(message_id,
|
|
444
|
+
currentText.set(message_id, body)
|
|
391
445
|
return true
|
|
392
446
|
},
|
|
393
447
|
),
|
|
@@ -15,7 +15,7 @@ import { finalizeCallback, type FinalizeCallbackContext } from '../inline-keyboa
|
|
|
15
15
|
|
|
16
16
|
interface Capture {
|
|
17
17
|
acks: Array<{ text?: string; show_alert?: boolean }>
|
|
18
|
-
edits: Array<{ text: string; opts: Record<string, unknown> }>
|
|
18
|
+
edits: Array<{ text: string | { markdown: string }; opts: Record<string, unknown> }>
|
|
19
19
|
ackThrows?: Error
|
|
20
20
|
editThrows?: Error
|
|
21
21
|
}
|
|
@@ -57,25 +57,27 @@ describe('finalizeCallback — three-invariant contract', () => {
|
|
|
57
57
|
expect(cap.acks[0]?.show_alert).toBe(true)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it('invariant 2: strips reply_markup AND edits the body in one atomic call', async () => {
|
|
60
|
+
it('invariant 2: strips reply_markup AND rich-edits the body in one atomic call', async () => {
|
|
61
61
|
const cap: Capture = { acks: [], edits: [] }
|
|
62
62
|
await finalizeCallback(mkCtx(cap), {
|
|
63
63
|
ackText: 'Approved',
|
|
64
|
-
newText: '
|
|
65
|
-
parseMode: 'HTML',
|
|
64
|
+
newText: '**✓ Approved**\n\nGrant minted at 22:38 UTC',
|
|
66
65
|
})
|
|
67
66
|
expect(cap.edits).toHaveLength(1)
|
|
68
|
-
|
|
67
|
+
// Default (non-literal) path wraps the body in the rich-message
|
|
68
|
+
// { markdown } object — raw GFM markdown, no parse_mode (#2669).
|
|
69
|
+
expect(cap.edits[0]?.text).toEqual({ markdown: '**✓ Approved**\n\nGrant minted at 22:38 UTC' })
|
|
69
70
|
expect(cap.edits[0]?.opts.reply_markup).toEqual({ inline_keyboard: [] })
|
|
70
|
-
expect(cap.edits[0]?.opts.parse_mode).
|
|
71
|
+
expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
|
|
71
72
|
// link_preview_options disabled by default — keeps the edited
|
|
72
73
|
// status line from rendering a stale preview card.
|
|
73
74
|
expect(cap.edits[0]?.opts.link_preview_options).toEqual({ is_disabled: true })
|
|
74
75
|
})
|
|
75
76
|
|
|
76
|
-
it('invariant 2:
|
|
77
|
+
it('invariant 2: literalText edits a plain string (no rich wrapper, no parse_mode)', async () => {
|
|
77
78
|
const cap: Capture = { acks: [], edits: [] }
|
|
78
|
-
await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain' })
|
|
79
|
+
await finalizeCallback(mkCtx(cap), { ackText: 'ok', newText: 'plain', literalText: true })
|
|
80
|
+
expect(cap.edits[0]?.text).toBe('plain')
|
|
79
81
|
expect(cap.edits[0]?.opts.parse_mode).toBeUndefined()
|
|
80
82
|
})
|
|
81
83
|
|
|
@@ -170,7 +172,7 @@ describe('finalizeCallback — three-invariant contract', () => {
|
|
|
170
172
|
log: (l) => logs.push(l),
|
|
171
173
|
})
|
|
172
174
|
expect(cap.edits).toHaveLength(1)
|
|
173
|
-
expect(cap.edits[0]?.text).
|
|
175
|
+
expect(cap.edits[0]?.text).toEqual({ markdown: 'edited body' })
|
|
174
176
|
expect(synthFired).toBe(true)
|
|
175
177
|
// ack fire-and-forget — its catch fires asynchronously; give it a tick
|
|
176
178
|
// so the log assertion is stable across runs.
|
|
@@ -91,7 +91,7 @@ describe('end-to-end render shape under ack-first', () => {
|
|
|
91
91
|
)
|
|
92
92
|
expect(html).not.toBeNull()
|
|
93
93
|
// newest child is the live, bold current step
|
|
94
|
-
expect(html).toContain('
|
|
94
|
+
expect(html).toContain('**→ ranking by resolution**')
|
|
95
95
|
// an earlier child is present as a done/italic step
|
|
96
96
|
expect(html).toContain('checking license on 4 candidates')
|
|
97
97
|
})
|