agent-relay 2.1.4 → 2.1.6

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 (132) hide show
  1. package/README.md +85 -236
  2. package/dist/index.cjs +281 -24
  3. package/package.json +19 -19
  4. package/packages/api-types/package.json +1 -1
  5. package/packages/benchmark/package.json +4 -4
  6. package/packages/bridge/dist/spawner.d.ts.map +1 -1
  7. package/packages/bridge/dist/spawner.js +39 -5
  8. package/packages/bridge/dist/spawner.js.map +1 -1
  9. package/packages/bridge/package.json +8 -8
  10. package/packages/bridge/src/spawner.ts +40 -5
  11. package/packages/cli-tester/package.json +1 -1
  12. package/packages/config/package.json +2 -2
  13. package/packages/continuity/package.json +2 -2
  14. package/packages/daemon/dist/server.d.ts +5 -0
  15. package/packages/daemon/dist/server.d.ts.map +1 -1
  16. package/packages/daemon/dist/server.js +31 -0
  17. package/packages/daemon/dist/server.js.map +1 -1
  18. package/packages/daemon/package.json +12 -12
  19. package/packages/daemon/src/server.ts +37 -0
  20. package/packages/hooks/package.json +4 -4
  21. package/packages/mcp/dist/cloud.d.ts +7 -114
  22. package/packages/mcp/dist/cloud.d.ts.map +1 -1
  23. package/packages/mcp/dist/cloud.js +21 -431
  24. package/packages/mcp/dist/cloud.js.map +1 -1
  25. package/packages/mcp/dist/errors.d.ts +4 -22
  26. package/packages/mcp/dist/errors.d.ts.map +1 -1
  27. package/packages/mcp/dist/errors.js +4 -43
  28. package/packages/mcp/dist/errors.js.map +1 -1
  29. package/packages/mcp/dist/hybrid-client.d.ts.map +1 -1
  30. package/packages/mcp/dist/hybrid-client.js +7 -1
  31. package/packages/mcp/dist/hybrid-client.js.map +1 -1
  32. package/packages/mcp/package.json +4 -3
  33. package/packages/mcp/src/cloud.ts +29 -511
  34. package/packages/mcp/src/errors.ts +12 -49
  35. package/packages/mcp/src/hybrid-client.ts +8 -1
  36. package/packages/mcp/tests/discover.test.ts +72 -11
  37. package/packages/memory/package.json +2 -2
  38. package/packages/policy/package.json +2 -2
  39. package/packages/protocol/dist/types.d.ts +17 -1
  40. package/packages/protocol/dist/types.d.ts.map +1 -1
  41. package/packages/protocol/package.json +1 -1
  42. package/packages/protocol/src/types.ts +23 -0
  43. package/packages/resiliency/package.json +1 -1
  44. package/packages/sdk/dist/browser-client.d.ts +212 -0
  45. package/packages/sdk/dist/browser-client.d.ts.map +1 -0
  46. package/packages/sdk/dist/browser-client.js +750 -0
  47. package/packages/sdk/dist/browser-client.js.map +1 -0
  48. package/packages/sdk/dist/browser-framing.d.ts +46 -0
  49. package/packages/sdk/dist/browser-framing.d.ts.map +1 -0
  50. package/packages/sdk/dist/browser-framing.js +122 -0
  51. package/packages/sdk/dist/browser-framing.js.map +1 -0
  52. package/packages/sdk/dist/client.d.ts +129 -2
  53. package/packages/sdk/dist/client.d.ts.map +1 -1
  54. package/packages/sdk/dist/client.js +312 -2
  55. package/packages/sdk/dist/client.js.map +1 -1
  56. package/packages/sdk/dist/discovery.d.ts +10 -0
  57. package/packages/sdk/dist/discovery.d.ts.map +1 -0
  58. package/packages/sdk/dist/discovery.js +22 -0
  59. package/packages/sdk/dist/discovery.js.map +1 -0
  60. package/packages/sdk/dist/errors.d.ts +9 -0
  61. package/packages/sdk/dist/errors.d.ts.map +1 -0
  62. package/packages/sdk/dist/errors.js +9 -0
  63. package/packages/sdk/dist/errors.js.map +1 -0
  64. package/packages/sdk/dist/index.d.ts +18 -2
  65. package/packages/sdk/dist/index.d.ts.map +1 -1
  66. package/packages/sdk/dist/index.js +27 -1
  67. package/packages/sdk/dist/index.js.map +1 -1
  68. package/packages/sdk/dist/transports/index.d.ts +92 -0
  69. package/packages/sdk/dist/transports/index.d.ts.map +1 -0
  70. package/packages/sdk/dist/transports/index.js +129 -0
  71. package/packages/sdk/dist/transports/index.js.map +1 -0
  72. package/packages/sdk/dist/transports/socket-transport.d.ts +30 -0
  73. package/packages/sdk/dist/transports/socket-transport.d.ts.map +1 -0
  74. package/packages/sdk/dist/transports/socket-transport.js +94 -0
  75. package/packages/sdk/dist/transports/socket-transport.js.map +1 -0
  76. package/packages/sdk/dist/transports/types.d.ts +69 -0
  77. package/packages/sdk/dist/transports/types.d.ts.map +1 -0
  78. package/packages/sdk/dist/transports/types.js +10 -0
  79. package/packages/sdk/dist/transports/types.js.map +1 -0
  80. package/packages/sdk/dist/transports/websocket-transport.d.ts +55 -0
  81. package/packages/sdk/dist/transports/websocket-transport.d.ts.map +1 -0
  82. package/packages/sdk/dist/transports/websocket-transport.js +180 -0
  83. package/packages/sdk/dist/transports/websocket-transport.js.map +1 -0
  84. package/packages/sdk/package.json +28 -4
  85. package/packages/sdk/src/browser-client.ts +985 -0
  86. package/packages/sdk/src/browser-framing.test.ts +115 -0
  87. package/packages/sdk/src/browser-framing.ts +150 -0
  88. package/packages/sdk/src/client.test.ts +425 -0
  89. package/packages/sdk/src/client.ts +397 -3
  90. package/packages/sdk/src/discovery.ts +38 -0
  91. package/packages/sdk/src/errors.ts +17 -0
  92. package/packages/sdk/src/index.ts +82 -1
  93. package/packages/sdk/src/transports/index.ts +197 -0
  94. package/packages/sdk/src/transports/socket-transport.ts +115 -0
  95. package/packages/sdk/src/transports/types.ts +77 -0
  96. package/packages/sdk/src/transports/websocket-transport.ts +245 -0
  97. package/packages/sdk/tsconfig.json +1 -1
  98. package/packages/spawner/package.json +1 -1
  99. package/packages/state/package.json +1 -1
  100. package/packages/storage/package.json +2 -2
  101. package/packages/storage/src/jsonl-adapter.test.ts +8 -3
  102. package/packages/telemetry/package.json +1 -1
  103. package/packages/trajectory/package.json +2 -2
  104. package/packages/user-directory/package.json +2 -2
  105. package/packages/utils/dist/cjs/discovery.js +328 -0
  106. package/packages/utils/dist/cjs/errors.js +81 -0
  107. package/packages/utils/dist/discovery.d.ts +123 -0
  108. package/packages/utils/dist/discovery.d.ts.map +1 -0
  109. package/packages/utils/dist/discovery.js +439 -0
  110. package/packages/utils/dist/discovery.js.map +1 -0
  111. package/packages/utils/dist/errors.d.ts +29 -0
  112. package/packages/utils/dist/errors.d.ts.map +1 -0
  113. package/packages/utils/dist/errors.js +50 -0
  114. package/packages/utils/dist/errors.js.map +1 -0
  115. package/packages/utils/package.json +15 -2
  116. package/packages/utils/src/consolidation.test.ts +125 -0
  117. package/packages/utils/src/discovery.test.ts +196 -0
  118. package/packages/utils/src/discovery.ts +524 -0
  119. package/packages/utils/src/errors.test.ts +83 -0
  120. package/packages/utils/src/errors.ts +56 -0
  121. package/packages/wrapper/dist/opencode-wrapper.d.ts +6 -2
  122. package/packages/wrapper/dist/opencode-wrapper.d.ts.map +1 -1
  123. package/packages/wrapper/dist/opencode-wrapper.js +34 -10
  124. package/packages/wrapper/dist/opencode-wrapper.js.map +1 -1
  125. package/packages/wrapper/dist/relay-pty-orchestrator.d.ts +22 -2
  126. package/packages/wrapper/dist/relay-pty-orchestrator.d.ts.map +1 -1
  127. package/packages/wrapper/dist/relay-pty-orchestrator.js +174 -4
  128. package/packages/wrapper/dist/relay-pty-orchestrator.js.map +1 -1
  129. package/packages/wrapper/package.json +6 -6
  130. package/packages/wrapper/src/opencode-wrapper.ts +37 -9
  131. package/packages/wrapper/src/relay-pty-orchestrator.ts +197 -4
  132. package/relay-snippets/agent-relay-snippet.md +17 -5
