switchroom 0.14.17 → 0.14.19

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.
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { describe, it, expect } from 'vitest'
10
- import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
10
+ import { createPendingInboundBuffer, redeliverBufferedInbound, idleDrainTick, planBufferedRedelivery, DEFAULT_PENDING_INBOUND_CAP } from '../gateway/pending-inbound-buffer.js'
11
11
  import type { InboundMessage } from '../gateway/ipc-protocol.js'
12
12
 
13
13
  function inbound(source: string, ts = Date.now()): InboundMessage {
@@ -23,6 +23,35 @@ function inbound(source: string, ts = Date.now()): InboundMessage {
23
23
  }
24
24
  }
25
25
 
26
+ /** An ordinary Telegram user message — NO meta.source, so it's mergeable. */
27
+ function userMsg(
28
+ opts: {
29
+ text: string
30
+ chatId?: string
31
+ threadId?: number
32
+ userId?: number
33
+ ts?: number
34
+ imagePath?: string
35
+ attachment?: InboundMessage['attachment']
36
+ },
37
+ ): InboundMessage {
38
+ const ts = opts.ts ?? Date.now()
39
+ const m: InboundMessage = {
40
+ type: 'inbound',
41
+ chatId: opts.chatId ?? 'c1',
42
+ messageId: ts,
43
+ user: 'alice',
44
+ userId: opts.userId ?? 42,
45
+ ts,
46
+ text: opts.text,
47
+ meta: {},
48
+ }
49
+ if (opts.threadId != null) m.threadId = opts.threadId
50
+ if (opts.imagePath != null) m.imagePath = opts.imagePath
51
+ if (opts.attachment != null) m.attachment = opts.attachment
52
+ return m
53
+ }
54
+
26
55
  describe('pending-inbound-buffer', () => {
27
56
  it('push + drain — FIFO order per agent', () => {
28
57
  const buf = createPendingInboundBuffer({ log: () => {} })
@@ -313,3 +342,258 @@ describe('durable-spool integration (finn/carrie lost-on-restart fix)', () => {
313
342
  })
314
343
  })
315
344
  })
