switchroom 0.12.19 → 0.12.21

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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * turn-state-purge.ts — defense-in-depth cleanup for the silence-poke
3
+ * framework-fallback (gateway.ts).
4
+ *
5
+ * The fallback's `purgeReactionTracking(fbKey)` clears the canonical
6
+ * `statusKey(chatId, threadId)` for the exact chat+thread that armed
7
+ * the silence-poke. But `activeTurnStartedAt` (and its siblings) can
8
+ * legitimately hold MORE than one entry for the same chat — e.g.:
9
+ *
10
+ * - a normal turn-end handler null'd `currentTurn` but skipped
11
+ * `purgeReactionTracking` on a code-path that didn't reach it
12
+ * (gateway.ts:5887/5921/6193 — purgeReactionTracking calls in
13
+ * those handlers are conditional/not on every branch), so
14
+ * `activeTurnStartedAt[oldKey]` is dangling when the fallback
15
+ * fires for a NEW key
16
+ * - a multi-thread chat (topic-based group, or a `null` vs
17
+ * `undefined` thread variant) has entries under different keys of
18
+ * the SAME chat
19
+ *
20
+ * In either case, the fallback purges `fbKey` but a sibling key for
21
+ * the same chat remains → `activeTurnStartedAt.size > 0` persists →
22
+ * `#1556`'s turn-gate holds every new inbound forever → user sees
23
+ * "not responding" while claude is actually idle (gymbro + klanker,
24
+ * 2026-05-20).
25
+ *
26
+ * This helper sweeps any sibling keys for the firing chat after the
27
+ * canonical `purgeReactionTracking(fbKey)`, via the same purger.
28
+ * Multi-chat safe: it only touches keys whose chatId matches — other
29
+ * chats' active turns are untouched, preserving the deliberate
30
+ * multi-chat safety #1546 kept.
31
+ *
32
+ * Key shape: `statusKey(chatId, threadId)` emits
33
+ * `${chatId}:${threadId ?? '_'}`. Chat ids are numeric strings (no
34
+ * `:` inside), so `indexOf(':')` is a safe prefix delimiter. A
35
+ * "prefix-without-separator" false positive (e.g. chatId `123` vs key
36
+ * `1234:_`) is structurally impossible because the helper splits on
37
+ * `:` and equates the prefix, NOT a string-startsWith.
38
+ *
39
+ * Pure / dependency-free so the multi-chat-safety and key-shape logic
40
+ * are unit-testable without standing up a gateway — mirrors the
41
+ * `#1544 / #1546 / #1549 / #1558` pure-seam idiom.
42
+ */
43
+
44
+ export interface PurgeStaleTurnsResult {
45
+ /** Keys for `chatId` that the helper purged via the callback. */
46
+ purged: string[]
47
+ }
48
+
49
+ export function purgeStaleTurnsForChat(
50
+ chatId: string,
51
+ keys: Iterable<string>,
52
+ purger: (key: string) => void,
53
+ ): PurgeStaleTurnsResult {
54
+ if (!chatId) return { purged: [] }
55
+ const purged: string[] = []
56
+ // Snapshot first — `purger` typically deletes from the same Map the
57
+ // caller is iterating; mutating during iteration is correct on JS
58
+ // Maps but a snapshot makes the contract explicit and lets the
59
+ // helper accept any iterable.
60
+ const snapshot: string[] = []
61
+ for (const k of keys) snapshot.push(k)
62
+ for (const key of snapshot) {
63
+ const sep = key.indexOf(':')
64
+ if (sep < 0) continue // malformed / non-statusKey shape — skip
65
+ const keyChat = key.slice(0, sep)
66
+ if (keyChat !== chatId) continue
67
+ purger(key)
68
+ purged.push(key)
69
+ }
70
+ return { purged }
71
+ }
@@ -94,7 +94,10 @@ export interface PtyHandlerDeps {
94
94
  }
95
95
 
96
96
  function streamKey(chatId: string, threadId?: number): string {
97
- return `${chatId}:${threadId ?? '_'}`
97
+ // Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
98
+ // expression in lockstep (treats 0/null/undefined the same). See #1564.
99
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
100
+ return `${chatId}:${t}`
98
101
  }
99
102
 
