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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +122 -88
  2. package/dist/auth-broker/index.js +463 -177
  3. package/dist/cli/autoaccept-poll.js +4842 -35
  4. package/dist/cli/drive-write-pretool.mjs +17 -14
  5. package/dist/cli/notion-write-pretool.mjs +117 -86
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +626 -0
  7. package/dist/cli/self-improve-stop.mjs +428 -0
  8. package/dist/cli/skill-validate-pretool.mjs +72 -72
  9. package/dist/cli/switchroom.js +3249 -1241
  10. package/dist/cli/ui/index.html +1 -1
  11. package/dist/host-control/main.js +2833 -355
  12. package/dist/vault/approvals/kernel-server.js +7482 -7439
  13. package/dist/vault/broker/server.js +11315 -11272
  14. package/examples/minimal.yaml +1 -0
  15. package/examples/switchroom.yaml +1 -0
  16. package/package.json +3 -3
  17. package/profiles/_base/start.sh.hbs +88 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +18 -0
  19. package/profiles/default/CLAUDE.md.hbs +3 -22
  20. package/telegram-plugin/.claude-plugin/plugin.json +2 -2
  21. package/telegram-plugin/answer-stream-flag.ts +12 -49
  22. package/telegram-plugin/answer-stream.ts +5 -150
  23. package/telegram-plugin/auth-snapshot-format.ts +280 -48
  24. package/telegram-plugin/auto-fallback-fleet.ts +44 -1
  25. package/telegram-plugin/context-exhaustion.ts +12 -0
  26. package/telegram-plugin/demo-mask.ts +154 -0
  27. package/telegram-plugin/dist/bridge/bridge.js +167 -124
  28. package/telegram-plugin/dist/gateway/gateway.js +3039 -1159
  29. package/telegram-plugin/dist/server.js +215 -172
  30. package/telegram-plugin/docs/waiting-ux-spec.md +2 -2
  31. package/telegram-plugin/draft-stream.ts +47 -410
  32. package/telegram-plugin/final-answer-detect.ts +17 -12
  33. package/telegram-plugin/fleet-fallback-resume.ts +131 -0
  34. package/telegram-plugin/format.ts +56 -19
  35. package/telegram-plugin/gateway/auth-add-flow.ts +332 -127
  36. package/telegram-plugin/gateway/auth-broker-client.ts +2 -2
  37. package/telegram-plugin/gateway/auth-command.ts +70 -14
  38. package/telegram-plugin/gateway/clean-shutdown-marker.ts +44 -0
  39. package/telegram-plugin/gateway/config-approval-handler.test.ts +91 -4
  40. package/telegram-plugin/gateway/config-approval-handler.ts +94 -13
  41. package/telegram-plugin/gateway/current-turn-map.ts +188 -0
  42. package/telegram-plugin/gateway/disconnect-flush.ts +3 -1
  43. package/telegram-plugin/gateway/effort-command.ts +8 -3
  44. package/telegram-plugin/gateway/emission-authority.ts +369 -0
  45. package/telegram-plugin/gateway/feed-open-gate.ts +292 -0
  46. package/telegram-plugin/gateway/gateway.ts +1837 -291
  47. package/telegram-plugin/gateway/inject-handler.test.ts +2 -1
  48. package/telegram-plugin/gateway/ms365-write-approval.test.ts +4 -4
  49. package/telegram-plugin/gateway/represent-guard.ts +72 -0
  50. package/telegram-plugin/gateway/status-surface-log.test.ts +5 -4
  51. package/telegram-plugin/gateway/status-surface-log.ts +14 -3
  52. package/telegram-plugin/history.ts +33 -11
  53. package/telegram-plugin/hooks/repo-context-pretool.mjs +26 -0
  54. package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +5 -0
  55. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +8 -0
  56. package/telegram-plugin/hooks/tool-label-pretool.mjs +39 -15
  57. package/telegram-plugin/issues-card.ts +4 -0
  58. package/telegram-plugin/model-unavailable.ts +124 -0
  59. package/telegram-plugin/narrative-dedup.ts +69 -0
  60. package/telegram-plugin/over-ping-safety-net.ts +70 -4
  61. package/telegram-plugin/package.json +3 -3
  62. package/telegram-plugin/pending-work-progress.ts +12 -0
  63. package/telegram-plugin/permission-rule.ts +32 -5
  64. package/telegram-plugin/permission-title.ts +152 -9
  65. package/telegram-plugin/quota-check.ts +13 -0
  66. package/telegram-plugin/quota-watch.ts +135 -7
  67. package/telegram-plugin/registry/turns-schema.test.ts +24 -0
  68. package/telegram-plugin/registry/turns-schema.ts +9 -0
  69. package/telegram-plugin/runtime-metrics.ts +13 -0
  70. package/telegram-plugin/session-tail.ts +96 -11
  71. package/telegram-plugin/silence-poke.ts +170 -24
  72. package/telegram-plugin/slot-banner-driver.ts +3 -0
  73. package/telegram-plugin/status-no-truncate.ts +44 -0
  74. package/telegram-plugin/status-reactions.ts +20 -3
  75. package/telegram-plugin/stream-controller.ts +4 -23
  76. package/telegram-plugin/stream-reply-handler.ts +6 -24
  77. package/telegram-plugin/streaming-metrics.ts +91 -0
  78. package/telegram-plugin/subagent-watcher.ts +212 -66
  79. package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +47 -0
  80. package/telegram-plugin/tests/answer-stream-dedup.test.ts +9 -26
  81. package/telegram-plugin/tests/answer-stream-flag.test.ts +25 -58
  82. package/telegram-plugin/tests/answer-stream-silent-markers.test.ts +41 -51
  83. package/telegram-plugin/tests/answer-stream.test.ts +2 -411
  84. package/telegram-plugin/tests/auth-add-flow.test.ts +488 -253
  85. package/telegram-plugin/tests/auth-command-format2.test.ts +71 -1
  86. package/telegram-plugin/tests/auth-snapshot-format.test.ts +376 -6
  87. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +120 -0
  88. package/telegram-plugin/tests/cross-turn-card-gate.test.ts +424 -0
  89. package/telegram-plugin/tests/demo-mask.test.ts +127 -0
  90. package/telegram-plugin/tests/draft-stream.test.ts +0 -827
  91. package/telegram-plugin/tests/emission-authority-card-drain-gate.test.ts +236 -0
  92. package/telegram-plugin/tests/emission-authority-facade.test.ts +488 -0
  93. package/telegram-plugin/tests/emission-authority-open-gate.test.ts +179 -0
  94. package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +395 -0
  95. package/telegram-plugin/tests/emission-determinism-wiring.test.ts +177 -0
  96. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +146 -0
  97. package/telegram-plugin/tests/feed-open-gate.test.ts +259 -0
  98. package/telegram-plugin/tests/feed-survival.test.ts +526 -0
  99. package/telegram-plugin/tests/fleet-fallback-resume.test.ts +197 -0
  100. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +117 -0
  101. package/telegram-plugin/tests/gateway-no-reply-single-emit.test.ts +4 -11
  102. package/telegram-plugin/tests/history.test.ts +60 -0
  103. package/telegram-plugin/tests/model-unavailable.test.ts +118 -0
  104. package/telegram-plugin/tests/narrative-dedup.test.ts +118 -0
  105. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +285 -0
  106. package/telegram-plugin/tests/over-ping-final-answer-decoupling.test.ts +194 -0
  107. package/telegram-plugin/tests/over-ping-safety-net.test.ts +2 -2
  108. package/telegram-plugin/tests/per-topic-current-turn.test.ts +373 -0
  109. package/telegram-plugin/tests/permission-card-origin-kill-switch.test.ts +42 -0
  110. package/telegram-plugin/tests/permission-rule.test.ts +17 -0
  111. package/telegram-plugin/tests/permission-title.test.ts +206 -17
  112. package/telegram-plugin/tests/quota-watch.test.ts +252 -9
  113. package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -1
  114. package/telegram-plugin/tests/repo-context-pretool.test.ts +62 -0
  115. package/telegram-plugin/tests/represent-guard.test.ts +162 -0
  116. package/telegram-plugin/tests/session-tail.test.ts +147 -3
  117. package/telegram-plugin/tests/silence-liveness-wiring.test.ts +18 -0
  118. package/telegram-plugin/tests/status-card-budget-parity.test.ts +72 -0
  119. package/telegram-plugin/tests/status-surface-log.test.ts +146 -0
  120. package/telegram-plugin/tests/subagent-watcher-clip-narrative.test.ts +58 -0
  121. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +102 -0
  122. package/telegram-plugin/tests/subagent-watcher-workflow-visibility.test.ts +225 -0
  123. package/telegram-plugin/tests/subagent-watcher.test.ts +147 -0
  124. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +597 -0
  125. package/telegram-plugin/tests/telegram-format.test.ts +101 -6
  126. package/telegram-plugin/tests/tool-activity-summary.test.ts +550 -15
  127. package/telegram-plugin/tests/tool-label-pretool.test.ts +73 -0
  128. package/telegram-plugin/tests/tool-label-sidecar.test.ts +44 -0
  129. package/telegram-plugin/tests/tool-labels.test.ts +67 -0
  130. package/telegram-plugin/tests/turn-liveness-floor.test.ts +196 -0
  131. package/telegram-plugin/tests/turn-liveness-invariant.test.ts +340 -0
  132. package/telegram-plugin/tests/welcome-text.test.ts +32 -3
  133. package/telegram-plugin/tests/worker-activity-feed.test.ts +470 -22
  134. package/telegram-plugin/tool-activity-summary.ts +375 -58
  135. package/telegram-plugin/turn-liveness-floor.ts +240 -0
  136. package/telegram-plugin/uat/assertions.ts +115 -0
  137. package/telegram-plugin/uat/driver.ts +68 -0
  138. package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +119 -133
  139. package/telegram-plugin/uat/scenarios/jtbd-answer-pings.test.ts +94 -0
  140. package/telegram-plugin/uat/scenarios/jtbd-cross-turn-card-dm.test.ts +109 -0
  141. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-thinkgap-dm.test.ts +478 -0
  142. package/telegram-plugin/uat/scenarios/jtbd-foreground-feed-visibility-dm.test.ts +396 -0
  143. package/telegram-plugin/uat/scenarios/jtbd-liveness-feed-open-dm.test.ts +202 -0
  144. package/telegram-plugin/uat/scenarios/jtbd-reply-is-last-dm.test.ts +202 -0
  145. package/telegram-plugin/uat/scenarios/reactions-dm.test.ts +93 -87
  146. package/telegram-plugin/welcome-text.ts +13 -1
  147. package/telegram-plugin/worker-activity-feed.ts +157 -82
  148. package/telegram-plugin/draft-transport.ts +0 -122
  149. package/telegram-plugin/tests/draft-retirement-wiring.test.ts +0 -82
  150. 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
