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.
@@ -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> = {}) => { rec('editMessageText', c, typeof b === 'string' ? b : (b as { markdown: string }).markdown, o); return {} },
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
+ })