agent-relay 3.1.1 → 3.1.2
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/package.json +8 -8
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.js +250 -0
- package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js +617 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js +29 -0
- package/packages/openclaw/dist/__tests__/spawn-manager.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/ws-client.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js +324 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -0
- package/packages/openclaw/dist/cli.js +1 -1
- package/packages/openclaw/dist/cli.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +33 -7
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +101 -50
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/types.d.ts +5 -1
- package/packages/openclaw/dist/types.d.ts.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +35 -13
- package/packages/openclaw/src/__tests__/SPEC-ws-client-testing.md +192 -0
- package/packages/openclaw/src/__tests__/gateway-control.test.ts +288 -0
- package/packages/openclaw/src/__tests__/gateway-threads.test.ts +746 -0
- package/packages/openclaw/src/__tests__/spawn-manager.test.ts +37 -0
- package/packages/openclaw/src/__tests__/ws-client.test.ts +395 -0
- package/packages/openclaw/src/cli.ts +1 -1
- package/packages/openclaw/src/gateway.ts +129 -56
- package/packages/openclaw/src/types.ts +5 -1
- package/packages/policy/package.json +2 -2
- package/packages/sdk/package.json +2 -2
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -37,6 +37,11 @@ const mockAgentClient = {
|
|
|
37
37
|
connected: registerHandler('connected'),
|
|
38
38
|
messageCreated: registerHandler('messageCreated'),
|
|
39
39
|
threadReply: registerHandler('threadReply'),
|
|
40
|
+
dmReceived: registerHandler('dmReceived'),
|
|
41
|
+
groupDmReceived: registerHandler('groupDmReceived'),
|
|
42
|
+
commandInvoked: registerHandler('commandInvoked'),
|
|
43
|
+
reactionAdded: registerHandler('reactionAdded'),
|
|
44
|
+
reactionRemoved: registerHandler('reactionRemoved'),
|
|
40
45
|
reconnecting: registerHandler('reconnecting'),
|
|
41
46
|
disconnected: registerHandler('disconnected'),
|
|
42
47
|
error: registerHandler('error'),
|
|
@@ -316,5 +321,617 @@ describe('InboundGateway — thread reply injection', () => {
|
|
|
316
321
|
await gateway.stop();
|
|
317
322
|
});
|
|
318
323
|
});
|
|
324
|
+
describe('DM event handling', () => {
|
|
325
|
+
it('should deliver DMs with [relaycast:dm] format', async () => {
|
|
326
|
+
const { gateway, sendMessage } = createGateway();
|
|
327
|
+
await gateway.start();
|
|
328
|
+
fireEvent('dmReceived', {
|
|
329
|
+
type: 'dm.received',
|
|
330
|
+
conversationId: 'conv_1',
|
|
331
|
+
message: {
|
|
332
|
+
id: 'dm_1',
|
|
333
|
+
agentName: 'alice',
|
|
334
|
+
text: 'hey there',
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
await vi.waitFor(() => {
|
|
338
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
const call = sendMessage.mock.calls[0][0];
|
|
341
|
+
expect(call.text).toBe('[relaycast:dm] @alice: hey there');
|
|
342
|
+
await gateway.stop();
|
|
343
|
+
});
|
|
344
|
+
it('should skip DMs from the claw itself (echo prevention)', async () => {
|
|
345
|
+
const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' });
|
|
346
|
+
await gateway.start();
|
|
347
|
+
fireEvent('dmReceived', {
|
|
348
|
+
type: 'dm.received',
|
|
349
|
+
conversationId: 'conv_2',
|
|
350
|
+
message: {
|
|
351
|
+
id: 'dm_2',
|
|
352
|
+
agentName: 'my-claw',
|
|
353
|
+
text: 'echo',
|
|
354
|
+
},
|
|
355
|
+
});
|
|
356
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
357
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
358
|
+
await gateway.stop();
|
|
359
|
+
});
|
|
360
|
+
it('should deduplicate DMs with the same message ID', async () => {
|
|
361
|
+
const { gateway, sendMessage } = createGateway();
|
|
362
|
+
await gateway.start();
|
|
363
|
+
const event = {
|
|
364
|
+
type: 'dm.received',
|
|
365
|
+
conversationId: 'conv_3',
|
|
366
|
+
message: {
|
|
367
|
+
id: 'dm_3',
|
|
368
|
+
agentName: 'bob',
|
|
369
|
+
text: 'duplicate dm',
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
fireEvent('dmReceived', event);
|
|
373
|
+
fireEvent('dmReceived', event);
|
|
374
|
+
await vi.waitFor(() => {
|
|
375
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
378
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
379
|
+
await gateway.stop();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
describe('Group DM event handling', () => {
|
|
383
|
+
it('should deliver group DMs with [relaycast:groupdm] format', async () => {
|
|
384
|
+
const { gateway, sendMessage } = createGateway();
|
|
385
|
+
await gateway.start();
|
|
386
|
+
fireEvent('groupDmReceived', {
|
|
387
|
+
type: 'group_dm.received',
|
|
388
|
+
conversationId: 'gconv_1',
|
|
389
|
+
message: {
|
|
390
|
+
id: 'gdm_1',
|
|
391
|
+
agentName: 'carol',
|
|
392
|
+
text: 'group message',
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
await vi.waitFor(() => {
|
|
396
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
397
|
+
});
|
|
398
|
+
const call = sendMessage.mock.calls[0][0];
|
|
399
|
+
expect(call.text).toBe('[relaycast:groupdm] @carol: group message');
|
|
400
|
+
await gateway.stop();
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
describe('Command invocation handling', () => {
|
|
404
|
+
it('should deliver command invocations with formatted text', async () => {
|
|
405
|
+
const { gateway, sendMessage } = createGateway();
|
|
406
|
+
await gateway.start();
|
|
407
|
+
fireEvent('commandInvoked', {
|
|
408
|
+
type: 'command.invoked',
|
|
409
|
+
command: 'deploy',
|
|
410
|
+
channel: 'general',
|
|
411
|
+
invokedBy: 'dave',
|
|
412
|
+
handlerAgentId: 'agent_1',
|
|
413
|
+
args: 'production --force',
|
|
414
|
+
parameters: null,
|
|
415
|
+
});
|
|
416
|
+
await vi.waitFor(() => {
|
|
417
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
418
|
+
});
|
|
419
|
+
const call = sendMessage.mock.calls[0][0];
|
|
420
|
+
expect(call.text).toBe('[relaycast:command:general] @dave /deploy production --force');
|
|
421
|
+
await gateway.stop();
|
|
422
|
+
});
|
|
423
|
+
it('should deliver command invocations without args', async () => {
|
|
424
|
+
const { gateway, sendMessage } = createGateway();
|
|
425
|
+
await gateway.start();
|
|
426
|
+
fireEvent('commandInvoked', {
|
|
427
|
+
type: 'command.invoked',
|
|
428
|
+
command: 'status',
|
|
429
|
+
channel: 'general',
|
|
430
|
+
invokedBy: 'eve',
|
|
431
|
+
handlerAgentId: 'agent_2',
|
|
432
|
+
args: null,
|
|
433
|
+
parameters: null,
|
|
434
|
+
});
|
|
435
|
+
await vi.waitFor(() => {
|
|
436
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
437
|
+
});
|
|
438
|
+
const call = sendMessage.mock.calls[0][0];
|
|
439
|
+
expect(call.text).toBe('[relaycast:command:general] @eve /status');
|
|
440
|
+
await gateway.stop();
|
|
441
|
+
});
|
|
442
|
+
it('should ignore commands from unsubscribed channels', async () => {
|
|
443
|
+
const { gateway, sendMessage } = createGateway({ channels: ['general'] });
|
|
444
|
+
await gateway.start();
|
|
445
|
+
fireEvent('commandInvoked', {
|
|
446
|
+
type: 'command.invoked',
|
|
447
|
+
command: 'deploy',
|
|
448
|
+
channel: 'random',
|
|
449
|
+
invokedBy: 'dave',
|
|
450
|
+
handlerAgentId: 'agent_1',
|
|
451
|
+
args: null,
|
|
452
|
+
parameters: null,
|
|
453
|
+
});
|
|
454
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
455
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
456
|
+
await gateway.stop();
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
describe('Reaction event handling', () => {
|
|
460
|
+
it('should deliver reaction added as soft notification', async () => {
|
|
461
|
+
const { gateway, sendMessage } = createGateway();
|
|
462
|
+
await gateway.start();
|
|
463
|
+
fireEvent('reactionAdded', {
|
|
464
|
+
type: 'reaction.added',
|
|
465
|
+
messageId: 'msg_800',
|
|
466
|
+
emoji: 'thumbsup',
|
|
467
|
+
agentName: 'eve',
|
|
468
|
+
});
|
|
469
|
+
await vi.waitFor(() => {
|
|
470
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
471
|
+
});
|
|
472
|
+
const call = sendMessage.mock.calls[0][0];
|
|
473
|
+
expect(call.text).toBe('[relaycast:reaction] @eve reacted thumbsup to message msg_800 (soft notification, no action required)');
|
|
474
|
+
await gateway.stop();
|
|
475
|
+
});
|
|
476
|
+
it('should deliver reaction removed as soft notification', async () => {
|
|
477
|
+
const { gateway, sendMessage } = createGateway();
|
|
478
|
+
await gateway.start();
|
|
479
|
+
fireEvent('reactionRemoved', {
|
|
480
|
+
type: 'reaction.removed',
|
|
481
|
+
messageId: 'msg_900',
|
|
482
|
+
emoji: 'rocket',
|
|
483
|
+
agentName: 'frank',
|
|
484
|
+
});
|
|
485
|
+
await vi.waitFor(() => {
|
|
486
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
487
|
+
});
|
|
488
|
+
const call = sendMessage.mock.calls[0][0];
|
|
489
|
+
expect(call.text).toBe('[relaycast:reaction] @frank removed rocket from message msg_900 (soft notification, no action required)');
|
|
490
|
+
await gateway.stop();
|
|
491
|
+
});
|
|
492
|
+
it('should skip reactions from the claw itself', async () => {
|
|
493
|
+
const { gateway, sendMessage } = createGateway({ clawName: 'my-claw' });
|
|
494
|
+
await gateway.start();
|
|
495
|
+
fireEvent('reactionAdded', {
|
|
496
|
+
type: 'reaction.added',
|
|
497
|
+
messageId: 'msg_1000',
|
|
498
|
+
emoji: 'check',
|
|
499
|
+
agentName: 'my-claw',
|
|
500
|
+
});
|
|
501
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
502
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
503
|
+
await gateway.stop();
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
describe('delivery fallback path', () => {
|
|
507
|
+
it('should fall back to openclawClient when relaySender fails', async () => {
|
|
508
|
+
const sendMessage = vi.fn().mockRejectedValue(new Error('relay down'));
|
|
509
|
+
const gateway = new InboundGateway({
|
|
510
|
+
config: {
|
|
511
|
+
apiKey: 'rk_live_test',
|
|
512
|
+
clawName: 'test-claw',
|
|
513
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
514
|
+
channels: ['general'],
|
|
515
|
+
openclawGatewayToken: 'tok_gateway',
|
|
516
|
+
openclawGatewayPort: 19999,
|
|
517
|
+
},
|
|
518
|
+
relaySender: { sendMessage },
|
|
519
|
+
});
|
|
520
|
+
await gateway.start();
|
|
521
|
+
fireEvent('messageCreated', {
|
|
522
|
+
type: 'message.created',
|
|
523
|
+
channel: 'general',
|
|
524
|
+
message: {
|
|
525
|
+
id: 'msg_fb_1',
|
|
526
|
+
agentName: 'alice',
|
|
527
|
+
text: 'fallback test',
|
|
528
|
+
attachments: [],
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
await vi.waitFor(() => {
|
|
532
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
533
|
+
});
|
|
534
|
+
// The relaySender threw, so it should have attempted openclawClient.
|
|
535
|
+
// Since openclawClient WS is not actually connected in test, both fail.
|
|
536
|
+
// We just verify the sendMessage was called (relay path attempted).
|
|
537
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
538
|
+
await gateway.stop();
|
|
539
|
+
});
|
|
540
|
+
it('should return method=failed when both relaySender and openclawClient fail', async () => {
|
|
541
|
+
const sendMessage = vi.fn().mockRejectedValue(new Error('relay down'));
|
|
542
|
+
const gateway = new InboundGateway({
|
|
543
|
+
config: {
|
|
544
|
+
apiKey: 'rk_live_test',
|
|
545
|
+
clawName: 'test-claw',
|
|
546
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
547
|
+
channels: ['general'],
|
|
548
|
+
},
|
|
549
|
+
relaySender: { sendMessage },
|
|
550
|
+
});
|
|
551
|
+
await gateway.start();
|
|
552
|
+
// No openclawClient (no token), sendMessage will throw
|
|
553
|
+
fireEvent('messageCreated', {
|
|
554
|
+
type: 'message.created',
|
|
555
|
+
channel: 'general',
|
|
556
|
+
message: {
|
|
557
|
+
id: 'msg_fb_2',
|
|
558
|
+
agentName: 'alice',
|
|
559
|
+
text: 'both fail',
|
|
560
|
+
attachments: [],
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
await vi.waitFor(() => {
|
|
564
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
567
|
+
await gateway.stop();
|
|
568
|
+
});
|
|
569
|
+
it('should treat unsupported_operation event_id as failure', async () => {
|
|
570
|
+
const sendMessage = vi.fn().mockResolvedValue({ event_id: 'unsupported_operation' });
|
|
571
|
+
const gateway = new InboundGateway({
|
|
572
|
+
config: {
|
|
573
|
+
apiKey: 'rk_live_test',
|
|
574
|
+
clawName: 'test-claw',
|
|
575
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
576
|
+
channels: ['general'],
|
|
577
|
+
},
|
|
578
|
+
relaySender: { sendMessage },
|
|
579
|
+
});
|
|
580
|
+
await gateway.start();
|
|
581
|
+
fireEvent('messageCreated', {
|
|
582
|
+
type: 'message.created',
|
|
583
|
+
channel: 'general',
|
|
584
|
+
message: {
|
|
585
|
+
id: 'msg_unsup_1',
|
|
586
|
+
agentName: 'bob',
|
|
587
|
+
text: 'unsupported test',
|
|
588
|
+
attachments: [],
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
await vi.waitFor(() => {
|
|
592
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
593
|
+
});
|
|
594
|
+
// unsupported_operation means relay delivery failed, should fall through
|
|
595
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
596
|
+
await gateway.stop();
|
|
597
|
+
});
|
|
598
|
+
it('should treat relaySender throwing as failure and fall through', async () => {
|
|
599
|
+
const sendMessage = vi.fn().mockRejectedValue(new Error('network error'));
|
|
600
|
+
const gateway = new InboundGateway({
|
|
601
|
+
config: {
|
|
602
|
+
apiKey: 'rk_live_test',
|
|
603
|
+
clawName: 'test-claw',
|
|
604
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
605
|
+
channels: ['general'],
|
|
606
|
+
},
|
|
607
|
+
relaySender: { sendMessage },
|
|
608
|
+
});
|
|
609
|
+
await gateway.start();
|
|
610
|
+
fireEvent('messageCreated', {
|
|
611
|
+
type: 'message.created',
|
|
612
|
+
channel: 'general',
|
|
613
|
+
message: {
|
|
614
|
+
id: 'msg_throw_1',
|
|
615
|
+
agentName: 'carol',
|
|
616
|
+
text: 'throw test',
|
|
617
|
+
attachments: [],
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
await vi.waitFor(() => {
|
|
621
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
622
|
+
});
|
|
623
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
624
|
+
await gateway.stop();
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
describe('delivery without relaySender', () => {
|
|
628
|
+
it('should attempt openclawClient directly when no relaySender is provided', async () => {
|
|
629
|
+
// No relaySender, no openclawClient token => both paths fail gracefully
|
|
630
|
+
const gateway = new InboundGateway({
|
|
631
|
+
config: {
|
|
632
|
+
apiKey: 'rk_live_test',
|
|
633
|
+
clawName: 'test-claw',
|
|
634
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
635
|
+
channels: ['general'],
|
|
636
|
+
},
|
|
637
|
+
// No relaySender provided
|
|
638
|
+
});
|
|
639
|
+
await gateway.start();
|
|
640
|
+
fireEvent('messageCreated', {
|
|
641
|
+
type: 'message.created',
|
|
642
|
+
channel: 'general',
|
|
643
|
+
message: {
|
|
644
|
+
id: 'msg_no_relay_1',
|
|
645
|
+
agentName: 'dave',
|
|
646
|
+
text: 'no relay sender',
|
|
647
|
+
attachments: [],
|
|
648
|
+
},
|
|
649
|
+
});
|
|
650
|
+
// Should not throw even with no delivery method available
|
|
651
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
652
|
+
await gateway.stop();
|
|
653
|
+
});
|
|
654
|
+
});
|
|
655
|
+
describe('formatDeliveryText coverage', () => {
|
|
656
|
+
it('should format dm messages as [relaycast:dm]', async () => {
|
|
657
|
+
const { gateway, sendMessage } = createGateway();
|
|
658
|
+
await gateway.start();
|
|
659
|
+
fireEvent('dmReceived', {
|
|
660
|
+
type: 'dm.received',
|
|
661
|
+
conversationId: 'conv_fmt_1',
|
|
662
|
+
message: {
|
|
663
|
+
id: 'dm_fmt_1',
|
|
664
|
+
agentName: 'alice',
|
|
665
|
+
text: 'dm format test',
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
await vi.waitFor(() => {
|
|
669
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
670
|
+
});
|
|
671
|
+
const call = sendMessage.mock.calls[0][0];
|
|
672
|
+
expect(call.text).toBe('[relaycast:dm] @alice: dm format test');
|
|
673
|
+
await gateway.stop();
|
|
674
|
+
});
|
|
675
|
+
it('should format groupdm messages as [relaycast:groupdm]', async () => {
|
|
676
|
+
const { gateway, sendMessage } = createGateway();
|
|
677
|
+
await gateway.start();
|
|
678
|
+
fireEvent('groupDmReceived', {
|
|
679
|
+
type: 'group_dm.received',
|
|
680
|
+
conversationId: 'gconv_fmt_1',
|
|
681
|
+
message: {
|
|
682
|
+
id: 'gdm_fmt_1',
|
|
683
|
+
agentName: 'bob',
|
|
684
|
+
text: 'group dm format test',
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
await vi.waitFor(() => {
|
|
688
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
689
|
+
});
|
|
690
|
+
const call = sendMessage.mock.calls[0][0];
|
|
691
|
+
expect(call.text).toBe('[relaycast:groupdm] @bob: group dm format test');
|
|
692
|
+
await gateway.stop();
|
|
693
|
+
});
|
|
694
|
+
it('should format command messages with pre-formatted text', async () => {
|
|
695
|
+
const { gateway, sendMessage } = createGateway();
|
|
696
|
+
await gateway.start();
|
|
697
|
+
fireEvent('commandInvoked', {
|
|
698
|
+
type: 'command.invoked',
|
|
699
|
+
command: 'build',
|
|
700
|
+
channel: 'general',
|
|
701
|
+
invokedBy: 'carol',
|
|
702
|
+
handlerAgentId: 'agent_fmt_1',
|
|
703
|
+
args: '--prod',
|
|
704
|
+
parameters: null,
|
|
705
|
+
});
|
|
706
|
+
await vi.waitFor(() => {
|
|
707
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
708
|
+
});
|
|
709
|
+
const call = sendMessage.mock.calls[0][0];
|
|
710
|
+
expect(call.text).toBe('[relaycast:command:general] @carol /build --prod');
|
|
711
|
+
await gateway.stop();
|
|
712
|
+
});
|
|
713
|
+
it('should format reaction messages with pre-formatted text', async () => {
|
|
714
|
+
const { gateway, sendMessage } = createGateway();
|
|
715
|
+
await gateway.start();
|
|
716
|
+
fireEvent('reactionAdded', {
|
|
717
|
+
type: 'reaction.added',
|
|
718
|
+
messageId: 'msg_fmt_react',
|
|
719
|
+
emoji: 'fire',
|
|
720
|
+
agentName: 'dave',
|
|
721
|
+
});
|
|
722
|
+
await vi.waitFor(() => {
|
|
723
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
724
|
+
});
|
|
725
|
+
const call = sendMessage.mock.calls[0][0];
|
|
726
|
+
expect(call.text).toBe('[relaycast:reaction] @dave reacted fire to message msg_fmt_react (soft notification, no action required)');
|
|
727
|
+
await gateway.stop();
|
|
728
|
+
});
|
|
729
|
+
it('should format thread messages with [thread] prefix', async () => {
|
|
730
|
+
const { gateway, sendMessage } = createGateway();
|
|
731
|
+
await gateway.start();
|
|
732
|
+
fireEvent('threadReply', {
|
|
733
|
+
type: 'thread.reply',
|
|
734
|
+
channel: 'general',
|
|
735
|
+
parentId: 'msg_fmt_parent',
|
|
736
|
+
message: {
|
|
737
|
+
id: 'msg_fmt_thread',
|
|
738
|
+
agentName: 'eve',
|
|
739
|
+
text: 'thread format test',
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
await vi.waitFor(() => {
|
|
743
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
744
|
+
});
|
|
745
|
+
const call = sendMessage.mock.calls[0][0];
|
|
746
|
+
expect(call.text).toBe('[thread] [relaycast:general] @eve: thread format test');
|
|
747
|
+
await gateway.stop();
|
|
748
|
+
});
|
|
749
|
+
it('should format default channel messages without prefix', async () => {
|
|
750
|
+
const { gateway, sendMessage } = createGateway();
|
|
751
|
+
await gateway.start();
|
|
752
|
+
fireEvent('messageCreated', {
|
|
753
|
+
type: 'message.created',
|
|
754
|
+
channel: 'general',
|
|
755
|
+
message: {
|
|
756
|
+
id: 'msg_fmt_chan',
|
|
757
|
+
agentName: 'frank',
|
|
758
|
+
text: 'channel format test',
|
|
759
|
+
attachments: [],
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
await vi.waitFor(() => {
|
|
763
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
764
|
+
});
|
|
765
|
+
const call = sendMessage.mock.calls[0][0];
|
|
766
|
+
expect(call.text).toBe('[relaycast:general] @frank: channel format test');
|
|
767
|
+
await gateway.stop();
|
|
768
|
+
});
|
|
769
|
+
});
|
|
770
|
+
describe('handleInbound dedup via processingMessageIds', () => {
|
|
771
|
+
it('should skip messages already being processed', async () => {
|
|
772
|
+
// Use a slow sendMessage to simulate a message still being processed
|
|
773
|
+
let resolveFirst = null;
|
|
774
|
+
const firstCallPromise = new Promise((r) => { resolveFirst = r; });
|
|
775
|
+
const sendMessage = vi.fn()
|
|
776
|
+
.mockImplementationOnce(async () => {
|
|
777
|
+
// Block until we manually resolve
|
|
778
|
+
await firstCallPromise;
|
|
779
|
+
return { event_id: 'evt_1' };
|
|
780
|
+
})
|
|
781
|
+
.mockResolvedValue({ event_id: 'evt_2' });
|
|
782
|
+
const gateway = new InboundGateway({
|
|
783
|
+
config: {
|
|
784
|
+
apiKey: 'rk_live_test',
|
|
785
|
+
clawName: 'test-claw',
|
|
786
|
+
baseUrl: 'https://api.relaycast.dev',
|
|
787
|
+
channels: ['general'],
|
|
788
|
+
},
|
|
789
|
+
relaySender: { sendMessage },
|
|
790
|
+
});
|
|
791
|
+
await gateway.start();
|
|
792
|
+
// Fire the same message twice quickly
|
|
793
|
+
fireEvent('messageCreated', {
|
|
794
|
+
type: 'message.created',
|
|
795
|
+
channel: 'general',
|
|
796
|
+
message: {
|
|
797
|
+
id: 'msg_dedup_proc',
|
|
798
|
+
agentName: 'alice',
|
|
799
|
+
text: 'dedup processing test',
|
|
800
|
+
attachments: [],
|
|
801
|
+
},
|
|
802
|
+
});
|
|
803
|
+
// Second fire of same message should be skipped (already processing or seen)
|
|
804
|
+
fireEvent('messageCreated', {
|
|
805
|
+
type: 'message.created',
|
|
806
|
+
channel: 'general',
|
|
807
|
+
message: {
|
|
808
|
+
id: 'msg_dedup_proc',
|
|
809
|
+
agentName: 'alice',
|
|
810
|
+
text: 'dedup processing test',
|
|
811
|
+
attachments: [],
|
|
812
|
+
},
|
|
813
|
+
});
|
|
814
|
+
// Resolve the first call
|
|
815
|
+
resolveFirst();
|
|
816
|
+
await vi.waitFor(() => {
|
|
817
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
818
|
+
});
|
|
819
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
820
|
+
// Should only have been called once since the second was deduped
|
|
821
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
822
|
+
await gateway.stop();
|
|
823
|
+
});
|
|
824
|
+
});
|
|
825
|
+
describe('handleInbound when not running', () => {
|
|
826
|
+
it('should be a no-op when gateway is stopped', async () => {
|
|
827
|
+
const { gateway, sendMessage } = createGateway();
|
|
828
|
+
await gateway.start();
|
|
829
|
+
await gateway.stop();
|
|
830
|
+
// Fire an event after the gateway has stopped
|
|
831
|
+
fireEvent('messageCreated', {
|
|
832
|
+
type: 'message.created',
|
|
833
|
+
channel: 'general',
|
|
834
|
+
message: {
|
|
835
|
+
id: 'msg_stopped_1',
|
|
836
|
+
agentName: 'alice',
|
|
837
|
+
text: 'should not deliver',
|
|
838
|
+
attachments: [],
|
|
839
|
+
},
|
|
840
|
+
});
|
|
841
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
842
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
843
|
+
});
|
|
844
|
+
});
|
|
845
|
+
describe('stop() method', () => {
|
|
846
|
+
it('should disconnect relay client and clear state', async () => {
|
|
847
|
+
const { gateway } = createGateway();
|
|
848
|
+
await gateway.start();
|
|
849
|
+
// Verify gateway is running by checking it can receive messages
|
|
850
|
+
await gateway.stop();
|
|
851
|
+
// Calling stop again should be safe (idempotent)
|
|
852
|
+
await gateway.stop();
|
|
853
|
+
});
|
|
854
|
+
it('should clear seenMessageIds and processingMessageIds on stop', async () => {
|
|
855
|
+
const { gateway, sendMessage } = createGateway();
|
|
856
|
+
await gateway.start();
|
|
857
|
+
// Send a message so it gets added to seenMessageIds
|
|
858
|
+
fireEvent('messageCreated', {
|
|
859
|
+
type: 'message.created',
|
|
860
|
+
channel: 'general',
|
|
861
|
+
message: {
|
|
862
|
+
id: 'msg_clear_1',
|
|
863
|
+
agentName: 'alice',
|
|
864
|
+
text: 'will be cleared',
|
|
865
|
+
attachments: [],
|
|
866
|
+
},
|
|
867
|
+
});
|
|
868
|
+
await vi.waitFor(() => {
|
|
869
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
870
|
+
});
|
|
871
|
+
await gateway.stop();
|
|
872
|
+
// Now restart and send the same message ID - it should be delivered again
|
|
873
|
+
// because stop() cleared the seen map
|
|
874
|
+
sendMessage.mockClear();
|
|
875
|
+
// Clear event handlers first since stop() unsubscribes
|
|
876
|
+
for (const key of Object.keys(eventHandlers)) {
|
|
877
|
+
eventHandlers[key] = [];
|
|
878
|
+
}
|
|
879
|
+
await gateway.start();
|
|
880
|
+
fireEvent('messageCreated', {
|
|
881
|
+
type: 'message.created',
|
|
882
|
+
channel: 'general',
|
|
883
|
+
message: {
|
|
884
|
+
id: 'msg_clear_1',
|
|
885
|
+
agentName: 'alice',
|
|
886
|
+
text: 'will be cleared',
|
|
887
|
+
attachments: [],
|
|
888
|
+
},
|
|
889
|
+
});
|
|
890
|
+
await vi.waitFor(() => {
|
|
891
|
+
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
892
|
+
});
|
|
893
|
+
await gateway.stop();
|
|
894
|
+
});
|
|
895
|
+
it('should unsubscribe all event handlers on stop', async () => {
|
|
896
|
+
const { gateway, sendMessage } = createGateway();
|
|
897
|
+
await gateway.start();
|
|
898
|
+
await gateway.stop();
|
|
899
|
+
// After stop, firing events should not trigger sendMessage
|
|
900
|
+
fireEvent('messageCreated', {
|
|
901
|
+
type: 'message.created',
|
|
902
|
+
channel: 'general',
|
|
903
|
+
message: {
|
|
904
|
+
id: 'msg_unsub_1',
|
|
905
|
+
agentName: 'alice',
|
|
906
|
+
text: 'should not deliver after stop',
|
|
907
|
+
attachments: [],
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
911
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
describe('channel name normalization', () => {
|
|
915
|
+
it('should normalize channel names with # prefix', async () => {
|
|
916
|
+
const { gateway, sendMessage } = createGateway({ channels: ['#general'] });
|
|
917
|
+
await gateway.start();
|
|
918
|
+
fireEvent('messageCreated', {
|
|
919
|
+
type: 'message.created',
|
|
920
|
+
channel: 'general',
|
|
921
|
+
message: {
|
|
922
|
+
id: 'msg_norm_1',
|
|
923
|
+
agentName: 'alice',
|
|
924
|
+
text: 'normalization test',
|
|
925
|
+
attachments: [],
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
await vi.waitFor(() => {
|
|
929
|
+
expect(sendMessage).toHaveBeenCalled();
|
|
930
|
+
});
|
|
931
|
+
const call = sendMessage.mock.calls[0][0];
|
|
932
|
+
expect(call.text).toBe('[relaycast:general] @alice: normalization test');
|
|
933
|
+
await gateway.stop();
|
|
934
|
+
});
|
|
935
|
+
});
|
|
319
936
|
});
|
|
320
937
|
//# sourceMappingURL=gateway-threads.test.js.map
|