agent-relay 3.1.10 → 3.1.12

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 (93) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +2 -2
  6. package/dist/src/cli/bootstrap.d.ts.map +1 -1
  7. package/dist/src/cli/bootstrap.js +2 -0
  8. package/dist/src/cli/bootstrap.js.map +1 -1
  9. package/dist/src/cli/commands/connect.d.ts +3 -0
  10. package/dist/src/cli/commands/connect.d.ts.map +1 -0
  11. package/dist/src/cli/commands/connect.js +18 -0
  12. package/dist/src/cli/commands/connect.js.map +1 -0
  13. package/dist/src/cli/lib/auth-ssh.d.ts.map +1 -1
  14. package/dist/src/cli/lib/auth-ssh.js +22 -270
  15. package/dist/src/cli/lib/auth-ssh.js.map +1 -1
  16. package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
  17. package/dist/src/cli/lib/broker-lifecycle.js +33 -0
  18. package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
  19. package/dist/src/cli/lib/connect-daytona.d.ts +15 -0
  20. package/dist/src/cli/lib/connect-daytona.d.ts.map +1 -0
  21. package/dist/src/cli/lib/connect-daytona.js +217 -0
  22. package/dist/src/cli/lib/connect-daytona.js.map +1 -0
  23. package/dist/src/cli/lib/ssh-interactive.d.ts +41 -0
  24. package/dist/src/cli/lib/ssh-interactive.d.ts.map +1 -0
  25. package/dist/src/cli/lib/ssh-interactive.js +320 -0
  26. package/dist/src/cli/lib/ssh-interactive.js.map +1 -0
  27. package/install.sh +2 -1
  28. package/package.json +13 -10
  29. package/packages/acp-bridge/package.json +2 -2
  30. package/packages/config/dist/cli-auth-config.d.ts +2 -0
  31. package/packages/config/dist/cli-auth-config.d.ts.map +1 -1
  32. package/packages/config/dist/cli-auth-config.js +1 -0
  33. package/packages/config/dist/cli-auth-config.js.map +1 -1
  34. package/packages/config/package.json +1 -1
  35. package/packages/config/src/cli-auth-config.ts +3 -0
  36. package/packages/hooks/package.json +4 -4
  37. package/packages/memory/package.json +2 -2
  38. package/packages/openclaw/dist/__tests__/gateway-control.test.js +13 -13
  39. package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -1
  40. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts +2 -0
  41. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts.map +1 -0
  42. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js +369 -0
  43. package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js.map +1 -0
  44. package/packages/openclaw/dist/__tests__/gateway-threads.test.js +57 -16
  45. package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
  46. package/packages/openclaw/dist/__tests__/ws-client.test.js +2 -0
  47. package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
  48. package/packages/openclaw/dist/config.d.ts +2 -0
  49. package/packages/openclaw/dist/config.d.ts.map +1 -1
  50. package/packages/openclaw/dist/config.js +99 -12
  51. package/packages/openclaw/dist/config.js.map +1 -1
  52. package/packages/openclaw/dist/gateway.d.ts +56 -2
  53. package/packages/openclaw/dist/gateway.d.ts.map +1 -1
  54. package/packages/openclaw/dist/gateway.js +819 -127
  55. package/packages/openclaw/dist/gateway.js.map +1 -1
  56. package/packages/openclaw/dist/runtime/openclaw-config.d.ts +2 -0
  57. package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -1
  58. package/packages/openclaw/dist/runtime/openclaw-config.js +1 -1
  59. package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -1
  60. package/packages/openclaw/dist/runtime/setup.d.ts +0 -2
  61. package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -1
  62. package/packages/openclaw/dist/runtime/setup.js +53 -8
  63. package/packages/openclaw/dist/runtime/setup.js.map +1 -1
  64. package/packages/openclaw/dist/types.d.ts +28 -0
  65. package/packages/openclaw/dist/types.d.ts.map +1 -1
  66. package/packages/openclaw/package.json +2 -2
  67. package/packages/openclaw/skill/SKILL.md +150 -44
  68. package/packages/openclaw/src/__tests__/gateway-control.test.ts +14 -14
  69. package/packages/openclaw/src/__tests__/gateway-poll-fallback.test.ts +467 -0
  70. package/packages/openclaw/src/__tests__/gateway-threads.test.ts +73 -23
  71. package/packages/openclaw/src/__tests__/ws-client.test.ts +71 -51
  72. package/packages/openclaw/src/config.ts +121 -12
  73. package/packages/openclaw/src/gateway.ts +1155 -252
  74. package/packages/openclaw/src/runtime/openclaw-config.ts +3 -1
  75. package/packages/openclaw/src/runtime/setup.ts +57 -16
  76. package/packages/openclaw/src/types.ts +31 -0
  77. package/packages/openclaw/test/vitest.setup.ts +1 -0
  78. package/packages/policy/package.json +2 -2
  79. package/packages/sdk/dist/__tests__/unit.test.js +131 -129
  80. package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
  81. package/packages/sdk/dist/relay.js +1 -1
  82. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  83. package/packages/sdk/dist/workflows/runner.js +5 -3
  84. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  85. package/packages/sdk/package.json +2 -2
  86. package/packages/sdk/src/__tests__/unit.test.ts +142 -157
  87. package/packages/sdk/src/relay.ts +1 -1
  88. package/packages/sdk/src/workflows/runner.ts +12 -9
  89. package/packages/sdk-py/pyproject.toml +1 -1
  90. package/packages/telemetry/package.json +1 -1
  91. package/packages/trajectory/package.json +2 -2
  92. package/packages/user-directory/package.json +2 -2
  93. 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\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")');
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\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_42")');
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\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_1")');
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\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_ws_recovering")');
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: {
@@ -163,7 +162,7 @@ describe('InboundGateway — thread reply injection', () => {
163
162
  });
164
163
 
165
164
  const call = sendMessage.mock.calls[0][0];
166
- expect(call.text).toBe('[relaycast:general] @alice: hello world');
165
+ expect(call.text).toBe('[relaycast:general] @alice: hello world\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_1")');
167
166
  expect(call.text).not.toContain('[thread]');
168
167
 
169
168
  await gateway.stop();
@@ -189,7 +188,7 @@ describe('InboundGateway — thread reply injection', () => {
189
188
  });
190
189
 
191
190
  const call = sendMessage.mock.calls[0][0];
192
- expect(call.text).toBe('[thread] [relaycast:general] @bob: replying in thread');
191
+ expect(call.text).toBe('[thread] [relaycast:general] @bob: replying in thread\n(reply with: reply_to_thread message_id="msg_parent_1")');
193
192
 
194
193
  await gateway.stop();
195
194
  });
