switchroom 0.19.26 → 0.19.28

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 (92) hide show
  1. package/bin/git-agent-attribution-hook.sh +144 -0
  2. package/dist/agent-scheduler/index.js +60 -2
  3. package/dist/auth-broker/index.js +244 -13
  4. package/dist/cli/autoaccept-poll.js +225 -17
  5. package/dist/cli/notion-write-pretool.mjs +60 -2
  6. package/dist/cli/switchroom.js +2843 -1220
  7. package/dist/host-control/main.js +245 -14
  8. package/dist/vault/approvals/kernel-server.js +242 -13
  9. package/dist/vault/broker/server.js +242 -13
  10. package/package.json +7 -2
  11. package/profiles/_base/cron-session.sh.hbs +8 -0
  12. package/profiles/_base/start.sh.hbs +175 -15
  13. package/telegram-plugin/card-layout.ts +328 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +94 -1
  15. package/telegram-plugin/dist/gateway/gateway.js +2544 -1182
  16. package/telegram-plugin/dist/server.js +97 -1
  17. package/telegram-plugin/edit-flood-fuse.ts +841 -57
  18. package/telegram-plugin/flood-429-ledger.ts +526 -0
  19. package/telegram-plugin/flood-circuit-breaker.ts +18 -0
  20. package/telegram-plugin/gateway/callback-query-handlers.ts +6 -0
  21. package/telegram-plugin/gateway/flood-reply-queue.ts +168 -0
  22. package/telegram-plugin/gateway/gateway.ts +67 -70
  23. package/telegram-plugin/gateway/mcp-failure-hook.ts +74 -0
  24. package/telegram-plugin/gateway/narrative-lane.ts +14 -0
  25. package/telegram-plugin/gateway/outbound-send-path.ts +36 -0
  26. package/telegram-plugin/gateway/outbox-sweep.ts +183 -6
  27. package/telegram-plugin/gateway/pinned-message-handler.ts +12 -16
  28. package/telegram-plugin/gateway/status-pin-retarget.ts +72 -36
  29. package/telegram-plugin/gateway/status-pin-store.ts +58 -9
  30. package/telegram-plugin/gateway/worker-pin-reaper.ts +56 -7
  31. package/telegram-plugin/inline-keyboard-callbacks.ts +202 -21
  32. package/telegram-plugin/llm-error-present.ts +61 -2
  33. package/telegram-plugin/mcp-credential-failure.ts +459 -0
  34. package/telegram-plugin/model-unavailable.ts +8 -0
  35. package/telegram-plugin/operator-events.ts +110 -5
  36. package/telegram-plugin/outbound-class.ts +81 -0
  37. package/telegram-plugin/provider-credit.ts +237 -0
  38. package/telegram-plugin/scripts/bun-test-ci.sh +36 -6
  39. package/telegram-plugin/send-gate.ts +24 -2
  40. package/telegram-plugin/status-no-truncate.ts +10 -48
  41. package/telegram-plugin/status-pin-driver.ts +33 -45
  42. package/telegram-plugin/status-pin.ts +18 -1
  43. package/telegram-plugin/tests/card-golden.test.ts +69 -0
  44. package/telegram-plugin/tests/card-lifecycle-render.test.ts +362 -0
  45. package/telegram-plugin/tests/card-type-distinguishability.test.ts +187 -164
  46. package/telegram-plugin/tests/card-variants.golden.txt +211 -0
  47. package/telegram-plugin/tests/card-variants.ts +366 -0
  48. package/telegram-plugin/tests/edit-flood-fuse-ban-awareness.test.ts +373 -0
  49. package/telegram-plugin/tests/edit-flood-fuse-default-deny.test.ts +319 -0
  50. package/telegram-plugin/tests/edit-flood-fuse-reply-reserve.test.ts +340 -0
  51. package/telegram-plugin/tests/edit-flood-fuse.test.ts +11 -2
  52. package/telegram-plugin/tests/feed-edit-rate-ceiling.test.ts +462 -0
  53. package/telegram-plugin/tests/finalize-callback-flood-policy.test.ts +298 -0
  54. package/telegram-plugin/tests/finalize-callback.test.ts +41 -8
  55. package/telegram-plugin/tests/fixtures/real-429-stream.ts +220 -0
  56. package/telegram-plugin/tests/flood-429-ledger.test.ts +278 -0
  57. package/telegram-plugin/tests/flood-429-recorder-wiring.test.ts +128 -0
  58. package/telegram-plugin/tests/flood-reply-queue.test.ts +418 -0
  59. package/telegram-plugin/tests/mcp-credential-failure.test.ts +310 -0
  60. package/telegram-plugin/tests/outbox-sweep-flood-breaker.test.ts +221 -0
  61. package/telegram-plugin/tests/pinned-card-collapse.test.ts +19 -24
  62. package/telegram-plugin/tests/pinned-message-handler.test.ts +15 -15
  63. package/telegram-plugin/tests/provider-credit-402.test.ts +243 -0
  64. package/telegram-plugin/tests/status-pin-api.test.ts +11 -11
  65. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +36 -37
  66. package/telegram-plugin/tests/status-pin-lifecycle.test.ts +602 -0
  67. package/telegram-plugin/tests/status-pin-retarget.test.ts +90 -62
  68. package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +7 -3
  69. package/telegram-plugin/tests/status-pin-store.test.ts +109 -60
  70. package/telegram-plugin/tests/status-pin.test.ts +56 -5
  71. package/telegram-plugin/tests/test-runner-coverage.test.ts +133 -0
  72. package/telegram-plugin/tests/worker-activity-feed.test.ts +12 -10
  73. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +23 -29
  74. package/telegram-plugin/tests/worker-feed-pin-persistence.test.ts +56 -59
  75. package/telegram-plugin/tests/worker-feed-terminal-edit-class.test.ts +335 -0
  76. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +1 -1
  77. package/telegram-plugin/tool-activity-summary.ts +239 -365
  78. package/telegram-plugin/uat/assertions.ts +22 -11
  79. package/telegram-plugin/uat/feed-matcher.test.ts +24 -17
  80. package/telegram-plugin/worker-activity-feed.ts +105 -47
  81. package/vendor/hindsight-memory/CLAUDE.md +45 -0
  82. package/vendor/hindsight-memory/scripts/drain_pending.py +433 -11
  83. package/vendor/hindsight-memory/scripts/lib/config.py +33 -0
  84. package/vendor/hindsight-memory/scripts/lib/pending.py +193 -28
  85. package/vendor/hindsight-memory/scripts/recall.py +176 -7
  86. package/vendor/hindsight-memory/scripts/tests/test_config_recall_passthrough_env.py +170 -0
  87. package/vendor/hindsight-memory/scripts/tests/test_drain_circuit_breaker.py +401 -0
  88. package/vendor/hindsight-memory/scripts/tests/test_drain_serialisation.py +286 -0
  89. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +817 -8
  90. package/vendor/hindsight-memory/scripts/tests/test_recall_min_score.py +464 -0
  91. package/vendor/hindsight-memory/settings.json +1 -1
  92. package/vendor/hindsight-memory/tests/test_hooks.py +11 -2
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Regression suite for switchroom#3848 — the terminal worker-card frame must
3
+ * not be shed with the heartbeat repaints.
4
+ *
5
+ * #3847 made the edit-flood fuse class-aware and tagged EVERY worker-feed edit
6
+ * `cosmetic`. That is right for a liveness repaint (dropping one costs
7
+ * nothing — the next render carries full state) and wrong for the TERMINAL
8
+ * frame: it is the last frame the operator ever reads for that card, and
9
+ * nothing newer is coming to repaint it. Classed cosmetic, the single frame
10
+ * carrying the finished state competed in the same starved budget as a
11
+ * heartbeat, and under sustained cosmetic pressure it was the frame dropped —
12
+ * leaving the card frozen mid-run forever.
13
+ *
14
+ * Every test here asserts an OUTCOME (did the call reach the API / what class
15
+ * did it carry), under the exact pressure scenario that sheds it. Each one
16
+ * goes RED if the terminal frame is classed `cosmetic` again — at the feed
17
+ * (`{ terminal: … }` no longer passed), at the mapping
18
+ * (`workerFeedEditPriorityClass`), or at the fuse.
19
+ */
20
+ import { describe, it, expect } from 'vitest'
21
+ import { readFileSync } from 'node:fs'
22
+ import { createEditFloodFuse, EDIT_FLOOD_FUSE_DEFAULTS } from '../edit-flood-fuse.js'
23
+ import { withOutboundClass } from '../outbound-class.js'
24
+ import type { Clock, PriorityClass } from '../send-gate.js'
25
+ import {
26
+ createWorkerActivityFeed,
27
+ workerFeedEditPriorityClass,
28
+ type BotApiForWorkerFeed,
29
+ type WorkerActivityView,
30
+ type WorkerFeedEditMeta,
31
+ } from '../worker-activity-feed.js'
32
+
33
+ const CHAT = '1001'
34
+
35
+ class FakeClock implements Clock {
36
+ private cur = 0
37
+ private seq = 0
38
+ private timers: { at: number; id: number; resolve: () => void }[] = []
39
+
40
+ now(): number { return this.cur }
41
+
42
+ sleep(ms: number): Promise<void> {
43
+ return new Promise<void>((resolve) => {
44
+ this.timers.push({ at: this.cur + ms, id: this.seq++, resolve })
45
+ })
46
+ }
47
+
48
+ async advance(ms: number): Promise<void> {
49
+ const target = this.cur + ms
50
+ for (;;) {
51
+ await flush()
52
+ const due = this.timers.filter((t) => t.at <= target).sort((a, b) => a.at - b.at || a.id - b.id)
53
+ if (due.length === 0) break
54
+ const t = due[0]!
55
+ this.timers = this.timers.filter((x) => x !== t)
56
+ this.cur = t.at
57
+ t.resolve()
58
+ await flush()
59
+ }
60
+ this.cur = target
61
+ await flush()
62
+ }
63
+ }
64
+
65
+ function flush(): Promise<void> {
66
+ return new Promise((r) => setImmediate(r))
67
+ }
68
+
69
+ async function drain(): Promise<void> {
70
+ for (let i = 0; i < 12; i++) await flush()
71
+ }
72
+
73
+ function view(step: string, elapsedMs: number, state: 'running' | 'done' = 'running'): WorkerActivityView {
74
+ return { description: 'ship the fix', lastTool: null, toolCount: 2, latestSummary: step, elapsedMs, state }
75
+ }
76
+
77
+ /**
78
+ * The gateway's worker-feed edit adapter, reproduced EXACTLY as
79
+ * `gateway.ts` wires it (see the `createWorkerActivityFeed({ bot: { … } })`
80
+ * block): map the feed's out-of-band `meta` to a priority class with
81
+ * `workerFeedEditPriorityClass`, publish it on the AsyncLocalStorage the fuse
82
+ * reads, then hand the call to the transformer.
83
+ *
84
+ * `withOutboundClass` is what the real send gate does with `priorityClass`;
85
+ * doing it here keeps this test on the production mapping rather than a
86
+ * hand-written class literal.
87
+ */
88
+ function gatewayEditAdapter(
89
+ fuse: { apply: <R>(m: string, p: unknown, next: () => Promise<R>) => Promise<R> },
90
+ onLand: (messageId: number, cls: PriorityClass) => void,
91
+ ) {
92
+ return (chatId: string, messageId: number, text: string, meta?: WorkerFeedEditMeta): Promise<unknown> => {
93
+ const cls: PriorityClass = workerFeedEditPriorityClass(meta)
94
+ return withOutboundClass(cls, () =>
95
+ fuse.apply('editMessageText', { chat_id: chatId, message_id: messageId, text }, async () => {
96
+ onLand(messageId, cls)
97
+ return true
98
+ }),
99
+ )
100
+ }
101
+ }
102
+
103
+ const D = EDIT_FLOOD_FUSE_DEFAULTS
104
+
105
+ /**
106
+ * Offset at which the frame under test is offered, relative to the pressure
107
+ * burst. Small and non-zero: the frame must arrive AFTER the chat's cosmetic
108
+ * allowance is spent (so it has to wait) but early enough that its own
109
+ * `maxDeferMs` deadline still falls inside the pressure window — which is
110
+ * precisely the "sustained cosmetic pressure" #3848 describes. Offering it at
111
+ * t=0 would race the burst's own late releases at the same instant.
112
+ */
113
+ const OFFER_AT_MS = 1_000
114
+
115
+ /**
116
+ * Saturate a chat's cosmetic allowance the way a busy agent does: N distinct
117
+ * worker cards all repainting at once. Both the per-chat cosmetic ceiling
118
+ * (`cosmeticPerChatMaxPerWindow`, 6/60s) and the bounded R1 late-release
119
+ * budget (`lateReleaseMaxPerWindow`, 2/60s) get spent — the latter matters
120
+ * because a LONE cosmetic frame is otherwise released late rather than
121
+ * dropped. That late-release budget is the partial mitigation #3848 records,
122
+ * and running it out is what turns the degradation into a guaranteed drop.
123
+ *
124
+ * Returns the still-pending burst; the caller advances the clock past
125
+ * `maxDeferMs` once, resolving the burst and the frame under test together.
126
+ */
127
+ function startCosmeticPressure(
128
+ fuse: { apply: <R>(m: string, p: unknown, next: () => Promise<R>) => Promise<R> },
129
+ landed: number[],
130
+ ): Promise<unknown>[] {
131
+ const total = D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow
132
+ const pending: Promise<unknown>[] = []
133
+ for (let i = 0; i < total; i++) {
134
+ pending.push(
135
+ withOutboundClass('cosmetic', () =>
136
+ fuse.apply('editMessageText', { chat_id: CHAT, message_id: 9000 + i, text: `card ${i}` }, async () => {
137
+ landed.push(9000 + i)
138
+ return true
139
+ }),
140
+ ),
141
+ )
142
+ }
143
+ return pending
144
+ }
145
+
146
+ /**
147
+ * Offer ONE lone edit to a fresh message id under an already-exhausted
148
+ * cosmetic budget, as `cls`, and report whether it reached the API.
149
+ */
150
+ async function offerLoneEditUnderPressure(cls: PriorityClass): Promise<boolean> {
151
+ const clock = new FakeClock()
152
+ const fuse = createEditFloodFuse({ clock })
153
+ const landed: number[] = []
154
+ const pressure = startCosmeticPressure(fuse, landed)
155
+ await clock.advance(OFFER_AT_MS)
156
+
157
+ let reachedApi = false
158
+ const call = withOutboundClass(cls, () =>
159
+ fuse.apply(
160
+ 'editMessageText',
161
+ { chat_id: CHAT, message_id: 4242, text: '✅ done · 12 tools' },
162
+ async () => { reachedApi = true; return true },
163
+ ),
164
+ )
165
+ await clock.advance(D.maxDeferMs + 1_000)
166
+ await Promise.all([...pressure, call])
167
+ // Sanity: the pressure really did consume the whole cosmetic allowance.
168
+ expect(landed).toHaveLength(D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow)
169
+ return reachedApi
170
+ }
171
+
172
+ describe('#3848 — a terminal worker-card frame survives an exhausted cosmetic budget', () => {
173
+ it('drops the terminal frame when it is classed cosmetic, and lands it when classed useful', async () => {
174
+ // Both halves run the SAME fuse config under the SAME pressure, so the
175
+ // ONLY difference is the class the terminal frame carries. The classes
176
+ // come from the production mapping, so reverting the promotion turns the
177
+ // second assertion into the first.
178
+ const asCosmetic = await offerLoneEditUnderPressure(workerFeedEditPriorityClass({ terminal: false }))
179
+ const asUseful = await offerLoneEditUnderPressure(workerFeedEditPriorityClass({ terminal: true }))
180
+
181
+ // The defect (#3848): with the cosmetic budget spent, the frame carrying
182
+ // the finished state never reaches Telegram — the card freezes mid-run.
183
+ expect(asCosmetic).toBe(false)
184
+ // The fix: the same frame, promoted because it is terminal, gets through.
185
+ expect(asUseful).toBe(true)
186
+ })
187
+
188
+ it('still holds INTERMEDIATE repaints to the cosmetic ceiling after the fix', async () => {
189
+ // Guards the other direction: promoting the terminal frame must not
190
+ // promote the heartbeat stream, or #3847's whole flood defence is undone.
191
+ expect(workerFeedEditPriorityClass({ terminal: false })).toBe('cosmetic')
192
+ expect(await offerLoneEditUnderPressure('cosmetic')).toBe(false)
193
+ })
194
+ })
195
+
196
+ describe('#3848 — the REAL feed tags its terminal frame, through the REAL gateway mapping', () => {
197
+ /**
198
+ * End-to-end over the production pieces: `createWorkerActivityFeed` drives
199
+ * the gateway's own edit adapter into a real `createEditFloodFuse`. Nothing
200
+ * here hand-writes a class — the class comes from whatever the feed passes
201
+ * as `meta`. If the feed stops passing `{ terminal: … }`, or the adapter
202
+ * stops consulting it, the terminal recap is shed and this goes RED.
203
+ */
204
+ it('lands the finish recap at the API while the chat cosmetic budget is exhausted', async () => {
205
+ const clock = new FakeClock()
206
+ const fuse = createEditFloodFuse({ clock })
207
+ const preLanded: number[] = []
208
+ const pressure = startCosmeticPressure(fuse, preLanded)
209
+ await clock.advance(OFFER_AT_MS)
210
+
211
+ const CARD = 777
212
+ const landedEdits: { messageId: number; cls: PriorityClass }[] = []
213
+ const edit = gatewayEditAdapter(fuse, (messageId, cls) => landedEdits.push({ messageId, cls }))
214
+
215
+ const bot: BotApiForWorkerFeed = {
216
+ sendMessage: async () => ({ message_id: CARD }),
217
+ editMessageText: (chatId, messageId, text, _opts, meta) => edit(chatId, messageId, text, meta),
218
+ }
219
+ const feed = createWorkerActivityFeed({
220
+ bot,
221
+ now: () => clock.now(),
222
+ minEditIntervalMs: 0,
223
+ elapsedRefreshMs: 0,
224
+ firstPaintMinMs: 0,
225
+ setInterval: () => 0,
226
+ clearInterval: () => {},
227
+ })
228
+
229
+ // First paint (a send — never dropped), then the worker finishes.
230
+ await feed.update('w1', CHAT, view('reading gateway.ts', 30_000))
231
+ await drain()
232
+
233
+ const finish = feed.finish('w1', view('opened the PR', 60_000, 'done'))
234
+ await clock.advance(D.maxDeferMs + 1_000)
235
+ await finish
236
+ await Promise.all(pressure)
237
+ await drain()
238
+
239
+ // The pressure really did spend the whole cosmetic allowance…
240
+ expect(preLanded).toHaveLength(D.cosmeticPerChatMaxPerWindow + D.lateReleaseMaxPerWindow)
241
+ // …and the terminal recap still reached Telegram, as `useful`.
242
+ expect(landedEdits.map((e) => e.messageId)).toContain(CARD)
243
+ expect(landedEdits.filter((e) => e.messageId === CARD).every((e) => e.cls === 'useful')).toBe(true)
244
+ })
245
+
246
+ it('classes the running repaints cosmetic and only the finish frame useful', async () => {
247
+ const clock = new FakeClock()
248
+ const CARD = 888
249
+ const seen: { text: string; meta?: WorkerFeedEditMeta }[] = []
250
+ const bot: BotApiForWorkerFeed = {
251
+ sendMessage: async () => ({ message_id: CARD }),
252
+ editMessageText: async (_c, _m, text, _opts, meta) => { seen.push({ text, meta }); return true },
253
+ }
254
+ const feed = createWorkerActivityFeed({
255
+ bot,
256
+ now: () => clock.now(),
257
+ minEditIntervalMs: 0,
258
+ elapsedRefreshMs: 0,
259
+ firstPaintMinMs: 0,
260
+ setInterval: () => 0,
261
+ clearInterval: () => {},
262
+ })
263
+
264
+ await feed.update('w1', CHAT, view('first step', 10_000))
265
+ await drain()
266
+ await feed.update('w1', CHAT, view('second step', 20_000))
267
+ await drain()
268
+ await feed.update('w1', CHAT, view('third step', 30_000))
269
+ await drain()
270
+ const beforeFinish = seen.length
271
+ await feed.finish('w1', view('all done', 40_000, 'done'))
272
+ await drain()
273
+
274
+ // At least one intermediate repaint and exactly the finish frame after it.
275
+ expect(beforeFinish).toBeGreaterThan(0)
276
+ expect(seen.length).toBeGreaterThan(beforeFinish)
277
+ for (const e of seen.slice(0, beforeFinish)) {
278
+ expect(e.meta?.terminal).toBe(false)
279
+ expect(workerFeedEditPriorityClass(e.meta)).toBe('cosmetic')
280
+ }
281
+ for (const e of seen.slice(beforeFinish)) {
282
+ expect(e.meta?.terminal).toBe(true)
283
+ expect(workerFeedEditPriorityClass(e.meta)).toBe('useful')
284
+ }
285
+ })
286
+ })
287
+
288
+ describe('#3848 — gateway.ts actually wires the mapping', () => {
289
+ /**
290
+ * `gateway.ts` is not unit-importable (a single 24k-line module with a live
291
+ * bot at construction), so `gatewayEditAdapter` above can only MIRROR its
292
+ * adapter. That mirror would keep passing if someone hard-coded
293
+ * `priorityClass: 'cosmetic'` back into the real adapter, which is exactly
294
+ * the regression #3848 fixes. This asserts the real wiring at the source,
295
+ * deterministically, instead of trusting the mirror.
296
+ */
297
+ const GATEWAY = new URL('../gateway/gateway.ts', import.meta.url)
298
+
299
+ it('passes the feed edit meta through workerFeedEditPriorityClass', () => {
300
+ const src = readFileSync(GATEWAY, 'utf8')
301
+ // The adapter must actually RECEIVE the meta argument from the feed.
302
+ const ANCHOR = 'editMessageText: (cid, mid, text, editOpts, editMeta) =>'
303
+ const at = src.indexOf(ANCHOR)
304
+ expect(at, `gateway.ts no longer declares the worker-feed edit adapter as \`${ANCHOR}\``).toBeGreaterThan(-1)
305
+
306
+ // Bound the slice to this one adapter: it ends at the `verb: 'worker-feed'`
307
+ // options object it passes to robustApiCall.
308
+ const verbAt = src.indexOf("verb: 'worker-feed'", at)
309
+ expect(verbAt).toBeGreaterThan(at)
310
+ const feedAdapter = src.slice(at, verbAt + 500)
311
+
312
+ expect(feedAdapter).toContain('priorityClass: workerFeedEditPriorityClass(editMeta)')
313
+ // The hard-coded class #3848 removed must not come back.
314
+ expect(feedAdapter).not.toContain("priorityClass: 'cosmetic'")
315
+ // And the symbol must be imported, or the above is dead source text.
316
+ expect(src).toContain('workerFeedEditPriorityClass')
317
+ })
318
+ })
319
+
320
+ describe('#3848 — the class mapping itself', () => {
321
+ it('maps terminal → useful and everything else → cosmetic', () => {
322
+ expect(workerFeedEditPriorityClass({ terminal: true })).toBe('useful')
323
+ expect(workerFeedEditPriorityClass({ terminal: false })).toBe('cosmetic')
324
+ // An adapter that forgets to forward `meta` must degrade to the SAFE
325
+ // class, not silently promote every repaint.
326
+ expect(workerFeedEditPriorityClass(undefined)).toBe('cosmetic')
327
+ })
328
+
329
+ it('returns a value assignable to the send gate PriorityClass union', () => {
330
+ // Compile-time half: this line fails `tsc` if the mapping ever returns a
331
+ // class the gate does not know (#3847's OutboundClass/PriorityClass lock).
332
+ const cls: PriorityClass = workerFeedEditPriorityClass({ terminal: true })
333
+ expect(['critical', 'useful', 'cosmetic']).toContain(cls)
334
+ })
335
+ })
@@ -253,7 +253,7 @@ describe('prose-silent background worker — end-to-end visibility harness (PR 2
253
253
  expect(h.bot.sent).toHaveLength(1)
254
254
  expect(h.feed.messageIdOf(h.agentId)).not.toBeNull()
255
255
  expect(h.bot.sent[0].chatId).toBe('chat-42')
256
- expect(h.bot.sent[0].text).toContain('└─ 🛠 **WORKER**')
256
+ expect(h.bot.sent[0].text.startsWith('🛠 **WORKER**')).toBe(true)
257
257
 
258
258
  // (b) KEEPS UPDATING: later heartbeats edit the message with a climbing
259
259
  // `· Ns` suffix so the still-alive worker visibly advances.