345
+
346
+ describe('planBufferedRedelivery — merge-on-drain (forwarded-burst across a turn boundary)', () => {
347
+ it('passes a single message through unchanged (run of one)', () => {
348
+ const a = userMsg({ text: 'solo', ts: 1 })
349
+ const plan = planBufferedRedelivery([a])
350
+ expect(plan).toHaveLength(1)
351
+ expect(plan[0]!.merged).toBe(a) // identity preserved, no synthetic copy
352
+ expect(plan[0]!.originals).toEqual([a])
353
+ })
354
+
355
+ it('merges consecutive same-sender user messages into one turn (texts joined by \\n)', () => {
356
+ const a = userMsg({ text: 'first', ts: 1 })
357
+ const b = userMsg({ text: 'second', ts: 2 })
358
+ const c = userMsg({ text: 'third', ts: 3 })
359
+ const plan = planBufferedRedelivery([a, b, c])
360
+ expect(plan).toHaveLength(1)
361
+ expect(plan[0]!.merged.text).toBe('first\nsecond\nthird')
362
+ expect(plan[0]!.originals).toEqual([a, b, c]) // all three acked/rebuffered together
363
+ })
364
+
365
+ it('anchors the merged turn on the LAST message identity/meta', () => {
366
+ const a = userMsg({ text: 'first', ts: 10 })
367
+ const b = userMsg({ text: 'second', ts: 20 })
368
+ const plan = planBufferedRedelivery([a, b])
369
+ expect(plan[0]!.merged.messageId).toBe(20)
370
+ expect(plan[0]!.merged.ts).toBe(20)
371
+ })
372
+
373
+ it('NEVER merges a system inbound — meta.source isolates the #1150 wake-up class', () => {
374
+ const u1 = userMsg({ text: 'hi', ts: 1 })
375
+ const grant = inbound('vault_grant_approved', 2)
376
+ const u2 = userMsg({ text: 'there', ts: 3 })
377
+ const plan = planBufferedRedelivery([u1, grant, u2])
378
+ // The grant breaks the run; nothing merges across it.
379
+ expect(plan.map((p) => p.merged.text)).toEqual([
380
+ 'hi',
381
+ 'synthetic vault_grant_approved',
382
+ 'there',
383
+ ])
384
+ expect(plan.every((p) => p.originals.length === 1)).toBe(true)
385
+ })
386
+
387
+ it('does not merge across different senders', () => {
388
+ const a = userMsg({ text: 'from-alice', userId: 1, ts: 1 })
389
+ const b = userMsg({ text: 'from-bob', userId: 2, ts: 2 })
390
+ const plan = planBufferedRedelivery([a, b])
391
+ expect(plan).toHaveLength(2)
392
+ })
393
+
394
+ it('does not merge across different topics (threadId)', () => {
395
+ const a = userMsg({ text: 'planning', threadId: 7, ts: 1 })
396
+ const b = userMsg({ text: 'admin', threadId: 9, ts: 2 })
397
+ const plan = planBufferedRedelivery([a, b])
398
+ expect(plan).toHaveLength(2)
399
+ })
400
+
401
+ it('does not merge across different chats', () => {
402
+ const a = userMsg({ text: 'dm', chatId: 'cA', ts: 1 })
403
+ const b = userMsg({ text: 'group', chatId: 'cB', ts: 2 })
404
+ const plan = planBufferedRedelivery([a, b])
405
+ expect(plan).toHaveLength(2)
406
+ })
407
+
408
+ it('carries a single attachment along even when it is NOT the last message', () => {
409
+ const photo = userMsg({ text: 'look', ts: 1, imagePath: '/tmp/p.jpg' })
410
+ const txt = userMsg({ text: 'at this', ts: 2 })
411
+ const plan = planBufferedRedelivery([photo, txt])
412
+ expect(plan).toHaveLength(1)
413
+ expect(plan[0]!.merged.text).toBe('look\nat this')
414
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
415
+ })
416
+
417
+ it('carries a single document attachment from the entry that owns it', () => {
418
+ const txt = userMsg({ text: 'here', ts: 1 })
419
+ const doc = userMsg({
420
+ text: '',
421
+ ts: 2,
422
+ attachment: { fileId: 'F1', mimeType: 'application/pdf', fileName: 'r.pdf' },
423
+ })
424
+ const plan = planBufferedRedelivery([txt, doc])
425
+ expect(plan).toHaveLength(1)
426
+ expect(plan[0]!.merged.attachment).toEqual({
427
+ fileId: 'F1',
428
+ mimeType: 'application/pdf',
429
+ fileName: 'r.pdf',
430
+ })
431
+ })
432
+
433
+ it('splits a run rather than putting two attachments in one turn (no silent media loss)', () => {
434
+ const p1 = userMsg({ text: 'one', ts: 1, imagePath: '/tmp/a.jpg' })
435
+ const p2 = userMsg({ text: 'two', ts: 2, imagePath: '/tmp/b.jpg' })
436
+ const plan = planBufferedRedelivery([p1, p2])
437
+ expect(plan).toHaveLength(2)
438
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/a.jpg')
439
+ expect(plan[1]!.merged.imagePath).toBe('/tmp/b.jpg')
440
+ })
441
+
442
+ it('a leading text then media then text → text+media merge, then a fresh run', () => {
443
+ const t1 = userMsg({ text: 'intro', ts: 1 })
444
+ const p = userMsg({ text: 'pic', ts: 2, imagePath: '/tmp/p.jpg' })
445
+ const t2 = userMsg({ text: 'caption', ts: 3 })
446
+ const plan = planBufferedRedelivery([t1, p, t2])
447
+ // All three share one attachment max → single merged turn.
448
+ expect(plan).toHaveLength(1)
449
+ expect(plan[0]!.merged.text).toBe('intro\npic\ncaption')
450
+ expect(plan[0]!.merged.imagePath).toBe('/tmp/p.jpg')
451
+ })
452
+
453
+ it('preserves the run total — sum of originals equals input length (lossless)', () => {
454
+ const msgs = [
455
+ userMsg({ text: 'a', ts: 1 }),
456
+ userMsg({ text: 'b', ts: 2 }),
457
+ inbound('cron', 3),
458
+ userMsg({ text: 'c', ts: 4 }),
459
+ ]
460
+ const plan = planBufferedRedelivery(msgs)
461
+ const total = plan.reduce((n, p) => n + p.originals.length, 0)
462
+ expect(total).toBe(msgs.length)
463
+ })
464
+
465
+ it('end-to-end: redeliverBufferedInbound fans a 3-message burst into ONE send', () => {
466
+ const buf = createPendingInboundBuffer({ log: () => {} })
467
+ buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
468
+ buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
469
+ buf.push('ziggy', userMsg({ text: 'part 3', ts: 3 }))
470
+ const sent: string[] = []
471
+ const r = redeliverBufferedInbound(buf, 'ziggy', (m) => {
472
+ sent.push(m.text)
473
+ return true
474
+ })
475
+ expect(sent).toEqual(['part 1\npart 2\npart 3']) // ONE turn, not three
476
+ expect(r).toEqual({ drained: 3, redelivered: 3, rebuffered: 0 })
477
+ expect(buf.depth('ziggy')).toBe(0)
478
+ })
479
+
480
+ it('end-to-end: a failed send re-buffers ALL originals of the merged run (lossless)', () => {
481
+ const buf = createPendingInboundBuffer({ log: () => {} })
482
+ buf.push('ziggy', userMsg({ text: 'part 1', ts: 1 }))
483
+ buf.push('ziggy', userMsg({ text: 'part 2', ts: 2 }))
484
+ const r = redeliverBufferedInbound(buf, 'ziggy', () => false)
485
+ expect(r).toEqual({ drained: 2, redelivered: 0, rebuffered: 2 })
486
+ expect(buf.depth('ziggy')).toBe(2) // both originals back, nothing lost
487
+ expect(buf.drain('ziggy').map((m) => m.text)).toEqual(['part 1', 'part 2'])
488
+ })
489
+ })
490
+
491
+ describe('planBufferedRedelivery — seeded fuzz over random burst schedules', () => {
492
+ // Tiny deterministic PRNG (mulberry32) so failures reproduce from the seed.
493
+ function rng(seed: number): () => number {
494
+ let s = seed >>> 0
495
+ return () => {
496
+ s |= 0
497
+ s = (s + 0x6d2b79f5) | 0
498
+ let t = Math.imul(s ^ (s >>> 15), 1 | s)
499
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
500
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
501
+ }
502
+ }
503
+
504
+ const SOURCES = ['vault_grant_approved', 'cron', 'reaction', 'approval', 'handback']
505
+
506
+ function randomSchedule(rand: () => number): InboundMessage[] {
507
+ const n = 1 + Math.floor(rand() * 12)
508
+ const out: InboundMessage[] = []
509
+ for (let i = 0; i < n; i++) {
510
+ const roll = rand()
511
+ if (roll < 0.3) {
512
+ // system inbound (has meta.source) — never mergeable
513
+ out.push(inbound(SOURCES[Math.floor(rand() * SOURCES.length)]!, i + 1))
514
+ } else {
515
+ // user message: random sender/topic/chat, sometimes media
516
+ const hasImg = rand() < 0.2
517
+ const hasDoc = !hasImg && rand() < 0.15
518
+ out.push(
519
+ userMsg({
520
+ text: `m${i}`,
521
+ ts: i + 1,
522
+ chatId: rand() < 0.5 ? 'cA' : 'cB',
523
+ userId: rand() < 0.5 ? 1 : 2,
524
+ threadId: rand() < 0.4 ? (rand() < 0.5 ? 7 : 9) : undefined,
525
+ imagePath: hasImg ? `/tmp/img${i}.jpg` : undefined,
526
+ attachment: hasDoc
527
+ ? { fileId: `F${i}`, mimeType: 'application/pdf' }
528
+ : undefined,
529
+ }),
530
+ )
531
+ }
532
+ }
533
+ return out
534
+ }
535
+
536
+ function hasMedia(m: InboundMessage): boolean {
537
+ return m.imagePath != null || m.attachment != null
538
+ }
539
+ function isSystem(m: InboundMessage): boolean {
540
+ return m.meta != null && m.meta.source != null
541
+ }
542
+
543
+ it('holds all invariants across 5000 random schedules', () => {
544
+ const rand = rng(0xC0FFEE)
545
+ for (let iter = 0; iter < 5000; iter++) {
546
+ const pending = randomSchedule(rand)
547
+ const plan = planBufferedRedelivery(pending)
548
+
549
+ // 1. Lossless + order-preserving: flattening originals reproduces input.
550
+ const flat = plan.flatMap((p) => p.originals)
551
+ expect(flat).toEqual(pending)
552
+
553
+ // 2. Count is conserved.
554
+ expect(flat.length).toBe(pending.length)
555
+
556
+ for (const { merged, originals } of plan) {
557
+ // 3. A multi-message run never carries >1 attachment.
558
+ const mediaCount = originals.filter(hasMedia).length
559
+ expect(mediaCount).toBeLessThanOrEqual(1)
560
+
561
+ // 4. A system inbound is NEVER part of a multi-message run, and is
562
+ // never silently mutated (passes through by identity).
563
+ if (originals.length > 1) {
564
+ expect(originals.every((m) => !isSystem(m))).toBe(true)
565
+ } else {
566
+ expect(merged).toBe(originals[0])
567
+ }
568
+
569
+ // 5. Every message in a merged run shares the same (chat, thread, user).
570
+ if (originals.length > 1) {
571
+ const k = (m: InboundMessage) =>
572
+ `${m.chatId}|${m.threadId ?? null}|${m.userId}`
573
+ expect(new Set(originals.map(k)).size).toBe(1)
574
+ // text is the \n-join of the run in order
575
+ expect(merged.text).toBe(originals.map((m) => m.text).join('\n'))
576
+ // the single attachment (if any) comes from the owning entry
577
+ const owner = originals.find(hasMedia)
578
+ expect(merged.imagePath).toBe(owner?.imagePath)
579
+ expect(merged.attachment).toEqual(owner?.attachment)
580
+ }
581
+ }
582
+ }
583
+ })
584
+
585
+ it('redeliver counts always satisfy drained == redelivered + rebuffered (5000 schedules)', () => {
586
+ const rand = rng(0x5EED)
587
+ for (let iter = 0; iter < 5000; iter++) {
588
+ const pending = randomSchedule(rand)
589
+ const buf = createPendingInboundBuffer({ log: () => {} })
590
+ for (const m of pending) buf.push('fuzz', m)
591
+ // Randomly succeed/fail each send to exercise both branches.
592
+ const r = redeliverBufferedInbound(buf, 'fuzz', () => rand() < 0.5)
593
+ expect(r.drained).toBe(pending.length)
594
+ expect(r.redelivered + r.rebuffered).toBe(r.drained)
595
+ // Whatever didn't deliver is still buffered (nothing lost).
596
+ expect(buf.depth('fuzz')).toBe(r.rebuffered)
597
+ }
598
+ })
599
+ })
@@ -341,6 +341,75 @@ describe('StatusReactionController', () => {
341
341
  expect(calls).toEqual(['👀'])
342
342
  })