@@ -330,10 +329,10 @@ describe('InboundGateway — thread reply injection', () => {
330
329
  });
331
330
 
332
331
  const firstCall = sendMessage.mock.calls[0][0];
333
- expect(firstCall.text).toBe('[relaycast:general] @frank: original message');
332
+ expect(firstCall.text).toBe('[relaycast:general] @frank: original message\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_600")');
334
333
 
335
334
  const secondCall = sendMessage.mock.calls[1][0];
336
- expect(secondCall.text).toBe('[thread] [relaycast:general] @grace: reply to frank');
335
+ expect(secondCall.text).toBe('[thread] [relaycast:general] @grace: reply to frank\n(reply with: reply_to_thread message_id="msg_600")');
337
336
 
338
337
  await gateway.stop();
339
338
  });
@@ -386,7 +385,7 @@ describe('InboundGateway — thread reply injection', () => {
386
385
  });
387
386
 
388
387
  const call = sendMessage.mock.calls[0][0];
389
- expect(call.text).toBe('[relaycast:dm] @alice: hey there');
388
+ expect(call.text).toBe('[relaycast:dm] @alice: hey there\n(reply with: send_dm to="alice")');
390
389
 
391
390
  await gateway.stop();
392
391
  });
@@ -459,7 +458,7 @@ describe('InboundGateway — thread reply injection', () => {
459
458
  });
460
459
 
461
460
  const call = sendMessage.mock.calls[0][0];
462
- expect(call.text).toBe('[relaycast:groupdm] @carol: group message');
461
+ expect(call.text).toBe('[relaycast:groupdm] @carol: group message\n(reply with: send_dm to="carol")');
463
462
 
