switchroom 0.13.51 → 0.13.53

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.
@@ -137,11 +137,24 @@ export function createInboundCoalescer<T>(opts: InboundCoalescerOptions<T>): Inb
137
137
  }
138
138
 
139
139
  /**
140
- * Build a coalesce key from `(chatId, userId)`. Identity-stable across
141
- * messages from the same sender in the same chat, distinct across
142
- * different senders so a user-driven reply isn't merged with a sibling
143
- * message from someone else in a group chat.
140
+ * Build a coalesce key from `(chatId, threadId, userId)`. Identity-stable
141
+ * across messages from the same sender in the same chat and topic,
142
+ * distinct across:
143
+ * - different senders (so one user's typing isn't merged with another's)
144
+ * - different topics (so a user typing in #planning isn't merged with
145
+ * the same user's message in #admin — supergroup-mode invariant,
146
+ * CPO decision #9 ratified 2026-05-27)
147
+ *
148
+ * `threadId` collapses `null`/`undefined`/`0` to `_` via the same
149
+ * convention as `chatKey()`. The 1.5s coalesce window is per-topic
150
+ * intent ("user sends 3 sentences as one thought") — applying it
151
+ * cross-topic merges genuinely separate conversations.
144
152
  */
145
- export function inboundCoalesceKey(chatId: string, userId: string): string {
146
- return `${chatId}:${userId}`
153
+ export function inboundCoalesceKey(
154
+ chatId: string,
155
+ threadId: number | null | undefined,
156
+ userId: string,
157
+ ): string {
158
+ const t = threadId == null || threadId === 0 ? '_' : String(threadId)
159
+ return `${chatId}:${t}:${userId}`
147
160
  }
@@ -23,6 +23,7 @@ import {
23
23
  type RetryPolicy,
24
24
  } from './stream-controller.js'
25
25
  import { sanitizeTelegramHtml } from './html-sanitize.js'
26
+ import { chatKey, chatKeyWithSuffix } from './gateway/chat-key.js'
26
27
 
27
28
  /**
28
29
  * Builds the inline status-accent header line for `reply` / `stream_reply`.
@@ -311,14 +312,15 @@ function streamKey(
311
312
  lane?: string,
312
313
  turnKey?: string,
313
314
  ): string {
314
- // Canonical chat-key derivation lives in gateway/chat-key.ts keep this
315
- // expression in lockstep with that helper (treats 0/null/undefined the
316
- // same), but inline here so this file doesn't introduce a cross-package
317
- // import for one expression. See #1564 for the sibling-key bug class.
318
- const t = threadId == null || threadId === 0 ? '_' : String(threadId)
319
- const base = `${chatId}:${t}`
320
- const withLane = lane != null && lane.length > 0 ? `${base}:${lane}` : base
321
- return turnKey != null && turnKey.length > 0 ? `${withLane}:${turnKey}` : withLane
315
+ // Adopt the canonical chatKey() / chatKeyWithSuffix() primitives from
316
+ // gateway/chat-key.ts (PR2 of supergroup mode kills the previously
317
+ // inlined copy of the key expression). The brand erases to string at
318
+ // runtime, so callers using `streamKey` as a `Map<string, T>` key
319
+ // continue to work unchanged.
320
+ const base = lane != null && lane.length > 0
321
+ ? chatKeyWithSuffix(chatId, threadId ?? null, lane)
322
+ : chatKey(chatId, threadId ?? null)
323
+ return turnKey != null && turnKey.length > 0 ? `${base}:${turnKey}` : base
322
324
  }
323
325
 
324
326
  export async function handleStreamReply(
@@ -50,6 +50,14 @@ function makeDeps(agentName: string | null) {
50
50
  ['chat1:thr1:msg1', 100],
51
51
  ['chat2:thr2:msg2', 200],
52
52
  ])
53
+ // PR3b: claudeBusyKeys tracks turns actually handed to claude. In a
54
+ // healthy registered-disconnect scenario both maps would carry the
55
+ // same keys (delivery succeeded); the dangling-sweep tests below
56
+ // override individual deps to exercise the orphaned-key path.
57
+ const claudeBusyKeys = new Set<string>([
58
+ 'chat1:thr1:msg1',
59
+ 'chat2:thr2:msg2',
60
+ ])
53
61
  const activeDraftStreams = new Map<string, FakeStream>([
54
62
  ['chat1:thr1:r1', { isFinal: () => false, finalize: finalizeA }],
55
63
  ['chat2:thr2:r2', { isFinal: () => true, finalize: finalizeB }],
@@ -66,6 +74,7 @@ function makeDeps(agentName: string | null) {
66
74
  activeStatusReactions,
67
75
  activeReactionMsgIds,
68
76
  activeTurnStartedAt,
77
+ claudeBusyKeys,
69
78
  activeDraftStreams,
70
79
  activeDraftParseModes,
71
80
  clearActiveReactions,
@@ -169,6 +178,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
169
178
  ['ghost:thr:msg', { chatId: 'ghost', messageId: 42 }],
170
179
  ]),
171
180
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
181
+ claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
172
182
  activeDraftStreams: new Map<string, FakeStream>(),
173
183
  activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
174
184
  clearActiveReactions,
@@ -179,6 +189,10 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
179
189
 
180
190
  flushOnAgentDisconnect(deps)
181
191
 
192
+ // PR3b: claudeBusyKeys swept alongside the activeTurnStartedAt
193
+ // dangling entry — both maps mirror each other on registered
194
+ // disconnects, so a key in one is always a key in the other.
195
+ expect(deps.claudeBusyKeys.size).toBe(0)
182
196
  // The sweep fired and cleared the dangling entry.
183
197
  expect(deps.activeTurnStartedAt.size).toBe(0)
184
198
  expect(deps.activeReactionMsgIds.size).toBe(0)
@@ -222,6 +236,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
222
236
  activeStatusReactions: new Map<string, FakeCtrl>(),
223
237
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
224
238
  activeTurnStartedAt: new Map<string, number>([['real-turn:thr:msg', 100]]),
239
+ claudeBusyKeys: new Set<string>(['real-turn:thr:msg']),
225
240
  activeDraftStreams: new Map<string, FakeStream>(),
226
241
  activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
227
242
  clearActiveReactions: vi.fn(),
@@ -237,6 +252,106 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
237
252
  expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
238
253
  })
239
254
 
255
+ // PR3b orphan-sweep regression: synthetic-inbound deliveries
256
+ // (cron, reactions, vault, button-callback) bypass handleInbound's
257
+ // fresh-turn branch and so never stamp activeTurnStartedAt. They
258
+ // DO mark claudeBusyKeys. If their turn dies without turn_end, the
259
+ // activeTurnStartedAt-keyed dangling sweep misses them — orphan
260
+ // persists in claudeBusyKeys → fleet gate wedges. This test pins
261
+ // the post-sweep claudeBusyKeys.clear() fix.
262
+ it('sweeps claudeBusyKeys orphans that have NO activeTurnStartedAt entry (PR3b follow-up)', () => {
263
+ const onDanglingTurnsSwept = vi.fn()
264
+ const log = vi.fn()
265
+ const deps = {
266
+ agentName: 'clerk',
267
+ activeStatusReactions: new Map<string, FakeCtrl>(),
268
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
269
+ activeTurnStartedAt: new Map<string, number>(),
270
+ // The orphan scenario: claude was handed a turn (e.g. cron
271
+ // synthetic delivered), so claudeBusyKeys has it, but
272
+ // activeTurnStartedAt was never set because cron bypasses
273
+ // handleInbound's fresh-turn branch.
274
+ claudeBusyKeys: new Set<string>(['cron-only-key:_']),
275
+ activeDraftStreams: new Map<string, FakeStream>(),
276
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
277
+ clearActiveReactions: vi.fn(),
278
+ disposeProgressDriver: vi.fn(),
279
+ onDanglingTurnsSwept,
280
+ log,
281
+ }
282
+
283
+ flushOnAgentDisconnect(deps)
284
+
285
+ // The orphan is cleared even though it never had an
286
+ // activeTurnStartedAt entry.
287
+ expect(deps.claudeBusyKeys.size).toBe(0)
288
+ // The activeTurnStartedAt-keyed sweep wasn't fired (nothing in
289
+ // that map to sweep) — so onDanglingTurnsSwept shouldn't fire
290
+ // either. The orphan sweep is a separate observation.
291
+ expect(onDanglingTurnsSwept).not.toHaveBeenCalled()
292
+ // But it logs the orphan-clear so operators can see it.
293
+ expect(
294
+ log.mock.calls.some((c: unknown[]) =>
295
+ typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
296
+ ),
297
+ ).toBe(true)
298
+ })
299
+
300
+ it('orphan-sweep singular vs plural log message agrees with count', () => {
301
+ // Tiny grammar regression: "1 entry" vs "2 entries".
302
+ const log = vi.fn()
303
+ const baseDeps = {
304
+ agentName: 'clerk',
305
+ activeStatusReactions: new Map<string, FakeCtrl>(),
306
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
307
+ activeTurnStartedAt: new Map<string, number>(),
308
+ activeDraftStreams: new Map<string, FakeStream>(),
309
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
310
+ clearActiveReactions: vi.fn(),
311
+ disposeProgressDriver: vi.fn(),
312
+ }
313
+ // Singular form.
314
+ flushOnAgentDisconnect({
315
+ ...baseDeps,
316
+ claudeBusyKeys: new Set<string>(['k1:_']),
317
+ log,
318
+ })
319
+ expect(log.mock.calls.some((c: unknown[]) =>
320
+ typeof c[0] === 'string' && / 1 orphan claudeBusyKeys entry /.test(c[0]),
321
+ )).toBe(true)
322
+ // Plural form.
323
+ log.mockClear()
324
+ flushOnAgentDisconnect({
325
+ ...baseDeps,
326
+ claudeBusyKeys: new Set<string>(['k1:_', 'k2:1']),
327
+ log,
328
+ })
329
+ expect(log.mock.calls.some((c: unknown[]) =>
330
+ typeof c[0] === 'string' && / 2 orphan claudeBusyKeys entries /.test(c[0]),
331
+ )).toBe(true)
332
+ })
333
+
334
+ it('does NOT fire orphan-sweep log when claudeBusyKeys is empty', () => {
335
+ // Zero-noise discipline: every disconnect for a healthy idle
336
+ // agent shouldn't produce a "0 orphan claudeBusyKeys" line.
337
+ const log = vi.fn()
338
+ flushOnAgentDisconnect({
339
+ agentName: 'clerk',
340
+ activeStatusReactions: new Map<string, FakeCtrl>(),
341
+ activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
342
+ activeTurnStartedAt: new Map<string, number>(),
343
+ claudeBusyKeys: new Set<string>(),
344
+ activeDraftStreams: new Map<string, FakeStream>(),
345
+ activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
346
+ clearActiveReactions: vi.fn(),
347
+ disposeProgressDriver: vi.fn(),
348
+ log,
349
+ })
350
+ expect(log.mock.calls.some((c: unknown[]) =>
351
+ typeof c[0] === 'string' && /orphan claudeBusyKeys/.test(c[0]),
352
+ )).toBe(false)
353
+ })
354
+
240
355
  it('omitting onDanglingTurnsSwept is safe (optional callback)', () => {
241
356
  // Backward-compat guard — existing callers that don't pass the new
242
357
  // callback still work without runtime error.
@@ -245,6 +360,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
245
360
  activeStatusReactions: new Map<string, FakeCtrl>(),
246
361
  activeReactionMsgIds: new Map<string, { chatId: string; messageId: number }>(),
247
362
  activeTurnStartedAt: new Map<string, number>([['ghost:thr:msg', 100]]),
363
+ claudeBusyKeys: new Set<string>(['ghost:thr:msg']),
248
364
  activeDraftStreams: new Map<string, FakeStream>(),
249
365
  activeDraftParseModes: new Map<string, 'HTML' | 'MarkdownV2' | undefined>(),
250
366
  clearActiveReactions: vi.fn(),
@@ -21,10 +21,26 @@ beforeEach(() => { vi.useFakeTimers() })
21
21
  afterEach(() => { vi.useRealTimers() })
22
22
 
23
23
  describe('inboundCoalesceKey', () => {
24
- it('combines chatId and userId so distinct senders never collide', () => {
25
- expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c1', 'u2'))
26
- expect(inboundCoalesceKey('c1', 'u1')).not.toBe(inboundCoalesceKey('c2', 'u1'))
27
- expect(inboundCoalesceKey('c1', 'u1')).toBe(inboundCoalesceKey('c1', 'u1'))
24
+ it('combines chatId, threadId, and userId so distinct senders never collide', () => {
25
+ expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u2'))
26
+ expect(inboundCoalesceKey('c1', null, 'u1')).not.toBe(inboundCoalesceKey('c2', null, 'u1'))
27
+ expect(inboundCoalesceKey('c1', null, 'u1')).toBe(inboundCoalesceKey('c1', null, 'u1'))
28
+ })
29
+
30
+ it('keeps the same user\'s messages in DIFFERENT topics in distinct buckets (supergroup-mode)', () => {
31
+ // CPO decision #9 ratified 2026-05-27: per-topic coalesce intent.
32
+ // The 1.5s window is "user sends 3 sentences as one thought" —
33
+ // applying it cross-topic merges genuinely separate conversations.
34
+ expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', 23, 'u1'))
35
+ expect(inboundCoalesceKey('c1', 17, 'u1')).not.toBe(inboundCoalesceKey('c1', null, 'u1'))
36
+ })
37
+
38
+ it('collapses null / undefined / 0 thread IDs to the same key (chatKey convention)', () => {
39
+ const k1 = inboundCoalesceKey('c1', null, 'u1')
40
+ const k2 = inboundCoalesceKey('c1', undefined, 'u1')
41
+ const k3 = inboundCoalesceKey('c1', 0, 'u1')
42
+ expect(k1).toBe(k2)
43
+ expect(k1).toBe(k3)
28
44
  })
29
45
  })
30
46
 
@@ -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()