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.
- package/bin/handoff-briefing.sh +213 -74
- package/dist/agent-scheduler/index.js +18 -1
- package/dist/auth-broker/index.js +19 -2
- package/dist/buzz-gateway/index.js +9367 -0
- package/dist/cli/notion-write-pretool.mjs +18 -1
- package/dist/cli/switchroom.js +24734 -16371
- package/dist/host-control/main.js +59 -9
- package/dist/vault/approvals/kernel-server.js +19 -2
- package/dist/vault/broker/server.js +19 -2
- package/package.json +6 -4
- package/profiles/_base/start.sh.hbs +148 -2
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/dev-protocol/SKILL.md +30 -1
- package/skills/switchroom-architecture/SKILL.md +5 -0
- package/skills/switchroom-cli/SKILL.md +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +7 -4
- package/telegram-plugin/dist/gateway/gateway.js +2376 -1039
- package/telegram-plugin/dist/server.js +7 -4
- 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 +586 -0
- package/telegram-plugin/gateway/boot-briefing-capability.ts +31 -0
- package/telegram-plugin/gateway/boot-briefing-wiring.ts +332 -0
- package/telegram-plugin/gateway/buzz-mirror-correlation-store.ts +285 -0
- package/telegram-plugin/gateway/buzz-mirror.ts +494 -0
- package/telegram-plugin/gateway/buzz-type-guards.ts +34 -0
- package/telegram-plugin/gateway/channel-route.ts +272 -0
- package/telegram-plugin/gateway/gateway.ts +115 -203
- package/telegram-plugin/gateway/inbound-router.ts +93 -3
- package/telegram-plugin/gateway/inbound-spool.ts +33 -1
- package/telegram-plugin/gateway/ipc-protocol.ts +81 -2
- package/telegram-plugin/gateway/ipc-server.ts +197 -2
- package/telegram-plugin/gateway/outbound-send-path.ts +85 -2
- package/telegram-plugin/gateway/pending-turn-env.ts +70 -0
- package/telegram-plugin/gateway/stream-render.ts +21 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +12 -0
- package/telegram-plugin/gateway/user-failure-notices.ts +172 -0
- package/telegram-plugin/history.ts +15 -0
- package/telegram-plugin/llm-error-present.ts +9 -4
- package/telegram-plugin/model-unavailable.ts +4 -0
- package/telegram-plugin/operator-events.fixtures.json +12 -12
- package/telegram-plugin/operator-events.ts +81 -9
- package/telegram-plugin/session-tail.ts +7 -1
- package/telegram-plugin/tests/boot-briefing-builder.test.ts +995 -0
- package/telegram-plugin/tests/buzz-mirror-correlation-store.test.ts +173 -0
- package/telegram-plugin/tests/buzz-mirror.test.ts +538 -0
- package/telegram-plugin/tests/buzz-origin-stamp-gate.test.ts +159 -0
- package/telegram-plugin/tests/channel-route.test.ts +306 -0
- package/telegram-plugin/tests/inbound-spool.test.ts +47 -0
- package/telegram-plugin/tests/ipc-server-buzz-dedup.test.ts +124 -0
- package/telegram-plugin/tests/ipc-server-buzz-peer.test.ts +269 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +63 -0
- package/telegram-plugin/tests/operator-events.test.ts +71 -7
- 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
- package/telegram-plugin/tests/user-failure-notices.test.ts +165 -0
- package/telegram-plugin/voice-normalize-text.ts +5 -0
- package/vendor/hindsight-memory/scripts/directive_verify.py +4 -0
- 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
|
+
})
|