464
463
  await gateway.stop();
465
464
  });
@@ -485,7 +484,7 @@ describe('InboundGateway — thread reply injection', () => {
485
484
  });
486
485
 
487
486
  const call = sendMessage.mock.calls[0][0];
488
- expect(call.text).toBe('[relaycast:command:general] @dave /deploy production --force');
487
+ expect(call.text).toBe('[relaycast:command:general] @dave /deploy production --force\n(command invocation \u2014 respond with: post_message channel="general")');
489
488
 
490
489
  await gateway.stop();
491
490
  });
@@ -509,7 +508,7 @@ describe('InboundGateway — thread reply injection', () => {
509
508
  });
510
509
 
511
510
  const call = sendMessage.mock.calls[0][0];
512
- expect(call.text).toBe('[relaycast:command:general] @eve /status');
511
+ expect(call.text).toBe('[relaycast:command:general] @eve /status\n(command invocation \u2014 respond with: post_message channel="general")');
513
512
 
514
513
  await gateway.stop();
515
514
  });
@@ -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
  });
@@ -785,7 +788,7 @@ describe('InboundGateway — thread reply injection', () => {
785
788
  });
786
789
 
787
790
  const call = sendMessage.mock.calls[0][0];
788
- expect(call.text).toBe('[relaycast:dm] @alice: dm format test');
791
+ expect(call.text).toBe('[relaycast:dm] @alice: dm format test\n(reply with: send_dm to="alice")');
789
792
 
790
793
  await gateway.stop();
791
794
  });
@@ -809,7 +812,7 @@ describe('InboundGateway — thread reply injection', () => {
809
812
  });
810
813
 
811
814
  const call = sendMessage.mock.calls[0][0];
812
- expect(call.text).toBe('[relaycast:groupdm] @bob: group dm format test');
815
+ expect(call.text).toBe('[relaycast:groupdm] @bob: group dm format test\n(reply with: send_dm to="bob")');
813
816
 
814
817
  await gateway.stop();
815
818
  });
@@ -833,7 +836,7 @@ describe('InboundGateway — thread reply injection', () => {
833
836
  });
834
837
 
835
838
  const call = sendMessage.mock.calls[0][0];
836
- expect(call.text).toBe('[relaycast:command:general] @carol /build --prod');
839
+ expect(call.text).toBe('[relaycast:command:general] @carol /build --prod\n(command invocation \u2014 respond with: post_message channel="general")');
837
840
 
838
841
  await gateway.stop();
839
842
  });
@@ -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
  });
@@ -879,7 +884,7 @@ describe('InboundGateway — thread reply injection', () => {
879
884
  });
880
885
 
881
886
  const call = sendMessage.mock.calls[0][0];
882
- expect(call.text).toBe('[thread] [relaycast:general] @eve: thread format test');
887
+ expect(call.text).toBe('[thread] [relaycast:general] @eve: thread format test\n(reply with: reply_to_thread message_id="msg_fmt_parent")');
883
888
 
884
889
  await gateway.stop();
885
890
  });
@@ -904,7 +909,7 @@ describe('InboundGateway — thread reply injection', () => {
904
909
  });
905
910
 
906
911
  const call = sendMessage.mock.calls[0][0];
907
- expect(call.text).toBe('[relaycast:general] @frank: channel format test');
912
+ expect(call.text).toBe('[relaycast:general] @frank: channel format test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_fmt_chan")');
908
913
 
909
914
  await gateway.stop();
910
915
  });
@@ -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', () => {
@@ -1101,7 +1151,7 @@ describe('InboundGateway — thread reply injection', () => {
1101
1151
  });
1102
1152
 
1103
1153
  const call = sendMessage.mock.calls[0][0];
1104
- expect(call.text).toBe('[relaycast:general] @alice: normalization test');
1154
+ expect(call.text).toBe('[relaycast:general] @alice: normalization test\n(reply with: post_message channel="general" or reply_to_thread message_id="msg_norm_1")');
1105
1155
 
1106
1156
  await gateway.stop();
1107
1157
  });