343
343
 
344
+ // setAwaiting(): park on 🙏 while a permission card waits for the
345
+ // operator. A turn blocked on a human is NOT stalled, so the watchdog
346
+ // must stay quiet — but it re-arms once the verdict resumes work.
347
+ describe('setAwaiting() — park on a human permission decision', () => {
348
+ it('emits 🙏 immediately (bypasses debounce)', async () => {
349
+ const { emit, calls } = makeEmitter()
350
+ const ctrl = new StatusReactionController(emit)
351
+ ctrl.setQueued()
352
+ await flush()
353
+ ctrl.setAwaiting()
354
+ await flush()
355
+ expect(calls).toEqual(['👀', '🙏'])
356
+ })
357
+
358
+ it('suppresses stall promotion (no 🥱/😨) while the card sits unanswered', async () => {
359
+ const { emit, calls } = makeEmitter()
360
+ const ctrl = new StatusReactionController(emit)
361
+ ctrl.setQueued()
362
+ ctrl.setTool('Bash') // working: 👨‍💻
363
+ vi.advanceTimersByTime(3500)
364
+ await flush()
365
+ ctrl.setAwaiting()
366
+ await flush()
367
+ // Well past both stall thresholds — awaiting must not yawn or panic.
368
+ vi.advanceTimersByTime(120000)
369
+ await flush()
370
+ expect(calls).not.toContain('🥱')
371
+ expect(calls).not.toContain('😨')
372
+ expect(calls[calls.length - 1]).toBe('🙏')
373
+ })
374
+
375
+ it('re-arms the stall watchdog once a working transition resumes the turn', async () => {
376
+ const { emit, calls } = makeEmitter()
377
+ const ctrl = new StatusReactionController(emit)
378
+ ctrl.setQueued()
379
+ await flush()
380
+ ctrl.setAwaiting()
381
+ await flush()
382
+ vi.advanceTimersByTime(120000) // long human wait — no stall
383
+ await flush()
384
+ expect(calls).toEqual(['👀', '🙏'])
385
+
386
+ // Verdict dispatched → gateway calls setThinking() to un-park.
387
+ ctrl.setThinking()
388
+ vi.advanceTimersByTime(3500)
389
+ await flush()
390
+ expect(calls).toEqual(['👀', '🙏', '🤔'])
391
+
392
+ // A genuine post-approval hang must still promote to 🥱 — the
393
+ // watchdog was re-armed by the resuming transition.
394
+ vi.advanceTimersByTime(30000)
395
+ await flush()
396
+ expect(calls).toContain('🥱')
397
+ })
398
+
399
+ it('is a no-op after finalize (cannot resurrect a finished controller)', async () => {
400
+ const { emit, calls } = makeEmitter()
401
+ const ctrl = new StatusReactionController(emit)
402
+ ctrl.setQueued()
403
+ ctrl.finalize('done')
404
+ await flush()
405
+ const snapshot = [...calls]
406
+ ctrl.setAwaiting()
407
+ vi.advanceTimersByTime(5000)
408
+ await flush()
409
+ expect(calls).toEqual(snapshot)
410
+ })
411
+ })
412
+
344
413
  // hold(): freeze on a WORKING glyph while background sub-agent workers
