switchroom 0.19.48 → 0.20.1

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 (60) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +18 -1
  3. package/dist/auth-broker/index.js +19 -2
  4. package/dist/buzz-gateway/index.js +9367 -0
  5. package/dist/cli/notion-write-pretool.mjs +18 -1
  6. package/dist/cli/switchroom.js +24734 -16371
  7. package/dist/host-control/main.js +59 -9
  8. package/dist/vault/approvals/kernel-server.js +19 -2
  9. package/dist/vault/broker/server.js +19 -2
  10. package/package.json +6 -4
  11. package/profiles/_base/start.sh.hbs +148 -2
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/dev-protocol/SKILL.md +30 -1
  14. package/skills/switchroom-architecture/SKILL.md +5 -0
  15. package/skills/switchroom-cli/SKILL.md +1 -1
  16. package/telegram-plugin/dist/bridge/bridge.js +7 -4
  17. package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
  18. package/telegram-plugin/dist/server.js +7 -4
  19. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  20. package/telegram-plugin/gateway/access-store.ts +194 -0
  21. package/telegram-plugin/gateway/boot-briefing-builder.ts +586 -0
  22. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  23. package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
  24. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  25. package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
  26. package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
  27. package/telegram-plugin/gateway/channel-route.ts +272 -0
  28. package/telegram-plugin/gateway/gateway.ts +115 -203
  29. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  30. package/telegram-plugin/gateway/inbound-spool.ts +33 -1
  31. package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
  32. package/telegram-plugin/gateway/ipc-server.ts +197 -2
  33. package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
  34. package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
  35. package/telegram-plugin/gateway/stream-render.ts +21 -0
  36. package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
  37. package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
  38. package/telegram-plugin/history.ts +15 -0
  39. package/telegram-plugin/llm-error-present.ts +9 -4
  40. package/telegram-plugin/model-unavailable.ts +4 -0
  41. package/telegram-plugin/operator-events.fixtures.json +12 -12
  42. package/telegram-plugin/operator-events.ts +81 -9
  43. package/telegram-plugin/session-tail.ts +7 -1
  44. package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
  45. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  46. package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
  47. package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
  48. package/telegram-plugin/tests/channel-route.test.ts +306 -0
  49. package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
  50. package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
  51. package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
  52. package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
  53. package/telegram-plugin/tests/operator-events.test.ts +71 -7
  54. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  55. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  56. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
  57. package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
  58. package/telegram-plugin/voice-normalize-text.ts +5 -0
  59. package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
  60. package/vendor/hindsight-memory/scripts/recall.py +7 -2
