switchroom 0.15.44 → 0.16.4
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 +122 -88
- package/dist/auth-broker/index.js +463 -177
- package/dist/cli/autoaccept-poll.js +4842 -35
- package/dist/cli/drive-write-pretool.mjs +17 -14
- package/dist/cli/notion-write-pretool.mjs +117 -86
- package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
- package/dist/cli/self-improve-stop.mjs +428 -0
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +3249 -1241
- package/dist/cli/ui/index.html +1 -1
- package/dist/host-control/main.js +2833 -355
- package/dist/vault/approvals/kernel-server.js +7482 -7439
- package/dist/vault/broker/server.js +11315 -11272
- package/examples/minimal.yaml +1 -0
- package/examples/switchroom.yaml +1 -0
- package/package.json +3 -3
- package/profiles/_base/start.sh.hbs +88 -1
- package/profiles/_shared/execution-discipline.md.hbs +18 -0
- package/profiles/default/CLAUDE.md.hbs +3 -22
- package/telegram-plugin/.claude-plugin/plugin.json +2 -2
- package/telegram-plugin/answer-stream-flag.ts +12 -49
- package/telegram-plugin/answer-stream.ts +5 -150
- package/telegram-plugin/auth-snapshot-format.ts +280 -48
- package/telegram-plugin/auto-fallback-fleet.ts +44 -1
- package/telegram-plugin/context-exhaustion.ts +12 -0
- package/telegram-plugin/demo-mask.ts +154 -0
- package/telegram-plugin/dist/bridge/bridge.js +167 -124
- package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
- package/telegram-plugin/dist/server.js +215 -172
- package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
- package/telegram-plugin/draft-stream.ts +47 -410
- package/telegram-plugin/final-answer-detect.ts +17 -12
- package/telegram-plugin/fleet-fallback-resume.ts +131 -0
- package/telegram-plugin/format.ts +56 -19
- package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
- package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
- package/telegram-plugin/gateway/auth-command.ts +70 -14
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
- package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
- package/telegram-plugin/gateway/current-turn-map.ts +188 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
- package/telegram-plugin/gateway/effort-command.ts +8 -3
- package/telegram-plugin/gateway/emission-authority.ts +369 -0
- package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
- package/telegram-plugin/gateway/gateway.ts +1837 -291
- package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
- package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
- package/telegram-plugin/gateway/represent-guard.ts +72 -0
- package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
- package/telegram-plugin/gateway/status-surface-log.ts +14 -3
- package/telegram-plugin/history.ts +33 -11
- package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
- package/telegram-plugin/issues-card.ts +4 -0
- package/telegram-plugin/model-unavailable.ts +124 -0
- package/telegram-plugin/narrative-dedup.ts +69 -0
- package/telegram-plugin/over-ping-safety-net.ts +70 -4
- package/telegram-plugin/package.json +3 -3
- package/telegram-plugin/pending-work-progress.ts +12 -0
- package/telegram-plugin/permission-rule.ts +32 -5
- package/telegram-plugin/permission-title.ts +152 -9
- package/telegram-plugin/quota-check.ts +13 -0
- package/telegram-plugin/quota-watch.ts +135 -7
- package/telegram-plugin/registry/turns-schema.test.ts +24 -0
- package/telegram-plugin/registry/turns-schema.ts +9 -0
- package/telegram-plugin/runtime-metrics.ts +13 -0
- package/telegram-plugin/session-tail.ts +96 -11
- package/telegram-plugin/silence-poke.ts +170 -24
- package/telegram-plugin/slot-banner-driver.ts +3 -0
- package/telegram-plugin/status-no-truncate.ts +44 -0
- package/telegram-plugin/status-reactions.ts +20 -3
- package/telegram-plugin/stream-controller.ts +4 -23
- package/telegram-plugin/stream-reply-handler.ts +6 -24
- package/telegram-plugin/streaming-metrics.ts +91 -0
- package/telegram-plugin/subagent-watcher.ts +212 -66
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
- package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
- package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
- package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
- package/telegram-plugin/tests/answer-stream.test.ts +2 -411
- package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
- package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
- package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
- package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
- package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
- package/telegram-plugin/tests/demo-mask.test.ts +127 -0
- package/telegram-plugin/tests/draft-stream.test.ts +0 -827
- package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
- package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
- package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
- package/telegram-plugin/tests/feed-survival.test.ts +526 -0
- package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
- package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
- package/telegram-plugin/tests/history.test.ts +60 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
- package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
- package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
- package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
- package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
- package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
- package/telegram-plugin/tests/permission-rule.test.ts +17 -0
- package/telegram-plugin/tests/permission-title.test.ts +206 -17
- package/telegram-plugin/tests/quota-watch.test.ts +252 -9
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
- package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
- package/telegram-plugin/tests/represent-guard.test.ts +162 -0
- package/telegram-plugin/tests/session-tail.test.ts +147 -3
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
- package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
- package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
- package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
- package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
- package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
- package/telegram-plugin/tests/telegram-format.test.ts +101 -6
- package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
- package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
- package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
- package/telegram-plugin/tests/tool-labels.test.ts +67 -0
- package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
- package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
- package/telegram-plugin/tests/welcome-text.test.ts +32 -3
- package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
- package/telegram-plugin/tool-activity-summary.ts +375 -58
- package/telegram-plugin/turn-liveness-floor.ts +240 -0
- package/telegram-plugin/uat/assertions.ts +115 -0
- package/telegram-plugin/uat/driver.ts +68 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
- package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
- package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
- package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
- package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
- package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
- package/telegram-plugin/welcome-text.ts +13 -1
- package/telegram-plugin/worker-activity-feed.ts +157 -82
- package/telegram-plugin/draft-transport.ts +0 -122
- package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
- package/telegram-plugin/tests/draft-transport.test.ts +0 -211
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type WorkerActivityView,
|
|
7
7
|
type BotApiForWorkerFeed,
|
|
8
8
|
} from '../worker-activity-feed.js'
|
|
9
|
+
import { STATUS_ROLLING_LINES, STATUS_LINE_MAX } from '../status-no-truncate.js'
|
|
9
10
|
|
|
10
11
|
describe('isWorkerActivityFeedEnabled (default ON)', () => {
|
|
11
12
|
it('defaults to true when the env var is unset', () => {
|
|
@@ -75,7 +76,8 @@ describe('renderWorkerActivity', () => {
|
|
|
75
76
|
it('renders the native header + running status + step feed', () => {
|
|
76
77
|
const out = renderWorkerActivity(view())
|
|
77
78
|
expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
|
|
78
|
-
|
|
79
|
+
// Unified header: running shows "<elapsed> · N tools" (no "running ·" word).
|
|
80
|
+
expect(out).toContain('<i>10s · 3 tools</i>')
|
|
79
81
|
expect(out).toContain('3 tools')
|
|
80
82
|
// No narrativeLines → the latestSummary surfaces as the newest `→` step.
|
|
81
83
|
expect(out).toContain('<b>→ scanning vendor pages</b>')
|
|
@@ -108,20 +110,41 @@ describe('renderWorkerActivity', () => {
|
|
|
108
110
|
view({ state: 'done', toolCount: 5, latestSummary: 'PR #21 opened' }),
|
|
109
111
|
)
|
|
110
112
|
expect(out).toContain('🛠 <b>Worker</b> · <i>research competitors</i>')
|
|
111
|
-
|
|
113
|
+
// Unified done header: "done · N tools · <elapsed>".
|
|
114
|
+
expect(out).toContain('<i>done · 5 tools · ')
|
|
112
115
|
expect(out).toContain('─────')
|
|
113
116
|
expect(out).toContain('✅ <i>PR #21 opened</i>')
|
|
117
|
+
// latestSummary is the RESULT on the finished path, never also a step.
|
|
118
|
+
expect(out).not.toContain('<i>✓ PR #21 opened</i>')
|
|
114
119
|
})
|
|
115
120
|
|
|
116
121
|
it('renders a failed terminal recap', () => {
|
|
117
122
|
const out = renderWorkerActivity(view({ state: 'failed', latestSummary: 'blew up' }))
|
|
118
|
-
|
|
123
|
+
// The header word reflects the failure (`failed · …`) AND the ⚠️ result
|
|
124
|
+
// block carries the detail — the two signals are complementary.
|
|
125
|
+
expect(out).toContain('<i>failed · ')
|
|
126
|
+
expect(out).not.toContain('<i>done · ')
|
|
119
127
|
expect(out).toContain('⚠️ <i>blew up</i>')
|
|
120
128
|
})
|
|
121
129
|
|
|
130
|
+
it('a failed worker with EMPTY result is never byte-identical to a done worker (regression: #2553 failure-honesty)', () => {
|
|
131
|
+
// Detail-less terminal error: resultText === '' (subagent-watcher path).
|
|
132
|
+
const failed = renderWorkerActivity(view({ state: 'failed', latestSummary: '' }))
|
|
133
|
+
const done = renderWorkerActivity(view({ state: 'done', latestSummary: '' }))
|
|
134
|
+
// (a) The two renders must differ — the failure signal cannot vanish.
|
|
135
|
+
expect(failed).not.toBe(done)
|
|
136
|
+
// (b) The failed render carries a visible failure marker even with no
|
|
137
|
+
// result block (the `failed` header word and/or the ⚠️ emoji).
|
|
138
|
+
expect(failed.includes('failed') || failed.includes('⚠️')).toBe(true)
|
|
139
|
+
expect(failed).toContain('<i>failed · ')
|
|
140
|
+
// The done render must NOT read as failed.
|
|
141
|
+
expect(done).toContain('<i>done · ')
|
|
142
|
+
expect(done).not.toContain('failed')
|
|
143
|
+
})
|
|
144
|
+
|
|
122
145
|
it('omits the rule + result line when the terminal result is empty', () => {
|
|
123
146
|
const out = renderWorkerActivity(view({ state: 'done', latestSummary: ' ' }))
|
|
124
|
-
expect(out).toContain('
|
|
147
|
+
expect(out).toContain('<i>done · ')
|
|
125
148
|
expect(out).not.toContain('─────')
|
|
126
149
|
})
|
|
127
150
|
|
|
@@ -153,15 +176,17 @@ describe('renderWorkerActivity', () => {
|
|
|
153
176
|
expect(stepCount(out)).toBe(2)
|
|
154
177
|
})
|
|
155
178
|
|
|
156
|
-
it('shows
|
|
157
|
-
const
|
|
179
|
+
it('shows a "+N earlier…" header when the feed exceeds STATUS_ROLLING_LINES (worker surface)', () => {
|
|
180
|
+
const total = STATUS_ROLLING_LINES + 3
|
|
181
|
+
const lines = Array.from({ length: total }, (_, i) => `step ${i + 1}`)
|
|
158
182
|
const out = renderWorkerActivity(view({ narrativeLines: lines }))
|
|
159
|
-
expect(out).toContain(
|
|
160
|
-
expect(out).not.toContain('step 1')
|
|
161
|
-
|
|
162
|
-
expect(out).toContain(
|
|
163
|
-
|
|
164
|
-
|
|
183
|
+
expect(out).toContain(`<i>✓ +${total - STATUS_ROLLING_LINES} earlier…</i>`)
|
|
184
|
+
expect(out).not.toContain('step 1<')
|
|
185
|
+
const firstVisible = total - STATUS_ROLLING_LINES + 1
|
|
186
|
+
expect(out).toContain(`<i>✓ step ${firstVisible}</i>`)
|
|
187
|
+
expect(out).toContain(`<b>→ step ${total}</b>`)
|
|
188
|
+
// STATUS_ROLLING_LINES visible step lines (the overflow header isn't a step).
|
|
189
|
+
expect(out.match(/step \d/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
|
|
165
190
|
})
|
|
166
191
|
|
|
167
192
|
it('strips Markdown markup from narrative + description + result', () => {
|
|
@@ -278,7 +303,7 @@ describe('createWorkerActivityFeed', () => {
|
|
|
278
303
|
clock = 10_500 // well within the throttle window
|
|
279
304
|
await feed.finish('w1', view({ state: 'done', toolCount: 5 }))
|
|
280
305
|
expect(bot.edits).toHaveLength(1)
|
|
281
|
-
expect(bot.edits[0].text).toContain('
|
|
306
|
+
expect(bot.edits[0].text).toContain('<i>done · 5 tools · ')
|
|
282
307
|
// finish forgets the worker.
|
|
283
308
|
expect(feed.has('w1')).toBe(false)
|
|
284
309
|
expect(feed.size).toBe(0)
|
|
@@ -391,23 +416,22 @@ describe('createWorkerActivityFeed', () => {
|
|
|
391
416
|
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(1)
|
|
392
417
|
})
|
|
393
418
|
|
|
394
|
-
it('
|
|
419
|
+
it('rolls the narrative block to the last STATUS_ROLLING_LINES lines', async () => {
|
|
395
420
|
const bot = makeFakeBot()
|
|
396
421
|
let clock = 10_000
|
|
397
422
|
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
398
423
|
|
|
399
|
-
|
|
424
|
+
const total = 9
|
|
425
|
+
for (let i = 1; i <= total; i++) {
|
|
400
426
|
clock += 1000
|
|
401
|
-
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `
|
|
427
|
+
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `ln-${String(i).padStart(3, '0')}` }))
|
|
402
428
|
}
|
|
403
429
|
|
|
404
430
|
const last = bot.edits.at(-1)!
|
|
405
|
-
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(
|
|
406
|
-
|
|
407
|
-
expect(last.text).not.toContain(
|
|
408
|
-
expect(last.text).
|
|
409
|
-
expect(last.text).toContain('line 4')
|
|
410
|
-
expect(last.text).toContain('line 9')
|
|
431
|
+
expect(last.text.match(/[✓→]/g) ?? []).toHaveLength(STATUS_ROLLING_LINES)
|
|
432
|
+
const firstVisible = total - STATUS_ROLLING_LINES + 1
|
|
433
|
+
for (let i = 1; i < firstVisible; i++) expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
434
|
+
for (let i = firstVisible; i <= total; i++) expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
411
435
|
})
|
|
412
436
|
|
|
413
437
|
it('grows the narrative even while throttled (line surfaces on next edit)', async () => {
|
|
@@ -501,3 +525,427 @@ describe('createWorkerActivityFeed — log sink', () => {
|
|
|
501
525
|
expect(logs.some((l) => l.startsWith('worker-feed: paint'))).toBe(false)
|
|
502
526
|
})
|
|
503
527
|
})
|
|
528
|
+
|
|
529
|
+
// ─── Rolling window + STATUS_LINE_MAX (flag retired) ─────────────────────────
|
|
530
|
+
// Single mode: last STATUS_ROLLING_LINES lines render in full (clipped per-line
|
|
531
|
+
// at STATUS_LINE_MAX=200), overflow → `+N earlier…` header on the worker surface,
|
|
532
|
+
// char-budget backstop is the only wire-limit ceiling.
|
|
533
|
+
|
|
534
|
+
describe('rolling window + STATUS_LINE_MAX — renderWorkerActivity', () => {
|
|
535
|
+
it('with 12 narrative lines, exactly the last STATUS_ROLLING_LINES render + a +N earlier header', () => {
|
|
536
|
+
const narrativeLines = Array.from({ length: 12 }, (_, i) => `stp-${String(i + 1).padStart(3, '0')}`)
|
|
537
|
+
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
538
|
+
const firstVisible = 12 - STATUS_ROLLING_LINES + 1
|
|
539
|
+
for (let i = firstVisible; i <= 12; i++) {
|
|
540
|
+
expect(out).toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
541
|
+
}
|
|
542
|
+
for (let i = 1; i < firstVisible; i++) {
|
|
543
|
+
expect(out).not.toContain(`stp-${String(i).padStart(3, '0')}`)
|
|
544
|
+
}
|
|
545
|
+
// Overflow header now appears on the worker surface too.
|
|
546
|
+
expect(out).toContain(`<i>✓ +${12 - STATUS_ROLLING_LINES} earlier…</i>`)
|
|
547
|
+
expect(out).toContain('<b>→ stp-012</b>')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('STATUS_LINE_MAX=200: a 250-char line is clipped to 200 with a trailing …', () => {
|
|
551
|
+
const longLine = 'a'.repeat(250)
|
|
552
|
+
const out = renderWorkerActivity(view({ narrativeLines: [longLine] }))
|
|
553
|
+
expect(out).toContain('…')
|
|
554
|
+
expect(out).not.toContain(longLine)
|
|
555
|
+
expect(out).toContain('a'.repeat(STATUS_LINE_MAX - 1) + '…')
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
it('a line at exactly STATUS_LINE_MAX is NOT clipped', () => {
|
|
559
|
+
const exact = 'b'.repeat(STATUS_LINE_MAX)
|
|
560
|
+
const out = renderWorkerActivity(view({ narrativeLines: [exact] }))
|
|
561
|
+
expect(out).toContain(exact)
|
|
562
|
+
expect(out).not.toContain('…')
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it('no "+N earlier…" overflow header when the feed fits the window', () => {
|
|
566
|
+
const narrativeLines = Array.from({ length: STATUS_ROLLING_LINES }, (_, i) => `step ${i + 1}`)
|
|
567
|
+
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
568
|
+
expect(out).not.toContain('earlier…')
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
it('pathologically oversized body: char-budget backstop fires, output ≤ 4096 chars', () => {
|
|
572
|
+
const bigLine = 'z'.repeat(900)
|
|
573
|
+
const narrativeLines = Array.from({ length: STATUS_ROLLING_LINES }, () => bigLine)
|
|
574
|
+
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
575
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
576
|
+
const hasBullet = out.includes('→') || out.includes('✓')
|
|
577
|
+
expect(hasBullet).toBe(true)
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
describe('rolling window — createWorkerActivityFeed narrative accumulation', () => {
|
|
582
|
+
it('with 12 pushes, only the last STATUS_ROLLING_LINES appear in the render', async () => {
|
|
583
|
+
const bot = makeFakeBot()
|
|
584
|
+
let clock = 10_000
|
|
585
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
586
|
+
|
|
587
|
+
for (let i = 1; i <= 12; i++) {
|
|
588
|
+
clock += 1000
|
|
589
|
+
await feed.update('w1', 'chat', view({ toolCount: i, latestSummary: `ln-${String(i).padStart(3, '0')}` }))
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const last = bot.edits.at(-1)!
|
|
593
|
+
const firstVisible = 12 - STATUS_ROLLING_LINES + 1
|
|
594
|
+
for (let i = firstVisible; i <= 12; i++) {
|
|
595
|
+
expect(last.text).toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
596
|
+
}
|
|
597
|
+
for (let i = 1; i < firstVisible; i++) {
|
|
598
|
+
expect(last.text).not.toContain(`ln-${String(i).padStart(3, '0')}`)
|
|
599
|
+
}
|
|
600
|
+
// The manager caps the in-memory narrative at STATUS_ROLLING_LINES, so the
|
|
601
|
+
// render never sees overflow — no "+N earlier…" marker on the manager path
|
|
602
|
+
// (it surfaces only on direct renderWorkerActivity calls with >5 lines).
|
|
603
|
+
expect(last.text).not.toContain('earlier…')
|
|
604
|
+
expect(last.text).toContain('<b>→ ln-012</b>')
|
|
605
|
+
})
|
|
606
|
+
})
|
|
607
|
+
|
|
608
|
+
// ─── Worker heartbeat (option a — suffix-only, never opens a new message) ─────
|
|
609
|
+
|
|
610
|
+
describe('createWorkerActivityFeed — heartbeat', () => {
|
|
611
|
+
it('(i) a tick fires a re-render with a climbing · Ns suffix on a stale worker', async () => {
|
|
612
|
+
const bot = makeFakeBot()
|
|
613
|
+
let clock = 10_000
|
|
614
|
+
const feed = createWorkerActivityFeed({
|
|
615
|
+
bot,
|
|
616
|
+
now: () => clock,
|
|
617
|
+
minEditIntervalMs: 2500,
|
|
618
|
+
heartbeatTickMs: 6000,
|
|
619
|
+
// No real timer: drive ticks manually.
|
|
620
|
+
setInterval: () => 1,
|
|
621
|
+
clearInterval: () => {},
|
|
622
|
+
})
|
|
623
|
+
// First paint at elapsed 0 (firstPaintMin default 8000 — use 9000).
|
|
624
|
+
clock = 19_000
|
|
625
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 9000, latestSummary: 'pulling data' }))
|
|
626
|
+
expect(bot.sent).toHaveLength(1)
|
|
627
|
+
const dispatchAt = clock - 9000
|
|
628
|
+
|
|
629
|
+
// Advance past the staleness window so the heartbeat ticks.
|
|
630
|
+
clock = 26_000 // lastEditAt(19000) + 7000 ≥ heartbeatTickMs(6000) and ≥ minEditInterval
|
|
631
|
+
feed.heartbeatTick()
|
|
632
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' })).catch(() => {})
|
|
633
|
+
// Drain the chain.
|
|
634
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 16_000, latestSummary: 'pulling data' }))
|
|
635
|
+
const edit1 = bot.edits.find((e) => /· \d+s<\/b>/.test(e.text))
|
|
636
|
+
expect(edit1).toBeDefined()
|
|
637
|
+
// The suffix reflects the LIVE elapsed (now - dispatchAt), not the stale view.
|
|
638
|
+
expect(edit1!.text).toContain(`· ${Math.floor((26_000 - dispatchAt) / 1000)}s`)
|
|
639
|
+
})
|
|
640
|
+
|
|
641
|
+
it('(ii) respects a 429 cooldown — no edit while cooldownUntil is in the future', async () => {
|
|
642
|
+
const bot = makeFakeBot()
|
|
643
|
+
let clock = 10_000
|
|
644
|
+
const feed = createWorkerActivityFeed({
|
|
645
|
+
bot,
|
|
646
|
+
now: () => clock,
|
|
647
|
+
minEditIntervalMs: 0,
|
|
648
|
+
heartbeatTickMs: 6000,
|
|
649
|
+
firstPaintMinMs: 0,
|
|
650
|
+
setInterval: () => 1,
|
|
651
|
+
clearInterval: () => {},
|
|
652
|
+
})
|
|
653
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, latestSummary: 'go' }))
|
|
654
|
+
expect(bot.sent).toHaveLength(1)
|
|
655
|
+
// Induce a cooldown by failing the next edit with a 429.
|
|
656
|
+
clock = 20_000
|
|
657
|
+
bot.failNextEditWith = { error_code: 429, parameters: { retry_after: 30 } }
|
|
658
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 11_000, latestSummary: 'changed' }))
|
|
659
|
+
const editsAfterCooldown = bot.edits.length
|
|
660
|
+
// Tick while still inside the cooldown window → no new edit.
|
|
661
|
+
clock = 27_000
|
|
662
|
+
feed.heartbeatTick()
|
|
663
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 18_000, latestSummary: 'changed' }))
|
|
664
|
+
expect(bot.edits.length).toBe(editsAfterCooldown)
|
|
665
|
+
})
|
|
666
|
+
|
|
667
|
+
it('(iii) does not edit after the handle is removed on finish', async () => {
|
|
668
|
+
const bot = makeFakeBot()
|
|
669
|
+
let clock = 10_000
|
|
670
|
+
const feed = createWorkerActivityFeed({
|
|
671
|
+
bot,
|
|
672
|
+
now: () => clock,
|
|
673
|
+
minEditIntervalMs: 0,
|
|
674
|
+
heartbeatTickMs: 6000,
|
|
675
|
+
setInterval: () => 1,
|
|
676
|
+
clearInterval: () => {},
|
|
677
|
+
})
|
|
678
|
+
await feed.update('w1', 'chat', view({ latestSummary: 'go' }))
|
|
679
|
+
clock = 20_000
|
|
680
|
+
await feed.finish('w1', view({ state: 'done', toolCount: 2 }))
|
|
681
|
+
expect(feed.has('w1')).toBe(false)
|
|
682
|
+
const editsBefore = bot.edits.length
|
|
683
|
+
clock = 30_000
|
|
684
|
+
feed.heartbeatTick()
|
|
685
|
+
// No handle → tick is a no-op, no further edit.
|
|
686
|
+
expect(bot.edits.length).toBe(editsBefore)
|
|
687
|
+
})
|
|
688
|
+
|
|
689
|
+
it('(iv) stop() clears the interval and a tick on empty handles is a no-op (no leak)', () => {
|
|
690
|
+
let cleared = false
|
|
691
|
+
const bot = makeFakeBot()
|
|
692
|
+
let clock = 10_000
|
|
693
|
+
const feed = createWorkerActivityFeed({
|
|
694
|
+
bot,
|
|
695
|
+
now: () => clock,
|
|
696
|
+
setInterval: () => 1,
|
|
697
|
+
clearInterval: () => { cleared = true },
|
|
698
|
+
})
|
|
699
|
+
// No handles yet → tick does nothing and does not throw.
|
|
700
|
+
expect(() => feed.heartbeatTick()).not.toThrow()
|
|
701
|
+
feed.stop()
|
|
702
|
+
expect(cleared).toBe(true)
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
it('(v) respects minEditInterval — a tick inside the throttle window does not edit', async () => {
|
|
706
|
+
const bot = makeFakeBot()
|
|
707
|
+
let clock = 10_000
|
|
708
|
+
const feed = createWorkerActivityFeed({
|
|
709
|
+
bot,
|
|
710
|
+
now: () => clock,
|
|
711
|
+
minEditIntervalMs: 2500,
|
|
712
|
+
heartbeatTickMs: 6000,
|
|
713
|
+
firstPaintMinMs: 0,
|
|
714
|
+
setInterval: () => 1,
|
|
715
|
+
clearInterval: () => {},
|
|
716
|
+
})
|
|
717
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 1000, latestSummary: 'go' }))
|
|
718
|
+
expect(bot.sent).toHaveLength(1)
|
|
719
|
+
const editsBefore = bot.edits.length
|
|
720
|
+
// Tick only 1000ms after the paint — inside minEditInterval (2500) → no edit.
|
|
721
|
+
clock = 11_000
|
|
722
|
+
feed.heartbeatTick()
|
|
723
|
+
await feed.update('w1', 'chat', view({ elapsedMs: 2000, latestSummary: 'go' })).catch(() => {})
|
|
724
|
+
expect(bot.edits.length).toBe(editsBefore)
|
|
725
|
+
})
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
// ─── Extreme-edge: single oversized narrative line (no-truncate ON) ──────────
|
|
729
|
+
// Reproduces the bug where accumulateNarrative's char-budget splice would push
|
|
730
|
+
// the oversized line then immediately splice it out, making the narrative empty
|
|
731
|
+
// and the step vanish from the rendered output.
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Cheap valid-HTML checker: balanced <b>/<i> tags and no dangling/partial entity.
|
|
735
|
+
* Checks for partial entities (e.g. `&am`, `&l`, `&` without trailing `;`)
|
|
736
|
+
* as produced by naive slicing of already-escaped HTML at an entity boundary.
|
|
737
|
+
*/
|
|
738
|
+
function isValidWorkerHtml(s: string): boolean {
|
|
739
|
+
const bOpen = (s.match(/<b>/g) ?? []).length
|
|
740
|
+
const bClose = (s.match(/<\/b>/g) ?? []).length
|
|
741
|
+
const iOpen = (s.match(/<i>/g) ?? []).length
|
|
742
|
+
const iClose = (s.match(/<\/i>/g) ?? []).length
|
|
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
|
+
}
|
|
755
|
+
return true
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
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 HTML', async () => {
|
|
760
|
+
const bot = makeFakeBot()
|
|
761
|
+
let clock = 10_000
|
|
762
|
+
const feed = createWorkerActivityFeed({ bot, now: () => clock, minEditIntervalMs: 0 })
|
|
763
|
+
|
|
764
|
+
// Build a latestSummary > STATUS_CARD_CHAR_BUDGET chars with && and special chars.
|
|
765
|
+
const base = 'run build && deploy && notify with <args> & flags=1 '
|
|
766
|
+
const hugeStep = base.repeat(80) + '&&'
|
|
767
|
+
expect(hugeStep.length).toBeGreaterThan(4000)
|
|
768
|
+
|
|
769
|
+
await feed.update('w1', 'chat', view({ toolCount: 1, latestSummary: hugeStep }))
|
|
770
|
+
expect(bot.sent).toHaveLength(1)
|
|
771
|
+
const out = bot.sent[0].text
|
|
772
|
+
|
|
773
|
+
// The step must NOT have vanished — some portion must appear.
|
|
774
|
+
// Since the step is huge it will be truncated, but the worker card itself
|
|
775
|
+
// must contain a step bullet (→ or ✓).
|
|
776
|
+
const hasBullet = out.includes('→') || out.includes('✓')
|
|
777
|
+
expect(hasBullet).toBe(true)
|
|
778
|
+
|
|
779
|
+
// Wire safety: output must be within the Telegram char budget.
|
|
780
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
781
|
+
|
|
782
|
+
// Valid HTML: balanced tags and no partial entity.
|
|
783
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
it('no-truncate ON: one ~4100-char step via renderWorkerActivity directly → ≤ budget and valid HTML', () => {
|
|
787
|
+
const base = 'compile && link && package && ship: action=deploy env=<prod> flag=1 '
|
|
788
|
+
const hugeStep = base.repeat(65)
|
|
789
|
+
expect(hugeStep.length).toBeGreaterThan(4000)
|
|
790
|
+
|
|
791
|
+
const out = renderWorkerActivity(view({ narrativeLines: [hugeStep] }))
|
|
792
|
+
|
|
793
|
+
// Step must be present in some form (truncated is fine, absent is not).
|
|
794
|
+
const hasBullet = out.includes('→') || out.includes('✓')
|
|
795
|
+
expect(hasBullet).toBe(true)
|
|
796
|
+
|
|
797
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
798
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
799
|
+
})
|
|
800
|
+
})
|
|
801
|
+
|
|
802
|
+
// ─── Bug 2: _fitWorkerBodyToCharBudget must not slice already-escaped HTML ───
|
|
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'.
|
|
809
|
+
//
|
|
810
|
+
// The fix mirrors _fitToCharBudget (tool-activity-summary.ts): truncate RAW
|
|
811
|
+
// content first, then escape, then wrap, re-checking post-escape because
|
|
812
|
+
// escaping can expand the string (&→& etc.).
|
|
813
|
+
//
|
|
814
|
+
// These tests place entity characters (&, <, >) at positions that, under the
|
|
815
|
+
// old naive slice, would produce exactly the broken fragments the issue
|
|
816
|
+
// identified (&am, &l). They assert the output is valid HTML and within budget.
|
|
817
|
+
|
|
818
|
+
describe('Bug 2 (#2506): _fitWorkerBodyToCharBudget does not split HTML entities', () => {
|
|
819
|
+
/**
|
|
820
|
+
* Build a narrative line that, after HTML-escaping, has the entity boundary
|
|
821
|
+
* at a precise position so a naive `escaped.slice(3, 3 + N)` would split it.
|
|
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.
|
|
825
|
+
*/
|
|
826
|
+
function buildEntityBoundaryLine(entityChar: string, charBudget: number): string {
|
|
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 '&'.
|
|
832
|
+
const fillLen = Math.max(0, charBudget - 9)
|
|
833
|
+
return 'x'.repeat(fillLen) + entityChar + 'y'.repeat(50)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
it('& at entity boundary: output is valid HTML (no &am, & without ; etc.)', () => {
|
|
837
|
+
// A line that places '&' right at the slice boundary so naive cut → &am
|
|
838
|
+
const line = buildEntityBoundaryLine('&', 4096)
|
|
839
|
+
expect(line.length).toBeGreaterThan(100) // sanity: line is substantial
|
|
840
|
+
|
|
841
|
+
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
842
|
+
|
|
843
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
844
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
845
|
+
// No partial entity fragments that the old code produced.
|
|
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
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('< at entity boundary: output is valid HTML (no &l, < without ; etc.)', () => {
|
|
852
|
+
const line = buildEntityBoundaryLine('<', 4096)
|
|
853
|
+
|
|
854
|
+
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
855
|
+
|
|
856
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
857
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
858
|
+
expect(out).not.toMatch(/<$/) // incomplete < at end
|
|
859
|
+
expect(out).not.toMatch(/&l[^t;]/) // &l followed by non-t
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
it('> at entity boundary: output is valid HTML (no &g, > without ; etc.)', () => {
|
|
863
|
+
const line = buildEntityBoundaryLine('>', 4096)
|
|
864
|
+
|
|
865
|
+
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
866
|
+
|
|
867
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
868
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
869
|
+
expect(out).not.toMatch(/>$/) // incomplete > at end
|
|
870
|
+
expect(out).not.toMatch(/&g[^t;]/) // &g followed by non-t
|
|
871
|
+
})
|
|
872
|
+
|
|
873
|
+
it('entity-dense line (& < > interleaved): output ≤ budget and valid HTML', () => {
|
|
874
|
+
// Mix entity characters throughout so any slice position is dangerous.
|
|
875
|
+
const chunk = '&' + 'x'.repeat(3) + '<' + 'y'.repeat(3) + '>' + 'z'.repeat(3)
|
|
876
|
+
const line = chunk.repeat(500) // ~10k chars raw → well over budget after escape
|
|
877
|
+
expect(line.length).toBeGreaterThan(4000)
|
|
878
|
+
|
|
879
|
+
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
880
|
+
|
|
881
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
882
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
883
|
+
})
|
|
884
|
+
|
|
885
|
+
it('single & alone in the line: valid HTML and within budget', () => {
|
|
886
|
+
// Regression: a one-char entity line is a degenerate case the while-loop
|
|
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)
|
|
890
|
+
expect(line.length).toBeGreaterThan(4000)
|
|
891
|
+
|
|
892
|
+
const out = renderWorkerActivity(view({ narrativeLines: [line] }))
|
|
893
|
+
|
|
894
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
895
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
896
|
+
})
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
// ─── Regression: header/status row + rolling overflow survive trimming ───────
|
|
900
|
+
//
|
|
901
|
+
// The unified renderer clips every line to STATUS_LINE_MAX (200) BEFORE the
|
|
902
|
+
// char-budget backstop, so ordinary "long step" turns fit the budget without
|
|
903
|
+
// dropping any bullets. The two-line header always survives, and a rolling
|
|
904
|
+
// "+N earlier…" marker appears when more than STATUS_ROLLING_LINES lines are
|
|
905
|
+
// rendered directly. The char-budget backstop (fitCardToBudget) is exercised
|
|
906
|
+
// only by genuinely pathological single oversized lines (covered elsewhere).
|
|
907
|
+
|
|
908
|
+
describe('header row + rolling overflow survive in the unified worker render', () => {
|
|
909
|
+
it('the two-line header survives even with many long lines (clipped to STATUS_LINE_MAX)', () => {
|
|
910
|
+
const bigLine = 'a'.repeat(700)
|
|
911
|
+
const narrativeLines = Array.from({ length: 6 }, (_, i) =>
|
|
912
|
+
i < 5 ? bigLine : 'final short step',
|
|
913
|
+
)
|
|
914
|
+
const out = renderWorkerActivity(view({ narrativeLines, toolCount: 7 }))
|
|
915
|
+
|
|
916
|
+
expect(out).toContain('🛠 <b>Worker</b>')
|
|
917
|
+
// Unified running status line.
|
|
918
|
+
expect(out).toContain('<i>10s · 7 tools</i>')
|
|
919
|
+
expect(out).toContain('7 tools')
|
|
920
|
+
// Every rendered line was clipped to STATUS_LINE_MAX → output well within budget.
|
|
921
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
922
|
+
// Newest bullet is the live → step.
|
|
923
|
+
expect(out).toContain('<b>→ final short step</b>')
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
it('a "+N earlier…" rolling marker appears when more than STATUS_ROLLING_LINES lines render', () => {
|
|
927
|
+
const narrativeLines = Array.from({ length: STATUS_ROLLING_LINES + 2 }, (_, i) => `step ${i + 1}`)
|
|
928
|
+
const out = renderWorkerActivity(view({ narrativeLines }))
|
|
929
|
+
|
|
930
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
931
|
+
expect(out).toContain('earlier…')
|
|
932
|
+
expect(out).toContain('🛠 <b>Worker</b>')
|
|
933
|
+
expect(out).toContain('<i>10s · ')
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
it('back-compat path (latestSummary only) still shows a step bullet, clipped + valid HTML', () => {
|
|
937
|
+
const hugeSummary = 'deploy service && run migrations && verify health checks '.repeat(80)
|
|
938
|
+
expect(hugeSummary.length).toBeGreaterThan(4000)
|
|
939
|
+
|
|
940
|
+
const out = renderWorkerActivity(
|
|
941
|
+
view({ narrativeLines: undefined, latestSummary: hugeSummary }),
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
expect(out.length).toBeLessThanOrEqual(4096)
|
|
945
|
+
const hasBullet = out.includes('→') || out.includes('✓')
|
|
946
|
+
expect(hasBullet).toBe(true)
|
|
947
|
+
expect(out).toContain('🛠 <b>Worker</b>')
|
|
948
|
+
expect(out).toContain('<i>10s · ')
|
|
949
|
+
expect(isValidWorkerHtml(out)).toBe(true)
|
|
950
|
+
})
|
|
951
|
+
})
|