- expect(out).toContain('running · ')
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
- expect(out).toContain('finished · completed · 5 tools · ')
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
- expect(out).toContain('finished · failed · ')
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('finished · completed · ')
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 an overflow header when the feed exceeds the cap', () => {
157
- const lines = Array.from({ length: 9 }, (_, i) => `step ${i + 1}`)
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('<i>✓ +3 earlier…</i>')
160
- expect(out).not.toContain('step 1')
161
- expect(out).toContain('<i>✓ step 4</i>')
162
- expect(out).toContain('<b>→ step 9</b>')
163
- // 6 visible step lines (the overflow header is not itself a step).
164
- expect(out.match(/step \d/g) ?? []).toHaveLength(6)
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('finished · completed · 5 tools')
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('caps the narrative block to the last 6 lines', async () => {
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
- for (let i = 1; i <= 9; i++) {
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: `line ${i}` }))
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(6)
406
- // Oldest lines evicted; newest retained.
407
- expect(last.text).not.toContain('line 1')
408
- expect(last.text).not.toContain('line 3')
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`, `&amp` 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 `&amp` (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 (&amp;, &lt;, &gt;), the output
807
+ // contained a broken entity fragment (&am, &l, &amp 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 (&→&amp; 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, &amp 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(/&amp$/) // incomplete &amp; 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, &lt without semicolon
849
+ })
850
+
851
+ it('< at entity boundary: output is valid HTML (no &l, &lt 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(/&lt$/) // incomplete &lt; 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, &gt 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(/&gt$/) // incomplete &gt; 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
+ })