agent-relay 3.1.10 → 3.1.11

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.
Files changed (63) hide show
  1. package/dist/index.cjs +2 -2
  2. package/install.sh +2 -1
  3. package/package.json +13 -10
  4. package/packages/acp-bridge/package.json +2 -2
  5. package/packages/config/package.json +1 -1
  6. package/packages/hooks/package.json +4 -4
  7. package/packages/memory/package.json +2 -2
  8. package/packages/openclaw/dist/__tests__/gateway-control.test.js +13 -13
  9. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -1
  10. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts +2 -0
  11. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts.map +1 -0
  12. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js +369 -0
  13. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js.map +1 -0
  14. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +43 -2
  15. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
  16. package/packages/openclaw/dist/__tests__/ws-client.test.js +2 -0
  17. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
  18. package/packages/openclaw/dist/config.d.ts +2 -0
  19. package/packages/openclaw/dist/config.d.ts.map +1 -1
  20. package/packages/openclaw/dist/config.js +99 -12
  21. package/packages/openclaw/dist/config.js.map +1 -1
  22. package/packages/openclaw/dist/gateway.d.ts +55 -1
  23. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  24. package/packages/openclaw/dist/gateway.js +807 -120
  25. package/packages/openclaw/dist/gateway.js.map +1 -1
  26. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +2 -0
  27. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -1
  28. package/packages/openclaw/dist/runtime/openclaw-config.js +1 -1
  29. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -1
  30. package/packages/openclaw/dist/runtime/setup.d.ts +0 -2
  31. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -1
  32. package/packages/openclaw/dist/runtime/setup.js +53 -8
  33. package/packages/openclaw/dist/runtime/setup.js.map +1 -1
  34. package/packages/openclaw/dist/types.d.ts +28 -0
  35. package/packages/openclaw/dist/types.d.ts.map +1 -1
  36. package/packages/openclaw/package.json +2 -2
  37. package/packages/openclaw/skill/SKILL.md +150 -44
  38. package/packages/openclaw/src/__tests__/gateway-control.test.ts +14 -14
  39. package/packages/openclaw/src/__tests__/gateway-poll-fallback.test.ts +467 -0
  40. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +59 -9
  41. package/packages/openclaw/src/__tests__/ws-client.test.ts +71 -51
  42. package/packages/openclaw/src/config.ts +121 -12
  43. package/packages/openclaw/src/gateway.ts +1143 -245
  44. package/packages/openclaw/src/runtime/openclaw-config.ts +3 -1
  45. package/packages/openclaw/src/runtime/setup.ts +57 -16
  46. package/packages/openclaw/src/types.ts +31 -0
  47. package/packages/openclaw/test/vitest.setup.ts +1 -0
  48. package/packages/policy/package.json +2 -2
  49. package/packages/sdk/dist/__tests__/unit.test.js +131 -129
  50. package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
  51. package/packages/sdk/dist/relay.js +1 -1
  52. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  53. package/packages/sdk/dist/workflows/runner.js +5 -3
  54. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  55. package/packages/sdk/package.json +2 -2
  56. package/packages/sdk/src/__tests__/unit.test.ts +142 -157
  57. package/packages/sdk/src/relay.ts +1 -1
  58. package/packages/sdk/src/workflows/runner.ts +12 -9
  59. package/packages/sdk-py/pyproject.toml +1 -1
  60. package/packages/telemetry/package.json +1 -1
  61. package/packages/trajectory/package.json +2 -2
  62. package/packages/user-directory/package.json +2 -2
  63. package/packages/utils/package.json +2 -2
