switchroom 0.19.23 → 0.19.24

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.
@@ -16,12 +16,16 @@
16
16
  * collapse; the latest snapshot lands floor-paced
17
17
  * - the floor is per-message (stream A does not delay stream B)
18
18
  * - the gate's no-op skip drops a repeat payload for the same message
19
- * - an open flood window sheds draft edits with ZERO API calls, and the
20
- * stream recovers with full state after the window closes
21
- * - a shed draft is NOT recorded as delivered — a later flush of the
22
- * SAME text (the completed answer) still lands
19
+ * - an open flood window COALESCES draft edits with ZERO API calls, and the
20
+ * newest state lands once the window closes (#3716 — cosmetic edits are
21
+ * never shed; the last edit of a burst is the one still on screen, so
22
+ * dropping it stranded the message on a stale body)
23
+ * - a draft held through a window still renders the completed answer — a
24
+ * later flush of the SAME text is then a benign no-op, not a loss
23
25
  * - the finalize flush is `critical`: never shed; waits out a short
24
26
  * window; fails fast (structured, logged) on a long one
27
+ * - shed-honesty (F2+F3) remains wired for any `SEND_GATE_SHED` the retry
28
+ * policy does return, pinned directly rather than through the gate
25
29
  * - regression pin: the controller passes messageId / editPayload /
26
30
  * priorityClass through the retry policy on every edit
27
31
  *
@@ -32,7 +36,7 @@
32
36
  */
33
37
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
34
38
  import { createStreamController, type RetryPolicy } from '../stream-controller.js'
35
- import { createSendGate, type Clock, type SendGateConfig } from '../send-gate.js'
39
+ import { createSendGate, SEND_GATE_SHED, type Clock, type SendGateConfig } from '../send-gate.js'
36
40
  import { isFloodWaitActiveError } from '../retry-api-call.js'
37
41
  import { renderOutboundChunks } from '../render/rich-render.js'
38
42
  import { createMockBot, installBotResetHook } from './bot-api.harness.js'
@@ -221,7 +225,7 @@ describe('stream-controller × send gate (#3110)', () => {
221
225
  expect(bot.api.editMessageText).toHaveBeenCalledTimes(1)
222
226
  })
223
227
 