@@ -0,0 +1,115 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { encodeFrameLegacyBrowser, BrowserFrameParser } from './browser-framing.js';
3
+
4
+ describe('browser-framing', () => {
5
+ describe('encodeFrameLegacyBrowser', () => {
6
+ it('encodes envelope with 4-byte length header', () => {
7
+ const envelope = { type: 'PING', v: 1, id: 'test', ts: 12345, payload: {} };
8
+ const frame = encodeFrameLegacyBrowser(envelope);
9
+
10
+ // Should be Uint8Array
11
+ expect(frame).toBeInstanceOf(Uint8Array);
12
+
13
+ // First 4 bytes should be the length in big-endian
14
+ const view = new DataView(frame.buffer);
15
+ const length = view.getUint32(0, false);
16
+
17
+ // Payload should match the encoded JSON length
18
+ const json = JSON.stringify(envelope);
19
+ expect(length).toBe(new TextEncoder().encode(json).length);
20
+ });
21
+
22
+ it('throws on oversized frames', () => {
23
+ const largePayload = 'x'.repeat(1024 * 1024 + 1);
24
+ const envelope = { type: 'SEND', v: 1, id: 'test', ts: 12345, payload: { body: largePayload } };
25
+
26
+ expect(() => encodeFrameLegacyBrowser(envelope)).toThrow(/Frame too large/);
27
+ });
28
+ });
29
+
30
+ describe('BrowserFrameParser', () => {
31
+ it('parses complete frame', () => {
32
+ const envelope = { type: 'PONG', v: 1, id: 'abc', ts: 999, payload: {} };
33
+ const frame = encodeFrameLegacyBrowser(envelope);
34
+
35
+ const parser = new BrowserFrameParser();
36
+ const parsed = parser.push(frame);
37
+
38
+ expect(parsed).toHaveLength(1);
39
+ expect(parsed[0]).toEqual(envelope);
40
+ });
41
+
42
+ it('handles partial frames', () => {
43
+ const envelope = { type: 'SEND', v: 1, id: 'xyz', ts: 1000, payload: { to: 'Agent', body: 'Hello' } };
44
+ const frame = encodeFrameLegacyBrowser(envelope);
45
+
46
+ const parser = new BrowserFrameParser();
47
+
48
+ // Send first half
49
+ const half = Math.floor(frame.length / 2);
50
+ let parsed = parser.push(frame.subarray(0, half));
51
+ expect(parsed).toHaveLength(0);
52
+
53
+ // Send second half
54
+ parsed = parser.push(frame.subarray(half));
55
+ expect(parsed).toHaveLength(1);
56
+ expect(parsed[0]).toEqual(envelope);
57
+ });
58
+
59
+ it('parses multiple frames in sequence', () => {
60
+ const envelopes = [
61
+ { type: 'PING', v: 1, id: '1', ts: 1, payload: {} },
62
+ { type: 'PONG', v: 1, id: '2', ts: 2, payload: {} },
63
+ { type: 'ACK', v: 1, id: '3', ts: 3, payload: { messageId: 'x' } },
64
+ ];
65
+
66
+ const frames = envelopes.map(e => encodeFrameLegacyBrowser(e));
67
+ const combined = new Uint8Array(frames.reduce((sum, f) => sum + f.length, 0));
68
+ let offset = 0;
69
+ for (const frame of frames) {
70
+ combined.set(frame, offset);
71
+ offset += frame.length;
72
+ }
73
+
74
+ const parser = new BrowserFrameParser();
75
+ const parsed = parser.push(combined);
76
+
77
+ expect(parsed).toHaveLength(3);
78
+ expect(parsed).toEqual(envelopes);
79
+ });
80
+
81
+ it('tracks pending bytes correctly', () => {
82
+ const parser = new BrowserFrameParser();
83
+ expect(parser.pendingBytes).toBe(0);
84
+
85
+ // Push partial header
86
+ parser.push(new Uint8Array([0, 0, 0, 10])); // Header claiming 10 byte payload
87
+ expect(parser.pendingBytes).toBe(4);
88
+
89
+ // Push partial payload
90
+ parser.push(new Uint8Array([123])); // Just '{'
91
+ expect(parser.pendingBytes).toBe(5);
92
+ });
93
+
94
+ it('throws on oversized frame', () => {
95
+ const parser = new BrowserFrameParser();
96
+
97
+ // Create header claiming 2MB payload
98
+ const header = new Uint8Array(4);
99
+ const view = new DataView(header.buffer);
100
+ view.setUint32(0, 2 * 1024 * 1024, false);
101
+
102
+ expect(() => parser.push(header)).toThrow(/Frame too large/);
103
+ });
104
+
105
+ it('resets parser state', () => {
106
+ const parser = new BrowserFrameParser();
107
+ parser.push(new Uint8Array([0, 0, 0, 5, 123])); // Partial frame
108
+
109
+ expect(parser.pendingBytes).toBeGreaterThan(0);
110
+
111
+ parser.reset();
112
+ expect(parser.pendingBytes).toBe(0);
113
+ });
114
+ });
115
+ });
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Browser-compatible frame encoding/decoding for the Agent Relay protocol.
3
+ *
4
+ * Uses Uint8Array and DataView instead of Node.js Buffer for browser compatibility.
5
+ *
6
+ * Wire format (legacy):
7
+ * - 4 bytes: big-endian payload length
8
+ * - N bytes: JSON payload
9
+ */
10
+
11
+ import type { Envelope } from '@agent-relay/protocol';
12
+
13
+ export const MAX_FRAME_BYTES = 1024 * 1024; // 1 MiB
14
+ export const LEGACY_HEADER_SIZE = 4;
15
+
16
+ const textEncoder = new TextEncoder();
17
+ const textDecoder = new TextDecoder();
18
+
19
+ /**
20
+ * Encode a frame in legacy format (4-byte header, JSON only).
21
+ * Browser-compatible version using Uint8Array.
22
+ */
23
+ export function encodeFrameLegacyBrowser(envelope: Envelope): Uint8Array {
24
+ const json = JSON.stringify(envelope);
25
+ const data = textEncoder.encode(json);
26
+
27
+ if (data.length > MAX_FRAME_BYTES) {
28
+ throw new Error(`Frame too large: ${data.length} > ${MAX_FRAME_BYTES}`);
29
+ }
30
+
31
+ const frame = new Uint8Array(LEGACY_HEADER_SIZE + data.length);
32
+ const view = new DataView(frame.buffer);
33
+
34
+ // Write 4-byte big-endian length header
35
+ view.setUint32(0, data.length, false);
36
+
37
+ // Copy payload
38
+ frame.set(data, LEGACY_HEADER_SIZE);
39
+
40
+ return frame;
41
+ }
42
+
43
+ /**
44
+ * Browser-compatible frame parser using Uint8Array and DataView.
45
+ */
46
+ export class BrowserFrameParser {
47
+ private buffer: Uint8Array;
48
+ private head = 0;
49
+ private tail = 0;
50
+ private readonly capacity: number;
51
+ private readonly maxFrameBytes: number;
52
+
53
+ constructor(maxFrameBytes: number = MAX_FRAME_BYTES) {
54
+ this.maxFrameBytes = maxFrameBytes;
55
+ this.capacity = maxFrameBytes * 2 + LEGACY_HEADER_SIZE;
56
+ this.buffer = new Uint8Array(this.capacity);
57
+ }
58
+
59
+ /**
60
+ * Get current unread bytes in buffer.
61
+ */
62
+ get pendingBytes(): number {
63
+ return this.tail - this.head;
64
+ }
65
+
66
+ /**
67
+ * Push data into the parser and extract complete frames.
68
+ *
69
+ * @param data - Incoming data as Uint8Array
70
+ * @returns Array of parsed envelope frames
71
+ */
72
+ push(data: Uint8Array): Envelope[] {
73
+ const spaceAtEnd = this.capacity - this.tail;
74
+
75
+ if (data.length > spaceAtEnd) {
76
+ this.compact();
77
+
78
+ if (data.length > this.capacity - this.tail) {
79
+ throw new Error(`Buffer overflow: data ${data.length} exceeds capacity`);
80
+ }
81
+ }
82
+
83
+ // Copy incoming data to buffer
84
+ this.buffer.set(data, this.tail);
85
+ this.tail += data.length;
86
+
87
+ return this.extractFrames();
88
+ }
89
+
90
+ private extractFrames(): Envelope[] {
91
+ const frames: Envelope[] = [];
92
+ const view = new DataView(this.buffer.buffer);
93
+
94
+ while (this.pendingBytes >= LEGACY_HEADER_SIZE) {
95
+ // Read 4-byte big-endian length
96
+ const frameLength = view.getUint32(this.head, false);
97
+
98
+ if (frameLength > this.maxFrameBytes) {
99
+ throw new Error(`Frame too large: ${frameLength} > ${this.maxFrameBytes}`);
100
+ }
101
+
102
+ const totalLength = LEGACY_HEADER_SIZE + frameLength;
103
+
104
+ if (this.pendingBytes < totalLength) {
105
+ break;
106
+ }
107
+
108
+ const payloadStart = this.head + LEGACY_HEADER_SIZE;
109
+ const payloadEnd = this.head + totalLength;
110
+
111
+ let envelope: Envelope;
112
+ try {
113
+ const payload = this.buffer.subarray(payloadStart, payloadEnd);
114
+ const json = textDecoder.decode(payload);
115
+ envelope = JSON.parse(json) as Envelope;
116
+ } catch (err) {
117
+ throw new Error(`Invalid frame payload: ${err}`);
118
+ }
119
+
120
+ this.head += totalLength;
121
+ frames.push(envelope);
122
+ }
123
+
124
+ if (this.head > this.capacity / 2 && this.pendingBytes < this.capacity / 4) {
125
+ this.compact();
126
+ }
127
+
128
+ return frames;
129
+ }
130
+
131
+ private compact(): void {
132
+ if (this.head === 0) return;
133
+
134
+ const pending = this.pendingBytes;
135
+ if (pending > 0) {
136
+ // Copy remaining data to start of buffer
137
+ this.buffer.copyWithin(0, this.head, this.tail);
138
+ }
139
+ this.tail = pending;
140
+ this.head = 0;
141
+ }
142
+
143
+ /**
144
+ * Reset the parser state.
145
+ */
146
+ reset(): void {
147
+ this.head = 0;
148
+ this.tail = 0;
149
+ }
150
+ }
@@ -10,6 +10,8 @@ import type {
10
10
  HealthResponsePayload,
11
11
  MetricsResponsePayload,
12
12
  InboxResponsePayload,
13
+ AgentReadyPayload,
14
+ SpawnResultPayload,
13
15
  } from '@agent-relay/protocol';
