switchroom 0.20.0 → 0.20.2

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 (29) hide show
  1. package/bin/handoff-briefing.sh +213 -74
  2. package/dist/agent-scheduler/index.js +2 -2
  3. package/dist/auth-broker/index.js +4 -3
  4. package/dist/buzz-gateway/index.js +166 -6
  5. package/dist/cli/notion-write-pretool.mjs +2 -2
  6. package/dist/cli/switchroom.js +24704 -16399
  7. package/dist/host-control/main.js +44 -10
  8. package/dist/vault/approvals/kernel-server.js +4 -3
  9. package/dist/vault/broker/server.js +4 -3
  10. package/package.json +3 -2
  11. package/profiles/_base/start.sh.hbs +79 -10
  12. package/telegram-plugin/dist/gateway/gateway.js +1400 -964
  13. package/telegram-plugin/gateway/access-store.test.ts +234 -0
  14. package/telegram-plugin/gateway/access-store.ts +194 -0
  15. package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
  16. package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
  17. package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
  18. package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
  19. package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
  20. package/telegram-plugin/gateway/gateway.ts +43 -123
  21. package/telegram-plugin/gateway/inbound-router.ts +93 -3
  22. package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
  23. package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
  24. package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
  25. package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
  26. package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
  27. package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
  28. package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
  29. package/telegram-plugin/tests/reply-to-buffer-history.test.ts +134 -0