224
- it('open flood window: draft edits shed with ZERO API calls; full state lands after it closes', async () => {
228
+ it('open flood window: draft edits COALESCE with ZERO API calls; newest state lands after it closes (#3716)', async () => {
225
229
  const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
226
230
  const stream = createStreamController({
227
231
  bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
@@ -233,17 +237,24 @@ describe('stream-controller × send gate (#3110)', () => {
233
237
  await flush()
234
238
  void stream.update('draft b')
235
239
  await tick()
236
- // Both drafts shed as cosmetic — nothing reached the API.
240
+ // Flood safety is unchanged — still ZERO API calls while the window is
241
+ // open. What changed is the mechanism: the drafts are HELD (last-write-
242
+ // wins), not discarded, so no state is lost.
237
243
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
238
- expect(gate.stats().global.shed).toBe(2)
244
+ expect(gate.stats().global.shed).toBe(0)
239
245
 
240
- await clock.advance(30_000) // window closes
246
+ await clock.advance(30_000) // window closes → the held draft lands
241
247
  void stream.update('draft c — full state')
242
248
  await tick()
243
- expect(editBodies()).toEqual(['draft c — full state'])
249
+ await clock.advance(1_500) // clear the per-message edit floor
250
+
251
+ // The guarantee is the newest state reaches the screen, never that some
252
+ // intermediate was dropped to get there.
253
+ expect(editBodies().at(-1)).toBe('draft c — full state')
254
+ expect(gate.stats().global.shed).toBe(0)
244
255
  })
245
256
 
246
- it('a shed draft is NOT recorded as delivered: a later finalize of the SAME text still lands', async () => {
257
+ it('a draft held through a window still renders: the completed answer reaches the screen exactly once', async () => {
247
258
  const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
248
259
  const stream = createStreamController({
249
260
  bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
@@ -253,12 +264,16 @@ describe('stream-controller × send gate (#3110)', () => {
253
264
  void stream.update('the completed answer')
254
265
  await flush()
255
266
  expect(bot.api.editMessageText).not.toHaveBeenCalled()
256
- expect(gate.stats().global.shed).toBe(1)
267
+ expect(gate.stats().global.shed).toBe(0)
257
268
 
258
- await clock.advance(30_000)
259
- // stream_reply done=true with the same text → finalize(text). If the shed
260
- // draft had been recorded as on-screen, draft-stream's dedupe would skip
261
- // this flush and the completed answer would never render.
269
+ await clock.advance(30_000) // window closes → the held draft lands
270
+
271
+ // stream_reply done=true with the same text → finalize(text). Pre-#3716
272
+ // the draft was SHED here, and the guarantee was that the stream must not
273
+ // record it as on-screen so this flush could re-deliver it. Now the draft
274
+ // is never dropped, so it renders on its own and the identical finalize is
275
+ // a benign no-op. Either way the user sees the completed answer — and now
276
+ // it costs one API call instead of two.
262
277
  await stream.finalize('the completed answer')
263
278
  expect(editBodies()).toEqual(['the completed answer'])
264
279
  })
@@ -277,7 +292,7 @@ describe('stream-controller × send gate (#3110)', () => {
277
292
  gate.openFloodWindow('global', clock.now() + 30_000) // short: <= 60s fail-fast ceiling
278
293
  void stream.update('draft while banned')
279
294
  await flush()
280
- expect(bot.api.editMessageText).not.toHaveBeenCalled() // draft shed
295
+ expect(bot.api.editMessageText).not.toHaveBeenCalled() // draft held, not sent
281
296
 
282
297
  const fin = stream.finalize('the answer')
283
298
  await flush()
@@ -286,9 +301,12 @@ describe('stream-controller × send gate (#3110)', () => {
286
301
 
287
302
  await clock.advance(30_000)
288
303
  await fin
304
+ // The critical finalize coalesced ONTO the held draft and upgraded its
305
+ // class, so the whole burst resolves as a single send carrying the final
306
+ // body — the draft is superseded rather than dropped.
289
307
  expect(editBodies()).toEqual(['the answer'])
290
308
  expect(editTimes).toEqual([30_000])
291
- expect(gate.stats().global.shed).toBe(1) // only the draft
309
+ expect(gate.stats().global.shed).toBe(0) // nothing is shed any more
292
310
  })
293
311
 
294
312
  it('finalize under a LONG window fails fast (structured FLOOD_WAIT_ACTIVE, logged) — no API call, no hang', async () => {
@@ -316,6 +334,40 @@ describe('stream-controller × send gate (#3110)', () => {
316
334
  }
317
335
  })
318
336
 
337
+ /**
338
+ * REGRESSION PIN for the trap #3716 opened. Once cosmetic edits stopped
339
+ * shedding they began OCCUPYING the driver, and draft-stream serializes its
340
+ * own flushes — so a draft parked behind a 6h ban held the finalize upstream
341
+ * of the gate, where the fail-fast path could never see it. `failedFast` went
342
+ * to 0 and the reply path wedged for the length of the ban: the exact failure
343
+ * the gate was built to eliminate, reintroduced by the fix for a different
344
+ * one. The preceding test does NOT catch this — it finalizes with no draft in
345
+ * flight.
346
+ */
347
+ it('a draft parked behind a LONG window never wedges the finalize behind it (#3716)', async () => {
348
+ const { clock, gate, retry } = makeGatedRetry({ editFloorMs: 1500 })
349
+ const logs: string[] = []
350
+ const stream = createStreamController({
351
+ bot, chatId: '1', throttleMs: 250, retry, initialMessageId: 7777,
352
+ log: (m) => logs.push(m),
353
+ })
354
+
355
+ gate.openFloodWindow('global', clock.now() + 21_397_000) // the 2026-07-12 ban: ~5.9h
356
+ void stream.update('draft while banned')
357
+ await flush()
358
+ expect(gate.stats().global.shed).toBe(0) // held, not dropped
359
+
360
+ // The cosmetic draft settles its caller as soon as it is queued, so the
361
+ // finalize reaches the gate. If it did not, this await never returns.
362
+ const fin = stream.finalize('the answer')
363
+ await tick()
364
+ await fin
365
+
366
+ expect(gate.stats().global.failedFast).toBe(1)
367
+ expect(bot.api.editMessageText).not.toHaveBeenCalled()
368
+ expect(logs.some((m) => m.includes('FLOOD_WAIT_ACTIVE'))).toBe(true)
369
+ })
370
+
319
371
  it('REGRESSION PIN: every edit passes messageId / editPayload / priorityClass to the retry policy', async () => {
320
372
  // Spy retry with NO gate — pins exactly what the controller hands to
321
373
  // robustApiCall (the #3110 bypass was these fields being absent).
@@ -474,7 +526,7 @@ describe('stream-controller × send gate (#3110)', () => {
474
526
  expect(bot.api.editMessageText.mock.calls.length).toBe(editCalls)
475
527
  })
476
528
 
477
- it('a shed TAIL is not recorded as delivered: argument-less finalize() re-flushes and lands it (F2+F3)', async () => {
529
+ it('a TAIL suppressed by a msg-scoped window is HELD, not lost: the completed answer lands when it closes', async () => {
478
530
  const { clock, gate, retry } = makeGatedRetry({
479
531
  editFloorMs: 1500,
480
532
  globalPerSec: 1000, globalBurst: 100, perChatPerSec: 1000, perChatBurst: 100,
@@ -495,27 +547,81 @@ describe('stream-controller × send gate (#3110)', () => {
495
547
  const lastTailId = anchorId + pieceCount - 1
496
548
 
497
549
  // Flood window scoped to the LAST tail message only (H1 msg-edit scope):
498
- // its edit sheds; the anchor and other pieces are unaffected.
550
+ // its edit is suppressed; the anchor and other pieces are unaffected.
499
551
  gate.openFloodWindow(`msg-edit:1:${lastTailId}`, clock.now() + 30_000)
500
552
 
501
553
  void stream.update(b2)
502
554
  await tick()
503
- // The changed piece is the suppressed tail → shed, zero edits landed on
504
- // it; the flush is reported shed and the snapshot preserved (F2), NOT
505
- // recorded as delivered (F3).
555
+ // The changed piece is the suppressed tail → zero edits land on it while
556
+ // the window is open. Pre-#3716 it was SHED and the completed answer only
557
+ // survived because the stream refused to record it as delivered; now the
558
+ // edit is held by the gate, so the content is safe by construction.
506
559
  const tailEdits = () =>
507
560
  bot.api.editMessageText.mock.calls.filter(([, id]) => id === lastTailId)
508
561
  expect(tailEdits()).toHaveLength(0)
509
- expect(gate.stats().global.shed).toBe(1)
510
- expect(logs.some((m) => m.includes('shed by send gate'))).toBe(true)
562
+ expect(gate.stats().global.shed).toBe(0)
563
+ expect(logs.some((m) => m.includes('shed by send gate'))).toBe(false)
511
564
 
512
- // Window closes; the gateway-style ARGUMENT-LESS finalize (the
513
- // disconnect-flush / turn-end cleanup path) must re-deliver the shed
514
- // snapshot — pre-F2 the content was silently lost here.
565
+ // Window closes → the held tail edit lands on its own. The gateway-style
566
+ // ARGUMENT-LESS finalize (disconnect-flush / turn-end cleanup) is then a
567
+ // no-op rather than a rescue.
515
568
  await clock.advance(31_000)
516
569
  await stream.finalize()
517
570
  expect(tailEdits()).toHaveLength(1)
518
571
  const [, , tailBody] = tailEdits()[0]
519
572
  expect(String(tailBody)).toContain('tail v2')
520
573
  })
574
+
575
+ /**
576
+ * #3716 removed the gate's cosmetic-EDIT shed, so no edit the stream makes
577
+ * can return `SEND_GATE_SHED` any more. The sentinel is still the contract
578
+ * for non-edit cosmetic sends, and the controller's F2/F3 shed-honesty
579
+ * handling is the guard if any edit path is ever re-tagged — so pin it
580
+ * directly against the retry seam instead of through the gate, where it
581
+ * would silently rot into an assertion about behaviour that cannot occur.
582
+ */
583
+ it('F2+F3 shed-honesty is still wired: a SHED tail is not recorded as delivered and re-flushes', async () => {
584
+ const base = ('a_b_c_d_e ').repeat(3000)
585
+ const b1 = `${base}tail v1`
586
+ const b2 = `${base}tail v2 — the completed answer`
587
+ const pieceCount = renderOutboundChunks(b1).length
588
+ expect(pieceCount).toBeGreaterThan(1)
589
+
590
+ // Shed exactly one message id, chosen after the first flush assigns ids.
591
+ let shedId: number | null = null
592
+ const retry: RetryPolicy = async (fn, opts) => {
593
+ if (shedId != null && opts?.messageId === shedId) {
594
+ return SEND_GATE_SHED as never
595
+ }
596
+ return await fn()
597
+ }
598
+
599
+ const logs: string[] = []
600
+ const stream = createStreamController({
601
+ bot, chatId: '1', throttleMs: 250, retry, log: (m) => logs.push(m),
602
+ })
603
+ void stream.update(b1)
604
+ await flush()
605
+ const anchorId = stream.getMessageId() as number
606
+ shedId = anchorId + pieceCount - 1
607
+
608
+ const tailEdits = () =>
609
+ bot.api.editMessageText.mock.calls.filter(([, id]) => id === shedId)
610
+
611
+ void stream.update(b2)
612
+ await tick()
613
+ expect(tailEdits()).toHaveLength(0)
614
+ expect(logs.some((m) => m.includes('shed by send gate'))).toBe(true)
615
+
616
+ // The shed piece must NOT have been recorded as on screen: an
617
+ // argument-less finalize re-delivers the preserved snapshot.
618
+ shedId = null
619
+ await stream.finalize()
620
+ const landed = bot.api.editMessageText.mock.calls.filter(
621
+ ([, id]) => id === anchorId + pieceCount - 1,
622
+ )
623
+ expect(landed).toHaveLength(1)
624
+ const [, , tailBody] = landed[0]
625
+ expect(String(tailBody)).toContain('tail v2')
626
+ })
521
627
  })
@@ -39,6 +39,28 @@ import {
39
39
  TYPING_REFRESH_MS,
40
40
  } from '../typing-emitter.js'
41
41
  import type { CurrentTurn } from '../gateway/gateway.js'
42
+ import type { ReplyOwnerTier } from '../reply-owner-resolve.js'
43
+
44
+ /** The owner-resolution shape `resolveReplyOwnerTurn` returns, including the
45
+ * candidate set the content-gate bypass corroborates against. These fixtures
46
+ * never exercise the supersede path, so the candidates mirror the resolved turn
47
+ * (the corroborated shape) with no override needed. */
48
+ function ownerRes(turn: CurrentTurn | null, tier: ReplyOwnerTier) {
49
+ const id = turn?.turnId ?? null
50
+ return {
51
+ turn,
52
+ tier,
53
+ candidates: {
54
+ liveTurnId: tier === 'live' ? id : null,
55
+ originTurnId: null,
56
+ quotedTurnId: null,
57
+ latestEndedTurnId: id,
58
+ latestEndedAgeMs: 1_000,
59
+ latestEndedTtlMs: 60_000,
60
+ },
61
+ }
62
+ }
63
+
42
64
 
43
65
  const CHAT = '1001'
44
66
 
@@ -251,7 +273,7 @@ function makeSendReplyDeps(dedup: OutboundDedupCache, sharedSupersede?: FlushedT
251
273
  assertSendable: () => {},
252
274
  statusKey: key,
253
275
  streamKey: key,
254
- resolveReplyOwnerTurn: () => ({ turn: null, tier: 'none' as const }),
276
+ resolveReplyOwnerTurn: () => ownerRes(null, 'none'),
255
277
  getLastSubagentHandbackAt: () => null,
256
278
  findTurnByOriginId: () => null,
257
279
  findTurnByQuotedMessageId: () => null,
@@ -443,7 +465,7 @@ describe('F3 — flush record() → same-turn reworded reply collapse (end-to-en
443
465
  // The model's REAL reply lands late with a REWORDED version of the same
444
466
  // answer: no live turn, latest-ended tier, NO handback in flight (CASE A).
445
467
  const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
446
- s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
468
+ s.deps.resolveReplyOwnerTurn = () => ownerRes(turn, 'latest-ended')
447
469
  // (getLastSubagentHandbackAt returns null in the base deps → own answer.)
448
470
 
449
471
  const res = await sendReply(s.deps, req(REWORDED))
@@ -496,7 +518,7 @@ describe('F5 — take()-before-record() interleaving delivers exactly one messag
496
518
  ).toBe('no-record') // record genuinely not written yet
497
519
 
498
520
  const s = makeSendReplyDeps(new OutboundDedupCache(), supersede)
499
- s.deps.resolveReplyOwnerTurn = () => ({ turn, tier: 'latest-ended' as const })
521
+ s.deps.resolveReplyOwnerTurn = () => ownerRes(turn, 'latest-ended')
500
522
  // The same answer landing again in the race window → latch backstop suppresses.
501
523
  const res = await sendReply(s.deps, req(ANSWER))
502
524