switchroom 0.19.6 → 0.19.8

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.
@@ -1,36 +1,165 @@
1
1
  /**
2
- * Unit coverage for the per-chat subagent-handback marker
3
- * (fix/backstop-duplicate-reply). The marker is the deterministic signal the
4
- * supersede path uses to tell a flushed turn's OWN reworded late reply (no
5
- * handback in flight → supersede) from a background handback attributed to that
6
- * ended turn (handback in flight → keep the #3429 content gate).
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).
7
8
  */
8
9
 
9
10
  import { describe, it, expect } from 'vitest'
10
- import { SubagentHandbackMarker } from '../gateway/subagent-handback-marker.js'
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'
11
19
 
12
20
  describe('SubagentHandbackMarker', () => {
13
21
  it('returns null for a chat with no recorded handback', () => {
14
22
  const m = new SubagentHandbackMarker()
15
- expect(m.lastAt('chatA')).toBe(null)
23
+ expect(m.lastAt('chatA', undefined)).toBe(null)
16
24
  })
17
25
 
18
26
  it('returns the recorded enqueue ts for the chat', () => {
19
27
  const m = new SubagentHandbackMarker()
20
- m.record('chatA', 1_000_000)
21
- expect(m.lastAt('chatA')).toBe(1_000_000)
28
+ m.record('chatA', undefined, 1_000_000)
29
+ expect(m.lastAt('chatA', undefined)).toBe(1_000_000)
22
30
  })
23
31
 
24
32
  it('is per-chat — one chat never leaks into another', () => {
25
33
  const m = new SubagentHandbackMarker()
26
- m.record('chatA', 1_000_000)
27
- expect(m.lastAt('chatB')).toBe(null)
34
+ m.record('chatA', undefined, 1_000_000)
35
+ expect(m.lastAt('chatB', undefined)).toBe(null)
28
36
  })
29
37
 
30
38
  it('keeps only the MOST RECENT enqueue (overwrites)', () => {
31
39
  const m = new SubagentHandbackMarker()
32
- m.record('chatA', 1_000_000)
33
- m.record('chatA', 1_050_000)
34
- expect(m.lastAt('chatA')).toBe(1_050_000)
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([])
35
164
  })
36
165
  })