@@ -0,0 +1,173 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ createCorrelationStore,
4
+ type CorrelationFsLike,
5
+ } from '../gateway/buzz-mirror-correlation-store.js'
6
+
7
+ /**
8
+ * In-memory fake filesystem implementing CorrelationFsLike. Shared across store
9
+ * instances to simulate a gateway restart against the SAME journal — the core
10
+ * of the #4222 fix (an edit_message correction must survive a restart).
11
+ */
12
+ function makeFakeFs(): CorrelationFsLike & { files: Map<string, string>; dirs: Set<string> } {
13
+ const files = new Map<string, string>()
14
+ const dirs = new Set<string>()
15
+ const fdPaths = new Map<number, string>()
16
+ let nextFd = 3
17
+ return {
18
+ files,
19
+ dirs,
20
+ existsSync: (p) => files.has(p) || dirs.has(p),
21
+ mkdirSync: (p) => { dirs.add(p) },
22
+ readFileSync: (p) => {
23
+ if (!files.has(p)) throw new Error(`ENOENT ${p}`)
24
+ return files.get(p)!
25
+ },
26
+ writeFileSync: (p, data) => { files.set(p, data) },
27
+ renameSync: (from, to) => {
28
+ files.set(to, files.get(from) ?? '')
29
+ files.delete(from)
30
+ },
31
+ openSync: (p) => {
32
+ if (!files.has(p)) files.set(p, '')
33
+ const fd = nextFd++
34
+ fdPaths.set(fd, p)
35
+ return fd
36
+ },
37
+ writeSync: (fd, data) => {
38
+ const p = fdPaths.get(fd)!
39
+ files.set(p, (files.get(p) ?? '') + data)
40
+ },
41
+ fsyncSync: () => { /* no-op */ },
42
+ closeSync: (fd) => { fdPaths.delete(fd) },
43
+ }
44
+ }
45
+
46
+ const JP = '/state/buzz/mirror-correlation.jsonl'
47
+
48
+ describe('createCorrelationStore — durable msg→Buzz correlation (#4222)', () => {
49
+ it('replays a persisted mapping into a NEW store instance (restart survival)', () => {
50
+ const fs = makeFakeFs()
51
+ const a = createCorrelationStore({ journalPath: JP, fs })
52
+ a.set('555:1001', { eventId: 'evt-A', channelId: 'chan-A' })
53
+ a.close()
54
+
55
+ // Fresh instance over the SAME journal — the mapping must reload.
56
+ const b = createCorrelationStore({ journalPath: JP, fs })
57
+ expect(b.get('555:1001')).toEqual({ eventId: 'evt-A', channelId: 'chan-A' })
58
+ expect(b.size()).toBe(1)
59
+ })
60
+
61
+ it('last-write-wins on a re-set key across a restart', () => {
62
+ const fs = makeFakeFs()
63
+ const a = createCorrelationStore({ journalPath: JP, fs })
64
+ a.set('555:1', { eventId: 'evt-old', channelId: 'chan-A' })
65
+ a.set('555:1', { eventId: 'evt-new', channelId: 'chan-A' })
66
+ a.close()
67
+
68
+ const b = createCorrelationStore({ journalPath: JP, fs })
69
+ expect(b.get('555:1')).toEqual({ eventId: 'evt-new', channelId: 'chan-A' })
70
+ expect(b.size()).toBe(1)
71
+ })
72
+
73
+ it('persists + replays the NIP-10 threadRoot (outbound thread continuity)', () => {
74
+ const fs = makeFakeFs()
75
+ const a = createCorrelationStore({ journalPath: JP, fs })
76
+ // A mirror that threaded under a deeper parent records a root distinct from
77
+ // its own event id — that root must survive a restart so a later reply can
78
+ // emit the correct NIP-10 `root` marker.
79
+ a.set('555:200', { eventId: 'evt-mid', channelId: 'chan-A', threadRoot: 'evt-root' })
80
+ a.close()
81
+
82
+ const b = createCorrelationStore({ journalPath: JP, fs })
83
+ expect(b.get('555:200')).toEqual({ eventId: 'evt-mid', channelId: 'chan-A', threadRoot: 'evt-root' })
84
+ // The record on disk actually carries the field (not just an in-memory echo).
85
+ expect(fs.files.get(JP) ?? '').toContain('"threadRoot":"evt-root"')
86
+ })
87
+
88
+ it('a pre-threadRoot journal record replays with threadRoot undefined (backward compat)', () => {
89
+ const fs = makeFakeFs()
90
+ fs.dirs.add('/state/buzz')
91
+ // A record written before threadRoot existed — no threadRoot key.
92
+ fs.files.set(JP, JSON.stringify({ key: '555:1', eventId: 'evt-A', channelId: 'chan-A' }) + '\n')
93
+ const s = createCorrelationStore({ journalPath: JP, fs })
94
+ const v = s.get('555:1')
95
+ expect(v?.eventId).toBe('evt-A')
96
+ expect(v?.channelId).toBe('chan-A')
97
+ expect(v?.threadRoot).toBeUndefined()
98
+ })
99
+
100
+ it('in-memory-only mode (no journalPath) never touches the fs but still bounds', () => {
101
+ const fs = makeFakeFs()
102
+ const s = createCorrelationStore({ capacity: 2, fs })
103
+ s.set('a', { eventId: 'e1', channelId: 'c' })
104
+ s.set('b', { eventId: 'e2', channelId: 'c' })
105
+ s.set('c', { eventId: 'e3', channelId: 'c' })
106
+ expect(s.get('a')).toBeUndefined() // evicted (FIFO, capacity 2)
107
+ expect(s.size()).toBe(2)
108
+ expect(fs.files.size).toBe(0) // no journal written
109
+ })
110
+
111
+ it('enforces the FIFO capacity bound in memory AND on disk after compaction', () => {
112
+ const fs = makeFakeFs()
113
+ const capacity = 4
114
+ const a = createCorrelationStore({ journalPath: JP, fs, capacity })
115
+ // Insert more than capacity — oldest keys evict.
116
+ for (let i = 0; i < 20; i++) a.set(`k:${i}`, { eventId: `e${i}`, channelId: 'c' })
117
+ expect(a.size()).toBe(capacity)
118
+ expect(a.get('k:0')).toBeUndefined()
119
+ expect(a.get('k:19')).toEqual({ eventId: 'e19', channelId: 'c' })
120
+ a.close()
121
+
122
+ // Restart: boot compaction must keep the on-disk journal bounded too — a new
123
+ // instance sees exactly `capacity` keys, and the compacted journal file has
124
+ // no more than `capacity` lines.
125
+ const b = createCorrelationStore({ journalPath: JP, fs, capacity })
126
+ expect(b.size()).toBe(capacity)
127
+ const lines = (fs.files.get(JP) ?? '').split('\n').filter((l) => l.trim())
128
+ expect(lines.length).toBeLessThanOrEqual(capacity)
129
+ expect(b.get('k:19')).toEqual({ eventId: 'e19', channelId: 'c' })
130
+ })
131
+
132
+ it('re-compacts in-session so the journal never grows unbounded', () => {
133
+ const fs = makeFakeFs()
134
+ const capacity = 4
135
+ const s = createCorrelationStore({ journalPath: JP, fs, capacity })
136
+ // Churn far more writes than capacity; the in-session compaction (at
137
+ // capacity * COMPACTION_FACTOR appends) must keep the file bounded.
138
+ for (let i = 0; i < 200; i++) s.set(`k:${i}`, { eventId: `e${i}`, channelId: 'c' })
139
+ const lines = (fs.files.get(JP) ?? '').split('\n').filter((l) => l.trim())
140
+ // Bounded by roughly capacity * COMPACTION_FACTOR (4*4=16) + the compacted
141
+ // baseline — assert it stays a small multiple of capacity, never 200.
142
+ expect(lines.length).toBeLessThan(capacity * 8)
143
+ expect(s.size()).toBe(capacity)
144
+ s.close()
145
+ })
146
+
147
+ it('tolerates a torn final journal line (crash mid-write)', () => {
148
+ const fs = makeFakeFs()
149
+ fs.dirs.add('/state/buzz')
150
+ fs.files.set(
151
+ JP,
152
+ JSON.stringify({ key: '1:1', eventId: 'e1', channelId: 'c' }) + '\n' + '{"key":"1:2","eventId', // truncated
153
+ )
154
+ const s = createCorrelationStore({ journalPath: JP, fs })
155
+ expect(s.get('1:1')).toEqual({ eventId: 'e1', channelId: 'c' })
156
+ expect(s.get('1:2')).toBeUndefined()
157
+ })
158
+
159
+ it('degrades to empty in-memory when the journal cannot be read', () => {
160
+ const fs = makeFakeFs()
161
+ fs.dirs.add('/state/buzz')
162
+ fs.files.set(JP, 'exists-but-unreadable')
163
+ const throwingFs: CorrelationFsLike = {
164
+ ...fs,
165
+ readFileSync: () => { throw new Error('EIO') },
166
+ }
167
+ const s = createCorrelationStore({ journalPath: JP, fs: throwingFs })
168
+ // No crash; store is empty and still usable.
169
+ expect(s.size()).toBe(0)
170
+ s.set('1:1', { eventId: 'e1', channelId: 'c' })
171
+ expect(s.get('1:1')).toEqual({ eventId: 'e1', channelId: 'c' })
172
+ })
173
+ })
@@ -1,12 +1,17 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest'
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'
2
5
  import {
3
6
  initBuzzMirror,
4
7
  getBuzzMirror,
5
8
  maybeBootBuzzMirror,
9
+ resolveCorrelationJournalPath,
6
10
  __resetBuzzMirrorForTests,
7
11
  CORRECTION_DEBOUNCE_MS,
8
12
  } from '../gateway/buzz-mirror.js'
