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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +2 -2
- package/dist/auth-broker/index.js +4 -3
- package/dist/buzz-gateway/index.js +166 -6
- package/dist/cli/notion-write-pretool.mjs +2 -2
- package/dist/cli/switchroom.js +24704 -16399
- package/dist/host-control/main.js +44 -10
- package/dist/vault/approvals/kernel-server.js +4 -3
- package/dist/vault/broker/server.js +4 -3
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +79 -10
- package/telegram-plugin/dist/gateway/gateway.js +1400 -964
- package/telegram-plugin/gateway/access-store.test.ts +234 -0
- package/telegram-plugin/gateway/access-store.ts +194 -0
- package/telegram-plugin/gateway/boot-briefing-builder.ts +135 -7
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +166 -4
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +177 -12
- package/telegram-plugin/gateway/gateway.ts +43 -123
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/outbound-send-path.ts +48 -1
- package/telegram-plugin/gateway/pending-turn-env.ts +10 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +422 -31
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +297 -1
- package/telegram-plugin/tests/outbound-send-path.test.ts +24 -0
- package/telegram-plugin/tests/reply-to-buffer-fallback.test.ts +273 -0
- 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
|
+
})
|