switchroom 0.20.21 → 0.21.0
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/dist/auth-broker/index.js +1 -1
- package/dist/cli/switchroom.js +1953 -1539
- package/dist/host-control/main.js +286 -122
- package/dist/vault/approvals/kernel-server.js +1 -1
- package/dist/vault/broker/server.js +1 -1
- package/package.json +1 -1
- package/skills/switchroom-release/SKILL.md +12 -1
- package/telegram-plugin/dist/gateway/gateway.js +1405 -851
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +2 -2
- package/telegram-plugin/gateway/boot-beacon.ts +9 -2
- package/telegram-plugin/gateway/gateway-heartbeat.ts +4 -3
- package/telegram-plugin/gateway/gateway.ts +42 -15
- package/telegram-plugin/gateway/inbound-router.ts +31 -6
- package/telegram-plugin/gateway/missed-approvals-store.ts +2 -2
- package/telegram-plugin/gateway/pending-card-store.ts +2 -2
- package/telegram-plugin/gateway/privacy-state.ts +2 -2
- package/telegram-plugin/gateway/scoped-grant-store.ts +2 -2
- package/telegram-plugin/gateway/system-message-observer.ts +242 -0
- package/telegram-plugin/gateway/turn-active-marker.ts +3 -4
- package/telegram-plugin/history.ts +178 -11
- package/telegram-plugin/registry/turns-schema.ts +8 -2
- package/telegram-plugin/tests/buzz-mirror.test.ts +12 -1
- package/telegram-plugin/tests/card-history-lane.test.ts +394 -0
- package/telegram-plugin/tests/system-message-observer.test.ts +216 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +88 -4
- package/vendor/hindsight-memory/scripts/lib/config.py +112 -11
- package/vendor/hindsight-memory/scripts/recall.py +11 -2
- package/vendor/hindsight-memory/scripts/reconcile_tail.py +36 -0
- package/vendor/hindsight-memory/scripts/retain.py +6 -1
- package/vendor/hindsight-memory/scripts/tests/test_config_retain_env.py +99 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_types_filter.py +81 -0
- package/vendor/hindsight-memory/scripts/tests/test_reconcile_durability.py +113 -0
- package/vendor/hindsight-memory/settings.json +2 -2
- package/vendor/hindsight-memory/tests/test_config.py +8 -3
- package/vendor/hindsight-memory/tests/test_hooks.py +10 -1
- package/vendor/hindsight-memory/tests/test_retain_context.py +69 -0
|
@@ -38,15 +38,14 @@
|
|
|
38
38
|
import {
|
|
39
39
|
closeSync,
|
|
40
40
|
existsSync,
|
|
41
|
-
mkdirSync,
|
|
42
41
|
openSync,
|
|
43
42
|
readFileSync,
|
|
44
43
|
statSync,
|
|
45
44
|
unlinkSync,
|
|
46
45
|
utimesSync,
|
|
47
|
-
writeFileSync,
|
|
48
46
|
} from "node:fs";
|
|
49
47
|
import { join } from "node:path";
|
|
48
|
+
import { mkdirStateSync, writeStateFileSync } from "../../src/util/state-owner.js";
|
|
50
49
|
|
|
51
50
|
export const TURN_ACTIVE_MARKER_FILE = "turn-active.json";
|
|
52
51
|
|
|
@@ -82,8 +81,8 @@ export interface TurnActiveMarker {
|
|
|
82
81
|
*/
|
|
83
82
|
export function writeTurnActiveMarker(stateDir: string, marker: TurnActiveMarker): void {
|
|
84
83
|
try {
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
mkdirStateSync(stateDir, { recursive: true });
|
|
85
|
+
writeStateFileSync(
|
|
87
86
|
join(stateDir, TURN_ACTIVE_MARKER_FILE),
|
|
88
87
|
JSON.stringify(marker, null, 2) + "\n",
|
|
89
88
|
{ mode: 0o600 },
|
|
@@ -25,8 +25,9 @@
|
|
|
25
25
|
* chat exists in the DB.
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
-
import { chmodSync, existsSync
|
|
28
|
+
import { chmodSync, existsSync } from 'fs'
|
|
29
29
|
import { join } from 'path'
|
|
30
|
+
import { adoptSqliteOwnership, mkdirStateSync } from '../src/util/state-owner.js'
|
|
30
31
|
import { redact } from './secret-detect/redact.js'
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -81,7 +82,22 @@ function loadDatabaseClass(): SqliteDatabaseConstructor {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
84
|
-
|
|
85
|
+
/**
|
|
86
|
+
* `system` is the CARD lane (#4571): a bot→user message the gateway posted as
|
|
87
|
+
* a UI surface rather than as an answer — the mid-turn activity card, the
|
|
88
|
+
* status pin, approval / boot / issues / worker-feed cards, restart notices,
|
|
89
|
+
* `progress_update` lines. Those consume real Telegram message ids, so the
|
|
90
|
+
* operator can (and does) quote-reply to one; before this lane existed the
|
|
91
|
+
* row simply did not exist and the reply resolved to nothing.
|
|
92
|
+
*
|
|
93
|
+
* They are deliberately NOT `assistant`: every delivery-accounting predicate
|
|
94
|
+
* in this file keys on `role = 'assistant'` meaning "the user was actually
|
|
95
|
+
* answered" (`getRecentOutboundCount`, `hasOutboundDeliveredSince`,
|
|
96
|
+
* `hasOutboundWithText`), and calling a card an answer would silently suppress
|
|
97
|
+
* the silence/over-ping/represent safety nets. A distinct value keeps every
|
|
98
|
+
* existing predicate byte-identical while making the id resolvable.
|
|
99
|
+
*/
|
|
100
|
+
export type MessageRole = 'user' | 'assistant' | 'system'
|
|
85
101
|
|
|
86
102
|
export interface RecordedMessage {
|
|
87
103
|
chat_id: string
|
|
@@ -127,6 +143,14 @@ export interface RecordedMessage {
|
|
|
127
143
|
forwarded_from_id: string | null
|
|
128
144
|
forwarded_date: string | null
|
|
129
145
|
forwarded_message_id: number | null
|
|
146
|
+
/**
|
|
147
|
+
* Discriminator for a `system` (card) row: the send's `verb` tag as it was
|
|
148
|
+
* passed to the gateway's retry wrapper, normalised (`activity-summary.send`
|
|
149
|
+
* → `activity-summary`). Null for `user` / `assistant` rows and for an
|
|
150
|
+
* untagged send. Surfaced to the agent as `reply_to_kind` when a native
|
|
151
|
+
* reply points at one of these messages.
|
|
152
|
+
*/
|
|
153
|
+
kind: string | null
|
|
130
154
|
}
|
|
131
155
|
|
|
132
156
|
export interface QueryOptions {
|
|
@@ -134,6 +158,14 @@ export interface QueryOptions {
|
|
|
134
158
|
thread_id?: number | null
|
|
135
159
|
limit?: number
|
|
136
160
|
before_message_id?: number
|
|
161
|
+
/**
|
|
162
|
+
* Include `system` (card) rows in the result. DEFAULT FALSE, deliberately:
|
|
163
|
+
* cards are ephemeral UI, and `get_recent_messages` renders straight into
|
|
164
|
+
* the operator-visible model context — a wall of activity-card rows there
|
|
165
|
+
* would be a regression. Card rows stay RESOLVABLE by id (see
|
|
166
|
+
* {@link lookupMessageRoleAndText}) without being LISTED.
|
|
167
|
+
*/
|
|
168
|
+
include_system?: boolean
|
|
137
169
|
}
|
|
138
170
|
|
|
139
171
|
const DEFAULT_LIMIT = 10
|
|
@@ -185,7 +217,7 @@ function isValidMessageId(id: unknown): id is number {
|
|
|
185
217
|
export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
186
218
|
if (db != null) return
|
|
187
219
|
const Database = loadDatabaseClass()
|
|
188
|
-
|
|
220
|
+
mkdirStateSync(stateDir, { recursive: true, mode: 0o700 })
|
|
189
221
|
const path = join(stateDir, 'history.db')
|
|
190
222
|
dbPath = path
|
|
191
223
|
db = new Database(path, { create: true })
|
|
@@ -212,6 +244,7 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
212
244
|
group_id INTEGER,
|
|
213
245
|
reply_to_message_id INTEGER,
|
|
214
246
|
reply_to_text TEXT,
|
|
247
|
+
kind TEXT,
|
|
215
248
|
PRIMARY KEY (chat_id, thread_id, message_id)
|
|
216
249
|
)
|
|
217
250
|
`)
|
|
@@ -232,6 +265,10 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
232
265
|
"forwarded_from_id TEXT",
|
|
233
266
|
"forwarded_date TEXT",
|
|
234
267
|
"forwarded_message_id INTEGER",
|
|
268
|
+
// #4571 — card/system-surface discriminator. Nullable with no default, so
|
|
269
|
+
// the ALTER is instant on a multi-thousand-row live DB and every existing
|
|
270
|
+
// row keeps its exact contents (SQLite backfills NULL without a rewrite).
|
|
271
|
+
"kind TEXT",
|
|
235
272
|
]) {
|
|
236
273
|
try {
|
|
237
274
|
db.exec(`ALTER TABLE messages ADD COLUMN ${column}`)
|
|
@@ -300,6 +337,12 @@ export function initHistory(stateDir: string, retentionDays = 30): void {
|
|
|
300
337
|
const f = path + suffix
|
|
301
338
|
if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
|
|
302
339
|
}
|
|
340
|
+
// Ownership rides the same hook as the mode, for the same reason. SQLite
|
|
341
|
+
// creates `-wal`/`-shm` itself, in C, so no `node:fs` write helper can ever
|
|
342
|
+
// see them — this is the only place we get to fix their owner. A root
|
|
343
|
+
// gateway would otherwise leave root:root DB files in an agent-owned state
|
|
344
|
+
// dir and EACCES every non-root reader. No-op off the root path.
|
|
345
|
+
adoptSqliteOwnership(path)
|
|
303
346
|
|
|
304
347
|
if (retentionDays > 0) {
|
|
305
348
|
const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400
|
|
@@ -422,6 +465,9 @@ export function checkpointWal(): boolean {
|
|
|
422
465
|
const f = dbPath + suffix
|
|
423
466
|
if (existsSync(f)) { try { chmodSync(f, 0o644) } catch { /* ignore */ } }
|
|
424
467
|
}
|
|
468
|
+
// …and ownership with it: a TRUNCATE checkpoint DELETES and RE-CREATES
|
|
469
|
+
// the sidecars, so the owner we set at init is gone by here.
|
|
470
|
+
adoptSqliteOwnership(dbPath)
|
|
425
471
|
}
|
|
426
472
|
return true
|
|
427
473
|
} catch {
|
|
@@ -609,12 +655,29 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
609
655
|
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
|
|
610
656
|
VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
|
|
611
657
|
`)
|
|
658
|
+
// #4571 — a real reply ALWAYS wins over a provisional card row.
|
|
659
|
+
//
|
|
660
|
+
// The system-lane observer (gateway `robustApiCall`) records every sent
|
|
661
|
+
// message the moment the API resolves, which is BEFORE the caller reaches
|
|
662
|
+
// its own `recordOutbound`. `INSERT OR REPLACE` alone would not reliably
|
|
663
|
+
// overwrite that row: the unique key folds `thread_id`, and the observer
|
|
664
|
+
// derives the thread from the Telegram response (which sets
|
|
665
|
+
// `message_thread_id` on reply chains in plain supergroups) while this
|
|
666
|
+
// writer uses the gateway's own `threadId` — a mismatch would leave BOTH
|
|
667
|
+
// rows and let the card row shadow the answer for id lookups. Deleting on
|
|
668
|
+
// (chat_id, message_id) — unique within a chat regardless of thread, the
|
|
669
|
+
// same assumption `recordEdit` / `recordReaction` already make — makes the
|
|
670
|
+
// promotion exact and is a no-op when no observer row exists.
|
|
671
|
+
const dropSystem = requireDb().prepare(
|
|
672
|
+
`DELETE FROM messages WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
|
|
673
|
+
)
|
|
612
674
|
// bun:sqlite has a transaction() helper. Cheap insurance against partial
|
|
613
675
|
// writes if the process dies mid-loop. The transaction signature is
|
|
614
676
|
// typed as variadic-unknown for genericity; cast the typed callback
|
|
615
677
|
// through the wider shape.
|
|
616
678
|
const tx = requireDb().transaction(((rows: Array<{ id: number; text: string; attachKind: string | null }>) => {
|
|
617
679
|
for (const r of rows) {
|
|
680
|
+
dropSystem.run(args.chat_id, r.id)
|
|
618
681
|
stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId)
|
|
619
682
|
}
|
|
620
683
|
}) as (...args: unknown[]) => unknown)
|
|
@@ -634,6 +697,97 @@ export function recordOutbound(args: RecordOutboundArgs): void {
|
|
|
634
697
|
}
|
|
635
698
|
}
|
|
636
699
|
|
|
700
|
+
export interface RecordSystemOutboundArgs {
|
|
701
|
+
chat_id: string
|
|
702
|
+
thread_id: number | null | undefined
|
|
703
|
+
message_id: number
|
|
704
|
+
/** Normalised send verb (`activity-summary`, `approval-card`, …) or null. */
|
|
705
|
+
kind: string | null | undefined
|
|
706
|
+
/** Rendered text as Telegram echoed it back. May be empty. */
|
|
707
|
+
text: string
|
|
708
|
+
/** Unix SECONDS. Defaults to now. */
|
|
709
|
+
ts?: number
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/**
|
|
713
|
+
* Record a CARD / system-surface outbound (#4571).
|
|
714
|
+
*
|
|
715
|
+
* Conditional insert on (chat_id, message_id): if ANY row already exists for
|
|
716
|
+
* that id — a real `assistant` reply recorded by `recordOutbound`, an inbound,
|
|
717
|
+
* or a previously-recorded card — this is a no-op and returns false. That is
|
|
718
|
+
* what makes the call safe to fire from a blanket send observer that cannot
|
|
719
|
+
* distinguish a fresh send from an edit of a message it already knows: an edit
|
|
720
|
+
* returns the SAME `message_id`, hits the `NOT EXISTS` guard, and falls through
|
|
721
|
+
* to {@link updateSystemOutboundText} instead of duplicating a row per edit.
|
|
722
|
+
*
|
|
723
|
+
* Never throws — a card row is a nice-to-have, and a failure here must not
|
|
724
|
+
* break the send path it is observing. Failures are logged via `warnHistory`.
|
|
725
|
+
*
|
|
726
|
+
* Returns true iff a new row was inserted.
|
|
727
|
+
*/
|
|
728
|
+
export function recordSystemOutbound(args: RecordSystemOutboundArgs): boolean {
|
|
729
|
+
if (!isValidMessageId(args.message_id)) return false
|
|
730
|
+
// History disabled / not yet initialised: stay silent. This is called from a
|
|
731
|
+
// blanket send observer, so a warn here would be one stderr line per API call.
|
|
732
|
+
if (db == null) return false
|
|
733
|
+
try {
|
|
734
|
+
const res = requireDb()
|
|
735
|
+
.prepare(`
|
|
736
|
+
INSERT INTO messages
|
|
737
|
+
(chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, kind)
|
|
738
|
+
SELECT ?, ?, ?, 'system', NULL, NULL, ?, ?, NULL, NULL, ?
|
|
739
|
+
WHERE NOT EXISTS (
|
|
740
|
+
SELECT 1 FROM messages WHERE chat_id = ? AND message_id = ?
|
|
741
|
+
)
|
|
742
|
+
`)
|
|
743
|
+
.run(
|
|
744
|
+
args.chat_id,
|
|
745
|
+
args.thread_id ?? null,
|
|
746
|
+
args.message_id,
|
|
747
|
+
args.ts ?? Math.floor(Date.now() / 1000),
|
|
748
|
+
// Same outbound redaction chokepoint as recordOutbound: a card body is
|
|
749
|
+
// rendered from live tool activity and can quote a secret.
|
|
750
|
+
redact(args.text),
|
|
751
|
+
args.kind ?? null,
|
|
752
|
+
args.chat_id,
|
|
753
|
+
args.message_id,
|
|
754
|
+
) as { changes?: number }
|
|
755
|
+
return (res?.changes ?? 0) > 0
|
|
756
|
+
} catch (err) {
|
|
757
|
+
warnHistory(
|
|
758
|
+
`recordSystemOutbound: INSERT failed (chat=${args.chat_id} id=${args.message_id}): ` +
|
|
759
|
+
`${err instanceof Error ? err.message : String(err)} — a posted card will not be ` +
|
|
760
|
+
`resolvable if the operator quote-replies to it`,
|
|
761
|
+
)
|
|
762
|
+
return false
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Refresh a card row's stored text in place (#4571). Only ever touches a
|
|
768
|
+
* `system` row, so an edit of a real reply can never be rewritten through this
|
|
769
|
+
* path (`recordEdit` owns that).
|
|
770
|
+
*
|
|
771
|
+
* `ts` is deliberately NOT bumped: it is the card's POST time, which is what
|
|
772
|
+
* retention prunes on. Never throws. Returns true iff a row was updated.
|
|
773
|
+
*/
|
|
774
|
+
export function updateSystemOutboundText(args: {
|
|
775
|
+
chat_id: string
|
|
776
|
+
message_id: number
|
|
777
|
+
text: string
|
|
778
|
+
}): boolean {
|
|
779
|
+
try {
|
|
780
|
+
const res = requireDb()
|
|
781
|
+
.prepare(
|
|
782
|
+
`UPDATE messages SET text = ? WHERE chat_id = ? AND message_id = ? AND role = 'system'`,
|
|
783
|
+
)
|
|
784
|
+
.run(redact(args.text), args.chat_id, args.message_id) as { changes?: number }
|
|
785
|
+
return (res?.changes ?? 0) > 0
|
|
786
|
+
} catch {
|
|
787
|
+
return false
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
637
791
|
interface RecordEditArgs {
|
|
638
792
|
chat_id: string
|
|
639
793
|
message_id: number
|
|
@@ -781,16 +935,27 @@ export function getLatestInboundMessageId(
|
|
|
781
935
|
export function lookupMessageRoleAndText(
|
|
782
936
|
chatId: string,
|
|
783
937
|
messageId: number,
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
938
|
+
opts?: {
|
|
939
|
+
/**
|
|
940
|
+
* Also resolve `system` (card) rows. DEFAULT FALSE so the two
|
|
941
|
+
* authorship-sensitive callers — the reaction trigger's
|
|
942
|
+
* `role === 'assistant'` bot-authored predicate and the
|
|
943
|
+
* `reaction_dispatch` preview — keep their exact pre-#4571 behaviour
|
|
944
|
+
* (a card was invisible to them, and still is). The reply-antecedent
|
|
945
|
+
* resolver opts IN: that is the whole point of the card lane.
|
|
946
|
+
*/
|
|
947
|
+
includeSystem?: boolean
|
|
948
|
+
},
|
|
949
|
+
): { role: MessageRole; text: string; kind: string | null } | null {
|
|
950
|
+
const sql =
|
|
951
|
+
`SELECT role, text, kind FROM messages WHERE chat_id = ? AND message_id = ?` +
|
|
952
|
+
(opts?.includeSystem === true ? '' : ` AND role <> 'system'`) +
|
|
953
|
+
` LIMIT 1`
|
|
954
|
+
const row = requireDb().prepare(sql).get(chatId, messageId) as
|
|
955
|
+
| { role: MessageRole; text: string | null; kind: string | null }
|
|
791
956
|
| undefined
|
|
792
957
|
if (!row) return null
|
|
793
|
-
return { role: row.role, text: row.text ?? '' }
|
|
958
|
+
return { role: row.role, text: row.text ?? '', kind: row.kind ?? null }
|
|
794
959
|
}
|
|
795
960
|
|
|
796
961
|
export function getRecentOutboundCount(
|
|
@@ -1007,6 +1172,8 @@ export function query(opts: QueryOptions): RecordedMessage[] {
|
|
|
1007
1172
|
const limit = Math.min(MAX_LIMIT, Math.max(1, opts.limit ?? DEFAULT_LIMIT))
|
|
1008
1173
|
const params: unknown[] = [opts.chat_id]
|
|
1009
1174
|
let sql = 'SELECT * FROM messages WHERE chat_id = ?'
|
|
1175
|
+
// #4571 — card rows are resolvable, not listable. See QueryOptions.include_system.
|
|
1176
|
+
if (opts.include_system !== true) sql += " AND role <> 'system'"
|
|
1010
1177
|
if (opts.thread_id !== undefined) {
|
|
1011
1178
|
if (opts.thread_id === null) {
|
|
1012
1179
|
sql += ' AND thread_id IS NULL'
|
|
@@ -36,8 +36,9 @@
|
|
|
36
36
|
* otherwise; the gateway then resumes or reports accordingly.
|
|
37
37
|
*/
|
|
38
38
|
|
|
39
|
-
import { chmodSync
|
|
39
|
+
import { chmodSync } from 'fs'
|
|
40
40
|
import { join } from 'path'
|
|
41
|
+
import { adoptSqliteOwnership, mkdirStateSync } from '../../src/util/state-owner.js'
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// bun:sqlite lazy-loader (same pattern as history.ts)
|
|
@@ -302,7 +303,7 @@ function applySchema(db: SqliteDatabase): void {
|
|
|
302
303
|
export function openTurnsDb(agentDir: string): SqliteDatabase {
|
|
303
304
|
const Database = loadDatabaseClass()
|
|
304
305
|
const dir = join(agentDir, 'telegram')
|
|
305
|
-
|
|
306
|
+
mkdirStateSync(dir, { recursive: true, mode: 0o700 })
|
|
306
307
|
const path = join(dir, 'registry.db')
|
|
307
308
|
const db = new Database(path, { create: true })
|
|
308
309
|
applySchema(db)
|
|
@@ -318,6 +319,11 @@ export function openTurnsDb(agentDir: string): SqliteDatabase {
|
|
|
318
319
|
} catch {
|
|
319
320
|
/* ignore — chmod not supported on some FUSE mounts */
|
|
320
321
|
}
|
|
322
|
+
// Same reasoning as the chmod above, for the owner rather than the mode:
|
|
323
|
+
// a root-running gateway must not leave root:root DB files in an
|
|
324
|
+
// agent-owned state dir. `-wal`/`-shm` are created by SQLite in C, so this
|
|
325
|
+
// hook is the only place we can reach them. No-op off the root path.
|
|
326
|
+
adoptSqliteOwnership(path)
|
|
321
327
|
return db
|
|
322
328
|
}
|
|
323
329
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest'
|
|
2
2
|
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
4
|
import { join } from 'node:path'
|
|
@@ -29,6 +29,17 @@ function mirrorWith(sender: (m: OutboundToBuzzMessage) => boolean, opts?: { defa
|
|
|
29
29
|
|
|
30
30
|
const BUZZ_COORDS = { channelId: 'chan-A', eventId: 'evt-1', threadRoot: 'root-1' }
|
|
31
31
|
|
|
32
|
+
// Cross-FILE isolation. `getBuzzMirror()` is a module-level singleton and the
|
|
33
|
+
// last describe here boots one (`maybeBootBuzzMirror`) with no trailing reset —
|
|
34
|
+
// every `beforeEach` in this file resets on the way IN, nothing resets on the
|
|
35
|
+
// way OUT. `bun test` runs every file in ONE process with no guaranteed file
|
|
36
|
+
// order, so a booted mirror leaks into whichever suite runs next: sendReply's
|
|
37
|
+
// Buzz hook (outbound-send-path.ts:2658) then fires in suites that never wired
|
|
38
|
+
// its deps and dies on `findLatestTurnForChat is not a function` (observed:
|
|
39
|
+
// 43 failures in send-reply-golden.test.ts, purely from readdir order).
|
|
40
|
+
// Unconditional teardown here is the only ordering-proof fix.
|
|
41
|
+
afterAll(() => __resetBuzzMirrorForTests())
|
|
42
|
+
|
|
32
43
|
describe('BuzzMirror.mirrorReplyDelivered — routing + S1 owner guard', () => {
|
|
33
44
|
beforeEach(() => __resetBuzzMirrorForTests())
|
|
34
45
|
|