@@ -0,0 +1,467 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const eventHandlers: Record<string, Array<(...args: unknown[]) => void>> = {};
4
+
5
+ function registerHandler(event: string) {
6
+ return (handler: (...args: unknown[]) => void) => {
7
+ if (!eventHandlers[event]) eventHandlers[event] = [];
8
+ eventHandlers[event].push(handler);
9
+ return () => {
10
+ eventHandlers[event] = eventHandlers[event].filter((entry) => entry !== handler);
11
+ };
12
+ };
13
+ }
14
+
15
+ function fireEvent(event: string, ...args: unknown[]) {
16
+ for (const handler of eventHandlers[event] ?? []) {
17
+ handler(...args);
18
+ }
19
+ }
20
+
21
+ const mockAgentClient = {
22
+ connect: vi.fn(),
23
+ disconnect: vi.fn().mockResolvedValue(undefined),
24
+ subscribe: vi.fn(),
25
+ channels: {
26
+ join: vi.fn().mockResolvedValue({ ok: true }),
27
+ create: vi.fn().mockResolvedValue({ name: 'general' }),
28
+ },
29
+ on: {
30
+ connected: registerHandler('connected'),
31
+ messageCreated: registerHandler('messageCreated'),
32
+ threadReply: registerHandler('threadReply'),
33
+ dmReceived: registerHandler('dmReceived'),
34
+ groupDmReceived: registerHandler('groupDmReceived'),
35
+ commandInvoked: registerHandler('commandInvoked'),
36
+ reactionAdded: registerHandler('reactionAdded'),
37
+ reactionRemoved: registerHandler('reactionRemoved'),
38
+ reconnecting: registerHandler('reconnecting'),
39
+ disconnected: registerHandler('disconnected'),
40
+ error: registerHandler('error'),
41
+ },
42
+ };
43
+
44
+ const registerOrGet = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_test' });
45
+ const registerOrRotate = vi.fn().mockResolvedValue({ name: 'test-claw', token: 'tok_rotated' });
46
+
47
+ const fsMocks = vi.hoisted(() => ({
48
+ readFile: vi.fn(),
49
+ writeFile: vi.fn(),
50
+ rename: vi.fn(),
51
+ mkdir: vi.fn(),
52
+ chmod: vi.fn(),
53
+ }));
54
+
55
+ const { readFile, writeFile, rename, mkdir } = fsMocks;
56
+
57
+ vi.mock('@relaycast/sdk', () => ({
58
+ RelayCast: vi.fn().mockImplementation(() => ({
59
+ agents: {
60
+ registerOrGet,
61
+ registerOrRotate,
62
+ },
63
+ as: vi.fn().mockReturnValue(mockAgentClient),
64
+ })),
65
+ }));
66
+
67
+ vi.mock('../spawn/manager.js', () => ({
68
+ SpawnManager: vi.fn().mockImplementation(() => ({
69
+ size: 0,
70
+ spawn: vi.fn(),
71
+ release: vi.fn(),
72
+ releaseByName: vi.fn(),
73
+ releaseAll: vi.fn().mockResolvedValue(undefined),
74
+ list: vi.fn().mockReturnValue([]),
75
+ get: vi.fn(),
76
+ })),
77
+ }));
78
+
79
+ vi.mock('node:fs/promises', () => ({
80
+ ...fsMocks,
81
+ }));
82
+
83
+ vi.mock('node:fs', () => ({
84
+ existsSync: vi.fn().mockReturnValue(false),
85
+ }));
86
+
87
+ vi.mock('node:http', async (importOriginal) => {
88
+ const actual = await importOriginal<typeof import('node:http')>();
89
+ return {
90
+ ...actual,
91
+ createServer: vi.fn().mockReturnValue({
92
+ listen: vi.fn((_port: number, _host: string, cb: () => void) => cb()),
93
+ close: vi.fn((cb?: () => void) => cb?.()),
94
+ on: vi.fn(),
95
+ address: vi.fn().mockReturnValue({ port: 18790 }),
96
+ }),
97
+ };
98
+ });
99
+
100
+ import { InboundGateway } from '../gateway.js';
101
+
102
+ function response(status: number, body: unknown, headers?: Record<string, string>) {
103
+ const normalized = Object.fromEntries(
104
+ Object.entries(headers ?? {}).map(([key, value]) => [key.toLowerCase(), value])
105
+ );
106
+ return {
107
+ ok: status >= 200 && status < 300,
108
+ status,
109
+ headers: {
110
+ get: (name: string) => normalized[name.toLowerCase()] ?? null,
111
+ },
112
+ json: vi.fn().mockResolvedValue(body),
113
+ };
114
+ }
115
+
116
+ function pendingFetch(init?: RequestInit): Promise<never> {
117
+ return new Promise((_resolve, reject) => {
118
+ const signal = init?.signal as AbortSignal | undefined;
119
+ signal?.addEventListener('abort', () => reject(new Error('aborted')), { once: true });
120
+ });
121
+ }
122
+
123
+ function createGateway(
124
+ pollFallbackOverrides: {
125
+ wsFailureThreshold?: number;
126
+ timeoutSeconds?: number;
127
+ limit?: number;
128
+ initialCursor?: string;
129
+ probeWs?: {
130
+ enabled?: boolean;
131
+ intervalMs?: number;
132
+ stableGraceMs?: number;
133
+ };
134
+ } = {}
135
+ ) {
136
+ const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_out_1' });
137
+ const gateway = new InboundGateway({
138
+ config: {
139
+ apiKey: 'rk_live_test',
140
+ clawName: 'test-claw',
141
+ baseUrl: 'http://127.0.0.1:8888',
142
+ channels: ['general'],
143
+ transport: {
144
+ pollFallback: {
145
+ enabled: true,
146
+ wsFailureThreshold: 1,
147
+ timeoutSeconds: 1,
148
+ ...pollFallbackOverrides,
149
+ probeWs: {
150
+ enabled: true,
151
+ intervalMs: 5_000,
152
+ stableGraceMs: 10,
153
+ ...pollFallbackOverrides.probeWs,
154
+ },
155
+ },
156
+ },
157
+ },
158
+ relaySender: { sendMessage },
159
+ });
160
+ return { gateway, sendMessage };
161
+ }
162
+
163
+ describe('InboundGateway poll fallback', () => {
164
+ const fetchMock = vi.fn();
165
+
166
+ beforeEach(() => {
167
+ vi.clearAllMocks();
168
+ vi.useRealTimers();
169
+ vi.stubGlobal('fetch', fetchMock);
170
+ for (const key of Object.keys(eventHandlers)) {
171
+ eventHandlers[key] = [];
172
+ }
173
+ readFile.mockReset();
174
+ readFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
175
+ writeFile.mockReset();
176
+ writeFile.mockResolvedValue(undefined);
177
+ rename.mockReset();
178
+ rename.mockResolvedValue(undefined);
179
+ mkdir.mockReset();
180
+ mkdir.mockResolvedValue(undefined);
181
+ });
182
+
183
+ afterEach(async () => {
184
+ vi.unstubAllGlobals();
185
+ vi.useRealTimers();
186
+ });
187
+
188
+ it('falls back to poll and persists the committed cursor after successful delivery', async () => {
189
+ fetchMock
190
+ .mockResolvedValueOnce(
191
+ response(200, {
192
+ events: [
193
+ {
194
+ id: 'evt_poll_1',
195
+ sequence: 1,
196
+ timestamp: '2026-03-06T04:00:00Z',
197
+ payload: {
198
+ type: 'message.created',
199
+ channel: 'general',
200
+ message: {
201
+ id: 'msg_1',
202
+ agentName: 'alice',
203
+ text: 'hello from poll',
204
+ },
205
+ },
206
+ },
207
+ ],
208
+ nextCursor: 'cursor_1',
209
+ hasMore: false,
210
+ })
211
+ )
212
+ .mockImplementation((_input, init) => pendingFetch(init));
213
+
214
+ const { gateway, sendMessage } = createGateway();
215
+ await gateway.start();
216
+
217
+ fireEvent('error', new Error('proxy blocked'));
218
+
219
+ await vi.waitFor(() => {
220
+ expect(sendMessage).toHaveBeenCalledTimes(1);
221
+ });
222
+
223
+ expect(sendMessage.mock.calls[0][0].text).toBe('[relaycast:general] @alice: hello from poll');
224
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain('/messages/poll');
225
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain('cursor=0');
226
+ expect(writeFile).toHaveBeenCalledWith(
227
+ expect.stringContaining('inbound-cursor.json.tmp'),
228
+ expect.stringContaining('"cursor": "cursor_1"'),
229
+ 'utf-8'
230
+ );
231
+
232
+ await gateway.stop();
233
+ });
234
+
235
+ it('resets a stale cursor on 409 and resumes from the initial cursor', async () => {
236
+ readFile.mockResolvedValueOnce(
237
+ JSON.stringify({
238
+ cursor: 'stale_cursor',
239
+ lastSequence: 41,
240
+ recentEventIds: [],
241
+ updatedAt: '2026-03-06T03:59:00Z',
242
+ })
243
+ );
244
+
245
+ fetchMock
246
+ .mockImplementationOnce(async (input) => {
247
+ expect(String(input)).toContain('cursor=stale_cursor');
248
+ return response(409, {});
249
+ })
250
+ .mockImplementationOnce(async (input) => {
251
+ expect(String(input)).toContain('cursor=0');
252
+ return response(200, {
253
+ events: [
254
+ {
255
+ id: 'evt_poll_42',
256
+ sequence: 42,
257
+ timestamp: '2026-03-06T04:01:00Z',
258
+ payload: {
259
+ type: 'message.created',
260
+ channel: 'general',
261
+ message: {
262
+ id: 'msg_42',
263
+ agentName: 'alice',
264
+ text: 'resumed after reset',
265
+ },
266
+ },
267
+ },
268
+ ],
269
+ nextCursor: 'cursor_42',
270
+ hasMore: false,
271
+ });
272
+ })
273
+ .mockImplementation((_input, init) => pendingFetch(init));
274
+
275
+ const { gateway, sendMessage } = createGateway();
276
+ await gateway.start();
277
+
278
+ fireEvent('disconnected');
279
+
280
+ await vi.waitFor(() => {
281
+ expect(sendMessage).toHaveBeenCalledTimes(1);
282
+ });
283
+
284
+ expect(sendMessage.mock.calls[0][0].text).toBe('[relaycast:general] @alice: resumed after reset');
285
+ expect(writeFile).toHaveBeenCalledWith(
286
+ expect.stringContaining('inbound-cursor.json.tmp'),
287
+ expect.stringContaining('"cursor": "cursor_42"'),
288
+ 'utf-8'
289
+ );
290
+
291
+ await gateway.stop();
292
+ });
293
+
294
+ it('promotes back to WS after a stable recovery window', async () => {
295
+ vi.useFakeTimers();
296
+
297
+ fetchMock
298
+ .mockResolvedValueOnce(
299
+ response(200, {
300
+ events: [],
301
+ nextCursor: 'cursor_0',
302
+ hasMore: false,
303
+ })
304
+ )
305
+ .mockImplementationOnce((_input, init) => pendingFetch(init))
306
+ .mockResolvedValueOnce(
307
+ response(200, {
308
+ events: [],
309
+ nextCursor: 'cursor_0',
310
+ hasMore: false,
311
+ })
312
+ )
313
+ .mockImplementation((_input, init) => pendingFetch(init));
314
+
315
+ const { gateway, sendMessage } = createGateway();
316
+ await gateway.start();
317
+
318
+ fireEvent('error', new Error('proxy blocked'));
319
+
320
+ await vi.waitFor(() => {
321
+ expect(fetchMock).toHaveBeenCalled();
322
+ });
323
+
324
+ fireEvent('connected');
325
+ await vi.advanceTimersByTimeAsync(1_100);
326
+
327
+ fireEvent('messageCreated', {
328
+ type: 'message.created',
329
+ channel: 'general',
330
+ message: {
331
+ id: 'msg_ws_1',
332
+ agentName: 'bob',
333
+ text: 'back on ws',
334
+ },
335
+ });
336
+
337
+ await vi.waitFor(() => {
338
+ expect(sendMessage).toHaveBeenCalledTimes(1);
339
+ });
340
+
341
+ expect(sendMessage.mock.calls[0][0].text).toBe('[relaycast:general] @bob: back on ws');
342
+
343
+ await gateway.stop();
344
+ });
345
+
346
+ it('processes WS messages during RECOVERING_WS before promotion completes', async () => {
347
+ vi.useFakeTimers();
348
+
349
+ fetchMock
350
+ .mockResolvedValueOnce(
351
+ response(200, {
352
+ events: [],
353
+ nextCursor: 'cursor_0',
354
+ hasMore: false,
355
+ })
356
+ )
357
+ .mockImplementation((_input, init) => pendingFetch(init));
358
+
359
+ const { gateway, sendMessage } = createGateway({
360
+ probeWs: {
361
+ stableGraceMs: 5_000,
362
+ },
363
+ });
364
+ await gateway.start();
365
+
366
+ fireEvent('error', new Error('proxy blocked'));
367
+
368
+ await vi.waitFor(() => {
369
+ expect(fetchMock).toHaveBeenCalled();
370
+ });
371
+
372
+ fireEvent('connected');
373
+ await vi.advanceTimersByTimeAsync(100);
374
+
375
+ fireEvent('messageCreated', {
376
+ type: 'message.created',
377
+ channel: 'general',
378
+ message: {
379
+ id: 'msg_ws_recovering',
380
+ agentName: 'carol',
381
+ text: 'delivered during recovery',
382
+ },
383
+ });
384
+
385
+ await vi.waitFor(() => {
386
+ expect(sendMessage).toHaveBeenCalledTimes(1);
387
+ });
388
+
389
+ expect(sendMessage.mock.calls[0][0].text).toBe('[relaycast:general] @carol: delivered during recovery');
390
+
391
+ await gateway.stop();
392
+ });
393
+
394
+ it('does not redeliver a message that was already committed in poll mode after WS recovery', async () => {
395
+ vi.useFakeTimers();
396
+
397
+ fetchMock
398
+ .mockResolvedValueOnce(
399
+ response(200, {
400
+ events: [
401
+ {
402
+ id: 'evt_poll_1',
403
+ sequence: 1,
404
+ timestamp: '2026-03-06T04:00:00Z',
405
+ payload: {
406
+ type: 'message.created',
407
+ channel: 'general',
408
+ message: {
409
+ id: 'msg_1',
410
+ agentName: 'alice',
411
+ text: 'hello from poll',
412
+ },
413
+ },
414
+ },
415
+ ],
416
+ nextCursor: 'cursor_1',
417
+ hasMore: false,
418
+ })
419
+ )
420
+ .mockImplementationOnce((_input, init) => pendingFetch(init))
421
+ .mockResolvedValueOnce(
422
+ response(200, {
423
+ events: [],
424
+ nextCursor: 'cursor_1',
425
+ hasMore: false,
426
+ })
427
+ )
428
+ .mockImplementation((_input, init) => pendingFetch(init));
429
+
430
+ const { gateway, sendMessage } = createGateway();
431
+ await gateway.start();
432
+
433
+ fireEvent('error', new Error('proxy blocked'));
434
+
435
+ await vi.waitFor(() => {
436
+ expect(sendMessage).toHaveBeenCalledTimes(1);
437
+ });
438
+
439
+ fireEvent('connected');
440
+ await vi.advanceTimersByTimeAsync(1_100);
441
+
442
+ fireEvent('messageCreated', {
443
+ type: 'message.created',
444
+ channel: 'general',
445
+ message: {
446
+ id: 'msg_1',
447
+ agentName: 'alice',
448
+ text: 'hello from poll',
449
+ },
450
+ });
451
+
452
+ await vi.advanceTimersByTimeAsync(0);
453
+ expect(sendMessage).toHaveBeenCalledTimes(1);
454
+
455
+ await gateway.stop();
456
+ });
457
+
458
+ it('uses a single jitter pass for 429 responses without Retry-After', async () => {
459
+ vi.spyOn(Math, 'random').mockReturnValue(0);
460
+ fetchMock.mockResolvedValueOnce(response(429, {}));
461
+
462
+ const { gateway } = createGateway();
463
+ const delayMs = await (gateway as any).pollOnce(1);
464
+
465
+ expect(delayMs).toBe(550);
466
+ });
467
+ });
@@ -80,6 +80,8 @@ vi.mock('node:fs/promises', () => ({
80
80
  readFile: vi.fn().mockResolvedValue('{"spawns":[]}'),
81
81
  writeFile: vi.fn().mockResolvedValue(undefined),
82
82
  mkdir: vi.fn().mockResolvedValue(undefined),
83
+ rename: vi.fn().mockResolvedValue(undefined),
84
+ chmod: vi.fn().mockResolvedValue(undefined),
83
85
  }));
