Skip to content

Commit 1d9225d

Browse files
khaliqgantclaude
andauthored
feat(sdk): multiplexed subscribe, @self DM routing, stable event ids (#128)
* feat(sdk): multiplexed subscribe, @self DM routing, stable event ids - AgentClient.subscribe(channels, onMessage): Subscription multiplexes a single websocket per agent, auto-resubscribes on reconnect, and filters the @self sentinel to DM-only delivery in the SDK. - Server resolves to: "@self" on DM send from the authenticated agentId rather than trusting client-side substitution. - WebSocket message events now carry a top-level UUID id derived deterministically from the underlying message id, so clients can dedupe across reconnects. - RelayError distinguishes rate_limited and backpressure from generic transport failures. Originally implemented as Track A of the proactive-runtime M3 workflow; the worker reported COMPLETE and ran the @relaycast/types, /sdk, and /server vitest suites green but never opened a PR. Re-verified locally: 38 + 294 + 437 tests pass on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sdk): address subscription review comments --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 207d117 commit 1d9225d

23 files changed

Lines changed: 642 additions & 29 deletions

README.md

Lines changed: 7 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ await alice.channels.create({ name: 'general', topic: 'Team chat' });
3838
await bob.channels.join('general');
3939
await carol.channels.join('general');
4040

