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.
- package/README.md +85 -236
- package/dist/index.cjs +281 -24
- package/package.json +19 -19
- package/packages/api-types/package.json +1 -1
- package/packages/benchmark/package.json +4 -4
- package/packages/bridge/dist/spawner.d.ts.map +1 -1
- package/packages/bridge/dist/spawner.js +39 -5
- package/packages/bridge/dist/spawner.js.map +1 -1
- package/packages/bridge/package.json +8 -8
- package/packages/bridge/src/spawner.ts +40 -5
- package/packages/cli-tester/package.json +1 -1
- package/packages/config/package.json +2 -2
- package/packages/continuity/package.json +2 -2
- package/packages/daemon/dist/server.d.ts +5 -0
- package/packages/daemon/dist/server.d.ts.map +1 -1
- package/packages/daemon/dist/server.js +31 -0
- package/packages/daemon/dist/server.js.map +1 -1
- package/packages/daemon/package.json +12 -12
- package/packages/daemon/src/server.ts +37 -0
- package/packages/hooks/package.json +4 -4
- package/packages/mcp/dist/cloud.d.ts +7 -114
- package/packages/mcp/dist/cloud.d.ts.map +1 -1
- package/packages/mcp/dist/cloud.js +21 -431
- package/packages/mcp/dist/cloud.js.map +1 -1
- package/packages/mcp/dist/errors.d.ts +4 -22
- package/packages/mcp/dist/errors.d.ts.map +1 -1
- package/packages/mcp/dist/errors.js +4 -43
- package/packages/mcp/dist/errors.js.map +1 -1
- package/packages/mcp/dist/hybrid-client.d.ts.map +1 -1
- package/packages/mcp/dist/hybrid-client.js +7 -1
- package/packages/mcp/dist/hybrid-client.js.map +1 -1
- package/packages/mcp/package.json +4 -3
- package/packages/mcp/src/cloud.ts +29 -511
- package/packages/mcp/src/errors.ts +12 -49
- package/packages/mcp/src/hybrid-client.ts +8 -1
- package/packages/mcp/tests/discover.test.ts +72 -11
- package/packages/memory/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/protocol/dist/types.d.ts +17 -1
- package/packages/protocol/dist/types.d.ts.map +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/types.ts +23 -0
- package/packages/resiliency/package.json +1 -1
- package/packages/sdk/dist/browser-client.d.ts +212 -0
- package/packages/sdk/dist/browser-client.d.ts.map +1 -0
- package/packages/sdk/dist/browser-client.js +750 -0
- package/packages/sdk/dist/browser-client.js.map +1 -0
- package/packages/sdk/dist/browser-framing.d.ts +46 -0
- package/packages/sdk/dist/browser-framing.d.ts.map +1 -0
- package/packages/sdk/dist/browser-framing.js +122 -0
- package/packages/sdk/dist/browser-framing.js.map +1 -0
- package/packages/sdk/dist/client.d.ts +129 -2
- package/packages/sdk/dist/client.d.ts.map +1 -1
- package/packages/sdk/dist/client.js +312 -2
- package/packages/sdk/dist/client.js.map +1 -1
- package/packages/sdk/dist/discovery.d.ts +10 -0
- package/packages/sdk/dist/discovery.d.ts.map +1 -0
- package/packages/sdk/dist/discovery.js +22 -0
- package/packages/sdk/dist/discovery.js.map +1 -0
- package/packages/sdk/dist/errors.d.ts +9 -0
- package/packages/sdk/dist/errors.d.ts.map +1 -0
- package/packages/sdk/dist/errors.js +9 -0
- package/packages/sdk/dist/errors.js.map +1 -0
- package/packages/sdk/dist/index.d.ts +18 -2
- package/packages/sdk/dist/index.d.ts.map +1 -1
- package/packages/sdk/dist/index.js +27 -1
- package/packages/sdk/dist/index.js.map +1 -1
- package/packages/sdk/dist/transports/index.d.ts +92 -0
- package/packages/sdk/dist/transports/index.d.ts.map +1 -0
- package/packages/sdk/dist/transports/index.js +129 -0
- package/packages/sdk/dist/transports/index.js.map +1 -0
- package/packages/sdk/dist/transports/socket-transport.d.ts +30 -0
- package/packages/sdk/dist/transports/socket-transport.d.ts.map +1 -0
- package/packages/sdk/dist/transports/socket-transport.js +94 -0
- package/packages/sdk/dist/transports/socket-transport.js.map +1 -0
- package/packages/sdk/dist/transports/types.d.ts +69 -0
- package/packages/sdk/dist/transports/types.d.ts.map +1 -0
- package/packages/sdk/dist/transports/types.js +10 -0
- package/packages/sdk/dist/transports/types.js.map +1 -0
- package/packages/sdk/dist/transports/websocket-transport.d.ts +55 -0
- package/packages/sdk/dist/transports/websocket-transport.d.ts.map +1 -0
- package/packages/sdk/dist/transports/websocket-transport.js +180 -0
- package/packages/sdk/dist/transports/websocket-transport.js.map +1 -0
- package/packages/sdk/package.json +28 -4
- package/packages/sdk/src/browser-client.ts +985 -0
- package/packages/sdk/src/browser-framing.test.ts +115 -0
- package/packages/sdk/src/browser-framing.ts +150 -0
- package/packages/sdk/src/client.test.ts +425 -0
- package/packages/sdk/src/client.ts +397 -3
- package/packages/sdk/src/discovery.ts +38 -0
- package/packages/sdk/src/errors.ts +17 -0
- package/packages/sdk/src/index.ts +82 -1
- package/packages/sdk/src/transports/index.ts +197 -0
- package/packages/sdk/src/transports/socket-transport.ts +115 -0
- package/packages/sdk/src/transports/types.ts +77 -0
- package/packages/sdk/src/transports/websocket-transport.ts +245 -0
- package/packages/sdk/tsconfig.json +1 -1
- package/packages/spawner/package.json +1 -1
- package/packages/state/package.json +1 -1
- package/packages/storage/package.json +2 -2
- package/packages/storage/src/jsonl-adapter.test.ts +8 -3
- 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/dist/cjs/discovery.js +328 -0
- package/packages/utils/dist/cjs/errors.js +81 -0
- package/packages/utils/dist/discovery.d.ts +123 -0
- package/packages/utils/dist/discovery.d.ts.map +1 -0
- package/packages/utils/dist/discovery.js +439 -0
- package/packages/utils/dist/discovery.js.map +1 -0
- package/packages/utils/dist/errors.d.ts +29 -0
- package/packages/utils/dist/errors.d.ts.map +1 -0
- package/packages/utils/dist/errors.js +50 -0
- package/packages/utils/dist/errors.js.map +1 -0
- package/packages/utils/package.json +15 -2
- package/packages/utils/src/consolidation.test.ts +125 -0
- package/packages/utils/src/discovery.test.ts +196 -0
- package/packages/utils/src/discovery.ts +524 -0
- package/packages/utils/src/errors.test.ts +83 -0
- package/packages/utils/src/errors.ts +56 -0
- package/packages/wrapper/dist/opencode-wrapper.d.ts +6 -2
- package/packages/wrapper/dist/opencode-wrapper.d.ts.map +1 -1
- package/packages/wrapper/dist/opencode-wrapper.js +34 -10
- package/packages/wrapper/dist/opencode-wrapper.js.map +1 -1
- package/packages/wrapper/dist/relay-pty-orchestrator.d.ts +22 -2
- package/packages/wrapper/dist/relay-pty-orchestrator.d.ts.map +1 -1
- package/packages/wrapper/dist/relay-pty-orchestrator.js +174 -4
- package/packages/wrapper/dist/relay-pty-orchestrator.js.map +1 -1
- package/packages/wrapper/package.json +6 -6
- package/packages/wrapper/src/opencode-wrapper.ts +37 -9
- package/packages/wrapper/src/relay-pty-orchestrator.ts +197 -4
- 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 });
|