14
16
  import { RelayClient } from './client.js';
15
17
 
@@ -256,6 +258,208 @@ describe('RelayClient', () => {
256
258
  });
257
259
  });
258
260
 
261
+ describe('request', () => {
262
+ it('resolves when matching response arrives via payload_meta.replyTo', async () => {
263
+ const client = new RelayClient({ reconnect: false, quiet: true });
264
+ (client as any)._state = 'READY';
265
+ const sendMock = vi.fn().mockReturnValue(true);
266
+ (client as any).send = sendMock;
267
+
268
+ const promise = client.request('Worker', 'Do task', { timeout: 1000 });
269
+ const sentEnvelope = sendMock.mock.calls[0][0];
270
+ const correlationId = sentEnvelope.payload.data._correlationId;
271
+
272
+ // Simulate response from Worker
273
+ const responseEnvelope: DeliverEnvelope = {
274
+ v: 1,
275
+ type: 'DELIVER',
276
+ id: 'response-1',
277
+ ts: Date.now(),
278
+ from: 'Worker',
279
+ payload: {
280
+ kind: 'message',
281
+ body: 'Task completed',
282
+ data: { result: 'success' },
283
+ },
284
+ payload_meta: {
285
+ replyTo: correlationId,
286
+ },
287
+ delivery: {
288
+ seq: 1,
289
+ session_id: 'session-1',
290
+ },
291
+ };
292
+
293
+ (client as any).processFrame(responseEnvelope);
294
+
295
+ const result = await promise;
296
+ expect(result.from).toBe('Worker');
297
+ expect(result.body).toBe('Task completed');
298
+ expect(result.data?.result).toBe('success');
299
+ expect(result.correlationId).toBe(correlationId);
300
+ });
301
+
302
+ it('resolves when matching response arrives via data._correlationId', async () => {
303
+ const client = new RelayClient({ reconnect: false, quiet: true });
304
+ (client as any)._state = 'READY';
305
+ const sendMock = vi.fn().mockReturnValue(true);
306
+ (client as any).send = sendMock;
307
+
308
+ const promise = client.request('Worker', 'Do task', { timeout: 1000 });
309
+ const sentEnvelope = sendMock.mock.calls[0][0];
310
+ const correlationId = sentEnvelope.payload.data._correlationId;
311
+
312
+ // Simulate response from Worker using data._correlationId
313
+ const responseEnvelope: DeliverEnvelope = {
314
+ v: 1,
315
+ type: 'DELIVER',
316
+ id: 'response-2',
317
+ ts: Date.now(),
318
+ from: 'Worker',
319
+ payload: {
320
+ kind: 'message',
321
+ body: 'Done!',
322
+ data: { _correlationId: correlationId, _isResponse: true },
323
+ },
324
+ delivery: {
325
+ seq: 2,
326
+ session_id: 'session-1',
327
+ },
328
+ };
329
+
330
+ (client as any).processFrame(responseEnvelope);
331
+
332
+ const result = await promise;
333
+ expect(result.from).toBe('Worker');
334
+ expect(result.body).toBe('Done!');
335
+ expect(result.correlationId).toBe(correlationId);
336
+ });
337
+
338
+ it('rejects on timeout', async () => {
339
+ vi.useFakeTimers();
340
+ try {
341
+ const client = new RelayClient({ reconnect: false, quiet: true });
342
+ (client as any)._state = 'READY';
343
+ const sendMock = vi.fn().mockReturnValue(true);
344
+ (client as any).send = sendMock;
345
+
346
+ const promise = client.request('Worker', 'Do task', { timeout: 50 });
347
+ const rejection = expect(promise).rejects.toThrow('Request timeout after 50ms waiting for response from Worker');
348
+ await vi.advanceTimersByTimeAsync(60);
349
+
350
+ await rejection;
351
+ } finally {
352
+ vi.useRealTimers();
353
+ }
354
+ });
355
+
356
+ it('rejects when not ready', async () => {
357
+ const client = new RelayClient({ reconnect: false });
358
+ await expect(client.request('Worker', 'Do task')).rejects.toThrow('Client not ready');
359
+ });
360
+
361
+ it('rejects when send fails', async () => {
362
+ const client = new RelayClient({ reconnect: false, quiet: true });
363
+ (client as any)._state = 'READY';
364
+ const sendMock = vi.fn().mockReturnValue(false);
365
+ (client as any).send = sendMock;
366
+
367
+ await expect(client.request('Worker', 'Do task')).rejects.toThrow('Failed to send request');
368
+ });
369
+
370
+ it('includes custom data and thread in the sent message', async () => {
371
+ const client = new RelayClient({ reconnect: false, quiet: true });
372
+ (client as any)._state = 'READY';
373
+ const sendMock = vi.fn().mockReturnValue(true);
374
+ (client as any).send = sendMock;
375
+
376
+ // Don't await - we just want to check what was sent
377
+ client.request('Worker', 'Do task', {
378
+ timeout: 1000,
379
+ data: { taskId: '123', priority: 'high' },
380
+ thread: 'task-thread-1',
381
+ }).catch(() => {}); // Ignore timeout
382
+
383
+ const sentEnvelope = sendMock.mock.calls[0][0];
384
+ expect(sentEnvelope.to).toBe('Worker');
385
+ expect(sentEnvelope.payload.body).toBe('Do task');
386
+ expect(sentEnvelope.payload.data.taskId).toBe('123');
387
+ expect(sentEnvelope.payload.data.priority).toBe('high');
388
+ expect(sentEnvelope.payload.data._correlationId).toBeDefined();
389
+ expect(sentEnvelope.payload.thread).toBe('task-thread-1');
390
+ expect(sentEnvelope.payload_meta.replyTo).toBe(sentEnvelope.payload.data._correlationId);
391
+ });
392
+
393
+ it('still calls onMessage after resolving request', async () => {
394
+ const client = new RelayClient({ reconnect: false, quiet: true });
395
+ (client as any)._state = 'READY';
396
+ const sendMock = vi.fn().mockReturnValue(true);
397
+ (client as any).send = sendMock;
398
+
399
+ const messages: any[] = [];
400
+ client.onMessage = (from, payload) => messages.push({ from, payload });
401
+
402
+ const promise = client.request('Worker', 'Do task', { timeout: 1000 });
403
+ const sentEnvelope = sendMock.mock.calls[0][0];
404
+ const correlationId = sentEnvelope.payload.data._correlationId;
405
+
406
+ const responseEnvelope: DeliverEnvelope = {
407
+ v: 1,
408
+ type: 'DELIVER',
409
+ id: 'response-3',
410
+ ts: Date.now(),
411
+ from: 'Worker',
412
+ payload: {
413
+ kind: 'message',
414
+ body: 'Task completed',
415
+ },
416
+ payload_meta: {
417
+ replyTo: correlationId,
418
+ },
419
+ delivery: {
420
+ seq: 3,
421
+ session_id: 'session-1',
422
+ },
423
+ };
424
+
425
+ (client as any).processFrame(responseEnvelope);
426
+
427
+ await promise;
428
+
429
+ // onMessage should still be called
430
+ expect(messages).toHaveLength(1);
431
+ expect(messages[0].from).toBe('Worker');
432
+ expect(messages[0].payload.body).toBe('Task completed');
433
+ });
434
+ });
435
+
436
+ describe('respond', () => {
437
+ it('returns false when not connected', () => {
438
+ const client = new RelayClient({ reconnect: false });
439
+ const result = client.respond('corr-123', 'Alice', 'Done');
440
+ expect(result).toBe(false);
441
+ });
442
+
443
+ it('sends response with correlation ID', () => {
444
+ const client = new RelayClient({ reconnect: false, quiet: true });
445
+ (client as any)._state = 'READY';
446
+ const sendMock = vi.fn().mockReturnValue(true);
447
+ (client as any).send = sendMock;
448
+
449
+ const result = client.respond('corr-123', 'Alice', 'Task completed', { result: 'success' });
450
+
451
+ expect(result).toBe(true);
452
+ const sentEnvelope = sendMock.mock.calls[0][0];
453
+ expect(sentEnvelope.type).toBe('SEND');
454
+ expect(sentEnvelope.to).toBe('Alice');
455
+ expect(sentEnvelope.payload.body).toBe('Task completed');
456
+ expect(sentEnvelope.payload.data._correlationId).toBe('corr-123');
457
+ expect(sentEnvelope.payload.data._isResponse).toBe(true);
458
+ expect(sentEnvelope.payload.data.result).toBe('success');
459
+ expect(sentEnvelope.payload_meta.replyTo).toBe('corr-123');
460
+ });
461
+ });
462
+
259
463
  describe('channel operations', () => {
260
464
  it('should return false for joinChannel when not connected', () => {
261
465
  const client = new RelayClient({ reconnect: false });
@@ -482,6 +686,227 @@ describe('RelayClient', () => {
482
686
  });
483
687
  });
484
688
 
689
+ describe('agent ready', () => {
690
+ it('should call onAgentReady when AGENT_READY received', () => {
691
+ const client = new RelayClient({ reconnect: false, quiet: true });
692
+ const readyEvents: AgentReadyPayload[] = [];
693
+ client.onAgentReady = (info) => readyEvents.push(info);
694
+
695
+ const agentReadyEnvelope: Envelope<AgentReadyPayload> = {
696
+ v: 1,
697
+ type: 'AGENT_READY',
698
+ id: 'ready-1',
699
+ ts: Date.now(),
700
+ payload: {
701
+ name: 'Worker',
702
+ cli: 'claude',
703
+ task: 'Do something',
704
+ connectedAt: Date.now(),
705
+ },
706
+ };
707
+
708
+ (client as any).processFrame(agentReadyEnvelope);
709
+
710
+ expect(readyEvents).toHaveLength(1);
711
+ expect(readyEvents[0].name).toBe('Worker');
712
+ expect(readyEvents[0].cli).toBe('claude');
713
+ });
714
+
715
+ it('resolves waitForAgentReady when AGENT_READY arrives', async () => {
716
+ const client = new RelayClient({ reconnect: false, quiet: true });
717
+ (client as any)._state = 'READY';
718
+
719
+ const promise = client.waitForAgentReady('Worker', 1000);
720
+
721
+ const agentReadyEnvelope: Envelope<AgentReadyPayload> = {
722
+ v: 1,
723
+ type: 'AGENT_READY',
724
+ id: 'ready-2',
725
+ ts: Date.now(),
726
+ payload: {
727
+ name: 'Worker',
728
+ cli: 'codex',
729
+ connectedAt: Date.now(),
730
+ },
731
+ };
732
+
733
+ (client as any).processFrame(agentReadyEnvelope);
734
+
735
+ const result = await promise;
736
+ expect(result.name).toBe('Worker');
737
+ expect(result.cli).toBe('codex');
738
+ });
739
+
740
+ it('rejects waitForAgentReady on timeout', async () => {
741
+ vi.useFakeTimers();
742
+ try {
743
+ const client = new RelayClient({ reconnect: false, quiet: true });
744
+ (client as any)._state = 'READY';
745
+
746
+ const promise = client.waitForAgentReady('Worker', 50);
747
+ const rejection = expect(promise).rejects.toThrow('Agent Worker did not become ready within 50ms');
748
+ await vi.advanceTimersByTimeAsync(60);
749
+
750
+ await rejection;
751
+ } finally {
752
+ vi.useRealTimers();
753
+ }
754
+ });
755
+
756
+ it('rejects waitForAgentReady when not ready', async () => {
757
+ const client = new RelayClient({ reconnect: false });
758
+ await expect(client.waitForAgentReady('Worker')).rejects.toThrow('Client not ready');
759
+ });
760
+
761
+ it('rejects waitForAgentReady when already waiting', async () => {
762
+ const client = new RelayClient({ reconnect: false, quiet: true });
763
+ (client as any)._state = 'READY';
764
+
765
+ // Start waiting
766
+ client.waitForAgentReady('Worker', 10000).catch(() => {});
767
+
768
+ // Try to wait again - should reject
769
+ await expect(client.waitForAgentReady('Worker')).rejects.toThrow('Already waiting for agent Worker');
770
+ });
771
+
772
+ it('spawn with waitForReady resolves with ready info', async () => {
773
+ const client = new RelayClient({ reconnect: false, quiet: true });
774
+ (client as any)._state = 'READY';
775
+ const sendMock = vi.fn().mockReturnValue(true);
776
+ (client as any).send = sendMock;
777
+
778
+ const spawnPromise = client.spawn(
779
+ {
780
+ name: 'Worker',
781
+ cli: 'claude',
782
+ task: 'Do work',
783
+ waitForReady: true,
784
+ readyTimeoutMs: 5000,
785
+ },
786
+ 1000
787
+ );
788
+
789
+ // First, SPAWN_RESULT arrives
790
+ const sentEnvelope = sendMock.mock.calls[0][0];
791
+ const spawnResultEnvelope: Envelope<SpawnResultPayload> = {
792
+ v: 1,
793
+ type: 'SPAWN_RESULT',
794
+ id: 'spawn-result-1',
795
+ ts: Date.now(),
796
+ payload: {
797
+ replyTo: sentEnvelope.id,
798
+ success: true,
799
+ name: 'Worker',
800
+ pid: 12345,
801
+ },
802
+ };
803
+ (client as any).processFrame(spawnResultEnvelope);
804
+
805
+ // Then, AGENT_READY arrives
806
+ const agentReadyEnvelope: Envelope<AgentReadyPayload> = {
807
+ v: 1,
808
+ type: 'AGENT_READY',
809
+ id: 'ready-3',
810
+ ts: Date.now(),
811
+ payload: {
812
+ name: 'Worker',
813
+ cli: 'claude',
814
+ task: 'Do work',
815
+ connectedAt: Date.now(),
816
+ },
817
+ };
818
+ (client as any).processFrame(agentReadyEnvelope);
819
+
820
+ const result = await spawnPromise;
821
+ expect(result.success).toBe(true);
822
+ expect(result.ready).toBe(true);
823
+ expect(result.readyInfo?.name).toBe('Worker');
824
+ expect(result.readyInfo?.cli).toBe('claude');
825
+ });
826
+
827
+ it('spawn with waitForReady returns ready:false on timeout', async () => {
828
+ vi.useFakeTimers();
829
+ try {
830
+ const client = new RelayClient({ reconnect: false, quiet: true });
831
+ (client as any)._state = 'READY';
832
+ const sendMock = vi.fn().mockReturnValue(true);
833
+ (client as any).send = sendMock;
834
+
835
+ const spawnPromise = client.spawn(
836
+ {
837
+ name: 'Worker',
838
+ cli: 'claude',
839
+ waitForReady: true,
840
+ readyTimeoutMs: 100,
841
+ },
842
+ 500
843
+ );
844
+
845
+ // SPAWN_RESULT arrives
846
+ const sentEnvelope = sendMock.mock.calls[0][0];
847
+ const spawnResultEnvelope: Envelope<SpawnResultPayload> = {
848
+ v: 1,
849
+ type: 'SPAWN_RESULT',
850
+ id: 'spawn-result-2',
851
+ ts: Date.now(),
852
+ payload: {
853
+ replyTo: sentEnvelope.id,
854
+ success: true,
855
+ name: 'Worker',
856
+ pid: 12346,
857
+ },
858
+ };
859
+ (client as any).processFrame(spawnResultEnvelope);
860
+
861
+ // Agent ready timeout expires
862
+ await vi.advanceTimersByTimeAsync(150);
863
+
864
+ const result = await spawnPromise;
865
+ expect(result.success).toBe(true);
866
+ expect(result.ready).toBe(false);
867
+ expect(result.readyInfo).toBeUndefined();
868
+ } finally {
869
+ vi.useRealTimers();
870
+ }
871
+ });
872
+
873
+ it('spawn without waitForReady does not wait for AGENT_READY', async () => {
874
+ const client = new RelayClient({ reconnect: false, quiet: true });
875
+ (client as any)._state = 'READY';
876
+ const sendMock = vi.fn().mockReturnValue(true);
877
+ (client as any).send = sendMock;
878
+
879
+ const spawnPromise = client.spawn(
880
+ {
881
+ name: 'Worker',
882
+ cli: 'claude',
883
+ },
884
+ 1000
885
+ );
886
+
887
+ // SPAWN_RESULT arrives
888
+ const sentEnvelope = sendMock.mock.calls[0][0];
889
+ const spawnResultEnvelope: Envelope<SpawnResultPayload> = {
890
+ v: 1,
891
+ type: 'SPAWN_RESULT',
892
+ id: 'spawn-result-3',
893
+ ts: Date.now(),
894
+ payload: {
895
+ replyTo: sentEnvelope.id,
896
+ success: true,
897
+ name: 'Worker',
898
+ pid: 12347,
899
+ },
900
+ };
901
+ (client as any).processFrame(spawnResultEnvelope);
902
+
903
+ const result = await spawnPromise;
904
+ expect(result.success).toBe(true);
905
+ expect(result.ready).toBeUndefined();
906
+ expect(result.readyInfo).toBeUndefined();
907
+ });
908
+ });
909
+
485
910
  describe('consensus operations', () => {
486
911
  it('should return false for createProposal when not connected', () => {
487
912
  const client = new RelayClient({ reconnect: false });