switchroom 0.16.23 → 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/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 +671 -430
- package/dist/host-control/main.js +156 -156
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- 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/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1855 -2077
- package/telegram-plugin/dist/server.js +160 -160
- 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 +43 -3
- 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
|
@@ -75,12 +75,12 @@ describe('renderWorkerActivity', () => {
|
|
|
75
75
|
|
|
76
76
|
it('renders the native header + running status + step feed', () => {
|
|
77
77
|
const out = renderWorkerActivity(view())
|
|
78
|
-
expect(out).toContain('🛠
|
|
78
|
+
expect(out).toContain('🛠 **Worker** · _research competitors_')
|
|
79
79
|
// Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
|
|
80
|
-
expect(out).toContain('
|
|
80
|
+
expect(out).toContain('_10s · 3 tools_')
|
|
81
81
|
expect(out).toContain('3 tools')
|
|
82
82
|
// No narrativeLines → the latestSummary surfaces as the newest `→` step.
|
|
83
|
-
expect(out).toContain('
|
|
83
|
+
expect(out).toContain('**→ scanning vendor pages**')
|
|
84
84
|
// The old tool/arg chrome is gone.
|
|
85
85
|
expect(out).not.toContain('⚡')
|
|
86
86
|
expect(out).not.toContain('<code>')
|
|
@@ -88,7 +88,7 @@ describe('renderWorkerActivity', () => {
|
|
|
88
88
|
|
|
89
89
|
it('shows a "starting…" line when no step has run yet', () => {
|
|
90
90
|
const out = renderWorkerActivity(view({ lastTool: null, latestSummary: '' }))
|
|
91
|
-
expect(out).toContain('🛠
|
|
91
|
+
expect(out).toContain('🛠 **Worker**')
|
|
92
92
|
expect(out).toContain('starting…')
|
|
93
93
|
expect(out).not.toContain('→')
|
|
94
94
|
})
|
|
@@ -109,22 +109,22 @@ describe('renderWorkerActivity', () => {
|
|
|
109
109
|
const out = renderWorkerActivity(
|
|
110
110
|
view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
|
|
111
111
|
)
|
|
112
|
-
expect(out).toContain('🛠
|
|
112
|
+
expect(out).toContain('🛠 **Worker** · _research competitors_')
|
|
113
113
|
// Unified done header: "done · N tools · <elapsed>".
|
|
114
|
-
expect(out).toContain('
|
|
114
|
+
expect(out).toContain('_done · 5 tools · ')
|
|
115
115
|
expect(out).toContain('─────')
|
|
116
|
-
expect(out).toContain('✅
|
|
116
|
+
expect(out).toContain('✅ _PR #21 opened_')
|
|
117
117
|
// latestSummary is the RESULT on the finished path, never also a step.
|
|
118
|
-
expect(out).not.toContain('
|
|
118
|
+
expect(out).not.toContain('_✓ PR #21 opened_')
|
|
119
119
|
})
|
|
120
120
|
|
|
121
121
|
it('renders a failed terminal recap', () => {
|
|
122
122
|
const out = renderWorkerActivity(view({ state: 'failed', latestSummary: 'blew up' }))
|
|
123
123
|
// The header word reflects the failure (`failed · …`) AND the ⚠️ result
|
|
124
124
|
// block carries the detail — the two signals are complementary.
|
|
125
|
-
expect(out).toContain('
|
|
126
|
-
expect(out).not.toContain('
|
|
127
|
-
expect(out).toContain('⚠️
|
|
125
|
+
expect(out).toContain('_failed · ')
|
|
126
|
+
expect(out).not.toContain('_done · ')
|
|
127
|
+
expect(out).toContain('⚠️ _blew up_')
|
|
128
128
|
})
|
|
129
129
|
|
|
130
130
|
it('a failed worker with EMPTY result is never byte-identical to a done worker (regression: #2553 failure-honesty)', () => {
|
|
@@ -136,15 +136,15 @@ describe('renderWorkerActivity', () => {
|
|
|
136
136
|
// (b) The failed render carries a visible failure marker even with no
|
|
137
137
|
// result block (the `failed` header word and/or the ⚠️ emoji).
|
|
138
138
|
expect(failed.includes('failed') || failed.includes('⚠️')).toBe(true)
|
|
139
|
-
expect(failed).toContain('
|
|
139
|
+
expect(failed).toContain('_failed · ')
|
|
140
140
|
// The done render must NOT read as failed.
|
|
141
|
-
expect(done).toContain('
|
|
141
|
+
expect(done).toContain('_done · ')
|
|
142
142
|
expect(done).not.toContain('failed')
|
|
143
143
|
})
|
|
144
144
|
|
|
145
145
|
it('omits the rule + result line when the terminal result is empty', () => {
|
|
146
146
|
const out = renderWorkerActivity(view({ state: 'done', latestSummary: ' ' }))
|
|
147
|
-
expect(out).toContain('
|
|
147
|
+
expect(out).toContain('_done · ')
|
|
148
148
|
expect(out).not.toContain('─────')
|
|
149
149
|
})
|
|
150
150
|
|
|
@@ -155,9 +155,9 @@ describe('renderWorkerActivity', () => {
|
|
|
155
155
|
narrativeLines: ['read the brief', 'scanned vendor A', 'scanned vendor B'],
|
|
156
156
|
}),
|
|
157
157
|
)
|
|
158
|
-
expect(out).toContain('
|
|
159
|
-
expect(out).toContain('
|
|
160
|
-
expect(out).toContain('
|
|
158
|
+
expect(out).toContain('_✓ read the brief_')
|
|
159
|
+
expect(out).toContain('_✓ scanned vendor A_')
|
|
160
|
+
expect(out).toContain('**→ scanned vendor B**')
|
|
161
161
|
// The single-line latestSummary fallback is NOT used when a block is present.
|
|
162
162
|
expect(out).not.toContain('newest only')
|
|
163
163
|
expect(stepCount(out)).toBe(3)
|
|
@@ -165,14 +165,14 @@ describe('renderWorkerActivity', () => {
|
|
|
165
165
|
|
|
166
166
|
it('falls back to latestSummary when narrativeLines is empty', () => {
|
|
167
167
|
const out = renderWorkerActivity(view({ narrativeLines: [], latestSummary: 'one line' }))
|
|
168
|
-
expect(out).toContain('
|
|
168
|
+
expect(out).toContain('**→ one line**')
|
|
169
169
|
expect(stepCount(out)).toBe(1)
|
|
170
170
|
})
|
|
171
171
|
|
|
172
172
|
it('drops blank narrative lines from the feed', () => {
|
|
173
173
|
const out = renderWorkerActivity(view({ narrativeLines: ['kept', ' ', 'also kept'] }))
|
|
174
|
-
expect(out).toContain('
|
|
175
|
-
expect(out).toContain('
|
|
174
|
+
expect(out).toContain('_✓ kept_')
|
|
175
|
+
expect(out).toContain('**→ also kept**')
|
|
176
176
|
expect(stepCount(out)).toBe(2)
|
|
177
177
|
})
|
|
178
178
|
|
|
@@ -180,33 +180,39 @@ describe('renderWorkerActivity', () => {
|
|
|
180
180
|
const total = STATUS_ROLLING_LINES + 3
|
|
181
181
|
const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
|
|
182
182
|
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
183
|
-
expect(out).toContain(
|
|
183
|
+
expect(out).toContain(`_✓ +${total - STATUS_ROLLING_LINES} earlier…_`)
|
|
184
184
|
expect(out).not.toContain('step 1<')
|
|
185
185
|
const firstVisible = total - STATUS_ROLLING_LINES + 1
|
|
186
|
-
expect(out).toContain(
|
|
187
|
-
expect(out).toContain(
|
|
186
|
+
expect(out).toContain(`_✓ step ${firstVisible}_`)
|
|
187
|
+
expect(out).toContain(`**→ step ${total}**`)
|
|
188
188
|
// STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
|
|
189
189
|
expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
|
|
190
190
|
})
|
|
191
191
|
|
|
192
|
-
it('strips Markdown markup
|
|
192
|
+
it('strips the CONTENT Markdown markup (the card keeps its own ** / _ wrappers, #2669)', () => {
|
|
193
|
+
// Post-#2669 the card itself is rich markdown — the header is **Worker**
|
|
194
|
+
// and steps are _✓ …_ / **→ …**. The per-line pipeline still runs
|
|
195
|
+
// stripMarkdown over the user-supplied CONTENT so a model-authored
|
|
196
|
+
// `**full**` / `` `git push` `` doesn't inject extra emphasis, but the
|
|
197
|
+
// card's own wrapper markup is expected.
|
|
193
198
|
const running = renderWorkerActivity(
|
|
194
199
|
view({
|
|
195
200
|
description: '**Build** the `sync`',
|
|
196
201
|
narrativeLines: ['- ran the **full** suite', '`git push`'],
|
|
197
202
|
}),
|
|
198
203
|
)
|
|
199
|
-
expect(running).toContain('🛠
|
|
204
|
+
expect(running).toContain('🛠 **Worker** · _Build the sync_')
|
|
200
205
|
expect(running).toContain('ran the full suite')
|
|
201
206
|
expect(running).toContain('git push')
|
|
202
|
-
|
|
207
|
+
// The CONTENT bold/code markers were stripped — no `**full**`, no backticks.
|
|
208
|
+
expect(running).not.toContain('**full**')
|
|
203
209
|
expect(running).not.toContain('`')
|
|
204
210
|
|
|
205
211
|
const done = renderWorkerActivity(
|
|
206
212
|
view({ state: 'done', latestSummary: '## Done\n\n**PR #21** opened\n\n---\n`merged`' }),
|
|
207
213
|
)
|
|
208
214
|
expect(done).toContain('Done PR #21 opened merged')
|
|
209
|
-
expect(done).not.toContain('**')
|
|
215
|
+
expect(done).not.toContain('**PR #21**')
|
|
210
216
|
expect(done).not.toContain('`')
|
|
211
217
|
// The card's own divider is the box-drawing rule, never a raw `---`.
|
|
212
218
|
expect(done).not.toMatch(/(^|\n)\s*-{3,}\s*(\n|$)/)
|
|
@@ -227,17 +233,19 @@ describe('renderWorkerActivity', () => {
|
|
|
227
233
|
expect(out).toContain('Done. Summary Fixed the bug where a bad password logged you out')
|
|
228
234
|
})
|
|
229
235
|
|
|
230
|
-
it('escapes
|
|
231
|
-
|
|
232
|
-
|
|
236
|
+
it('markdown-escapes emphasis specials inside narrative lines; passes < > & through (#2669)', () => {
|
|
237
|
+
// The per-line pipeline strips paired markdown then escapes survivors.
|
|
238
|
+
// `<`, `>`, `&` are literal in rich markdown; an intra-word `_` is escaped.
|
|
239
|
+
const out = renderWorkerActivity(view({ narrativeLines: ['a <tag> & b_c y'] }))
|
|
240
|
+
expect(out).toContain('a <tag> & b\\_c y')
|
|
233
241
|
})
|
|
234
242
|
|
|
235
|
-
it('escapes
|
|
243
|
+
it('markdown-escapes specials in description; passes < > & through', () => {
|
|
236
244
|
const out = renderWorkerActivity(
|
|
237
|
-
view({ description: 'a <
|
|
245
|
+
view({ description: 'a <tag> task b_c', latestSummary: 'a > b' }),
|
|
238
246
|
)
|
|
239
|
-
expect(out).toContain('a
|
|
240
|
-
expect(out).toContain('a
|
|
247
|
+
expect(out).toContain('a <tag> task b\\_c')
|
|
248
|
+
expect(out).toContain('a > b')
|
|
241
249
|
})
|
|
242
250
|
})
|
|
243
251
|
|
|
@@ -261,7 +269,10 @@ describe('createWorkerActivityFeed', () => {
|
|
|
261
269
|
await feed.update('w1', 'chat', view({ elapsedMs: 9000 }))
|
|
262
270
|
expect(bot.sent).toHaveLength(1)
|
|
263
271
|
expect(bot.sent[0].chatId).toBe('chat')
|
|
264
|
-
|
|
272
|
+
// #2669: the worker feed body is raw GFM markdown sent through the rich
|
|
273
|
+
// path (the gateway wires sendMessage → sendRichMessage). No parse_mode.
|
|
274
|
+
expect(bot.sent[0].opts?.parse_mode).toBeUndefined()
|
|
275
|
+
expect(bot.sent[0].text).toContain('🛠 **Worker**')
|
|
265
276
|
expect(feed.has('w1')).toBe(true)
|
|
266
277
|
})
|
|
267
278
|
|
|
@@ -303,7 +314,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
303
314
|
clock = 10_500 // well within the throttle window
|
|
304
315
|
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
305
316
|
expect(bot.edits).toHaveLength(1)
|
|
306
|
-
expect(bot.edits[0].text).toContain('
|
|
317
|
+
expect(bot.edits[0].text).toContain('_done · 5 tools · ')
|
|
307
318
|
// finish forgets the worker.
|
|
308
319
|
expect(feed.has('w1')).toBe(false)
|
|
309
320
|
expect(feed.size).toBe(0)
|
|
@@ -387,7 +398,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
387
398
|
|
|
388
399
|
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: 'read the brief' }))
|
|
389
400
|
expect(bot.sent).toHaveLength(1)
|
|
390
|
-
expect(bot.sent[0].text).toContain('
|
|
401
|
+
expect(bot.sent[0].text).toContain('**→ read the brief**')
|
|
391
402
|
|
|
392
403
|
clock = 11_000
|
|
393
404
|
await feed.update('w1', 'chat', view({ toolCount: 2, latestSummary: 'scanned vendor A' }))
|
|
@@ -395,9 +406,9 @@ describe('createWorkerActivityFeed', () => {
|
|
|
395
406
|
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'scanned vendor B' }))
|
|
396
407
|
|
|
397
408
|
const last = bot.edits.at(-1)!
|
|
398
|
-
expect(last.text).toContain('
|
|
399
|
-
expect(last.text).toContain('
|
|
400
|
-
expect(last.text).toContain('
|
|
409
|
+
expect(last.text).toContain('_✓ read the brief_')
|
|
410
|
+
expect(last.text).toContain('_✓ scanned vendor A_')
|
|
411
|
+
expect(last.text).toContain('**→ scanned vendor B**')
|
|
401
412
|
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(3)
|
|
402
413
|
})
|
|
403
414
|
|
|
@@ -451,9 +462,9 @@ describe('createWorkerActivityFeed', () => {
|
|
|
451
462
|
clock = 13_000
|
|
452
463
|
await feed.update('w1', 'chat', view({ toolCount: 3, latestSummary: 'line C' }))
|
|
453
464
|
const last = bot.edits.at(-1)!
|
|
454
|
-
expect(last.text).toContain('
|
|
455
|
-
expect(last.text).toContain('
|
|
456
|
-
expect(last.text).toContain('
|
|
465
|
+
expect(last.text).toContain('_✓ line A_')
|
|
466
|
+
expect(last.text).toContain('_✓ line B_')
|
|
467
|
+
expect(last.text).toContain('**→ line C**')
|
|
457
468
|
})
|
|
458
469
|
|
|
459
470
|
it('forwards threadId as message_thread_id on send', async () => {
|
|
@@ -543,8 +554,8 @@ describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
|
543
554
|
expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
544
555
|
}
|
|
545
556
|
// Overflow header now appears on the worker surface too.
|
|
546
|
-
expect(out).toContain(
|
|
547
|
-
expect(out).toContain('
|
|
557
|
+
expect(out).toContain(`_✓ +${12 - STATUS_ROLLING_LINES} earlier…_`)
|
|
558
|
+
expect(out).toContain('**→ stp-012**')
|
|
548
559
|
})
|
|
549
560
|
|
|
550
561
|
it('STATUS_LINE_MAX=200: a 250-char line is clipped to 200 with a trailing …', () => {
|
|
@@ -601,7 +612,7 @@ describe('rolling window — createWorkerActivityFeed narrative accumulation', (
|
|
|
601
612
|
// render never sees overflow — no "+N earlier…" marker on the manager path
|
|
602
613
|
// (it surfaces only on direct renderWorkerActivity calls with >5 lines).
|
|
603
614
|
expect(last.text).not.toContain('earlier…')
|
|
604
|
-
expect(last.text).toContain('
|
|
615
|
+
expect(last.text).toContain('**→ ln-012**')
|
|
605
616
|
})
|
|
606
617
|
})
|
|
607
618
|
|
|
@@ -632,7 +643,7 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
632
643
|
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
|
|
633
644
|
// Drain the chain.
|
|
634
645
|
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
|
|
635
|
-
const edit1 = bot.edits.find((e) => /· \d+s
|
|
646
|
+
const edit1 = bot.edits.find((e) => /· \d+s\*\*/.test(e.text))
|
|
636
647
|
expect(edit1).toBeDefined()
|
|
637
648
|
// The suffix reflects the LIVE elapsed (now - dispatchAt), not the stale view.
|
|
638
649
|
expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
|
|
@@ -731,32 +742,23 @@ describe('createWorkerActivityFeed — heartbeat', () => {
|
|
|
731
742
|
// and the step vanish from the rendered output.
|
|
732
743
|
|
|
733
744
|
/**
|
|
734
|
-
* Cheap valid-
|
|
735
|
-
*
|
|
736
|
-
*
|
|
745
|
+
* Cheap valid-rich-markdown checker (post-#2669): the `**…**` wrappers the
|
|
746
|
+
* worker renderer emits must be balanced, and no escape backslash may be left
|
|
747
|
+
* dangling at the very end (it would swallow the following close marker). This
|
|
748
|
+
* is the markdown analogue of the old "no partial HTML entity at a slice
|
|
749
|
+
* boundary" check — a naive RAW-slice that split a `\*` escape would surface
|
|
750
|
+
* here as an odd trailing-backslash count.
|
|
737
751
|
*/
|
|
738
|
-
function
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
const
|
|
742
|
-
|
|
743
|
-
if (bOpen !== bClose || iOpen !== iClose) return false
|
|
744
|
-
// Check every `&` occurrence: the run of letters after it must end with `;`.
|
|
745
|
-
// A partial entity like `&am`, `&l`, or `&` (no ;) would fail this.
|
|
746
|
-
// We scan every `&` manually so there's no regex backtracking ambiguity.
|
|
747
|
-
for (let i = 0; i < s.length; i++) {
|
|
748
|
-
if (s[i] !== '&') continue
|
|
749
|
-
let j = i + 1
|
|
750
|
-
while (j < s.length && s[j] >= 'a' && s[j] <= 'z') j++
|
|
751
|
-
// j is now at the character after the letter run.
|
|
752
|
-
// If there were letters and the next char isn't ';', it's a broken entity.
|
|
753
|
-
if (j > i + 1 && (j >= s.length || s[j] !== ';')) return false
|
|
754
|
-
}
|
|
752
|
+
function isValidWorkerMarkdown(s: string): boolean {
|
|
753
|
+
const bold = (s.match(/\*\*/g) ?? []).length
|
|
754
|
+
if (bold % 2 !== 0) return false
|
|
755
|
+
const trailing = /(\\*)$/.exec(s)?.[1].length ?? 0
|
|
756
|
+
if (trailing % 2 === 1) return false
|
|
755
757
|
return true
|
|
756
758
|
}
|
|
757
759
|
|
|
758
760
|
describe('extreme-edge: single oversized narrative line (no-truncate ON)', () => {
|
|
759
|
-
it('no-truncate ON: one ~4100-char step is shown (truncated) not discarded, output ≤ budget and valid
|
|
761
|
+
it('no-truncate ON: one ~4100-char step is shown (truncated) not discarded, output ≤ budget and valid markdown', async () => {
|
|
760
762
|
const bot = makeFakeBot()
|
|
761
763
|
let clock = 10_000
|
|
762
764
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
@@ -780,10 +782,10 @@ describe('extreme-edge: single oversized narrative line (no-truncate ON)', () =>
|
|
|
780
782
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
781
783
|
|
|
782
784
|
// Valid HTML: balanced tags and no partial entity.
|
|
783
|
-
expect(
|
|
785
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
784
786
|
})
|
|
785
787
|
|
|
786
|
-
it('no-truncate ON: one ~4100-char step via renderWorkerActivity directly → ≤ budget and valid
|
|
788
|
+
it('no-truncate ON: one ~4100-char step via renderWorkerActivity directly → ≤ budget and valid markdown', () => {
|
|
787
789
|
const base = 'compile && link && package && ship: action=deploy env=<prod> flag=1 '
|
|
788
790
|
const hugeStep = base.repeat(65)
|
|
789
791
|
expect(hugeStep.length).toBeGreaterThan(4000)
|
|
@@ -795,104 +797,84 @@ describe('extreme-edge: single oversized narrative line (no-truncate ON)', () =>
|
|
|
795
797
|
expect(hasBullet).toBe(true)
|
|
796
798
|
|
|
797
799
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
798
|
-
expect(
|
|
800
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
799
801
|
})
|
|
800
802
|
})
|
|
801
803
|
|
|
802
|
-
// ─── Bug 2:
|
|
803
|
-
//
|
|
804
|
-
// Before the fix, the extreme fallback in _fitWorkerBodyToCharBudget sliced
|
|
805
|
-
// directly into the already-HTML-escaped newest line string. If the slice
|
|
806
|
-
// boundary landed inside an HTML entity (&, <, >), the output
|
|
807
|
-
// contained a broken entity fragment (&am, &l, & without ;), which
|
|
808
|
-
// Telegram Bot API rejects with HTTP 400 on parse_mode:'HTML'.
|
|
804
|
+
// ─── Bug 2: fitCardToBudget must not slice already-escaped markdown ──────────
|
|
809
805
|
//
|
|
810
|
-
//
|
|
811
|
-
//
|
|
812
|
-
//
|
|
813
|
-
//
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
describe('Bug 2 (#2506):
|
|
806
|
+
// Post-#2669 the wire format is GFM markdown. The same class of bug applies:
|
|
807
|
+
// the extreme budget fallback must truncate the RAW newest line first, THEN
|
|
808
|
+
// escape, THEN wrap — never slice an already-escaped string. A naive slice
|
|
809
|
+
// that landed right after an escape backslash (`\` from a `\*` escape) would
|
|
810
|
+
// leave a dangling backslash that swallows the bold close marker, producing
|
|
811
|
+
// `**→ …\**` → unbalanced. These tests place markdown specials at the slice
|
|
812
|
+
// boundary and assert the output stays valid (balanced ** + no dangling \).
|
|
813
|
+
|
|
814
|
+
describe('Bug 2 (#2506): fitCardToBudget does not split a markdown escape', () => {
|
|
819
815
|
/**
|
|
820
|
-
* Build a narrative line
|
|
821
|
-
*
|
|
822
|
-
*
|
|
823
|
-
* Strategy: fill with 'x' characters up to a budget, then append an entity
|
|
824
|
-
* character so the entity starts right where the slice would land.
|
|
816
|
+
* Build a narrative line whose markdown-special character lands at roughly
|
|
817
|
+
* the budget boundary so a naive escaped-string slice would cut a `\*`
|
|
818
|
+
* escape in half.
|
|
825
819
|
*/
|
|
826
|
-
function
|
|
827
|
-
// The fitter computes sliceAt ≈ charBudget - tagOverhead - closingTag.length.
|
|
828
|
-
// After the "→ " prefix (2 chars) and <b>…</b> wrapper (7 chars total overhead
|
|
829
|
-
// in the old code), the inner slice window is roughly charBudget - 9.
|
|
830
|
-
// Place the entity character so it lands at the very start of where the
|
|
831
|
-
// naive slice would begin — i.e., fill with (charBudget - 9) 'x's then '&'.
|
|
820
|
+
function buildEscapeBoundaryLine(special: string, charBudget: number): string {
|
|
832
821
|
const fillLen = Math.max(0, charBudget - 9)
|
|
833
|
-
return 'x'.repeat(fillLen) +
|
|
822
|
+
return 'x'.repeat(fillLen) + special + 'y'.repeat(50)
|
|
834
823
|
}
|
|
835
824
|
|
|
836
|
-
it('
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
expect(line.length).toBeGreaterThan(100) // sanity: line is substantial
|
|
825
|
+
it("'*' at the budget boundary: output is valid markdown (balanced **, no dangling \\)", () => {
|
|
826
|
+
const line = buildEscapeBoundaryLine('*', 4096)
|
|
827
|
+
expect(line.length).toBeGreaterThan(100)
|
|
840
828
|
|
|
841
829
|
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
842
830
|
|
|
843
831
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
844
|
-
expect(
|
|
845
|
-
//
|
|
846
|
-
expect(out).not.toMatch(/&$/) // incomplete & at end
|
|
847
|
-
expect(out).not.toMatch(/&am[^p]/) // &am followed by non-p (e.g. &amy)
|
|
848
|
-
expect(out).not.toMatch(/&[lg]t?[^;]/) // &l, < without semicolon
|
|
832
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
833
|
+
expect(out).not.toMatch(/\\$/) // no dangling escape backslash at the end
|
|
849
834
|
})
|
|
850
835
|
|
|
851
|
-
it('
|
|
852
|
-
const line =
|
|
836
|
+
it("'_' at the budget boundary: output is valid markdown", () => {
|
|
837
|
+
const line = buildEscapeBoundaryLine('_', 4096)
|
|
853
838
|
|
|
854
839
|
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
855
840
|
|
|
856
841
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
857
|
-
expect(
|
|
858
|
-
expect(out).not.toMatch(
|
|
859
|
-
expect(out).not.toMatch(/&l[^t;]/) // &l followed by non-t
|
|
842
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
843
|
+
expect(out).not.toMatch(/\\$/)
|
|
860
844
|
})
|
|
861
845
|
|
|
862
|
-
it('
|
|
863
|
-
const line =
|
|
846
|
+
it("'`' (code span) at the budget boundary: output is valid markdown", () => {
|
|
847
|
+
const line = buildEscapeBoundaryLine('`', 4096)
|
|
864
848
|
|
|
865
849
|
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
866
850
|
|
|
867
851
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
868
|
-
expect(
|
|
869
|
-
expect(out).not.toMatch(
|
|
870
|
-
expect(out).not.toMatch(/&g[^t;]/) // &g followed by non-t
|
|
852
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
853
|
+
expect(out).not.toMatch(/\\$/)
|
|
871
854
|
})
|
|
872
855
|
|
|
873
|
-
it('
|
|
874
|
-
// Mix
|
|
875
|
-
const chunk = '
|
|
856
|
+
it('special-dense line (* _ ` ~ interleaved): output ≤ budget and valid markdown', () => {
|
|
857
|
+
// Mix markdown specials throughout so any slice position is dangerous.
|
|
858
|
+
const chunk = '*' + 'x'.repeat(3) + '_' + 'y'.repeat(3) + '`' + 'z'.repeat(3)
|
|
876
859
|
const line = chunk.repeat(500) // ~10k chars raw → well over budget after escape
|
|
877
860
|
expect(line.length).toBeGreaterThan(4000)
|
|
878
861
|
|
|
879
862
|
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
880
863
|
|
|
881
864
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
882
|
-
expect(
|
|
865
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
866
|
+
expect(out).not.toMatch(/\\$/)
|
|
883
867
|
})
|
|
884
868
|
|
|
885
|
-
it('single
|
|
886
|
-
|
|
887
|
-
// must handle without infinite-looping or returning empty content.
|
|
888
|
-
// Build a huge line: filler xs then '&' then more xs
|
|
889
|
-
const line = 'x'.repeat(3000) + '&' + 'x'.repeat(1000)
|
|
869
|
+
it('single special alone in a huge line: valid markdown and within budget', () => {
|
|
870
|
+
const line = 'x'.repeat(3000) + '*' + 'x'.repeat(1000)
|
|
890
871
|
expect(line.length).toBeGreaterThan(4000)
|
|
891
872
|
|
|
892
873
|
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
893
874
|
|
|
894
875
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
895
|
-
expect(
|
|
876
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
877
|
+
expect(out).not.toMatch(/\\$/)
|
|
896
878
|
})
|
|
897
879
|
})
|
|
898
880
|
|
|
@@ -913,14 +895,14 @@ describe('header row + rolling overflow survive in the unified worker render', (
|
|
|
913
895
|
)
|
|
914
896
|
const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
|
|
915
897
|
|
|
916
|
-
expect(out).toContain('🛠
|
|
898
|
+
expect(out).toContain('🛠 **Worker**')
|
|
917
899
|
// Unified running status line.
|
|
918
|
-
expect(out).toContain('
|
|
900
|
+
expect(out).toContain('_10s · 7 tools_')
|
|
919
901
|
expect(out).toContain('7 tools')
|
|
920
902
|
// Every rendered line was clipped to STATUS_LINE_MAX → output well within budget.
|
|
921
903
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
922
904
|
// Newest bullet is the live → step.
|
|
923
|
-
expect(out).toContain('
|
|
905
|
+
expect(out).toContain('**→ final short step**')
|
|
924
906
|
})
|
|
925
907
|
|
|
926
908
|
it('a "+N earlier…" rolling marker appears when more than STATUS_ROLLING_LINES lines render', () => {
|
|
@@ -929,11 +911,11 @@ describe('header row + rolling overflow survive in the unified worker render', (
|
|
|
929
911
|
|
|
930
912
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
931
913
|
expect(out).toContain('earlier…')
|
|
932
|
-
expect(out).toContain('🛠
|
|
933
|
-
expect(out).toContain('
|
|
914
|
+
expect(out).toContain('🛠 **Worker**')
|
|
915
|
+
expect(out).toContain('_10s · ')
|
|
934
916
|
})
|
|
935
917
|
|
|
936
|
-
it('back-compat path (latestSummary only) still shows a step bullet, clipped + valid
|
|
918
|
+
it('back-compat path (latestSummary only) still shows a step bullet, clipped + valid markdown', () => {
|
|
937
919
|
const hugeSummary = 'deploy service && run migrations && verify health checks '.repeat(80)
|
|
938
920
|
expect(hugeSummary.length).toBeGreaterThan(4000)
|
|
939
921
|
|
|
@@ -944,8 +926,8 @@ describe('header row + rolling overflow survive in the unified worker render', (
|
|
|
944
926
|
expect(out.length).toBeLessThanOrEqual(4096)
|
|
945
927
|
const hasBullet = out.includes('→') || out.includes('✓')
|
|
946
928
|
expect(hasBullet).toBe(true)
|
|
947
|
-
expect(out).toContain('🛠
|
|
948
|
-
expect(out).toContain('
|
|
949
|
-
expect(
|
|
929
|
+
expect(out).toContain('🛠 **Worker**')
|
|
930
|
+
expect(out).toContain('_10s · ')
|
|
931
|
+
expect(isValidWorkerMarkdown(out)).toBe(true)
|
|
950
932
|
})
|
|
951
933
|
})
|
|
@@ -34,22 +34,19 @@
|
|
|
34
34
|
* (smoking gun, delve, etc.). Substituting those mid-clause risks
|
|
35
35
|
* semantic loss, so they stay with the prompt-side voice guidance.
|
|
36
36
|
*
|
|
37
|
-
* Pipeline integration. Apply
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* collapses cleanly).
|
|
37
|
+
* Pipeline integration. Apply on the original model text BEFORE it is
|
|
38
|
+
* handed to the rich-message path, so the scrub runs on the raw markdown
|
|
39
|
+
* (not on a partly-rendered body). Apply BEFORE outboundDedup.check so
|
|
40
|
+
* dedup keys see the post-scrub content (same text from a retry collapses
|
|
41
|
+
* cleanly).
|
|
43
42
|
*
|
|
44
43
|
* Code-region awareness. The scrubber MUST preserve dashes inside:
|
|
45
44
|
* - fenced code blocks: ```lang\n...\n```
|
|
46
45
|
* - inline code: `...`
|
|
47
|
-
* - explicit Telegram HTML code tags: <code>...</code>, <pre>...</pre>
|
|
48
46
|
* - URLs (rare to contain em-dashes, but technically valid IDN)
|
|
49
|
-
* The strategy is to park each protected region with a sentinel,
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* format.ts:254-272.
|
|
47
|
+
* The strategy is to park each protected region with a sentinel, scrub the
|
|
48
|
+
* rest, then restore — the scrub is fully self-contained (it does its own
|
|
49
|
+
* code-region parking; it does not depend on any render pass).
|
|
53
50
|
*
|
|
54
51
|
* Kill switch. `SWITCHROOM_DISABLE_VOICE_SCRUB=1` returns the input
|
|
55
52
|
* unchanged and reports zero replacements. Same shape every other
|