switchroom 0.13.52 → 0.13.54

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.
Files changed (39) hide show
  1. package/dist/agent-scheduler/index.js +399 -213
  2. package/dist/auth-broker/index.js +576 -237
  3. package/dist/cli/drive-write-pretool.mjs +28 -13
  4. package/dist/cli/ms-365-write-pretool.mjs +259 -0
  5. package/dist/cli/skill-validate-pretool.mjs +72 -72
  6. package/dist/cli/switchroom.js +3241 -1382
  7. package/dist/host-control/main.js +396 -276
  8. package/dist/vault/approvals/kernel-server.js +8266 -8142
  9. package/dist/vault/broker/server.js +2894 -2770
  10. package/package.json +1 -1
  11. package/profiles/_base/start.sh.hbs +17 -0
  12. package/profiles/_shared/telegram-style.md.hbs +2 -0
  13. package/skills/switchroom-status/SKILL.md +8 -6
  14. package/telegram-plugin/chat-lock.ts +87 -19
  15. package/telegram-plugin/dist/bridge/bridge.js +112 -112
  16. package/telegram-plugin/dist/gateway/gateway.js +1283 -343
  17. package/telegram-plugin/dist/server.js +160 -160
  18. package/telegram-plugin/gateway/disconnect-flush.ts +32 -0
  19. package/telegram-plugin/gateway/gateway.ts +485 -72
  20. package/telegram-plugin/gateway/inbound-coalesce.ts +19 -6
  21. package/telegram-plugin/gateway/ipc-protocol.ts +37 -0
  22. package/telegram-plugin/gateway/ipc-server.ts +59 -0
  23. package/telegram-plugin/gateway/ms365-write-approval.test.ts +314 -0
  24. package/telegram-plugin/gateway/ms365-write-approval.ts +335 -0
  25. package/telegram-plugin/stream-reply-handler.ts +10 -8
  26. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +116 -0
  27. package/telegram-plugin/tests/inbound-coalesce.test.ts +20 -4
  28. package/telegram-plugin/tests/ipc-validator.test.ts +61 -0
  29. package/telegram-plugin/tests/outbound-ordering.test.ts +228 -0
  30. package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +217 -0
  31. package/telegram-plugin/tests/slash-command-smart-split.test.ts +115 -0
  32. package/telegram-plugin/tests/typing-wrap.test.ts +65 -8
  33. package/telegram-plugin/typing-wrap.ts +43 -21
  34. package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +35 -0
  35. package/vendor/hindsight-memory/scripts/recall.py +164 -4
  36. package/vendor/hindsight-memory/scripts/retain.py +52 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +42 -0
  38. package/vendor/hindsight-memory/scripts/tests/test_recall_topic_filter.py +139 -0
  39. package/profiles/default/CLAUDE.md +0 -122
@@ -170,6 +170,234 @@ describe('wrapBot — bot.api.* calls auto-lock by first-arg chat id', () => {
170
170
  expect(rb.message_id).toBe(20)
171
171
  })
172
172
 
