switchroom 0.19.5 → 0.19.7
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/cli/switchroom.js +1 -1
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +156 -13
- package/telegram-plugin/flushed-turn-supersede.ts +43 -7
- package/telegram-plugin/gateway/gateway.ts +36 -4
- package/telegram-plugin/gateway/outbound-send-path.ts +91 -12
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +40 -0
- package/telegram-plugin/gateway/subagent-handback-marker.ts +221 -0
- package/telegram-plugin/reply-owner-resolve.ts +43 -7
- package/telegram-plugin/tests/flushed-turn-supersede.test.ts +89 -0
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +2 -1
- package/telegram-plugin/tests/reply-owner-resolve.test.ts +74 -0
- package/telegram-plugin/tests/send-reply-golden.test.ts +435 -6
- package/telegram-plugin/tests/stream-render-golden.test.ts +142 -5
- package/telegram-plugin/tests/subagent-handback-marker.test.ts +165 -0
|
@@ -56,7 +56,12 @@ function makeFakeBot() {
|
|
|
56
56
|
const api = {
|
|
57
57
|
sendRichMessage: async (c: string, b: { markdown: string }, o: Record<string, unknown> = {}) => rec('sendRichMessage', c, b.markdown, o),
|
|
58
58
|
sendMessage: async (c: string, t: string, o: Record<string, unknown> = {}) => rec('sendMessage', c, t, o),
|
|
59
|
-
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
59
|
+
editMessageText: async (c: string, m: number, b: unknown, o: Record<string, unknown> = {}) => {
|
|
60
|
+
// Preserve the REAL edit target id (m) so tests can assert an edit-in-place
|
|
61
|
+
// hit the flushed message, not a synthetic fresh id.
|
|
62
|
+
calls.push({ method: 'editMessageText', chat_id: c, text: typeof b === 'string' ? b : (b as { markdown: string }).markdown, opts: o, reply_markup: o.reply_markup ?? null, message_id: m })
|
|
63
|
+
return {}
|
|
64
|
+
},
|
|
60
65
|
deleteMessage: async (c: string, m: number) => { rec('deleteMessage', c, null); return true },
|
|
61
66
|
}
|
|
62
67
|
return { api, calls }
|
|
@@ -100,6 +105,7 @@ function makeStreamDeps(opts?: {
|
|
|
100
105
|
dedup?: OutboundDedupCache
|
|
101
106
|
turn?: CurrentTurn | null
|
|
102
107
|
deliverResult?: { sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }
|
|
108
|
+
flushedTurnSupersede?: FlushedTurnSupersedeRegistry
|
|
103
109
|
}): StreamHarness {
|
|
104
110
|
const { api, calls } = makeFakeBot()
|
|
105
111
|
const dedup = opts?.dedup ?? new OutboundDedupCache()
|
|
@@ -135,7 +141,7 @@ function makeStreamDeps(opts?: {
|
|
|
135
141
|
activeTurnStartedAt: new Map(),
|
|
136
142
|
backstopDeliveryLedger: ledger,
|
|
137
143
|
bot: { api },
|
|
138
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
144
|
+
flushedTurnSupersede: opts?.flushedTurnSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
139
145
|
idleTracker: { noteEvent: noop },
|
|
140
146
|
lastPtyPreviewByChat: new Map(),
|
|
141
147
|
obligationLedger: { close: noop, noteTurnEnded: noop },
|
|
@@ -206,12 +212,12 @@ function makeStreamDeps(opts?: {
|
|
|
206
212
|
}
|
|
207
213
|
|
|
208
214
|
// ── the P2 sendReply harness (same content, sharing the ONE cache) ─────────
|
|
209
|
-
function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
215
|
+
function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedTurnSupersedeRegistry) {
|
|
210
216
|
const { api, calls } = makeFakeBot()
|
|
211
217
|
const key = (c: string, t?: number | null) => `${c}:${t ?? 'main'}`
|
|
212
218
|
const deps = {
|
|
213
219
|
outboundDedup: dedup,
|
|
214
|
-
flushedTurnSupersede: new FlushedTurnSupersedeRegistry(),
|
|
220
|
+
flushedTurnSupersede: sharedSupersede ?? new FlushedTurnSupersedeRegistry(),
|
|
215
221
|
firstTextReplyLogged: new Set<string>(),
|
|
216
222
|
suppressPtyPreview: new Set<string>(),
|
|
217
223
|
activeDraftStreams: new Map(),
|
|
@@ -238,7 +244,8 @@ function makeSendReplyDeps(dedup: OutboundDedupCache) {
|
|
|
238
244
|
assertSendable: () => {},
|
|
239
245
|
statusKey: key,
|
|
240
246
|
streamKey: key,
|
|
241
|
-
resolveReplyOwnerTurn: () => null,
|
|
247
|
+
resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
|
|
248
|
+
getLastSubagentHandbackAt: () => null,
|
|
242
249
|
findTurnByOriginId: () => null,
|
|
243
250
|
findTurnByQuotedMessageId: () => null,
|
|
244
251
|
resolveAnswerThreadWithLog: (_c: string, explicit: number | undefined) => explicit,
|
|
@@ -385,6 +392,136 @@ describe('cross-surface dedup — ONE OutboundDedupCache across P4 stream + P2 r
|
|
|
385
392
|
})
|
|
386
393
|
})
|
|
387
394
|
|
|
395
|
+
// ── F3 (dup-audit 2026-07-21): the flush RECORD wiring is load-bearing ──────
|
|
396
|
+
//
|
|
397
|
+
// The entire flush→reply dedup depends on stream-render.ts recording the
|
|
398
|
+
// flush's delivered ids into the shared FlushedTurnSupersedeRegistry
|
|
399
|
+
// (`flushedTurnSupersede.record(...)`). Every OTHER outcome test seeds that
|
|
400
|
+
// record by hand (send-reply-golden's seedRecord/seedFlushRecord), so DELETING
|
|
401
|
+
// the record call would reintroduce the dominant flush→reply duplicate with all
|
|
402
|
+
// those tests still green. This drives the REAL flush record() end-to-end (no
|
|
403
|
+
// pre-seed) across ONE shared registry and asserts the reworded same-turn reply
|
|
404
|
+
// collapses to EXACTLY ONE message — so it goes RED if the record() call is
|
|
405
|
+
// removed. This is the guard the audit flagged as missing.
|
|
406
|
+
describe('F3 — flush record() → same-turn reworded reply collapse (end-to-end, no pre-seed)', () => {
|
|
407
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
408
|
+
const FLUSHED_ANSWER =
|
|
409
|
+
'All twelve agents are healthy right now. The gateway, the vault broker and the ' +
|
|
410
|
+
'approval kernel all report green health checks, and no container has restarted in ' +
|
|
411
|
+
'the last twenty four hours, so there is nothing that needs your attention at the moment.'
|
|
412
|
+
const REWORDED =
|
|
413
|
+
'Good news on the fleet. Every one of the twelve agents is running fine at the moment. ' +
|
|
414
|
+
'Gateway, vault broker and approval kernel are all green, and nothing has restarted in ' +
|
|
415
|
+
'the past day, so you do not need to do anything right now.'
|
|
416
|
+
|
|
417
|
+
it('flush → record → late reworded reply collapses to ONE message ' +
|
|
418
|
+
'(RED if stream-render flushedTurnSupersede.record is removed)', async () => {
|
|
419
|
+
// ONE shared supersede registry across BOTH surfaces — exactly the gateway
|
|
420
|
+
// singleton wiring. The flush RECORDS (the real stream-render.ts:1828 call);
|
|
421
|
+
// the reply CONSUMES. Nothing is pre-seeded.
|
|
422
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
423
|
+
const turn = makeTurn({ capturedText: [FLUSHED_ANSWER], capturedBlockMeta: [true] })
|
|
424
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
425
|
+
|
|
426
|
+
// Drive the REAL turn-flush path: it delivers message A (deliverAnswer → id
|
|
427
|
+
// 4242) AND records {turnId, [4242]} into the shared registry.
|
|
428
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
429
|
+
await settleFlush()
|
|
430
|
+
expect(sh.delivered).toContain(FLUSHED_ANSWER)
|
|
431
|
+
// The record actually landed (this is what a removal breaks first).
|
|
432
|
+
expect(
|
|
433
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
434
|
+
).toBe('supersede')
|
|
435
|
+
|
|
436
|
+
// The model's REAL reply lands late with a REWORDED version of the same
|
|
437
|
+
// answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
|
|
438
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
439
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
440
|
+
// (getLastSubagentHandbackAt returns null in the base deps → own answer.)
|
|
441
|
+
|
|
442
|
+
const res = await sendReply(s.deps, req(REWORDED))
|
|
443
|
+
|
|
444
|
+
// EXACTLY ONE client-visible message: the flushed message (4242) edited in
|
|
445
|
+
// place into the reworded reply — NO fresh second bubble. Without the
|
|
446
|
+
// record(), the reply finds no record, falls to the latch branch, sees
|
|
447
|
+
// reworded ≠ flushed content, does NOT suppress, and ships a DUPLICATE
|
|
448
|
+
// sendRichMessage (edits=0, sends=1) → these assertions fail.
|
|
449
|
+
const edits = s.calls.filter((c) => c.method === 'editMessageText')
|
|
450
|
+
expect(edits).toHaveLength(1)
|
|
451
|
+
expect(edits[0]!.message_id).toBe(4242)
|
|
452
|
+
expect(s.calls.filter((c) => c.method === 'sendRichMessage')).toHaveLength(0)
|
|
453
|
+
expect(res.content[0]!.text).toMatch(/^sent/)
|
|
454
|
+
})
|
|
455
|
+
})
|
|
456
|
+
|
|
457
|
+
// ── F5 (dup-audit): true take()-before-record() interleaving ────────────────
|
|
458
|
+
//
|
|
459
|
+
// The residual race the supersede registry cannot reach: a reply whose
|
|
460
|
+
// supersede take() runs in the window AFTER the flush FIRED but BEFORE it
|
|
461
|
+
// recorded its message ids. The flush arms `turn.answerDelivered='flush'` (+
|
|
462
|
+
// flushedAnswerText) SYNCHRONOUSLY at fire time — before its async deliver and
|
|
463
|
+
// before record — so a same-answer reply landing in that window is suppressed by
|
|
464
|
+
// the latch, not shipped as a duplicate. This drives a genuine two-emitter
|
|
465
|
+
// interleave (inverted ordering: reply take() strictly before flush record())
|
|
466
|
+
// and asserts exactly one delivered message.
|
|
467
|
+
describe('F5 — take()-before-record() interleaving delivers exactly one message', () => {
|
|
468
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
469
|
+
// ≥200 chars so the late reply is a substantive final answer (the floor the
|
|
470
|
+
// flush latch is scoped to) — else it would never trip the suppression.
|
|
471
|
+
const ANSWER =
|
|
472
|
+
'Yes, that is all done and confirmed. The migration ran cleanly against the staging ' +
|
|
473
|
+
'database, every integration check passed on the first attempt, the rollback plan is ' +
|
|
474
|
+
'staged in case it is ever needed, and I have written the full run log to the shared ' +
|
|
475
|
+
'drive so the team can review exactly what changed and when it happened.'
|
|
476
|
+
|
|
477
|
+
it('a reply whose take() runs BEFORE the flush record() is suppressed by the ' +
|
|
478
|
+
'flush-armed latch — one delivered message, not two', async () => {
|
|
479
|
+
const supersede = new FlushedTurnSupersedeRegistry()
|
|
480
|
+
const turn = makeTurn({ capturedText: [ANSWER], capturedBlockMeta: [true] })
|
|
481
|
+
const sh = makeStreamDeps({ turn, flushedTurnSupersede: supersede })
|
|
482
|
+
|
|
483
|
+
// Fire the flush. Its latch is set SYNCHRONOUSLY here; deliverAnswer + record
|
|
484
|
+
// run in the async IIFE that has NOT completed — the pre-record window.
|
|
485
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 })
|
|
486
|
+
// INVERTED ORDERING: the reply's take() runs now, before the flush record().
|
|
487
|
+
expect(
|
|
488
|
+
supersede.peek(CHAT, undefined, { liveTurnId: turn.turnId, now: Date.now() }).reason,
|
|
489
|
+
).toBe('no-record') // record genuinely not written yet
|
|
490
|
+
|
|
491
|
+
const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
|
|
492
|
+
s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
|
|
493
|
+
// The same answer landing again in the race window → latch backstop suppresses.
|
|
494
|
+
const res = await sendReply(s.deps, req(ANSWER))
|
|
495
|
+
|
|
496
|
+
expect(s.calls).toHaveLength(0) // the reply shipped nothing
|
|
497
|
+
expect(res.content[0]!.text).toContain('deduped')
|
|
498
|
+
|
|
499
|
+
await settleFlush() // let the flush's async deliver + record complete
|
|
500
|
+
// Exactly one message reached the user: the flush's message A.
|
|
501
|
+
expect(sh.delivered).toHaveLength(1)
|
|
502
|
+
expect(sh.delivered[0]).toContain('done and confirmed')
|
|
503
|
+
})
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// ── Double-flush idempotency at the delivery primitive (audit §3.5) ─────────
|
|
507
|
+
//
|
|
508
|
+
// The E2-vs-E3 double flush (answer-ready quiescence THEN turn-end backstop for
|
|
509
|
+
// the SAME turn) is guarded by backstopDeliveryLedger.claim. The ledger is
|
|
510
|
+
// unit-tested, but the audit wanted it pinned at the SEND-COUNT level in the
|
|
511
|
+
// flush integration. Two turn_end dispatches for one turn must deliver once.
|
|
512
|
+
describe('double-flush idempotency — deliverAnswer fires once (send-count level)', () => {
|
|
513
|
+
const settleFlush = () => new Promise((r) => setTimeout(r, 650))
|
|
514
|
+
it('two turn_end dispatches for the same turn deliver the answer EXACTLY once', async () => {
|
|
515
|
+
const turn = makeTurn()
|
|
516
|
+
const answer = turn.capturedText.join('')
|
|
517
|
+
const sh = makeStreamDeps({ turn })
|
|
518
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claims the latch
|
|
519
|
+
handleSessionEvent(sh.deps, { kind: 'turn_end', durationMs: 1200 }) // claim fails → no-op
|
|
520
|
+
await settleFlush()
|
|
521
|
+
expect(sh.delivered).toEqual([answer])
|
|
522
|
+
})
|
|
523
|
+
})
|
|
524
|
+
|
|
388
525
|
describe('structural — the singleton lives once in gateway, never in the modules (Amendment 1)', () => {
|
|
389
526
|
const gatewaySrc = readFileSync(new URL('../gateway/gateway.ts', import.meta.url), 'utf8')
|
|
390
527
|
const streamSrc = readFileSync(new URL('../gateway/stream-render.ts', import.meta.url), 'utf8')
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit coverage for the per-chat/thread subagent-handback marker
|
|
3
|
+
* (fix/backstop-duplicate-reply; thread-keying — dup-audit F2 2026-07-21). The
|
|
4
|
+
* marker is the deterministic signal the supersede path uses to tell a flushed
|
|
5
|
+
* turn's OWN reworded late reply (no handback in flight → supersede) from a
|
|
6
|
+
* background handback attributed to that ended turn (handback in flight → keep
|
|
7
|
+
* the #3429 content gate).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest'
|
|
11
|
+
import { readFileSync, readdirSync } from 'node:fs'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
import {
|
|
15
|
+
SubagentHandbackMarker,
|
|
16
|
+
stampsHandbackMarker,
|
|
17
|
+
INBOUND_SOURCE_CLASSIFICATION,
|
|
18
|
+
} from '../gateway/subagent-handback-marker.js'
|
|
19
|
+
|
|
20
|
+
describe('SubagentHandbackMarker', () => {
|
|
21
|
+
it('returns null for a chat with no recorded handback', () => {
|
|
22
|
+
const m = new SubagentHandbackMarker()
|
|
23
|
+
expect(m.lastAt('chatA', undefined)).toBe(null)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns the recorded enqueue ts for the chat', () => {
|
|
27
|
+
const m = new SubagentHandbackMarker()
|
|
28
|
+
m.record('chatA', undefined, 1_000_000)
|
|
29
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('is per-chat — one chat never leaks into another', () => {
|
|
33
|
+
const m = new SubagentHandbackMarker()
|
|
34
|
+
m.record('chatA', undefined, 1_000_000)
|
|
35
|
+
expect(m.lastAt('chatB', undefined)).toBe(null)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('keeps only the MOST RECENT enqueue (overwrites)', () => {
|
|
39
|
+
const m = new SubagentHandbackMarker()
|
|
40
|
+
m.record('chatA', undefined, 1_000_000)
|
|
41
|
+
m.record('chatA', undefined, 1_050_000)
|
|
42
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_050_000)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ── F2: thread-keying (dup-audit 2026-07-21) ────────────────────────────
|
|
46
|
+
it('is per-THREAD — a handback in topic A does not leak into topic B', () => {
|
|
47
|
+
const m = new SubagentHandbackMarker()
|
|
48
|
+
m.record('chatA', 111, 1_000_000)
|
|
49
|
+
// Same chat, different topic → no marker: topic B's CASE-A collapse is
|
|
50
|
+
// untouched by topic A's handback (the visible-dup gap F2 closed).
|
|
51
|
+
expect(m.lastAt('chatA', 222)).toBe(null)
|
|
52
|
+
expect(m.lastAt('chatA', 111)).toBe(1_000_000)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('a thread handback does not leak into the DM (no-thread) lane of the same chat', () => {
|
|
56
|
+
const m = new SubagentHandbackMarker()
|
|
57
|
+
m.record('chatA', 111, 1_000_000)
|
|
58
|
+
expect(m.lastAt('chatA', undefined)).toBe(null)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('the no-thread lane keys identically to the supersede registry (chat only)', () => {
|
|
62
|
+
const m = new SubagentHandbackMarker()
|
|
63
|
+
m.record('chatA', undefined, 1_000_000)
|
|
64
|
+
// A later thread read must NOT see the DM stamp, and vice-versa.
|
|
65
|
+
expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
|
|
66
|
+
expect(m.lastAt('chatA', 111)).toBe(null)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
// ── MUST-FIX 2 (dup-audit / Fable): chat-wide gate read ─────────────────
|
|
70
|
+
it('lastAtInChat returns the MOST RECENT handback across ALL topics of a chat', () => {
|
|
71
|
+
const m = new SubagentHandbackMarker()
|
|
72
|
+
m.record('chatA', 111, 1_000)
|
|
73
|
+
m.record('chatA', 222, 3_000)
|
|
74
|
+
m.record('chatA', undefined, 2_000)
|
|
75
|
+
expect(m.lastAtInChat('chatA')).toBe(3_000)
|
|
76
|
+
expect(m.lastAtInChat('chatB')).toBe(null)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('lastAtInChat makes the gate un-steerable: a topic-A stamp is seen chat-wide ' +
|
|
80
|
+
'even when a thread-specific read of topic B would miss it', () => {
|
|
81
|
+
const m = new SubagentHandbackMarker()
|
|
82
|
+
m.record('chatA', 111, 5_000)
|
|
83
|
+
// The content gate reads chat-wide → sees topic A's handback…
|
|
84
|
+
expect(m.lastAtInChat('chatA')).toBe(5_000)
|
|
85
|
+
// …whereas a thread-specific read of topic 222 (the F2 regression) missed it,
|
|
86
|
+
// which is exactly how a steered `message_thread_id` bypassed the gate.
|
|
87
|
+
expect(m.lastAt('chatA', 222)).toBe(null)
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// ── MUST-FIX 3 (dup-audit / Fable): fail-safe classification + exhaustiveness ──
|
|
92
|
+
describe('inbound source classification (F1 durability)', () => {
|
|
93
|
+
it('stampsHandbackMarker: subagent_handback stamps; other known sources do not', () => {
|
|
94
|
+
expect(stampsHandbackMarker('subagent_handback')).toBe(true)
|
|
95
|
+
expect(stampsHandbackMarker('cron')).toBe(false)
|
|
96
|
+
expect(stampsHandbackMarker('reaction')).toBe(false)
|
|
97
|
+
expect(stampsHandbackMarker('resume_interrupted')).toBe(false)
|
|
98
|
+
expect(stampsHandbackMarker('vault_grant_approved')).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('a normal user inbound (no meta.source) never stamps', () => {
|
|
102
|
+
expect(stampsHandbackMarker(null)).toBe(false)
|
|
103
|
+
expect(stampsHandbackMarker(undefined)).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('FAIL-SAFE default: an UNKNOWN synthesized source STAMPS (visible dup, never ' +
|
|
107
|
+
'silent edit-over)', () => {
|
|
108
|
+
// The unsafe old default (deny → no stamp → bypass eligible → silent loss)
|
|
109
|
+
// is inverted: an unclassified future decoupled source keeps the content gate.
|
|
110
|
+
expect(stampsHandbackMarker('some_future_decoupled_source')).toBe(true)
|
|
111
|
+
expect(stampsHandbackMarker('another_unclassified_source')).toBe(true)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// The real tripwire: scan the gateway for meta.source literals and FAIL when a
|
|
115
|
+
// new one is added without a registry classification. The grep-the-predicate
|
|
116
|
+
// structural test could not catch rot; this does.
|
|
117
|
+
it('exhaustiveness: every gateway-inbound meta.source literal is classified in the registry', () => {
|
|
118
|
+
const gatewayDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'gateway')
|
|
119
|
+
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
120
|
+
// `source:` fields that are NOT inbound meta.source tags (config cascade, the
|
|
121
|
+
// model-source selector, doc comments, typeof guards). Allowlisted so a
|
|
122
|
+
// genuinely new INBOUND source still trips this guard.
|
|
123
|
+
const NON_INBOUND_SOURCE_LITERALS = new Set([
|
|
124
|
+
'env', 'config', 'default', 'transcript', 'override', 'gateway', 'cli', 'string', 'github',
|
|
125
|
+
])
|
|
126
|
+
// The scan surface = every gateway module PLUS the out-of-gateway inbound
|
|
127
|
+
// builders whose synthesized inbounds are delivered THROUGH the gateway
|
|
128
|
+
// (dup-audit pass-2 / Fable): `src/web/webhook-dispatch.ts` builds the
|
|
129
|
+
// `webhook` / `linear` inbounds injected via `webhookInject` → the same
|
|
130
|
+
// buffer chokepoint. Grep for other `meta:`+`source:` inbound builders if new
|
|
131
|
+
// dirs appear.
|
|
132
|
+
const files: string[] = [
|
|
133
|
+
...readdirSync(gatewayDir)
|
|
134
|
+
.filter((f) => f.endsWith('.ts') && !f.endsWith('.test.ts'))
|
|
135
|
+
.map((f) => join(gatewayDir, f)),
|
|
136
|
+
join(repoRoot, 'src', 'web', 'webhook-dispatch.ts'),
|
|
137
|
+
]
|
|
138
|
+
const found = new Set<string>()
|
|
139
|
+
// Catch BOTH quote styles and full source spellings (digits / underscores /
|
|
140
|
+
// any case) — a single-quote-only, `[a-z_]+`-only regex silently missed the
|
|
141
|
+
// double-quoted `mental_model_proposal_*` and the out-of-gateway webhook
|
|
142
|
+
// sources (pass-2 porosity). Variable-valued `source: expr` stays structurally
|
|
143
|
+
// invisible; the fail-safe stamp default is the safety boundary, not this scan.
|
|
144
|
+
const patterns = [
|
|
145
|
+
/source:\s*['"]([A-Za-z0-9_]+)['"]/g,
|
|
146
|
+
/meta\??\.source\s*===\s*['"]([A-Za-z0-9_]+)['"]/g,
|
|
147
|
+
]
|
|
148
|
+
for (const f of files) {
|
|
149
|
+
const src = readFileSync(f, 'utf8')
|
|
150
|
+
for (const re of patterns) {
|
|
151
|
+
let m: RegExpExecArray | null
|
|
152
|
+
while ((m = re.exec(src)) != null) found.add(m[1]!)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const unclassified = [...found]
|
|
156
|
+
.filter((s) => !NON_INBOUND_SOURCE_LITERALS.has(s))
|
|
157
|
+
.filter((s) => INBOUND_SOURCE_CLASSIFICATION[s] == null)
|
|
158
|
+
.sort()
|
|
159
|
+
// If this fails: a new meta.source literal was added. Classify it in
|
|
160
|
+
// INBOUND_SOURCE_CLASSIFICATION (decoupledCompletion true ONLY if its reply
|
|
161
|
+
// can land as a late reply with no live turn of its own), or — if it is a
|
|
162
|
+
// non-inbound `source:` field — add it to NON_INBOUND_SOURCE_LITERALS.
|
|
163
|
+
expect(unclassified).toEqual([])
|
|
164
|
+
})
|
|
165
|
+
})
|