switchroom 0.14.16 → 0.14.18

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
+ })
@@ -0,0 +1,140 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { Subagent } from '../registry/subagents-schema.js'
3
+ import { resolveWorkerFeedDispatch } from '../gateway/worker-feed-dispatch.js'
4
+
5
+ function makeSub(over: Partial<Subagent>): Subagent {
6
+ return {
7
+ id: 'toolu_01ABC',
8
+ parent_session_id: null,
9
+ parent_turn_key: null,
10
+ agent_type: 'general-purpose',
11
+ description: null,
12
+ background: false,
13
+ started_at: 0,
14
+ last_activity_at: null,
15
+ ended_at: null,
16
+ status: 'running',
17
+ result_summary: null,
18
+ jsonl_agent_id: 'a37ad7639ae61476c',
19
+ ...over,
20
+ }
21
+ }
22
+
23
+ describe('resolveWorkerFeedDispatch (#2002 regression pin)', () => {
24
+ it('uses the real registry description for the feed header, not the watcher label', () => {
25
+ const sub = makeSub({ background: true, description: 'Background ten-step worker' })
26
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
27
+ expect(out.isBackground).toBe(true)
28
+ expect(out.feedDescription).toBe('Background ten-step worker')
29
+ })
30
+
31
+ it('falls back to the watcher label when the registry row is missing', () => {
32
+ const out = resolveWorkerFeedDispatch(null, 'sub-agent')
33
+ expect(out.isBackground).toBe(false)
34
+ expect(out.feedDescription).toBe('sub-agent')
35
+ })
36
+
37
+ it('falls back to the watcher label when the registry description is null', () => {
38
+ const sub = makeSub({ background: true, description: null })
39
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
40
+ expect(out.isBackground).toBe(true)
41
+ expect(out.feedDescription).toBe('sub-agent')
42
+ })
43
+
44
+ it('falls back to the watcher label when the registry description is empty', () => {
45
+ const sub = makeSub({ background: true, description: '' })
46
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
47
+ expect(out.feedDescription).toBe('sub-agent')
48
+ })
49
+
50
+ it('reports a foreground sub-agent as not background', () => {
51
+ const sub = makeSub({ background: false, description: 'inline helper' })
52
+ const out = resolveWorkerFeedDispatch(sub, 'sub-agent')
53
+ expect(out.isBackground).toBe(false)
54
+ // description still resolves — callers gate on isBackground separately.
55
+ expect(out.feedDescription).toBe('inline helper')
56
+ })
57
+
58
+ it('a missing row defaults isBackground false so the feed never fires blind', () => {
59
+ // The gateway gates the feed on isBackground; a registry miss must not
60
+ // flip a foreground turn into a background one.
61
+ expect(resolveWorkerFeedDispatch(null, '').isBackground).toBe(false)
62
+ })
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
+ })