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.
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +2 -2
- package/dist/src/cli/bootstrap.d.ts.map +1 -1
- package/dist/src/cli/bootstrap.js +2 -0
- package/dist/src/cli/bootstrap.js.map +1 -1
- package/dist/src/cli/commands/connect.d.ts +3 -0
- package/dist/src/cli/commands/connect.d.ts.map +1 -0
- package/dist/src/cli/commands/connect.js +18 -0
- package/dist/src/cli/commands/connect.js.map +1 -0
- package/dist/src/cli/lib/auth-ssh.d.ts.map +1 -1
- package/dist/src/cli/lib/auth-ssh.js +22 -270
- package/dist/src/cli/lib/auth-ssh.js.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.d.ts.map +1 -1
- package/dist/src/cli/lib/broker-lifecycle.js +33 -0
- package/dist/src/cli/lib/broker-lifecycle.js.map +1 -1
- package/dist/src/cli/lib/connect-daytona.d.ts +15 -0
- package/dist/src/cli/lib/connect-daytona.d.ts.map +1 -0
- package/dist/src/cli/lib/connect-daytona.js +217 -0
- package/dist/src/cli/lib/connect-daytona.js.map +1 -0
- package/dist/src/cli/lib/ssh-interactive.d.ts +41 -0
- package/dist/src/cli/lib/ssh-interactive.d.ts.map +1 -0
- package/dist/src/cli/lib/ssh-interactive.js +320 -0
- package/dist/src/cli/lib/ssh-interactive.js.map +1 -0
- package/install.sh +2 -1
- package/package.json +13 -10
- package/packages/acp-bridge/package.json +2 -2
- package/packages/config/dist/cli-auth-config.d.ts +2 -0
- package/packages/config/dist/cli-auth-config.d.ts.map +1 -1
- package/packages/config/dist/cli-auth-config.js +1 -0
- package/packages/config/dist/cli-auth-config.js.map +1 -1
- package/packages/config/package.json +1 -1
- package/packages/config/src/cli-auth-config.ts +3 -0
- 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 +57 -16
- 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 +56 -2
- package/packages/openclaw/dist/gateway.d.ts.map +1 -1
- package/packages/openclaw/dist/gateway.js +819 -127
- 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 +73 -23
- 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 +1155 -252
- 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\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(
|
|
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
|
});
|
|
@@ -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(
|
|
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) => {
|
|
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', () => {
|
|
@@ -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
|
});
|