41-
// 6) Realtime listeners (on.messageCreated is the onMessage-style hook)
41+
// 6) Realtime listeners on one multiplexed websocket per agent
4242
const agents = [
4343
{ name: 'Alice', client: alice },
4444
{ name: 'Bob', client: bob },
@@ -49,18 +49,15 @@ await Promise.all(
4949
agents.map(
5050
({ name, client }) =>
5151
new Promise<void>((resolve) => {
52-
client.connect();
52+
client.subscribe(['general', '@self'], (event) => {
53+
console.log(`[${name} stream] ${event.message.agentName}: ${event.message.text}`);
54+
});
5355

5456
const stopConnected = client.on.connected(() => {
55-
client.subscribe(['general']);
5657
console.log(`${name} websocket connected`);
5758
stopConnected();
5859
resolve();
5960
});
60-
61-
client.on.messageCreated((event) => {
62-
console.log(`[${name} stream] ${event.message.agentName}: ${event.message.text}`);
63-
});
6461
}),
6562
),
6663
);
@@ -75,7 +72,6 @@ await new Promise((resolve) => setTimeout(resolve, 1500));
7572

7673
// 8) Cleanup
7774
for (const { client } of agents) {
78-
client.unsubscribe(['general']);
7975
await client.disconnect();
8076
}
8177
```
@@ -140,8 +136,7 @@ const { token } = await relay.agents.register({ name: 'Reviewer', type: 'agent'
140136
const me = relay.as(token);
141137

142138
me.connect();
143-
me.on.connected(() => me.subscribe(['general']));
144-
me.on.messageCreated((event) => {
139+
me.subscribe(['general', '@self'], (event) => {
145140
console.log(`${event.message.agentName}: ${event.message.text}`);
146141
});
147142

@@ -172,19 +167,12 @@ const relay = new RelayCast({ apiKey, baseUrl: localBaseUrl });
172167
Realtime example:
173168

174169
```typescript
175-
me.connect();
176-
const stopConnected = me.on.connected(() => {
177-
me.subscribe(['general']);
178-
stopConnected();
179-
});
180-
181-
const unsub = me.on.messageCreated((event) => {
170+
const sub = me.subscribe(['general', '@self'], (event) => {
182171
console.log(`${event.message.agentName}: ${event.message.text}`);
183172
});
184173

185174
// later
186-
unsub();
187-
me.unsubscribe(['general']);
175+
sub.unsubscribe();
188176
await me.disconnect();
189177
```
190178

openapi.yaml

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1545,7 +1545,12 @@ paths:
15451545
/dm:
15461546
post:
15471547
summary: Send DM
1548-
description: Send a direct message to another agent
1548+
description: |
1549+
Send a direct message to another agent.
1550+
1551+
The `to` field also accepts the `@self` sentinel, which is resolved
1552+
on the server to the authenticated agent identity so callers do not
1553+
need to guess their own routed name.
15491554
tags:
15501555
- Direct Messages
15511556
security:
@@ -1567,7 +1572,7 @@ paths:
15671572
properties:
15681573
to:
15691574
type: string
1570-
description: Recipient agent name
1575+
description: Recipient agent name or `@self`
15711576
text:
15721577
type: string
15731578
attachments:

packages/sdk-typescript/src/__tests__/agent-messaging.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ describe('AgentClient', () => {
104104
});
105105
});
106106

107+
describe('post()', () => {
108+
it('aliases send() for channel messages', async () => {
109+
mockFetch.mockImplementation(() => mockResponse({ id: 'm_1' }));
110+
111+
await me.post('#general', 'hello');
112+
113+
expect(mockFetch).toHaveBeenCalledTimes(1);
114+
const [url, init] = mockFetch.mock.calls[0]!;
115+
expect(url).toBe('https://api.relaycast.dev/v1/channels/general/messages');
116+
expect(init.method).toBe('POST');
117+
expect(init.body).toBe(JSON.stringify({ text: 'hello', mode: 'wait' }));
118+
});
119+
});
120+
107121
describe('messages()', () => {
108122
it('gets from /v1/channels/:name/messages', async () => {
109123
mockFetch.mockImplementation(() => mockResponse([{ id: 'm_1' }]));

packages/sdk-typescript/src/__tests__/agent-ws.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import { AgentClient, type AgentClientOptions } from '../agent.js';
3+
import { stableRelaycastEventId } from '../event-id.js';
34
import { HttpClient } from '../client.js';
45

56
class MockWebSocket {
@@ -142,6 +143,30 @@ describe('AgentClient WebSocket integration', () => {
142143
expect(MockWebSocket.instances).toHaveLength(2);
143144
});
144145

146+
it('disconnect() clears manual and managed subscriptions before future reconnect', async () => {
147+
const agent = createAgent();
148+
agent.connect();
149+
const ws1 = MockWebSocket.instances[0]!;
150+
ws1.simulateOpen();
151+
agent.subscribe(['general']);
152+
agent.subscribe(['dev'], vi.fn());
153+
154+
const p = agent.disconnect();
155+
await vi.advanceTimersByTimeAsync(200);
156+
await p;
157+
158+
agent.subscribe(['random'], vi.fn());
159+
const ws2 = MockWebSocket.instances[1]!;
160+
ws2.simulateOpen();
161+
162+
const subscribePayloads = ws2.send.mock.calls
163+
.map(([payload]) => JSON.parse(String(payload)))
164+
.filter((payload) => payload.type === 'subscribe');
165+
expect(subscribePayloads).toEqual([
166+
{ type: 'subscribe', channels: ['random'] },
167+
]);
168+
});
169+
145170
// --- typed on.* handlers ---
146171

147172
it('on.messageCreated fires with message.created event', () => {
@@ -248,6 +273,8 @@ describe('AgentClient WebSocket integration', () => {
248273
agent.connect();
249274
const ws = MockWebSocket.instances[0]!;
250275
ws.simulateOpen();
276+
agent.subscribe(['dev']);
277+
ws.send.mockClear();
251278

252279
agent.unsubscribe(['dev']);
253280

@@ -268,6 +295,93 @@ describe('AgentClient WebSocket integration', () => {
268295
agent.unsubscribe(['general']);
269296
});
270297

298+
it('subscribe(channels, handler) multiplexes channel and self DM events on one socket', async () => {
299+
const agent = createAgent();
300+
const handler = vi.fn();
301+
302+
const subscription = agent.subscribe(['#general', '@self'], handler);
303+
304+
expect(MockWebSocket.instances).toHaveLength(1);
305+
const ws = MockWebSocket.instances[0]!;
306+
ws.simulateOpen();
307+
308+
expect(ws.send).toHaveBeenCalledWith(
309+
JSON.stringify({ type: 'subscribe', channels: ['general'] }),
310+
);
311+
312+
ws.simulateMessage({
313+
id: stableRelaycastEventId('m_1'),
314+
type: 'message.created',
315+
channel: 'general',
316+
message: { id: 'm_1', agent_name: 'Bot', text: 'hi', attachments: [] },
317+
});
318+
ws.simulateMessage({
319+
id: stableRelaycastEventId('dm_1'),
320+
type: 'dm.received',
321+
conversation_id: 'conv_1',
322+
message: { id: 'dm_1', agent_name: 'Alice', text: 'hello' },
323+
});
324+
ws.simulateMessage({
325+
id: stableRelaycastEventId('m_2'),
326+
type: 'message.created',
327+
channel: 'random',
328+
message: { id: 'm_2', agent_name: 'Bot', text: 'skip', attachments: [] },
329+
});
330+
await Promise.resolve();
331+
332+
expect(handler).toHaveBeenCalledTimes(2);
333+
expect(handler.mock.calls[0]![0]).toMatchObject({ id: stableRelaycastEventId('m_1'), type: 'message.created' });
334+
expect(handler.mock.calls[1]![0]).toMatchObject({ id: stableRelaycastEventId('dm_1'), type: 'dm.received' });
335+
336+
subscription.unsubscribe();
337+
expect(ws.send).toHaveBeenCalledWith(
338+
JSON.stringify({ type: 'unsubscribe', channels: ['general'] }),
339+
);
340+
});
341+
342+
it('subscribe(channels, handler) logs rejected handler promises', async () => {
343+
const agent = createAgent();
344+
const err = new Error('handler failed');
345+
const handler = vi.fn().mockRejectedValue(err);
346+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
347+
348+
agent.subscribe(['general'], handler);
349+
const ws = MockWebSocket.instances[0]!;
350+
ws.simulateOpen();
351+
ws.simulateMessage({
352+
type: 'message.created',
353+
channel: 'general',
354+
message: { id: 'm_1', agent_name: 'Bot', text: 'hi', attachments: [] },
355+
});
356+
357+
for (let i = 0; i < 5; i += 1) {
358+
await Promise.resolve();
359+
}
360+
361+
expect(handler).toHaveBeenCalledTimes(1);
362+
expect(errorSpy).toHaveBeenCalledWith('[relaycast] Subscription handler failed', err);
363+
errorSpy.mockRestore();
364+
});
365+
366+
it('resubscribes managed channels after reconnect', () => {
367+
const agent = createAgent();
368+
const subscription = agent.subscribe(['general'], vi.fn());
369+
370+
const ws1 = MockWebSocket.instances[0]!;
371+
ws1.simulateOpen();
372+
ws1.simulateClose();
373+
374+
vi.advanceTimersByTime(1000);
375+
376+
const ws2 = MockWebSocket.instances[1]!;
377+
ws2.simulateOpen();
378+
expect(ws2.send).toHaveBeenCalledWith(
379+
JSON.stringify({ type: 'subscribe', channels: ['general'] }),
380+
);
381+
382+
subscription.unsubscribe();
383+
});
384+
271385
// --- lifecycle events ---
272386

273387
it('on.connected fires on WebSocket open', () => {

packages/sdk-typescript/src/__tests__/errors.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ describe('normalizeRelayErrorCode', () => {
6464
expect(normalizeRelayErrorCode('agent_not_found')).toBe('not_found');
6565
});
6666

67+
it('maps rate_limit_exceeded to rate_limited', () => {
68+
expect(normalizeRelayErrorCode('rate_limit_exceeded')).toBe('rate_limited');
69+
});
70+
71+
it('maps backpressure to backpressure', () => {
72+
expect(normalizeRelayErrorCode('backpressure')).toBe('backpressure');
73+
});
74+
75+
it('maps queue_overloaded to backpressure', () => {
76+
expect(normalizeRelayErrorCode('queue_overloaded')).toBe('backpressure');
77+
});
78+
79+
it('maps workspace_stream_backpressure to backpressure', () => {
80+
expect(normalizeRelayErrorCode('workspace_stream_backpressure')).toBe('backpressure');
81+
});
82+
6783
it('maps not_found to not_found', () => {
6884
expect(normalizeRelayErrorCode('not_found')).toBe('not_found');
6985
});
@@ -92,6 +108,10 @@ describe('normalizeRelayErrorCode', () => {
92108
expect(normalizeRelayErrorCode('unknown_code', 404)).toBe('not_found');
93109
});
94110

111+
it('falls back to statusCode-based mapping for 429', () => {
112+
expect(normalizeRelayErrorCode('unknown_code', 429)).toBe('rate_limited');
113+
});
114+
95115
it('falls back to statusCode-based mapping for 409', () => {
96116
expect(normalizeRelayErrorCode('unknown_code', 409)).toBe('name_conflict');
97117
});
@@ -126,6 +146,14 @@ describe('relayErrorRetryable', () => {
126146
expect(relayErrorRetryable('unauthorized', 401)).toBe(false);
127147
});
128148

149+
it('returns true for rate_limited', () => {
150+
expect(relayErrorRetryable('rate_limited', 429)).toBe(true);
151+
});
152+
153+
it('returns true for backpressure', () => {
154+
expect(relayErrorRetryable('backpressure', 429)).toBe(true);
155+
});
156+
129157
it('returns false for workspace_mismatch', () => {
130158
expect(relayErrorRetryable('workspace_mismatch', 403)).toBe(false);
131159
});
@@ -167,6 +195,12 @@ describe('relayErrorFromApi', () => {
167195
expect(err.code).toBe('not_found');
168196
});
169197

198+
it('maps 429 api errors to rate_limited', () => {
199+
const err = relayErrorFromApi('rate_limit_exceeded', 'slow down', 429);
200+
expect(err.code).toBe('rate_limited');
201+
expect(err.retryable).toBe(true);
202+
});
203+
170204
it('defaults to transport_error for completely unknown', () => {
171205
const err = relayErrorFromApi(undefined, 'oops', 500);
172206
expect(err.code).toBe('transport_error');
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { stableRelaycastEventId } from '../event-id.js';
3+
4+
describe('stableRelaycastEventId', () => {
5+
it('preserves part boundaries when hashing', () => {
6+
expect(stableRelaycastEventId('a', 'b:c')).not.toBe(stableRelaycastEventId('a:b', 'c'));
7+
});
8+
9+
it('uses the stable empty-event fallback for blank input', () => {
10+
expect(stableRelaycastEventId()).toBe(stableRelaycastEventId('empty-event'));
11+
expect(stableRelaycastEventId(' ', undefined, null)).toBe(stableRelaycastEventId('empty-event'));
12+
});
13+
});

0 commit comments

Comments
 (0)