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.
- package/dist/agent-scheduler/index.js +3 -0
- package/dist/auth-broker/index.js +3 -0
- package/dist/cli/notion-write-pretool.mjs +3 -0
- package/dist/cli/switchroom.js +39 -2
- package/dist/host-control/main.js +3 -0
- package/dist/vault/approvals/kernel-server.js +3 -0
- package/dist/vault/broker/server.js +3 -0
- package/package.json +1 -1
- package/profiles/_shared/telegram-style.md.hbs +6 -5
- package/telegram-plugin/dist/gateway/gateway.js +166 -33
- package/telegram-plugin/gateway/gateway.ts +119 -29
- package/telegram-plugin/gateway/inbound-coalesce.ts +8 -7
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +100 -9
- package/telegram-plugin/status-reactions.ts +18 -0
- package/telegram-plugin/tests/inbound-coalesce.test.ts +21 -0
- package/telegram-plugin/tests/pending-inbound-buffer.test.ts +285 -1
- package/telegram-plugin/tests/status-reactions.test.ts +69 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +77 -0
|
@@ -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
|
+
})
|