84
86
 
85
87
  vi.mock('node:fs', () => ({
@@ -109,10 +111,7 @@ import { InboundGateway } from '../gateway.js';
109
111
  // Helpers
110
112
  // ---------------------------------------------------------------------------
111
113
 
112
- function createGateway(overrides?: {
113
- clawName?: string;
114
- channels?: string[];
115
- }) {
114
+ function createGateway(overrides?: { clawName?: string; channels?: string[] }) {
116
115
  const sendMessage = vi.fn().mockResolvedValue({ event_id: 'evt_1' });
117
116
  const gateway = new InboundGateway({
118
117
  config: {
@@ -552,7 +551,9 @@ describe('InboundGateway — thread reply injection', () => {
552
551
  });
553
552
 
554
553
  const call = sendMessage.mock.calls[0][0];
555
- expect(call.text).toBe('[relaycast:reaction] @eve reacted thumbsup to message msg_800 (soft notification, no action required)');
554
+ expect(call.text).toBe(
555
+ '[relaycast:reaction] @eve reacted thumbsup to message msg_800 (soft notification, no action required)'
556
+ );
556
557
 
557
558
  await gateway.stop();
558
559
  });
@@ -573,7 +574,9 @@ describe('InboundGateway — thread reply injection', () => {
573
574
  });
574
575
 
575
576
  const call = sendMessage.mock.calls[0][0];
576
- expect(call.text).toBe('[relaycast:reaction] @frank removed rocket from message msg_900 (soft notification, no action required)');
577
+ expect(call.text).toBe(
578
+ '[relaycast:reaction] @frank removed rocket from message msg_900 (soft notification, no action required)'
579
+ );
577
580
 
578
581
  await gateway.stop();
579
582
  });
@@ -854,7 +857,9 @@ describe('InboundGateway — thread reply injection', () => {
854
857
  });
855
858
 
856
859
  const call = sendMessage.mock.calls[0][0];
857
- expect(call.text).toBe('[relaycast:reaction] @dave reacted fire to message msg_fmt_react (soft notification, no action required)');
860
+ expect(call.text).toBe(
861
+ '[relaycast:reaction] @dave reacted fire to message msg_fmt_react (soft notification, no action required)'
862
+ );
858
863
 
859
864
  await gateway.stop();
860
865
  });
@@ -914,8 +919,11 @@ describe('InboundGateway — thread reply injection', () => {
914
919
  it('should skip messages already being processed', async () => {
915
920
  // Use a slow sendMessage to simulate a message still being processed
916
921
  let resolveFirst: (() => void) | null = null;
917
- const firstCallPromise = new Promise<void>((r) => { resolveFirst = r; });
918
- const sendMessage = vi.fn()
922
+ const firstCallPromise = new Promise<void>((r) => {
923
+ resolveFirst = r;
924
+ });
925
+ const sendMessage = vi
926
+ .fn()
919
927
  .mockImplementationOnce(async () => {
920
928
  // Block until we manually resolve
921
929
  await firstCallPromise;
@@ -971,6 +979,48 @@ describe('InboundGateway — thread reply injection', () => {
971
979
 
972
980
  await gateway.stop();
973
981
  });
982
+
983
+ it('should retry a replayed message after a failed delivery', async () => {
984
+ const sendMessage = vi
985
+ .fn()
986
+ .mockResolvedValueOnce({ event_id: 'unsupported_operation' })
987
+ .mockResolvedValueOnce({ event_id: 'evt_retry_ok' });
988
+
989
+ const gateway = new InboundGateway({
990
+ config: {
991
+ apiKey: 'rk_live_test',
992
+ clawName: 'test-claw',
993
+ baseUrl: 'https://api.relaycast.dev',
994
+ channels: ['general'],
995
+ },
996
+ relaySender: { sendMessage },
997
+ });
998
+ await gateway.start();
999
+
1000
+ const event = {
1001
+ type: 'message.created',
1002
+ channel: 'general',
1003
+ message: {
1004
+ id: 'msg_retry_1',
1005
+ agentName: 'alice',
1006
+ text: 'retry me',
1007
+ attachments: [],
1008
+ },
1009
+ };
1010
+
1011
+ fireEvent('messageCreated', event);
1012
+ await vi.waitFor(() => {
1013
+ expect(sendMessage).toHaveBeenCalledTimes(1);
1014
+ });
1015
+ await new Promise((resolve) => setTimeout(resolve, 0));
1016
+
1017
+ fireEvent('messageCreated', event);
1018
+ await vi.waitFor(() => {
1019
+ expect(sendMessage).toHaveBeenCalledTimes(2);
1020
+ });
1021
+
1022
+ await gateway.stop();
1023
+ });
974
1024
  });
975
1025
 
976
1026
  describe('handleInbound when not running', () => {