9
13
  import type { OutboundToBuzzMessage } from '../gateway/ipc-protocol.js'
14
+ import { buildThreadTags } from '../../src/buzz-gateway/transform.js'
10
15
 
11
16
  // Hub-side mirror behaviour (Phase 2b). The mirror is DOWNSTREAM of a delivered
12
17
  // Telegram copy; a publish is emitted via the attached peer sender. These tests
@@ -122,6 +127,193 @@ describe('BuzzMirror.mirrorReplyDelivered — routing + S1 owner guard', () => {
122
127
  })
123
128
  })
124
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
+
125
317
  describe('BuzzMirror.mirrorCorrection — debounced, only for mirrored messages', () => {
126
318
  beforeEach(() => {
127
319
  __resetBuzzMirrorForTests()
@@ -204,6 +396,110 @@ describe('BuzzMirror.mirrorCorrection — debounced, only for mirrored messages'
204
396
  })
205
397
  })
206
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
+
207
503
  describe('maybeBootBuzzMirror — dark by default (S2)', () => {
208
504
  beforeEach(() => __resetBuzzMirrorForTests())
209
505
 
@@ -18,6 +18,7 @@ import {
18
18
  computeReplyChunks,
19
19
  resplitOversizeChunk,
20
20
  chunkText,
21
+ resolveMirrorAntecedentKey,
21
22
  } from '../gateway/outbound-send-path.js'
22
23
 
23
24
  /**
@@ -413,3 +414,26 @@ describe('outbound-send-path — temporal pass wiring (#3501)', () => {
413
414
  expect(viaSeam).toBe(viaModule)
414
415
  })
415
416
  })
417
+
418
+ // ── Buzz-mirror antecedent gating (#4300 / #4301) ──────────────────────────
419
+ describe('resolveMirrorAntecedentKey — Buzz-mirror antecedent gating', () => {
420
+ it('#4300: replyMode "off" stamps NO antecedent → Buzz mirror stays flat', () => {
421
+ // With reply-mode off the Telegram copy renders no reply, so the Buzz copy
422
+ // must not thread either — otherwise the surfaces diverge.
423
+ expect(resolveMirrorAntecedentKey('555', 100, 'off')).toBeUndefined()
424
+ })
425
+
426
+ it('stamps the antecedent for reply-rendering modes (first / all)', () => {
427
+ expect(resolveMirrorAntecedentKey('555', 100, 'first')).toBe('555:100')
428
+ expect(resolveMirrorAntecedentKey('555', 100, 'all')).toBe('555:100')
429
+ })
430
+
431
+ it('#4301: a non-numeric reply_to (NaN) stamps NO antecedent (no chat:NaN key)', () => {
432
+ expect(resolveMirrorAntecedentKey('555', Number('not-a-number'), 'first')).toBeUndefined()
433
+ expect(resolveMirrorAntecedentKey('555', NaN, 'all')).toBeUndefined()
434
+ })
435
+
436
+ it('stamps NO antecedent when there is no reply_to', () => {
437
+ expect(resolveMirrorAntecedentKey('555', undefined, 'first')).toBeUndefined()
438
+ })
439
+ })