100
103
  /**
@@ -313,7 +313,12 @@ function streamKey(
313
313
  lane?: string,
314
314
  turnKey?: string,
315
315
  ): string {
316
- const base = `${chatId}:${threadId ?? '_'}`
316
+ // Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
317
+ // expression in lockstep with that helper (treats 0/null/undefined the
318
+ // same), but inline here so this file doesn't introduce a cross-package
319
+ // import for one expression. See #1564 for the sibling-key bug class.
320
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
321
+ const base = `${chatId}:${t}`
317
322
  const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
318
323
  return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
319
324
  }
@@ -50,7 +50,11 @@ function statusKey(chatId: string, threadId?: number): string {
50
50
  }
51
51
 
52
52
  function streamKey(chatId: string, threadId?: number): string {
53
- return `${chatId}:${threadId ?? 'default'}`
53
+ // Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
54
+ // 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
55
+ // here would let the harness pass with a key shape production rejects.
56
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
57
+ return `${chatId}:${t}`
54
58
  }
55
59
 
56
60
  interface PluginState {
@@ -29,7 +29,11 @@ function statusKey(chatId: string, threadId?: number): string {
29
29
  return `${chatId}:${threadId ?? '_'}`
30
30
  }
31
31
  function streamKey(chatId: string, threadId?: number): string {
32
- return `${chatId}:${threadId ?? 'default'}`
32
+ // Mirrors production's canonical chat-key derivation (gateway/chat-key.ts):
33
+ // 0/null/undefined thread IDs all collapse to '_'. Diverging sentinels
34
+ // here would let the harness pass with a key shape production rejects.
35
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
36
+ return `${chatId}:${t}`
33
37
  }
34
38
 
35
39
  interface PluginState {
@@ -0,0 +1,109 @@
1
+ /**
2
+ * turn-state-purge — the silence-poke fallback's defense-in-depth
3
+ * cleanup. Pins the multi-chat safety + key-shape correctness that
4
+ * the gymbro/klanker held-mid-turn fix (2026-05-20) depends on.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+ import { purgeStaleTurnsForChat } from '../gateway/turn-state-purge.js'
9
+
10
+ function statusKey(chatId: string, threadId?: number): string {
11
+ return `${chatId}:${threadId ?? '_'}`
12
+ }
13
+
14
+ describe('purgeStaleTurnsForChat', () => {
15
+ it('returns [] and calls nothing when keys is empty', () => {
16
+ let calls = 0
17
+ const r = purgeStaleTurnsForChat('123', [], () => calls++)
18
+ expect(r.purged).toEqual([])
19
+ expect(calls).toBe(0)
20
+ })
21
+
22
+ it('returns [] and calls nothing when chatId is empty (guard)', () => {
23
+ let calls = 0
24
+ const r = purgeStaleTurnsForChat('', [statusKey('123')], () => calls++)
25
+ expect(r.purged).toEqual([])
26
+ expect(calls).toBe(0)
27
+ })
28
+
29
+ it('purges the canonical DM key for the firing chat (typical case)', () => {
30
+ const purged: string[] = []
31
+ const r = purgeStaleTurnsForChat(
32
+ '12345',
33
+ [statusKey('12345')],
34
+ (k) => purged.push(k),
35
+ )
36
+ expect(r.purged).toEqual(['12345:_'])
37
+ expect(purged).toEqual(['12345:_'])
38
+ })
39
+
40
+ it('purges every sibling key for the same chat (multi-thread / dangling)', () => {
41
+ // This is the gymbro/klanker symptom: a stale activeTurnStartedAt
42
+ // entry under a different thread of the same chat survives the
43
+ // canonical purgeReactionTracking(fbKey).
44
+ const purged: string[] = []
45
+ const r = purgeStaleTurnsForChat(
46
+ '12345',
47
+ [
48
+ statusKey('12345'), // DM, no thread
49
+ statusKey('12345', 42), // thread 42
50
+ statusKey('12345', 99), // thread 99
51
+ ],
52
+ (k) => purged.push(k),
53
+ )
54
+ expect(r.purged.sort()).toEqual(['12345:42', '12345:99', '12345:_'])
55
+ expect(purged).toHaveLength(3)
56
+ })
57
+
58
+ it('NEVER touches keys for OTHER chats (multi-chat safety — the #1546 guard)', () => {
59
+ const purged: string[] = []
60
+ const r = purgeStaleTurnsForChat(
61
+ '12345',
62
+ [
63
+ statusKey('12345'), // target chat — purge
64
+ statusKey('-1001234567890'), // different chat — must NOT touch
65
+ statusKey('-1001234567890', 7), // different chat thread — must NOT touch
66
+ ],
67
+ (k) => purged.push(k),
68
+ )
69
+ expect(r.purged).toEqual(['12345:_'])
70
+ expect(purged).toEqual(['12345:_']) // ONLY the target chat
71
+ })
72
+
73
+ it('does not false-match on a chatId-prefix superstring (e.g. 123 vs 1234)', () => {
74
+ // If we used a naive startsWith on the WHOLE key, chatId "123"
75
+ // would falsely match key "1234:_". The helper splits on `:` and
76
+ // equates the chatId slice, which prevents that.
77
+ const purged: string[] = []
78
+ const r = purgeStaleTurnsForChat(
79
+ '123',
80
+ [statusKey('123'), statusKey('1234'), statusKey('12')],
81
+ (k) => purged.push(k),
82
+ )
83
+ expect(r.purged).toEqual(['123:_']) // ONLY the exact match
84
+ })
85
+
86
+ it('skips malformed keys (no `:`)', () => {
87
+ let calls = 0
88
+ const r = purgeStaleTurnsForChat(
89
+ '123',
90
+ ['malformed', '', 'no-colon-here', statusKey('123')],
91
+ () => calls++,
92
+ )
93
+ expect(r.purged).toEqual(['123:_'])
94
+ expect(calls).toBe(1)
95
+ })
96
+
97
+ it('accepts any iterable (snapshots before iteration — purger may delete)', () => {
98
+ // The real call site iterates `activeTurnStartedAt.keys()` and
99
+ // the purger deletes from that same Map. Snapshotting in the
100
+ // helper prevents iterator skew.
101
+ const map = new Map<string, number>()
102
+ map.set('123:_', 1)
103
+ map.set('123:7', 2)
104
+ map.set('999:_', 3)
105
+ const r = purgeStaleTurnsForChat('123', map.keys(), (k) => map.delete(k))
106
+ expect(r.purged.sort()).toEqual(['123:7', '123:_'])
107
+ expect([...map.keys()]).toEqual(['999:_']) // multi-chat safety preserved
108
+ })
109
+ })