@@ -0,0 +1,538 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2
+ import { mkdtempSync, rmSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import {
6
+ initBuzzMirror,
7
+ getBuzzMirror,
8
+ maybeBootBuzzMirror,
9
+ resolveCorrelationJournalPath,
10
+ __resetBuzzMirrorForTests,
11
+ CORRECTION_DEBOUNCE_MS,
12
+ } from '../gateway/buzz-mirror.js'
13
+ import type { OutboundToBuzzMessage } from '../gateway/ipc-protocol.js'
14
+ import { buildThreadTags } from '../../src/buzz-gateway/transform.js'
15
+
16
+ // Hub-side mirror behaviour (Phase 2b). The mirror is DOWNSTREAM of a delivered
17
+ // Telegram copy; a publish is emitted via the attached peer sender. These tests
18
+ // spy the sender to assert WHETHER and WITH WHAT a publish is issued.
19
+
20
+ function mirrorWith(sender: (m: OutboundToBuzzMessage) => boolean, opts?: { defaultChannelId?: string }) {
21
+ const m = initBuzzMirror({
22
+ mode: 'both',
23
+ agentName: 'klanker',
24
+ defaultChannelId: opts?.defaultChannelId ?? 'default-chan',
25
+ })
26
+ m.attachSender(sender)
27
+ return m
28
+ }
29
+
30
+ const BUZZ_COORDS = { channelId: 'chan-A', eventId: 'evt-1', threadRoot: 'root-1' }
31
+
32
+ describe('BuzzMirror.mirrorReplyDelivered — routing + S1 owner guard', () => {
33
+ beforeEach(() => __resetBuzzMirrorForTests())
34
+
35
+ it('T-6b (hub): a live buzz owner + un-echoed reply + a recent different-origin turn ⇒ NO publish', () => {
36
+ const sender = vi.fn(() => true)
37
+ const m = mirrorWith(sender)
38
+ m.mirrorReplyDelivered({
39
+ scrubbedText: 'answer',
40
+ ownerOriginChannel: 'buzz',
41
+ ownerBuzzCoords: BUZZ_COORDS,
42
+ ownerEchoed: false, // the reply did NOT echo this buzz turn's id
43
+ hasRecentDifferentOriginTurn: true, // a prior Telegram DM turn is live/recent
44
+ telegramMessageKeys: ['555:1001'],
45
+ })
46
+ // The S1 guard must refuse the ambiguous threaded publish — Telegram-only.
47
+ expect(sender).not.toHaveBeenCalled()
48
+ })
49
+
50
+ it('publishes a THREADED reply when the buzz owner id was echoed', () => {
51
+ const sender = vi.fn(() => true)
52
+ const m = mirrorWith(sender)
53
+ m.mirrorReplyDelivered({
54
+ scrubbedText: 'answer',
55
+ ownerOriginChannel: 'buzz',
56
+ ownerBuzzCoords: BUZZ_COORDS,
57
+ ownerEchoed: true,
58
+ hasRecentDifferentOriginTurn: true, // irrelevant once echoed
59
+ telegramMessageKeys: ['555:1001'],
60
+ })
61
+ expect(sender).toHaveBeenCalledTimes(1)
62
+ const msg = sender.mock.calls[0][0]
63
+ expect(msg.type).toBe('outbound_to_buzz')
64
+ expect(msg.channelId).toBe(BUZZ_COORDS.channelId)
65
+ expect(msg.replyToEventId).toBe(BUZZ_COORDS.eventId)
66
+ expect(msg.threadRootId).toBe(BUZZ_COORDS.threadRoot)
67
+ expect(msg.payload).toEqual({ kind: 'message', text: 'answer' })
68
+ expect(msg.agentName).toBe('klanker')
69
+ })
70
+
71
+ it('publishes a buzz-origin threaded reply when un-echoed but NO different-origin turn exists', () => {
72
+ const sender = vi.fn(() => true)
73
+ const m = mirrorWith(sender)
74
+ m.mirrorReplyDelivered({
75
+ scrubbedText: 'answer',
76
+ ownerOriginChannel: 'buzz',
77
+ ownerBuzzCoords: BUZZ_COORDS,
78
+ ownerEchoed: false,
79
+ hasRecentDifferentOriginTurn: false,
80
+ telegramMessageKeys: ['555:1001'],
81
+ })
82
+ expect(sender).toHaveBeenCalledTimes(1)
83
+ })
84
+
85
+ it('mirrors a TELEGRAM-origin answer as a fresh top-level post to defaultChannelId', () => {
86
+ const sender = vi.fn(() => true)
87
+ const m = mirrorWith(sender, { defaultChannelId: 'grp-top' })
88
+ m.mirrorReplyDelivered({
89
+ scrubbedText: 'tele answer',
90
+ ownerOriginChannel: 'telegram',
91
+ ownerEchoed: false,
92
+ hasRecentDifferentOriginTurn: false,
93
+ telegramMessageKeys: ['555:2002'],
94
+ })
95
+ expect(sender).toHaveBeenCalledTimes(1)
96
+ const msg = sender.mock.calls[0][0]
97
+ expect(msg.channelId).toBe('grp-top')
98
+ expect(msg.replyToEventId).toBeUndefined() // top-level, not a thread reply
99
+ expect(msg.threadRootId).toBeUndefined()
100
+ })
101
+
102
+ it('drops a telegram-origin mirror when no default channel is configured', () => {
103
+ const sender = vi.fn(() => true)
104
+ const m = mirrorWith(sender, { defaultChannelId: '' })
105
+ m.mirrorReplyDelivered({
106
+ scrubbedText: 'x',
107
+ ownerOriginChannel: 'telegram',
108
+ ownerEchoed: false,
109
+ hasRecentDifferentOriginTurn: false,
110
+ telegramMessageKeys: ['555:3003'],
111
+ })
112
+ expect(sender).not.toHaveBeenCalled()
113
+ })
114
+
115
+ it('never throws even if the sender throws (the Telegram copy is already delivered)', () => {
116
+ const sender = vi.fn(() => { throw new Error('peer exploded') })
117
+ const m = mirrorWith(sender)
118
+ expect(() =>
119
+ m.mirrorReplyDelivered({
120
+ scrubbedText: 'x',
121
+ ownerOriginChannel: 'telegram',
122
+ ownerEchoed: false,
123
+ hasRecentDifferentOriginTurn: false,
124
+ telegramMessageKeys: ['555:4004'],
125
+ }),
126
+ ).not.toThrow()
127
+ })
128
+ })
129
+
130
+ describe('BuzzMirror.mirrorReplyDelivered — NIP-10 OUTBOUND thread continuity', () => {
131
+ beforeEach(() => __resetBuzzMirrorForTests())
132
+
133
+ // Helper: mirror a telegram-origin answer, complete its publish so the
134
+ // correlation store learns its event id, and return the published event id.
135
+ function mirrorTelegramAndComplete(
136
+ m: ReturnType<typeof mirrorWith>,
137
+ sender: ReturnType<typeof vi.fn>,
138
+ opts: { telegramKey: string; antecedentKey?: string; eventId: string },
139
+ ): OutboundToBuzzMessage {
140
+ m.mirrorReplyDelivered({
141
+ scrubbedText: 'answer',
142
+ ownerOriginChannel: 'telegram',
143
+ ownerEchoed: false,
144
+ hasRecentDifferentOriginTurn: false,
145
+ telegramMessageKeys: [opts.telegramKey],
146
+ antecedentTelegramMessageKey: opts.antecedentKey,
147
+ })
148
+ const call = sender.mock.calls[sender.mock.calls.length - 1][0] as OutboundToBuzzMessage
149
+ m.onPublishResult({ correlationId: call.correlationId, ok: true, eventId: opts.eventId })
150
+ return call
151
+ }
152
+
153
+ it('threads a telegram-origin reply UNDER its antecedent`s mirrored event (root === parent for a top-level antecedent)', () => {
154
+ const sender = vi.fn(() => true)
155
+ const m = mirrorWith(sender, { defaultChannelId: 'grp' })
156
+
157
+ // A1: a top-level telegram-origin answer, published as evt-A.
158
+ mirrorTelegramAndComplete(m, sender, { telegramKey: '555:100', eventId: 'evt-A' })
159
+ // A2: a telegram-origin answer that REPLIES to A1's Telegram message.
160
+ m.mirrorReplyDelivered({
161
+ scrubbedText: 'follow-up',
162
+ ownerOriginChannel: 'telegram',
163
+ ownerEchoed: false,
164
+ hasRecentDifferentOriginTurn: false,
165
+ telegramMessageKeys: ['555:200'],
166
+ antecedentTelegramMessageKey: '555:100',
167
+ })
168
+
169
+ const msg = sender.mock.calls[sender.mock.calls.length - 1][0] as OutboundToBuzzMessage
170
+ // The immediate parent is A1's event; A1 was top-level so IT is the root.
171
+ expect(msg.replyToEventId).toBe('evt-A')
172
+ expect(msg.threadRootId).toBe('evt-A')
173
+ // NIP-10: root === parent collapses to a single reply-to-root e-tag.
174
+ expect(buildThreadTags({ threadRootId: msg.threadRootId, replyToEventId: msg.replyToEventId })).toEqual([
175
+ ['e', 'evt-A', '', 'root'],
176
+ ])
177
+ })
178
+
179
+ it('emits DISTINCT root + reply markers for a reply into a DEEPER thread', () => {
180
+ const sender = vi.fn(() => true)
181
+ const m = mirrorWith(sender, { defaultChannelId: 'grp' })
182
+
183
+ // A0 top-level → evt-root.
184
+ mirrorTelegramAndComplete(m, sender, { telegramKey: '555:100', eventId: 'evt-root' })
185
+ // A1 replies to A0 → threads under evt-root; published as evt-mid. Its
186
+ // recorded threadRoot must stay evt-root (NOT evt-mid).
187
+ mirrorTelegramAndComplete(m, sender, {
188
+ telegramKey: '555:200',
189
+ antecedentKey: '555:100',
190
+ eventId: 'evt-mid',
191
+ })
192
+ // A2 replies to A1 → parent is evt-mid, but the thread root is still evt-root.
193
+ m.mirrorReplyDelivered({
194
+ scrubbedText: 'deep',
195
+ ownerOriginChannel: 'telegram',
196
+ ownerEchoed: false,
197
+ hasRecentDifferentOriginTurn: false,
198
+ telegramMessageKeys: ['555:300'],
199
+ antecedentTelegramMessageKey: '555:200',
200
+ })
201
+
202
+ const msg = sender.mock.calls[sender.mock.calls.length - 1][0] as OutboundToBuzzMessage
203
+ expect(msg.replyToEventId).toBe('evt-mid') // immediate parent
204
+ expect(msg.threadRootId).toBe('evt-root') // thread root, NOT the parent
205
+ expect(buildThreadTags({ threadRootId: msg.threadRootId, replyToEventId: msg.replyToEventId })).toEqual([
206
+ ['e', 'evt-root', '', 'root'],
207
+ ['e', 'evt-mid', '', 'reply'],
208
+ ])
209
+ })
210
+
211
+ it('a reply whose antecedent is a correlation-store MISS mirrors FLAT (no wrong/guessed tag)', () => {
212
+ const logs: string[] = []
213
+ __resetBuzzMirrorForTests()
214
+ const sender = vi.fn(() => true)
215
+ const m = initBuzzMirror({ mode: 'both', agentName: 'klanker', defaultChannelId: 'grp', log: (l) => logs.push(l) })
216
+ m.attachSender(sender)
217
+
218
+ // The antecedent key was NEVER mirrored (e.g. it is the user's own inbound
219
+ // message) — the lookup misses. The mirror must NOT invent a thread tag.
220
+ m.mirrorReplyDelivered({
221
+ scrubbedText: 'answer',
222
+ ownerOriginChannel: 'telegram',
223
+ ownerEchoed: false,
224
+ hasRecentDifferentOriginTurn: false,
225
+ telegramMessageKeys: ['555:200'],
226
+ antecedentTelegramMessageKey: '555:100', // not in the store
227
+ })
228
+
229
+ expect(sender).toHaveBeenCalledTimes(1)
230
+ const msg = sender.mock.calls[0][0] as OutboundToBuzzMessage
231
+ expect(msg.replyToEventId).toBeUndefined()
232
+ expect(msg.threadRootId).toBeUndefined()
233
+ // The flat fallback still PUBLISHES (top-level), just with no thread tags.
234
+ expect(buildThreadTags({ threadRootId: msg.threadRootId, replyToEventId: msg.replyToEventId })).toEqual([])
235
+ expect(logs.some((l) => /outbound thread MISS/.test(l))).toBe(true)
236
+ })
237
+
238
+ it('#4299: a FOREIGN-CHANNEL antecedent mirrors FLAT (no cross-group e-tag)', () => {
239
+ const logs: string[] = []
240
+ __resetBuzzMirrorForTests()
241
+ const sender = vi.fn(() => true)
242
+ // Telegram-origin mirrors post into 'grp'; the antecedent below was mirrored
243
+ // into a DIFFERENT (buzz-origin) channel 'chan-A'.
244
+ const m = initBuzzMirror({ mode: 'both', agentName: 'klanker', defaultChannelId: 'grp', log: (l) => logs.push(l) })
245
+ m.attachSender(sender)
246
+
247
+ // Record '555:100' → an event published into buzz channel 'chan-A' (its
248
+ // channelId comes from ownerBuzzCoords, NOT defaultChannelId). This is the
249
+ // exact shape #4299 warns about: a parent recorded via a buzz-origin
250
+ // threaded reply whose channelId came from the inbound event's own h-tag.
251
+ m.mirrorReplyDelivered({
252
+ scrubbedText: 'buzz answer',
253
+ ownerOriginChannel: 'buzz',
254
+ ownerBuzzCoords: { channelId: 'chan-A', eventId: 'evt-buzz', threadRoot: 'root-buzz' },
255
+ ownerEchoed: true,
256
+ hasRecentDifferentOriginTurn: false,
257
+ telegramMessageKeys: ['555:100'],
258
+ })
259
+ const buzzCall = sender.mock.calls[sender.mock.calls.length - 1][0] as OutboundToBuzzMessage
260
+ expect(buzzCall.channelId).toBe('chan-A')
261
+ m.onPublishResult({ correlationId: buzzCall.correlationId, ok: true, eventId: 'evt-buzz' })
262
+
263
+ // A later TELEGRAM-origin answer replies to '555:100'. Its target channel is
264
+ // 'grp' ≠ 'chan-A' — threading under evt-buzz would carry e-tags into the
265
+ // foreign 'chan-A' group. It MUST mirror flat instead.
266
+ m.mirrorReplyDelivered({
267
+ scrubbedText: 'tele follow-up',
268
+ ownerOriginChannel: 'telegram',
269
+ ownerEchoed: false,
270
+ hasRecentDifferentOriginTurn: false,
271
+ telegramMessageKeys: ['555:200'],
272
+ antecedentTelegramMessageKey: '555:100',
273
+ })
274
+
275
+ const msg = sender.mock.calls[sender.mock.calls.length - 1][0] as OutboundToBuzzMessage
276
+ expect(msg.channelId).toBe('grp')
277
+ // FLAT: no thread tags at all — crucially NOT evt-buzz / root-buzz.
278
+ expect(msg.replyToEventId).toBeUndefined()
279
+ expect(msg.threadRootId).toBeUndefined()
280
+ expect(buildThreadTags({ threadRootId: msg.threadRootId, replyToEventId: msg.replyToEventId })).toEqual([])
281
+ // Logged distinctly as a cross-channel guard, NOT as an eviction MISS.
282
+ expect(logs.some((l) => /outbound thread CROSS-CHANNEL/.test(l))).toBe(true)
283
+ expect(logs.some((l) => /outbound thread MISS/.test(l))).toBe(false)
284
+ })
285
+
286
+ it('#4301: a quote-opt-in DEFAULT antecedent miss is NOT logged as an eviction MISS', () => {
287
+ const logs: string[] = []
288
+ __resetBuzzMirrorForTests()
289
+ const sender = vi.fn(() => true)
290
+ const m = initBuzzMirror({ mode: 'both', agentName: 'klanker', defaultChannelId: 'grp', log: (l) => logs.push(l) })
291
+ m.attachSender(sender)
292
+
293
+ // The antecedent is the latest INBOUND user message (quote-opt-in default),
294
+ // which is never in the correlation store — an EXPECTED miss, not eviction.
295
+ m.mirrorReplyDelivered({
296
+ scrubbedText: 'answer',
297
+ ownerOriginChannel: 'telegram',
298
+ ownerEchoed: false,
299
+ hasRecentDifferentOriginTurn: false,
300
+ telegramMessageKeys: ['555:200'],
301
+ antecedentTelegramMessageKey: '555:100', // latest inbound, never mirrored
302
+ antecedentIsQuoteOptInDefault: true,
303
+ })
304
+
305
+ expect(sender).toHaveBeenCalledTimes(1)
306
+ const msg = sender.mock.calls[0][0] as OutboundToBuzzMessage
307
+ // Still a flat top-level post.
308
+ expect(msg.replyToEventId).toBeUndefined()
309
+ expect(msg.threadRootId).toBeUndefined()
310
+ // The expected default-quote miss must NOT masquerade as an eviction MISS,
311
+ // so a genuine eviction miss stays distinguishable in the logs.
312
+ expect(logs.some((l) => /outbound thread MISS/.test(l))).toBe(false)
313
+ expect(logs.some((l) => /default-quote/.test(l))).toBe(true)
314
+ })
315
+ })
316
+
317
+ describe('BuzzMirror.mirrorCorrection — debounced, only for mirrored messages', () => {
318
+ beforeEach(() => {
319
+ __resetBuzzMirrorForTests()
320
+ vi.useFakeTimers()
321
+ })
322
+ // restore real timers after each via afterEach-less pattern
323
+ it('no-ops for a Telegram message that was never mirrored to Buzz', () => {
324
+ const sender = vi.fn(() => true)
325
+ const m = mirrorWith(sender)
326
+ m.mirrorCorrection({ telegramMessageKey: '555:9999', scrubbedText: 'fixed' })
327
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS + 1000)
328
+ expect(sender).not.toHaveBeenCalled()
329
+ vi.useRealTimers()
330
+ })
331
+
332
+ it('publishes a debounced correction for a message recorded via onPublishResult', () => {
333
+ const sender = vi.fn(() => true)
334
+ const m = mirrorWith(sender)
335
+ // First: a delivered buzz publish, then its OK result records the mapping.
336
+ m.mirrorReplyDelivered({
337
+ scrubbedText: 'answer',
338
+ ownerOriginChannel: 'buzz',
339
+ ownerBuzzCoords: BUZZ_COORDS,
340
+ ownerEchoed: true,
341
+ hasRecentDifferentOriginTurn: false,
342
+ telegramMessageKeys: ['555:1001'],
343
+ })
344
+ const correlationId = sender.mock.calls[0][0].correlationId
345
+ m.onPublishResult({ correlationId, ok: true, eventId: 'published-evt' })
346
+ sender.mockClear()
347
+
348
+ m.mirrorCorrection({ telegramMessageKey: '555:1001', scrubbedText: 'corrected' })
349
+ // Debounced: nothing before the window elapses.
350
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS - 10)
351
+ expect(sender).not.toHaveBeenCalled()
352
+ vi.advanceTimersByTime(20)
353
+ expect(sender).toHaveBeenCalledTimes(1)
354
+ const corr = sender.mock.calls[0][0]
355
+ expect(corr.payload).toEqual({ kind: 'correction', text: 'corrected', targetEventId: 'published-evt' })
356
+ vi.useRealTimers()
357
+ })
358
+
359
+ it('S4/F6: two rapid edits COALESCE to a SINGLE published correction (last-write-wins)', () => {
360
+ const sender = vi.fn(() => true)
361
+ const m = mirrorWith(sender)
362
+ // Record a mirrored message so there is a Buzz event to correct.
363
+ m.mirrorReplyDelivered({
364
+ scrubbedText: 'answer',
365
+ ownerOriginChannel: 'buzz',
366
+ ownerBuzzCoords: BUZZ_COORDS,
367
+ ownerEchoed: true,
368
+ hasRecentDifferentOriginTurn: false,
369
+ telegramMessageKeys: ['555:1001'],
370
+ })
371
+ const correlationId = sender.mock.calls[0][0].correlationId
372
+ m.onPublishResult({ correlationId, ok: true, eventId: 'published-evt' })
373
+ sender.mockClear()
374
+
375
+ // Edit #1 at t=0 arms a timer for t=DEBOUNCE. Edit #2 arrives INSIDE the
376
+ // window and must CLEAR edit #1's timer (buzz-mirror.ts:192-193) so only the
377
+ // latest text is ever published, and only once.
378
+ m.mirrorCorrection({ telegramMessageKey: '555:1001', scrubbedText: 'first edit' })
379
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS - 5_000) // still inside the window
380
+ m.mirrorCorrection({ telegramMessageKey: '555:1001', scrubbedText: 'second edit' })
381
+
382
+ // Step PAST edit #1's original deadline. If coalescing were broken, edit #1's
383
+ // timer would fire here — it must NOT (it was cleared).
384
+ vi.advanceTimersByTime(6_000)
385
+ expect(sender).not.toHaveBeenCalled()
386
+
387
+ // Reach edit #2's deadline: EXACTLY ONE correction, carrying the LATEST text.
388
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS)
389
+ expect(sender).toHaveBeenCalledTimes(1)
390
+ expect(sender.mock.calls[0][0].payload).toEqual({
391
+ kind: 'correction',
392
+ text: 'second edit',
393
+ targetEventId: 'published-evt',
394
+ })
395
+ vi.useRealTimers()
396
+ })
397
+ })
398
+
399
+ describe('BuzzMirror correction correlation SURVIVES a gateway restart (#4222)', () => {
400
+ let dir: string
401
+ let journalPath: string
402
+
403
+ beforeEach(() => {
404
+ __resetBuzzMirrorForTests()
405
+ vi.useFakeTimers()
406
+ dir = mkdtempSync(join(tmpdir(), 'buzz-mirror-corr-'))
407
+ journalPath = join(dir, 'buzz', 'mirror-correlation.jsonl')
408
+ })
409
+ afterEach(() => {
410
+ __resetBuzzMirrorForTests()
411
+ vi.useRealTimers()
412
+ rmSync(dir, { recursive: true, force: true })
413
+ })
414
+
415
+ function bootMirror(sender: (m: OutboundToBuzzMessage) => boolean) {
416
+ const m = initBuzzMirror({
417
+ mode: 'both',
418
+ agentName: 'klanker',
419
+ defaultChannelId: 'default-chan',
420
+ correlationJournalPath: journalPath,
421
+ })
422
+ m.attachSender(sender)
423
+ return m
424
+ }
425
+
426
+ it('an edit_message on a pre-restart-mirrored answer STILL emits the superseding correction', () => {
427
+ // --- Session 1: mirror an answer and record its published Buzz event. ---
428
+ const sender1 = vi.fn(() => true)
429
+ const m1 = bootMirror(sender1)
430
+ m1.mirrorReplyDelivered({
431
+ scrubbedText: 'answer',
432
+ ownerOriginChannel: 'buzz',
433
+ ownerBuzzCoords: BUZZ_COORDS,
434
+ ownerEchoed: true,
435
+ hasRecentDifferentOriginTurn: false,
436
+ telegramMessageKeys: ['555:1001'],
437
+ })
438
+ const correlationId = sender1.mock.calls[0][0].correlationId
439
+ m1.onPublishResult({ correlationId, ok: true, eventId: 'published-evt' })
440
+
441
+ // --- Restart: a NEW BuzzMirror over the SAME journal (map reloads from disk).
442
+ __resetBuzzMirrorForTests() // closes m1's journal fd
443
+ const sender2 = vi.fn(() => true)
444
+ const m2 = bootMirror(sender2)
445
+
446
+ // An edit lands AFTER the restart on a message mirrored BEFORE it. On today's
447
+ // in-memory-only code the reloaded map is empty and this is silently skipped;
448
+ // with the durable journal the correction still fires.
449
+ m2.mirrorCorrection({ telegramMessageKey: '555:1001', scrubbedText: 'corrected' })
450
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS + 1000)
451
+
452
+ expect(sender2).toHaveBeenCalledTimes(1)
453
+ const corr = sender2.mock.calls[0][0]
454
+ expect(corr.payload).toEqual({
455
+ kind: 'correction',
456
+ text: 'corrected',
457
+ targetEventId: 'published-evt',
458
+ })
459
+ expect(m2.getCorrectionMisses()).toBe(0)
460
+ })
461
+
462
+ it('a correction on a truly-unknown key logs a LOUD miss (never silent) and never publishes', () => {
463
+ const logs: string[] = []
464
+ __resetBuzzMirrorForTests()
465
+ const sender = vi.fn(() => true)
466
+ const m = initBuzzMirror({
467
+ mode: 'both',
468
+ agentName: 'klanker',
469
+ defaultChannelId: 'default-chan',
470
+ correlationJournalPath: journalPath,
471
+ log: (msg) => logs.push(msg),
472
+ })
473
+ m.attachSender(sender)
474
+
475
+ m.mirrorCorrection({ telegramMessageKey: '555:404', scrubbedText: 'fixed' })
476
+ vi.advanceTimersByTime(CORRECTION_DEBOUNCE_MS + 1000)
477
+
478
+ expect(sender).not.toHaveBeenCalled()
479
+ expect(m.getCorrectionMisses()).toBe(1)
480
+ expect(logs.some((l) => /CORRECTION MISS/.test(l))).toBe(true)
481
+ })
482
+ })
483
+
484
+ describe('resolveCorrelationJournalPath — distinct from the sidecar dedup journal', () => {
485
+ it('derives mirror-correlation.jsonl under $TELEGRAM_STATE_DIR/buzz (NOT journal.jsonl)', () => {
486
+ const p = resolveCorrelationJournalPath({ TELEGRAM_STATE_DIR: '/state/agent/telegram' })
487
+ expect(p).toBe('/state/agent/telegram/buzz/mirror-correlation.jsonl')
488
+ // Must not collide with the sidecar's journal.jsonl on the shared buzz dir.
489
+ expect(p).not.toContain('journal.jsonl')
490
+ })
491
+
492
+ it('honours the BUZZ_MIRROR_CORRELATION_PATH override', () => {
493
+ expect(
494
+ resolveCorrelationJournalPath({ BUZZ_MIRROR_CORRELATION_PATH: '/custom/corr.jsonl' }),
495
+ ).toBe('/custom/corr.jsonl')
496
+ })
497
+
498
+ it('returns undefined (in-memory only) when TELEGRAM_STATE_DIR is unset', () => {
499
+ expect(resolveCorrelationJournalPath({})).toBeUndefined()
500
+ })
501
+ })
502
+
503
+ describe('maybeBootBuzzMirror — dark by default (S2)', () => {
504
+ beforeEach(() => __resetBuzzMirrorForTests())
505
+
506
+ it('stays dark (returns null, getBuzzMirror null) when BUZZ_ENABLED is unset', () => {
507
+ const booted = maybeBootBuzzMirror(() => true, {})
508
+ expect(booted).toBeNull()
509
+ expect(getBuzzMirror()).toBeNull()
510
+ })
511
+
512
+ it('stays dark when enabled but mode is off/origin (S2 degrade)', () => {
513
+ expect(maybeBootBuzzMirror(() => true, { BUZZ_ENABLED: '1', BUZZ_MIRROR: 'off' })).toBeNull()
514
+ expect(maybeBootBuzzMirror(() => true, { BUZZ_ENABLED: '1', BUZZ_MIRROR: 'origin' })).toBeNull()
515
+ expect(getBuzzMirror()).toBeNull()
516
+ })
517
+
518
+ it('boots when enabled AND mode both, wiring the sender', () => {
519
+ const sender = vi.fn(() => true)
520
+ const booted = maybeBootBuzzMirror(sender, {
521
+ BUZZ_ENABLED: '1',
522
+ BUZZ_MIRROR: 'both',
523
+ SWITCHROOM_AGENT_NAME: 'klanker',
524
+ BUZZ_CHANNEL_IDS: 'grp-top',
525
+ })
526
+ expect(booted).not.toBeNull()
527
+ expect(getBuzzMirror()).toBe(booted)
528
+ // Sender is wired: a telegram-origin mirror flows to it.
529
+ booted!.mirrorReplyDelivered({
530
+ scrubbedText: 'x',
531
+ ownerOriginChannel: 'telegram',
532
+ ownerEchoed: false,
533
+ hasRecentDifferentOriginTurn: false,
534
+ telegramMessageKeys: ['555:1'],
535
+ })
536
+ expect(sender).toHaveBeenCalledTimes(1)
537
+ })
538
+ })