173
+ it('SAME chat + DIFFERENT thread sendMessage calls dispatch concurrently (supergroup-mode unblock)', async () => {
174
+ // PR2 of supergroup-mode: chat-lock moved from chatId-only keying
175
+ // to (chat, thread) keying. Two topics in the same supergroup must
176
+ // not artificially serialize at the bot.api layer.
177
+ const lock = createChatLock()
178
+ const bot = createMockBot()
179
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
180
+
181
+ const dTopicA = deferred<{ message_id: number }>()
182
+ const dTopicB = deferred<{ message_id: number }>()
183
+ const started: string[] = []
184
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
185
+ started.push(`A:${t}`)
186
+ return dTopicA.promise
187
+ })
188
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
189
+ started.push(`B:${t}`)
190
+ return dTopicB.promise
191
+ })
192
+
193
+ // Same chatId "supergrp" but different message_thread_id.
194
+ const pA = wrapped.api.sendMessage('supergrp', 'planning msg', { message_thread_id: 17 })
195
+ const pB = wrapped.api.sendMessage('supergrp', 'cron digest', { message_thread_id: 23 })
196
+ await Promise.resolve(); await Promise.resolve()
197
+ // BOTH should have started — supergroup-mode parallelism guarantee.
198
+ expect(started).toEqual(['A:planning msg', 'B:cron digest'])
199
+
200
+ // Resolve B first; A's resolution must not delay B's return.
201
+ dTopicB.resolve({ message_id: 200 })
202
+ const rB = await pB
203
+ expect(rB.message_id).toBe(200)
204
+
205
+ dTopicA.resolve({ message_id: 100 })
206
+ const rA = await pA
207
+ expect(rA.message_id).toBe(100)
208
+ })
209
+
210
+ it('SAME chat + SAME thread sendMessage calls STILL serialize (per-topic ordering preserved)', async () => {
211
+ // Per-topic message order matters (a reply tied to a message_id
212
+ // must see that message exist first). The lock must still serialize
213
+ // within a topic.
214
+ const lock = createChatLock()
215
+ const bot = createMockBot()
216
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
217
+
218
+ const dSlow = deferred<{ message_id: number }>()
219
+ const startOrder: string[] = []
220
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
221
+ startOrder.push(`first:${t}`)
222
+ const r = await dSlow.promise
223
+ startOrder.push(`first:end:${t}`)
224
+ return r
225
+ })
226
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
227
+ startOrder.push(`second:${t}`)
228
+ return { message_id: 2 }
229
+ })
230
+
231
+ const p1 = wrapped.api.sendMessage('supergrp', 'first', { message_thread_id: 17 })
232
+ const p2 = wrapped.api.sendMessage('supergrp', 'second', { message_thread_id: 17 })
233
+
234
+ await Promise.resolve(); await Promise.resolve()
235
+ // Same thread → second waits for first.
236
+ expect(startOrder).toEqual(['first:first'])
237
+
238
+ dSlow.resolve({ message_id: 1 })
239
+ await Promise.all([p1, p2])
240
+ expect(startOrder).toEqual(['first:first', 'first:end:first', 'second:second'])
241
+ })
242
+
243
+ it('a 429 from one topic does NOT stall sends to a different topic in the same chat', async () => {
244
+ // CPO #8=B guardrail: when grammY's autoRetry transformer backs off
245
+ // on a 429 in topic A, topic B's sends must keep flowing. This test
246
+ // pins the contract by simulating a slow/rejecting first call (proxy
247
+ // for a 429+backoff path) on topic A and asserting topic B doesn't
248
+ // wait on it.
249
+ const lock = createChatLock()
250
+ const bot = createMockBot()
251
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
252
+
253
+ // Topic A's send hangs (proxy for an in-flight 429-backoff path);
254
+ // topic B's send must NOT wait on it.
255
+ const dTopicA = deferred<{ message_id: number }>()
256
+ const started: string[] = []
257
+ bot.api.sendMessage.mockImplementationOnce(async () => {
258
+ started.push('topicA:start')
259
+ return dTopicA.promise
260
+ })
261
+ bot.api.sendMessage.mockImplementationOnce(async () => {
262
+ started.push('topicB:start')
263
+ return { message_id: 2 }
264
+ })
265
+
266
+ const pA = wrapped.api.sendMessage('supergrp', 'overloaded', { message_thread_id: 17 })
267
+ const pB = wrapped.api.sendMessage('supergrp', 'unaffected', { message_thread_id: 23 })
268
+
269
+ // Topic B should complete WITHOUT waiting on topic A's hang.
270
+ const rB = await pB
271
+ expect(rB.message_id).toBe(2)
272
+ expect(started).toContain('topicB:start')
273
+
274
+ // Resolve A so the lock chain drains cleanly.
275
+ dTopicA.resolve({ message_id: 1 })
276
+ const rA = await pA
277
+ expect(rA.message_id).toBe(1)
278
+ })
279
+
280
+ it('strips message_thread_id=1 from sendMessage opts (General-topic Bot API quirk)', async () => {
281
+ // Telegram's General topic has id=1 at MTProto but the Bot API
282
+ // REJECTS message_thread_id=1 on send with HTTP 400 "message thread
283
+ // not found" (tdlib/telegram-bot-api#447). The wrapper strips id=1
284
+ // from the opts bag before the underlying API call.
285
+ const lock = createChatLock()
286
+ const bot = createMockBot()
287
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
288
+
289
+ bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
290
+
291
+ await wrapped.api.sendMessage('supergrp', 'hello General', { message_thread_id: 1 })
292
+
293
+ const callOpts = bot.api.sendMessage.mock.calls[0]![2]
294
+ expect(callOpts).toBeDefined()
295
+ expect((callOpts as Record<string, unknown>).message_thread_id).toBeUndefined()
296
+ })
297
+
298
+ it('preserves other opts when stripping message_thread_id=1', async () => {
299
+ const lock = createChatLock()
300
+ const bot = createMockBot()
301
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
302
+ bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
303
+
304
+ await wrapped.api.sendMessage('supergrp', 'hello', {
305
+ message_thread_id: 1,
306
+ parse_mode: 'HTML',
307
+ reply_to_message_id: 42,
308
+ })
309
+
310
+ const callOpts = bot.api.sendMessage.mock.calls[0]![2] as Record<string, unknown>
311
+ expect(callOpts.message_thread_id).toBeUndefined()
312
+ expect(callOpts.parse_mode).toBe('HTML')
313
+ expect(callOpts.reply_to_message_id).toBe(42)
314
+ })
315
+
316
+ it('does NOT mutate the caller\'s opts object (strip uses a copy)', async () => {
317
+ const lock = createChatLock()
318
+ const bot = createMockBot()
319
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
320
+ bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
321
+
322
+ const callerOpts = { message_thread_id: 1, parse_mode: 'HTML' as const }
323
+ await wrapped.api.sendMessage('supergrp', 'hello', callerOpts)
324
+
325
+ expect(callerOpts.message_thread_id).toBe(1) // caller's object untouched
326
+ })
327
+
328
+ it('does NOT strip non-1 thread IDs', async () => {
329
+ const lock = createChatLock()
330
+ const bot = createMockBot()
331
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
332
+ bot.api.sendMessage.mockImplementationOnce(async () => ({ message_id: 1 }))
333
+
334
+ await wrapped.api.sendMessage('supergrp', 'planning msg', { message_thread_id: 17 })
335
+
336
+ const callOpts = bot.api.sendMessage.mock.calls[0]![2] as Record<string, unknown>
337
+ expect(callOpts.message_thread_id).toBe(17) // non-General threads untouched
338
+ })
339
+
340
+ it('two SAME-chat General-topic sends still serialize (id=1 normalized to chat-root lane)', async () => {
341
+ // After stripping id=1, the lock key normalizes to chatKey(chatId, null)
342
+ // = `chatId:_`. Two General-topic sends queue through the same
343
+ // chat-root lane preserving order (Telegram per-chat order matters).
344
+ const lock = createChatLock()
345
+ const bot = createMockBot()
346
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
347
+
348
+ const dSlow = deferred<{ message_id: number }>()
349
+ const order: string[] = []
350
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
351
+ order.push(`first:${t}`)
352
+ const r = await dSlow.promise
353
+ order.push(`first:end:${t}`)
354
+ return r
355
+ })
356
+ bot.api.sendMessage.mockImplementationOnce(async (_c: string, t: string) => {
357
+ order.push(`second:${t}`)
358
+ return { message_id: 2 }
359
+ })
360
+
361
+ const p1 = wrapped.api.sendMessage('supergrp', 'general msg 1', { message_thread_id: 1 })
362
+ const p2 = wrapped.api.sendMessage('supergrp', 'general msg 2', { message_thread_id: 1 })
363
+
364
+ await Promise.resolve(); await Promise.resolve()
365
+ expect(order).toEqual(['first:general msg 1']) // second waits
366
+
367
+ dSlow.resolve({ message_id: 1 })
368
+ await Promise.all([p1, p2])
369
+ expect(order).toEqual(['first:general msg 1', 'first:end:general msg 1', 'second:general msg 2'])
370
+ })
371
+
372
+ it('General-topic strip does NOT serialize against non-General sends in the same chat', async () => {
373
+ // id=1 → chat-root lane. id=17 → its own lane. Independent dispatch.
374
+ const lock = createChatLock()
375
+ const bot = createMockBot()
376
+ const wrapped = lock.wrapBot({ api: bot.api as unknown as Record<string, unknown> }) as unknown as typeof bot
377
+
378
+ const dGeneral = deferred<{ message_id: number }>()
379
+ const dPlanning = deferred<{ message_id: number }>()
380
+ const started: string[] = []
381
+ bot.api.sendMessage.mockImplementationOnce(async () => {
382
+ started.push('general')
383
+ return dGeneral.promise
384
+ })
385
+ bot.api.sendMessage.mockImplementationOnce(async () => {
386
+ started.push('planning')
387
+ return dPlanning.promise
388
+ })
389
+
390
+ const pGeneral = wrapped.api.sendMessage('supergrp', 'g', { message_thread_id: 1 })
391
+ const pPlanning = wrapped.api.sendMessage('supergrp', 'p', { message_thread_id: 17 })
392
+
393
+ await Promise.resolve(); await Promise.resolve()
394
+ expect(started).toEqual(['general', 'planning']) // both started concurrently
395
+
396
+ dPlanning.resolve({ message_id: 17 })
397
+ dGeneral.resolve({ message_id: 1 })
398
+ await Promise.all([pGeneral, pPlanning])
399
+ })
400
+
173
401
  it('react (setMessageReaction) queues behind an in-flight reply (sendMessage) to the same chat', async () => {
174
402
  const lock = createChatLock()
175
403
  const bot = createMockBot()
@@ -0,0 +1,217 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ /**
4
+ * PR3b regression pin: supergroup-mode parallel-turns deadlock fix.
5
+ *
6
+ * The bug. Pre-fix, the gateway used ONE map `activeTurnStartedAt`
7
+ * for two distinct concerns:
8
+ * (a) "user-visible turn started" — set eagerly in the fresh-turn
9
+ * branch on inbound RECEIPT, used by per-key reads (status-query
10
+ * metric, wedge detection, progress timeout, etc.) that want a
11
+ * receipt-side timestamp.
12
+ * (b) "claude is currently busy on this turn" — the fleet-wide gate
13
+ * at purgeReactionTracking / releaseTurnBufferGate /
14
+ * idle-drain / inbound buffer-or-deliver, where `.size === 0`
15
+ * means "claude is idle, safe to flush buffered inbound."
16
+ *
17
+ * Under fleet-shared / DM topology the two concerns coincide — every
18
+ * received inbound is delivered to claude — so the singleton worked.
19
+ *
20
+ * Under SUPERGROUP-OWNED topology (one agent owns the whole
21
+ * supergroup, multiple topics share the gateway process), they
22
+ * diverge:
23
+ *
24
+ * 1. Topic A delivers + processes — keyA in activeTurnStartedAt
25
+ * (set on receipt) AND claudeBusyKeys (set on delivery).
26
+ * 2. Topic B inbound arrives → fresh-turn branch eagerly sets
27
+ * keyB in activeTurnStartedAt, displays 👀 / starts typing.
28
+ * 3. `decideInboundDelivery` reads `turnInFlight = .size > 0` →
29
+ * TRUE (keyA present) → B is buffered, NOT delivered to claude.
30
+ * 4. A's turn_end → purgeReactionTracking deletes keyA →
31
+ * `.size === 0` check fires the held-inbound flush. BUT under
32
+ * old (singleton) semantics, keyB is STILL in
33
+ * activeTurnStartedAt (set in step 2, never cleared because B
34
+ * never started in claude so no turn_end ever fires for B).
35
+ * 5. `.size > 0` (keyB lingers) → flush never runs → B's buffered
36
+ * msg never delivered → B's user sees 👀 forever.
37
+ *
38
+ * DEADLOCK.
39
+ *
40
+ * The fix splits concerns. `activeTurnStartedAt` keeps semantic (a)
41
+ * — set on receipt, all per-key reads use it. `claudeBusyKeys` (new)
42
+ * carries semantic (b) — set ONLY on successful sendToAgent
43
+ * (delivery), cleared on turn_end / disconnect / buffer-gate-release.
44
+ * Fleet gates switch to claudeBusyKeys. The deadlock breaks because
45
+ * step 5's gate now reads `claudeBusyKeys.size` which only ever held
46
+ * keyA (delivered) → after step 4's delete, size === 0 → flush →
47
+ * B's buffered msg delivered → claudeBusyKeys.add(keyB) → ... →
48
+ * B's eventual turn_end clears keyB.
49
+ *
50
+ * This test pins the load-bearing invariants. The actual wiring
51
+ * lives in `gateway.ts` and `disconnect-flush.ts`; this file is the
52
+ * structural-contract regression guard so a future "let's simplify
53
+ * the gates back to one map" refactor fails loudly here.
54
+ */
55
+
56
+ describe('PR3b parallel-turns deadlock fix: claudeBusyKeys decoupled from activeTurnStartedAt', () => {
57
+ function makeState() {
58
+ const activeTurnStartedAt = new Map<string, number>()
59
+ const claudeBusyKeys = new Set<string>()
60
+ return { activeTurnStartedAt, claudeBusyKeys }
61
+ }
62
+
63
+ // Mirror the gateway's fresh-turn-on-receipt path (the part that
64
+ // updates the two maps). Stripped to ONLY the state mutations we
65
+ // care about for this invariant.
66
+ function receiveInbound(state: ReturnType<typeof makeState>, key: string, at: number): void {
67
+ state.activeTurnStartedAt.set(key, at)
68
+ // CRITICAL: claudeBusyKeys is NOT touched here. The whole point
69
+ // of the split is that receipt ≠ delivery.
70
+ }
71
+
72
+ function deliverToClaude(state: ReturnType<typeof makeState>, key: string): void {
73
+ // Mirror of markClaudeBusyForInbound at every successful
74
+ // sendToAgent callsite in gateway.ts.
75
+ state.claudeBusyKeys.add(key)
76
+ }
77
+
78
+ function turnEnd(state: ReturnType<typeof makeState>, key: string): void {
79
+ // Mirror of purgeReactionTracking + releaseTurnBufferGate —
80
+ // both maps cleared together at turn_end.
81
+ state.activeTurnStartedAt.delete(key)
82
+ state.claudeBusyKeys.delete(key)
83
+ }
84
+
85
+ function fleetGateOpen(state: ReturnType<typeof makeState>): boolean {
86
+ // Mirror of the four fleet-wide gates:
87
+ // purgeReactionTracking line 1393: if (claudeBusyKeys.size === 0)
88
+ // releaseTurnBufferGate line 1484: if (claudeBusyKeys.size === 0)
89
+ // onScheduleRestart line 4020: turnInFlight = .size > 0
90
+ // idle-drain tick line 4343: if (.size > 0) return false
91
+ // handleInbound line 8087: turnInFlightAtReceipt = .size > 0
92
+ return state.claudeBusyKeys.size === 0
93
+ }
94
+
95
+ it('pre-fix scenario: singleton map deadlocks supergroup-mode parallel turns', () => {
96
+ // Simulate the BROKEN behavior — one map serving both concerns
97
+ // (i.e. what would happen if claudeBusyKeys did NOT exist and
98
+ // the gates read activeTurnStartedAt.size). This is the
99
+ // *opposite* of the test below; documents what we're fixing.
100
+ const legacyState = new Map<string, number>()
101
+ const legacyFleetGate = () => legacyState.size === 0
102
+
103
+ // Step 1: A delivered (eager set on receipt)
104
+ legacyState.set('keyA', 100)
105
+ // Step 2: B received but buffered (eager set fires regardless)
106
+ legacyState.set('keyB', 200)
107
+ expect(legacyFleetGate()).toBe(false)
108
+
109
+ // Step 4: A's turn_end clears keyA
110
+ legacyState.delete('keyA')
111
+ // DEADLOCK — keyB lingers forever because B never started
112
+ // in claude so no turn_end ever fires for B.
113
+ expect(legacyFleetGate()).toBe(false)
114
+ expect(legacyState.has('keyB')).toBe(true)
115
+ })
116
+
117
+ it('post-fix scenario: split maps let A.turn_end unblock B', () => {
118
+ const state = makeState()
119
+
120
+ // Step 1: A's inbound received AND delivered to claude.
121
+ receiveInbound(state, 'keyA', 100)
122
+ deliverToClaude(state, 'keyA')
123
+ expect(state.activeTurnStartedAt.has('keyA')).toBe(true)
124
+ expect(state.claudeBusyKeys.has('keyA')).toBe(true)
125
+
126
+ // Step 2: B's inbound received, but `decideInboundDelivery`
127
+ // returned buffer-until-idle (turnInFlightAtReceipt was true
128
+ // because keyA is in claudeBusyKeys). B's fresh-turn branch
129
+ // STILL fired — eager activeTurnStartedAt[keyB] set —
130
+ // for the user-visible 👀 / typing indicator.
131
+ receiveInbound(state, 'keyB', 200)
132
+ // CRITICAL: claudeBusyKeys does NOT contain keyB because B was
133
+ // buffered, not delivered. The split semantics is the entire
134
+ // PR3b contract.
135
+ expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
136
+ expect(state.claudeBusyKeys.has('keyB')).toBe(false)
137
+ expect(state.claudeBusyKeys.size).toBe(1)
138
+ expect(fleetGateOpen(state)).toBe(false)
139
+
140
+ // Step 4: A's turn_end clears keyA from BOTH maps.
141
+ turnEnd(state, 'keyA')
142
+
143
+ // Step 5 (the deadlock fix): fleet gate now OPENS because
144
+ // claudeBusyKeys is empty, even though activeTurnStartedAt
145
+ // still has keyB (which is fine — that's the user-visible
146
+ // receipt timestamp, not the claude-busy flag).
147
+ expect(state.claudeBusyKeys.size).toBe(0)
148
+ expect(state.activeTurnStartedAt.has('keyB')).toBe(true)
149
+ expect(fleetGateOpen(state)).toBe(true)
150
+
151
+ // The flush triggers → B's buffered msg gets sent →
152
+ // deliverToClaude fires for keyB → busy gate closes again.
153
+ deliverToClaude(state, 'keyB')
154
+ expect(fleetGateOpen(state)).toBe(false)
155
+
156
+ // B's turn_end completes the cycle.
157
+ turnEnd(state, 'keyB')
158
+ expect(state.activeTurnStartedAt.size).toBe(0)
159
+ expect(state.claudeBusyKeys.size).toBe(0)
160
+ expect(fleetGateOpen(state)).toBe(true)
161
+ })
162
+
163
+ it('per-key reads (priorTurnStartedAt timing, status-query metric) keep working on activeTurnStartedAt', () => {
164
+ // The split must not regress per-key timestamp reads. These all
165
+ // want the RECEIPT timestamp (the user's send-time, not when
166
+ // claude finally got to it), so they correctly read
167
+ // activeTurnStartedAt — preserved across the buffer window.
168
+ const state = makeState()
169
+ receiveInbound(state, 'keyA', 100)
170
+ deliverToClaude(state, 'keyA')
171
+ receiveInbound(state, 'keyB', 200) // buffered, no deliverToClaude
172
+
173
+ // A second message on keyB arriving during the buffer window —
174
+ // mid-turn classification reads activeTurnStartedAt.get(keyB)
175
+ // for `priorTurnStartedAt`, which must still be 200 (the
176
+ // original receipt time, even though B never reached claude).
177
+ expect(state.activeTurnStartedAt.get('keyB')).toBe(200)
178
+ // And keyA's receipt timestamp is preserved too — A's
179
+ // follow-ups during its own processing window get accurate
180
+ // secondsSinceTurnStart metric.
181
+ expect(state.activeTurnStartedAt.get('keyA')).toBe(100)
182
+ })
183
+
184
+ it('disconnect-flush sweeps both maps together (the bridge died, all in-flight turns are dead)', () => {
185
+ // Mirror of the disconnect-flush sweep loop in disconnect-flush.ts —
186
+ // a registered-agent disconnect clears both maps because every
187
+ // turn it was processing is dead by definition.
188
+ const state = makeState()
189
+ receiveInbound(state, 'keyA', 100)
190
+ deliverToClaude(state, 'keyA')
191
+ receiveInbound(state, 'keyB', 200) // buffered
192
+
193
+ // Bridge dies (claude crashed mid-A). Sweep both maps.
194
+ for (const k of [...state.activeTurnStartedAt.keys()]) {
195
+ state.activeTurnStartedAt.delete(k)
196
+ state.claudeBusyKeys.delete(k)
197
+ }
198
+
199
+ expect(state.activeTurnStartedAt.size).toBe(0)
200
+ expect(state.claudeBusyKeys.size).toBe(0)
201
+ expect(fleetGateOpen(state)).toBe(true)
202
+ })
203
+
204
+ it('idempotent: multiple inbounds for the same key (e.g. A user spamming) are a single entry', () => {
205
+ // Set semantics — A's 5 follow-up messages while A is processing
206
+ // collapse to a single claudeBusyKeys entry. Turn_end clears
207
+ // once; size accounting stays correct.
208
+ const state = makeState()
209
+ receiveInbound(state, 'keyA', 100)
210
+ deliverToClaude(state, 'keyA')
211
+ deliverToClaude(state, 'keyA')
212
+ deliverToClaude(state, 'keyA')
213
+ expect(state.claudeBusyKeys.size).toBe(1)
214
+ turnEnd(state, 'keyA')
215
+ expect(state.claudeBusyKeys.size).toBe(0)
216
+ })
217
+ })
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { resolveOutboundTopic } from '../../src/telegram/topic-router.js'
3
+
4
+ /**
5
+ * PR5 — supergroup-mode slash-command smart-split (CPO #4).
6
+ *
7
+ * The gateway wires `runSwitchroomCommand` → `switchroomReply` →
8
+ * `slashCommandReplyOpts(ctx, classification)` → `resolveOutboundTopic`.
9
+ * The helper is a thin classifier on top of the existing router; this
10
+ * test pins the END contract that drives all 4 heavy-output commands
11
+ * (/logs, /audit, /upgradestatus, /memory) and any future mutation
12
+ * additions:
13
+ *
14
+ * - query → follows the originating topic (or undefined for fleet/DM)
15
+ * - mutation → admin alias (or undefined for fleet/DM)
16
+ * - heavy → admin alias (or undefined for fleet/DM)
17
+ *
18
+ * The gateway wrapper additionally collapses `target === originThreadId`
19
+ * back to `{}` so a query in the originating topic doesn't write a
20
+ * redundant `message_thread_id` opt. That's a wire-shape micro-opt
21
+ * tested separately at the call site.
22
+ */
23
+
24
+ describe('PR5 slash-command smart split — router contract', () => {
25
+ const supergroup = {
26
+ default_topic_id: 1,
27
+ topic_aliases: { planning: 17, admin: 31, alerts: 42 },
28
+ }
29
+ const fleet = {} // no chat_id / default_topic_id → fleet/DM
30
+
31
+ describe('query class', () => {
32
+ it('supergroup: follows originThreadId', () => {
33
+ expect(
34
+ resolveOutboundTopic(supergroup, {
35
+ kind: 'command-query',
36
+ originThreadId: 17,
37
+ }),
38
+ ).toBe(17)
39
+ })
40
+
41
+ it('fleet: returns originThreadId unchanged (caller passes-through)', () => {
42
+ expect(
43
+ resolveOutboundTopic(fleet, {
44
+ kind: 'command-query',
45
+ originThreadId: 17,
46
+ }),
47
+ ).toBe(17)
48
+ })
49
+
50
+ it('supergroup, no origin thread (chat root): default_topic_id fallback', () => {
51
+ // command-query returns originThreadId verbatim, including
52
+ // undefined; the wrapper collapses undefined to "no override"
53
+ // and grammY's ctx.reply picks the originating topic anyway.
54
+ expect(
55
+ resolveOutboundTopic(supergroup, {
56
+ kind: 'command-query',
57
+ originThreadId: undefined,
58
+ }),
59
+ ).toBeUndefined()
60
+ })
61
+ })
62
+
63
+ describe('mutation class', () => {
64
+ it('supergroup: routes to admin alias', () => {
65
+ expect(resolveOutboundTopic(supergroup, { kind: 'command-mutation' })).toBe(31)
66
+ })
67
+
68
+ it('supergroup with no admin alias: default_topic_id fallback', () => {
69
+ const cfg = { default_topic_id: 1, topic_aliases: { planning: 17 } }
70
+ expect(resolveOutboundTopic(cfg, { kind: 'command-mutation' })).toBe(1)
71
+ })
72
+
73
+ it('fleet: returns undefined (caller falls through to ctx.reply)', () => {
74
+ expect(resolveOutboundTopic(fleet, { kind: 'command-mutation' })).toBeUndefined()
75
+ })
76
+ })
77
+
78
+ describe('heavy class (the 4 commands actually wired in PR5)', () => {
79
+ it('supergroup: /logs /audit /upgradestatus /memory all route to admin', () => {
80
+ // All four commands fold through the same `slashCommandReplyOpts(ctx, "heavy")`
81
+ // wrapper, which fires the same router event. One assertion covers
82
+ // all of them.
83
+ expect(resolveOutboundTopic(supergroup, { kind: 'command-heavy' })).toBe(31)
84
+ })
85
+
86
+ it('supergroup with no admin alias: default_topic_id fallback', () => {
87
+ const cfg = { default_topic_id: 1, topic_aliases: { planning: 17 } }
88
+ expect(resolveOutboundTopic(cfg, { kind: 'command-heavy' })).toBe(1)
89
+ })
90
+
91
+ it('fleet: returns undefined (caller falls through to ctx.reply)', () => {
92
+ expect(resolveOutboundTopic(fleet, { kind: 'command-heavy' })).toBeUndefined()
93
+ })
94
+ })
95
+
96
+ describe('separation contract: query vs mutation/heavy take different paths', () => {
97
+ // Pins the structural intent: a query and a mutation issued from
98
+ // the SAME originating topic in the SAME supergroup must resolve
99
+ // to DIFFERENT topics. If anyone collapses the three classes back
100
+ // to one event kind, this test fails loudly.
101
+ it('query.originThread !== mutation.adminAlias', () => {
102
+ const q = resolveOutboundTopic(supergroup, {
103
+ kind: 'command-query',
104
+ originThreadId: 17,
105
+ })
106
+ const m = resolveOutboundTopic(supergroup, { kind: 'command-mutation' })
107
+ const h = resolveOutboundTopic(supergroup, { kind: 'command-heavy' })
108
+ expect(q).toBe(17)
109
+ expect(m).toBe(31)
110
+ expect(h).toBe(31)
111
+ expect(q).not.toBe(m)
112
+ expect(m).toBe(h) // mutation and heavy both → admin
113
+ })
114
+ })
115
+ })
@@ -2,8 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { createTypingWrapper } from '../typing-wrap.js'
3
3
 
4
4
  function makeDeps(overrides: { isSurfaceTool?: (name: string) => boolean } = {}) {
5
- const startTypingLoop = vi.fn<(chatId: string) => void>()
6
- const stopTypingLoop = vi.fn<(chatId: string) => void>()
5
+ // PR3 supergroup-mode: start/stop now take (chatId, threadId?). The
6
+ // existing tests cover the chatId-only case (threadId omitted → null);
7
+ // new tests below pin the per-thread isolation.
8
+ const startTypingLoop = vi.fn<(chatId: string, threadId?: number | null) => void>()
9
+ const stopTypingLoop = vi.fn<(chatId: string, threadId?: number | null) => void>()
7
10
  const isSurfaceTool =
8
11
  overrides.isSurfaceTool ??
9
12
  ((name: string) =>
@@ -28,7 +31,7 @@ describe('createTypingWrapper', () => {
28
31
  w.onToolUse('t1', 'chat-A', 'Bash')
29
32
  // First tool on a fresh chat fires immediately — no timer wait required.
30
33
  expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
31
- expect(deps.startTypingLoop).toHaveBeenCalledWith('chat-A')
34
+ expect(deps.startTypingLoop).toHaveBeenCalledWith('chat-A', null)
32
35
  })
33
36
 
34
37
  it('a parallel second tool on the same chat uses the debounce', () => {
@@ -69,7 +72,7 @@ describe('createTypingWrapper', () => {
69
72
  expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
70
73
  w.onToolResult('t1')
71
74
  expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
72
- expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A')
75
+ expect(deps.stopTypingLoop).toHaveBeenCalledWith('chat-A', null)
73
76
  })
74
77
 
75
78
  it('skips surface tools (reply/stream_reply/edit_message/react)', () => {
@@ -93,16 +96,16 @@ describe('createTypingWrapper', () => {
93
96
  w.onToolUse('t1', 'chat-A', 'Bash')
94
97
  w.onToolUse('t2', 'chat-B', 'Grep')
95
98
  expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
96
- expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A')
97
- expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-B')
99
+ expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A', null)
100
+ expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-B', null)
98
101
 
99
102
  w.onToolResult('t1')
100
103
  expect(deps.stopTypingLoop).toHaveBeenCalledTimes(1)
101
- expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A')
104
+ expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A', null)
102
105
 
103
106
  w.onToolResult('t2')
104
107
  expect(deps.stopTypingLoop).toHaveBeenCalledTimes(2)
105
- expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-B')
108
+ expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-B', null)
106
109
  })
107
110
 
108
111
  it('drainAll clears pending entries and stops any started loops', () => {
@@ -138,4 +141,58 @@ describe('createTypingWrapper', () => {
138
141
  vi.advanceTimersByTime(2)
139
142
  expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
140
143
  })
144
+
145
+ // ─── PR3 supergroup-mode: per-(chat,thread) lane isolation ────────────
146
+ it('SAME chat + DIFFERENT threads each get their own immediate-fire lane', () => {
147
+ const deps = makeDeps()
148
+ const w = createTypingWrapper(deps)
149
+ // Both are "first tool on lane" — both fire immediately, not debounced.
150
+ w.onToolUse('t1', 'chat-A', 'Bash', 17)
151
+ w.onToolUse('t2', 'chat-A', 'Read', 23)
152
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
153
+ expect(deps.startTypingLoop).toHaveBeenNthCalledWith(1, 'chat-A', 17)
154
+ expect(deps.startTypingLoop).toHaveBeenNthCalledWith(2, 'chat-A', 23)
155
+ })
156
+
157
+ it('SAME chat + SAME thread STILL uses debounce on the second tool', () => {
158
+ const deps = makeDeps()
159
+ const w = createTypingWrapper(deps)
160
+ w.onToolUse('t1', 'chat-A', 'Bash', 17)
161
+ w.onToolUse('t2', 'chat-A', 'Read', 17)
162
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(1)
163
+ vi.advanceTimersByTime(500)
164
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
165
+ })
166
+
167
+ it('stopping topic A does NOT clear topic B\'s lane (the headline bug fix)', () => {
168
+ // The bug: chatId-only keying meant `activeChats.delete(chatId)`
169
+ // when topic A's tool ended ALSO marked topic B's lane as inactive,
170
+ // so topic B's next tool would re-fire immediately (wrong — it's
171
+ // already typing) and a subsequent stop could mismatch.
172
+ // Per-(chat,thread) lane keying preserves independence.
173
+ const deps = makeDeps()
174
+ const w = createTypingWrapper(deps)
175
+ w.onToolUse('t1', 'chat-A', 'Bash', 17) // topic A lane: active
176
+ w.onToolUse('t2', 'chat-A', 'Read', 23) // topic B lane: active (independent)
177
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(2)
178
+
179
+ w.onToolResult('t1') // topic A done
180
+ expect(deps.stopTypingLoop).toHaveBeenLastCalledWith('chat-A', 17)
181
+ // Topic B is still active — a third tool on topic B should DEBOUNCE
182
+ // (lane is still active), not fire immediately.
183
+ w.onToolUse('t3', 'chat-A', 'Edit', 23)
184
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(2) // no immediate fire
185
+ vi.advanceTimersByTime(500)
186
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(3)
187
+ expect(deps.startTypingLoop).toHaveBeenLastCalledWith('chat-A', 23)
188
+ })
189
+
190
+ it('treats undefined / null threadId as the same lane (chatKey null/0 collapse)', () => {
191
+ const deps = makeDeps()
192
+ const w = createTypingWrapper(deps)
193
+ w.onToolUse('t1', 'chat-A', 'Bash') // undefined thread
194
+ w.onToolUse('t2', 'chat-A', 'Read', null) // null thread — same lane
195
+ expect(deps.startTypingLoop).toHaveBeenCalledTimes(1) // only first fires immediately
196
+ expect(deps.startTypingLoop).toHaveBeenLastCalledWith('chat-A', null)
197
+ })
141
198
  })