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.
- package/dist/index.cjs +2 -2
- package/install.sh +2 -1
- package/package.json +13 -10
- 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.js +13 -13
- package/packages/openclaw/dist/__tests__/gateway-control.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts +2 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.d.ts.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js +369 -0
- package/packages/openclaw/dist/__tests__/gateway-poll-fallback.test.js.map +1 -0
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js +43 -2
- package/packages/openclaw/dist/__tests__/gateway-threads.test.js.map +1 -1
- package/packages/openclaw/dist/__tests__/ws-client.test.js +2 -0
- package/packages/openclaw/dist/__tests__/ws-client.test.js.map +1 -1
- package/packages/openclaw/dist/config.d.ts +2 -0
- package/packages/openclaw/dist/config.d.ts.map +1 -1
- package/packages/openclaw/dist/config.js +99 -12
- package/packages/openclaw/dist/config.js.map +1 -1
- package/packages/openclaw/dist/gateway.d.ts +55 -1
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +807 -120
- package/packages/openclaw/dist/gateway.js.map +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts +2 -0
- package/packages/openclaw/dist/runtime/openclaw-config.d.ts.map +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.js +1 -1
- package/packages/openclaw/dist/runtime/openclaw-config.js.map +1 -1
- package/packages/openclaw/dist/runtime/setup.d.ts +0 -2
- package/packages/openclaw/dist/runtime/setup.d.ts.map +1 -1
- package/packages/openclaw/dist/runtime/setup.js +53 -8
- package/packages/openclaw/dist/runtime/setup.js.map +1 -1
- package/packages/openclaw/dist/types.d.ts +28 -0
- package/packages/openclaw/dist/types.d.ts.map +1 -1
- package/packages/openclaw/package.json +2 -2
- package/packages/openclaw/skill/SKILL.md +150 -44
- package/packages/openclaw/src/__tests__/gateway-control.test.ts +14 -14
- package/packages/openclaw/src/__tests__/gateway-poll-fallback.test.ts +467 -0
- package/packages/openclaw/src/__tests__/gateway-threads.test.ts +59 -9
- package/packages/openclaw/src/__tests__/ws-client.test.ts +71 -51
- package/packages/openclaw/src/config.ts +121 -12
- package/packages/openclaw/src/gateway.ts +1143 -245
- package/packages/openclaw/src/runtime/openclaw-config.ts +3 -1
- package/packages/openclaw/src/runtime/setup.ts +57 -16
- package/packages/openclaw/src/types.ts +31 -0
- package/packages/openclaw/test/vitest.setup.ts +1 -0
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/__tests__/unit.test.js +131 -129
- package/packages/sdk/dist/__tests__/unit.test.js.map +1 -1
- package/packages/sdk/dist/relay.js +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +5 -3
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/unit.test.ts +142 -157
- package/packages/sdk/src/relay.ts +1 -1
- package/packages/sdk/src/workflows/runner.ts +12 -9
- 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
|
@@ -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(
|
|
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(
|
|
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(
|
|
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) => {
|
|
918
|
-
|
|
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', () => {
|