345
414
  // outlive the parent turn, deferring the terminal 👍 (worker-reaction fix).
346
415
  describe('hold() — defer 👍 while a background worker runs', () => {
@@ -61,3 +61,80 @@ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
61
61
  expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
62
62
  })
63
63
  })
64
+
65
+ // Deterministic PRNG (mulberry32) so a failing case is reproducible from the
66
+ // printed seed rather than flaking once and vanishing.
67
+ function mulberry32(seed: number): () => number {
68
+ let a = seed >>> 0
69
+ return () => {
70
+ a |= 0
71
+ a = (a + 0x6d2b79f5) | 0
72
+ let t = Math.imul(a ^ (a >>> 15), 1 | a)
73
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
74
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
75
+ }
76
+ }
77
+
78
+ // Adversarial description fragments the gateway might hand us — empty, the
79
+ // literal watcher placeholder, whitespace-only, control chars, multi-byte
80
+ // unicode/emoji, and a pathologically long task. A registry description is
81
+ // only "real" (must win the feed header) when it's a non-empty string.
82
+ const DESC_POOL: (string | null)[] = [
83
+ null,
84
+ '',
85
+ 'sub-agent',
86
+ ' ',
87
+ '\n\t',
88
+ 'Background ten-step worker',
89
+ 'Crawl the repo for dead code',
90
+ '🔧 deploy · staging',
91
+ 'café — naïve façade',
92
+ '0',
93
+ 'false',
94
+ 'a'.repeat(4096),
95
+ 'line1\nline2\nline3',
96
+ 'desc with "quotes" & <tags>',
97
+ ]
98
+
99
+ const WATCHER_POOL = ['sub-agent', '', 'sub-agent ', 'fallback', '🤖', 'x'.repeat(512)]
100
+
101
+ describe('resolveWorkerFeedDispatch — randomized property sweep', () => {
102
+ it('holds the #2002 invariants across 20k random registry rows', () => {
103
+ for (let seed = 1; seed <= 20000; seed++) {
104
+ const rng = mulberry32(seed)
105
+ const pick = <T>(arr: T[]): T => arr[Math.floor(rng() * arr.length)]!
106
+
107
+ const rowMissing = rng() < 0.25
108
+ const background = rng() < 0.5
109
+ const description = pick(DESC_POOL)
110
+ const watcher = pick(WATCHER_POOL)
111
+ const sub = rowMissing ? null : makeSub({ background, description })
112
+
113
+ const out = resolveWorkerFeedDispatch(sub, watcher)
114
+ const ctx = `seed=${seed} rowMissing=${rowMissing} bg=${background} desc=${JSON.stringify(description)} watcher=${JSON.stringify(watcher)}`
115
+
116
+ // 1. Types are always concrete — the feed renderer never sees undefined.
117
+ expect(typeof out.isBackground, ctx).toBe('boolean')
118
+ expect(typeof out.feedDescription, ctx).toBe('string')
119
+
120
+ // 2. isBackground mirrors the row (or false when missing) — a registry
121
+ // miss must never promote a foreground turn into a background one.
122
+ expect(out.isBackground, ctx).toBe(rowMissing ? false : background)
123
+
124
+ const realDescription =
125
+ !rowMissing && typeof description === 'string' && description.length > 0
126
+
127
+ if (realDescription) {
128
+ // 3. THE bug guard: a real registry description always wins the header,
129
+ // regardless of the watcher's generic 'sub-agent' placeholder.
130
+ expect(out.feedDescription, ctx).toBe(description)
131
+ } else {
132
+ // 4. Otherwise we fall back to exactly the watcher label, untouched.
133
+ expect(out.feedDescription, ctx).toBe(watcher)
134
+ }
135
+
136
+ // 5. Pure + deterministic: identical inputs yield a deep-equal result.
137
+ expect(resolveWorkerFeedDispatch(sub, watcher), ctx).toEqual(out)
138
+ }
139
+ })
140
+ })