@robota-sdk/agent-transport-ws 3.0.0-beta.78 → 3.0.0-beta.81
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 +14 -4
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +161 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +124 -175
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +36 -17
- package/src/__tests__/outbound-delivery-carrier.test.ts +195 -0
- package/src/__tests__/payload-channels.test.ts +233 -0
- package/src/__tests__/ws-payload-channel.e2e.test.ts +255 -0
- package/src/__tests__/ws-session-delivery.test.ts +86 -0
- package/src/__tests__/ws-transport-auth.test.ts +220 -0
- package/src/__tests__/ws-transport-lifecycle.test.ts +135 -0
- package/src/__tests__/ws-transport.test.ts +69 -5
- package/src/index.ts +5 -5
- package/src/payload-channels.ts +204 -0
- package/src/ws-connection-guards.ts +104 -0
- package/src/ws-message-data.ts +8 -0
- package/src/ws-session-delivery.ts +109 -0
- package/src/ws-transport-config.ts +114 -0
- package/src/ws-transport-configurable.ts +217 -40
- package/src/ws-transport.ts +60 -12
- package/src/__tests__/ws-handler.test.ts +0 -429
- package/src/ws-background-messages.ts +0 -170
- package/src/ws-handler.ts +0 -287
- package/src/ws-protocol.ts +0 -78
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeChannelFrame,
|
|
3
|
+
encodeBinaryFrame,
|
|
4
|
+
encodeChannelEventFrame,
|
|
5
|
+
} from '@robota-sdk/agent-transport';
|
|
6
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { PayloadChannelRegistry } from '../payload-channels.js';
|
|
9
|
+
|
|
10
|
+
import type { IBinaryFrame } from '@robota-sdk/agent-interface-transport';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* TRANS-001 — consumer-declared channels on the payload-agnostic carrier.
|
|
14
|
+
*
|
|
15
|
+
* The registry is the transport-side half of `IPayloadChannelHost`: consumers declare a channel
|
|
16
|
+
* (its name, its event types, whether it carries opaque bytes) and get a typed handle. Undeclared
|
|
17
|
+
* traffic is an explicit protocol error in BOTH directions — never a silent pass-through.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A remote peer's outbound binary frame, built through the shared codec (never re-implemented here). */
|
|
21
|
+
function peerBinary(channel: string, seq: number, payload: Uint8Array): Uint8Array {
|
|
22
|
+
return encodeBinaryFrame({ kind: 'binary', channel, seq, payload });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A remote peer's outbound event frame — including event names the local channel never declared. */
|
|
26
|
+
function peerEvent(channel: string, seq: number, event: string, payload: unknown): Uint8Array {
|
|
27
|
+
return encodeChannelEventFrame({
|
|
28
|
+
kind: 'event',
|
|
29
|
+
channel,
|
|
30
|
+
seq,
|
|
31
|
+
event,
|
|
32
|
+
payload: payload as never,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('PayloadChannelRegistry (TRANS-001)', () => {
|
|
37
|
+
it('fans a declared event out to every attached sink as an encoded frame', () => {
|
|
38
|
+
const registry = new PayloadChannelRegistry();
|
|
39
|
+
const channel = registry.registerChannel<{ caption: { text: string } }>({
|
|
40
|
+
name: 'captions',
|
|
41
|
+
events: ['caption'],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const sinkA: Uint8Array[] = [];
|
|
45
|
+
const sinkB: Uint8Array[] = [];
|
|
46
|
+
registry.addSink((b) => sinkA.push(b));
|
|
47
|
+
registry.addSink((b) => sinkB.push(b));
|
|
48
|
+
|
|
49
|
+
channel.sendEvent('caption', { text: 'hi' });
|
|
50
|
+
|
|
51
|
+
expect(sinkA).toHaveLength(1);
|
|
52
|
+
expect(sinkB).toHaveLength(1);
|
|
53
|
+
const decoded = decodeChannelFrame(sinkA[0]!);
|
|
54
|
+
expect(decoded.ok).toBe(true);
|
|
55
|
+
if (!decoded.ok) return;
|
|
56
|
+
expect(decoded.frame.kind).toBe('event');
|
|
57
|
+
expect(decoded.frame.channel).toBe('captions');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('assigns a monotonic per-channel seq across interleaved binary and event frames', () => {
|
|
61
|
+
const registry = new PayloadChannelRegistry();
|
|
62
|
+
const channel = registry.registerChannel<{ mark: { at: number } }>({
|
|
63
|
+
name: 'mixed',
|
|
64
|
+
events: ['mark'],
|
|
65
|
+
binary: true,
|
|
66
|
+
});
|
|
67
|
+
const wire: Uint8Array[] = [];
|
|
68
|
+
registry.addSink((b) => wire.push(b));
|
|
69
|
+
|
|
70
|
+
channel.sendBinary(Uint8Array.from([1]));
|
|
71
|
+
channel.sendEvent('mark', { at: 1 });
|
|
72
|
+
channel.sendBinary(Uint8Array.from([2]));
|
|
73
|
+
|
|
74
|
+
const seqs = wire.map((b) => {
|
|
75
|
+
const d = decodeChannelFrame(b);
|
|
76
|
+
return d.ok ? d.frame.seq : -1;
|
|
77
|
+
});
|
|
78
|
+
expect(seqs).toEqual([0, 1, 2]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps each channel on its own independent seq counter', () => {
|
|
82
|
+
const registry = new PayloadChannelRegistry();
|
|
83
|
+
const a = registry.registerChannel({ name: 'a', events: [], binary: true });
|
|
84
|
+
const b = registry.registerChannel({ name: 'b', events: [], binary: true });
|
|
85
|
+
const wire: Uint8Array[] = [];
|
|
86
|
+
registry.addSink((x) => wire.push(x));
|
|
87
|
+
|
|
88
|
+
a.sendBinary(Uint8Array.from([1]));
|
|
89
|
+
b.sendBinary(Uint8Array.from([1]));
|
|
90
|
+
a.sendBinary(Uint8Array.from([2]));
|
|
91
|
+
|
|
92
|
+
const pairs = wire.map((x) => {
|
|
93
|
+
const d = decodeChannelFrame(x);
|
|
94
|
+
return d.ok ? `${d.frame.channel}:${d.frame.seq}` : 'bad';
|
|
95
|
+
});
|
|
96
|
+
expect(pairs).toEqual(['a:0', 'b:0', 'a:1']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('dispatches an inbound binary frame to that channel only', () => {
|
|
100
|
+
const registry = new PayloadChannelRegistry();
|
|
101
|
+
const audio = registry.registerChannel({ name: 'audio', events: [], binary: true });
|
|
102
|
+
const other = registry.registerChannel({ name: 'other', events: [], binary: true });
|
|
103
|
+
|
|
104
|
+
const audioFrames: IBinaryFrame[] = [];
|
|
105
|
+
const otherFrames: IBinaryFrame[] = [];
|
|
106
|
+
audio.onBinary((f) => audioFrames.push(f));
|
|
107
|
+
other.onBinary((f) => otherFrames.push(f));
|
|
108
|
+
|
|
109
|
+
const result = registry.receive(peerBinary('audio', 4, Uint8Array.from([7, 8, 9])));
|
|
110
|
+
|
|
111
|
+
expect(result.ok).toBe(true);
|
|
112
|
+
expect(otherFrames).toHaveLength(0);
|
|
113
|
+
expect(audioFrames).toHaveLength(1);
|
|
114
|
+
expect(audioFrames[0]!.seq).toBe(4);
|
|
115
|
+
expect(Array.from(audioFrames[0]!.payload)).toEqual([7, 8, 9]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('dispatches an inbound event only to handlers registered for that event name', () => {
|
|
119
|
+
const registry = new PayloadChannelRegistry();
|
|
120
|
+
const channel = registry.registerChannel<{
|
|
121
|
+
caption: { text: string };
|
|
122
|
+
speaker: { id: string };
|
|
123
|
+
}>({ name: 'app', events: ['caption', 'speaker'] });
|
|
124
|
+
const onCaption = vi.fn();
|
|
125
|
+
const onSpeaker = vi.fn();
|
|
126
|
+
channel.onEvent('caption', onCaption);
|
|
127
|
+
channel.onEvent('speaker', onSpeaker);
|
|
128
|
+
|
|
129
|
+
const result = registry.receive(peerEvent('app', 0, 'caption', { text: 'x' }));
|
|
130
|
+
|
|
131
|
+
expect(result.ok).toBe(true);
|
|
132
|
+
// The handler receives the payload plus the full frame (channel/seq envelope).
|
|
133
|
+
expect(onCaption).toHaveBeenCalledWith(
|
|
134
|
+
{ text: 'x' },
|
|
135
|
+
expect.objectContaining({ kind: 'event', channel: 'app', event: 'caption', seq: 0 }),
|
|
136
|
+
);
|
|
137
|
+
expect(onSpeaker).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('unsubscribes a handler through the returned disposer', () => {
|
|
141
|
+
const registry = new PayloadChannelRegistry();
|
|
142
|
+
const channel = registry.registerChannel({ name: 'c', events: [], binary: true });
|
|
143
|
+
const handler = vi.fn();
|
|
144
|
+
const off = channel.onBinary(handler);
|
|
145
|
+
|
|
146
|
+
registry.receive(peerBinary('c', 0, Uint8Array.from([1])));
|
|
147
|
+
off();
|
|
148
|
+
registry.receive(peerBinary('c', 1, Uint8Array.from([2])));
|
|
149
|
+
|
|
150
|
+
expect(handler).toHaveBeenCalledTimes(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('returns an error result for a frame addressed to an unregistered channel', () => {
|
|
154
|
+
const registry = new PayloadChannelRegistry();
|
|
155
|
+
const result = registry.receive(peerBinary('ghost', 0, Uint8Array.from([1])));
|
|
156
|
+
expect(result.ok).toBe(false);
|
|
157
|
+
if (result.ok) return;
|
|
158
|
+
expect(result.error).toMatch(/ghost/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns an error result for an event the channel never declared', () => {
|
|
162
|
+
const registry = new PayloadChannelRegistry();
|
|
163
|
+
registry.registerChannel<{ caption: { text: string } }>({ name: 'app', events: ['caption'] });
|
|
164
|
+
const result = registry.receive(peerEvent('app', 0, 'not-declared', { a: 1 }));
|
|
165
|
+
expect(result.ok).toBe(false);
|
|
166
|
+
if (result.ok) return;
|
|
167
|
+
expect(result.error).toMatch(/not-declared/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns an error result for binary traffic on a non-binary channel', () => {
|
|
171
|
+
const registry = new PayloadChannelRegistry();
|
|
172
|
+
registry.registerChannel({ name: 'text-only', events: [] });
|
|
173
|
+
const result = registry.receive(peerBinary('text-only', 0, Uint8Array.from([1])));
|
|
174
|
+
expect(result.ok).toBe(false);
|
|
175
|
+
if (result.ok) return;
|
|
176
|
+
expect(result.error).toMatch(/binary/i);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('surfaces a malformed frame as an error result', () => {
|
|
180
|
+
const registry = new PayloadChannelRegistry();
|
|
181
|
+
const result = registry.receive(Uint8Array.from([1, 2, 3]));
|
|
182
|
+
expect(result.ok).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('rejects a duplicate channel registration', () => {
|
|
186
|
+
const registry = new PayloadChannelRegistry();
|
|
187
|
+
registry.registerChannel({ name: 'dup', events: [] });
|
|
188
|
+
expect(() => registry.registerChannel({ name: 'dup', events: [] })).toThrow(/already/i);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('rejects sending an undeclared event name', () => {
|
|
192
|
+
const registry = new PayloadChannelRegistry();
|
|
193
|
+
const channel = registry.registerChannel<{ known: { a: number } }>({
|
|
194
|
+
name: 'app',
|
|
195
|
+
events: ['known'],
|
|
196
|
+
});
|
|
197
|
+
// Cast: the compiler already rejects this call; the runtime guard is the wire-level contract.
|
|
198
|
+
const untyped = channel as unknown as { sendEvent: (e: string, p: unknown) => void };
|
|
199
|
+
expect(() => untyped.sendEvent('nope', {})).toThrow(/nope/);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('rejects sending binary on a channel that did not opt into binary', () => {
|
|
203
|
+
const registry = new PayloadChannelRegistry();
|
|
204
|
+
const channel = registry.registerChannel({ name: 'text-only', events: [] });
|
|
205
|
+
expect(() => channel.sendBinary(Uint8Array.from([1]))).toThrow(/binary/i);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('stops delivering to a closed channel and frees the name', () => {
|
|
209
|
+
const registry = new PayloadChannelRegistry();
|
|
210
|
+
const channel = registry.registerChannel({ name: 'temp', events: [], binary: true });
|
|
211
|
+
const handler = vi.fn();
|
|
212
|
+
channel.onBinary(handler);
|
|
213
|
+
channel.close();
|
|
214
|
+
|
|
215
|
+
const result = registry.receive(peerBinary('temp', 0, Uint8Array.from([1])));
|
|
216
|
+
expect(result.ok).toBe(false);
|
|
217
|
+
expect(handler).not.toHaveBeenCalled();
|
|
218
|
+
expect(() => registry.registerChannel({ name: 'temp', events: [] })).not.toThrow();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('drops a detached sink', () => {
|
|
222
|
+
const registry = new PayloadChannelRegistry();
|
|
223
|
+
const channel = registry.registerChannel({ name: 'c', events: [], binary: true });
|
|
224
|
+
const seen: Uint8Array[] = [];
|
|
225
|
+
const detach = registry.addSink((b) => seen.push(b));
|
|
226
|
+
|
|
227
|
+
channel.sendBinary(Uint8Array.from([1]));
|
|
228
|
+
detach();
|
|
229
|
+
channel.sendBinary(Uint8Array.from([2]));
|
|
230
|
+
|
|
231
|
+
expect(seen).toHaveLength(1);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
4
|
+
import { decodeChannelFrame, encodeBinaryFrame } from '@robota-sdk/agent-transport';
|
|
5
|
+
import { WebSocket } from 'ws';
|
|
6
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
9
|
+
|
|
10
|
+
import type { IBinaryFrame } from '@robota-sdk/agent-interface-transport';
|
|
11
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
12
|
+
import type { TServerMessage } from '@robota-sdk/agent-transport';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TRANS-001 functional / user-execution test.
|
|
16
|
+
*
|
|
17
|
+
* One WebSocket connection carries THREE things at once: the existing text-agent protocol
|
|
18
|
+
* (`text_delta` …), opaque binary frames the transport never interprets, and a consumer-declared
|
|
19
|
+
* custom event. The receiver must reassemble the bytes byte-identically and in order, while the
|
|
20
|
+
* text-delta profile keeps working unchanged.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type TListener = (...args: unknown[]) => void;
|
|
24
|
+
|
|
25
|
+
/** Minimal session double with a REAL emitter, so `text_delta` genuinely flows over the wire. */
|
|
26
|
+
function emittingSession(): IInteractiveSession & { emit: (event: string, arg: unknown) => void } {
|
|
27
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
28
|
+
// ARCH-012: the conformant double plus a real emitter. The hand-rolled partial this replaces was
|
|
29
|
+
// `as unknown as IInteractiveSession` over nine members — a cast that let the double omit the rest,
|
|
30
|
+
// including the ones the contract now requires, so this suite ran against a session shape no
|
|
31
|
+
// implementation could actually have.
|
|
32
|
+
const session = createTestInteractiveSession({
|
|
33
|
+
// A spy, because a case below asserts on its calls. The double's default is a real no-op, which
|
|
34
|
+
// is the right default and the wrong thing to assert against.
|
|
35
|
+
submit: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
|
|
37
|
+
getContextState: vi.fn().mockReturnValue({ usedPercentage: 0, usedTokens: 0, maxTokens: 1 }),
|
|
38
|
+
on: ((event: string, fn: TListener) => {
|
|
39
|
+
const set = listeners.get(event) ?? new Set<TListener>();
|
|
40
|
+
set.add(fn);
|
|
41
|
+
listeners.set(event, set);
|
|
42
|
+
}) as IInteractiveSession['on'],
|
|
43
|
+
off: ((event: string, fn: TListener) => {
|
|
44
|
+
listeners.get(event)?.delete(fn);
|
|
45
|
+
}) as IInteractiveSession['off'],
|
|
46
|
+
});
|
|
47
|
+
return Object.assign(session, {
|
|
48
|
+
emit: (event: string, arg: unknown) => {
|
|
49
|
+
for (const fn of listeners.get(event) ?? []) fn(arg);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const started: WsTransport[] = [];
|
|
55
|
+
const clients: WebSocket[] = [];
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
while (clients.length) clients.pop()!.close();
|
|
58
|
+
while (started.length) await started.pop()!.stop();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Connect and attach the message listener BEFORE the socket opens — the server sends its
|
|
63
|
+
* handshake frames (`messages`, `execution_workspace_event`) the instant the connection lands, so a
|
|
64
|
+
* listener attached after `open` would race them.
|
|
65
|
+
*/
|
|
66
|
+
async function connect(
|
|
67
|
+
port: number,
|
|
68
|
+
onMessage: (data: Buffer, isBinary: boolean) => void,
|
|
69
|
+
): Promise<WebSocket> {
|
|
70
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
71
|
+
ws.binaryType = 'nodebuffer';
|
|
72
|
+
clients.push(ws);
|
|
73
|
+
ws.on('message', onMessage);
|
|
74
|
+
await new Promise<void>((resolve, reject) => {
|
|
75
|
+
ws.on('open', () => resolve());
|
|
76
|
+
ws.on('error', reject);
|
|
77
|
+
});
|
|
78
|
+
return ws;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve once `predicate` holds, or reject at the deadline (no arbitrary sleeps). */
|
|
82
|
+
async function until(predicate: () => boolean, label: string, timeoutMs = 5000): Promise<void> {
|
|
83
|
+
const deadline = Date.now() + timeoutMs;
|
|
84
|
+
while (!predicate()) {
|
|
85
|
+
if (Date.now() > deadline) throw new Error(`timed out waiting for ${label}`);
|
|
86
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
describe('WsTransport payload-agnostic channels (TRANS-001 e2e)', () => {
|
|
91
|
+
it('carries interleaved text deltas, opaque binary frames, and a custom event on one connection', async () => {
|
|
92
|
+
const transport = new WsTransport({
|
|
93
|
+
port: 17840,
|
|
94
|
+
maxRetries: 40,
|
|
95
|
+
open: true,
|
|
96
|
+
openReason: 'SEC-008: this case is about payload channels, not admission',
|
|
97
|
+
});
|
|
98
|
+
const channel = transport.registerChannel<{ manifest: { name: string; size: number } }>({
|
|
99
|
+
name: 'file',
|
|
100
|
+
events: ['manifest'],
|
|
101
|
+
binary: true,
|
|
102
|
+
});
|
|
103
|
+
const session = emittingSession();
|
|
104
|
+
transport.attach(session);
|
|
105
|
+
await transport.start();
|
|
106
|
+
started.push(transport);
|
|
107
|
+
|
|
108
|
+
// Receiver side: split text (agent protocol) from binary (payload-agnostic carrier).
|
|
109
|
+
const serverMessages: TServerMessage[] = [];
|
|
110
|
+
const received: IBinaryFrame[] = [];
|
|
111
|
+
const customEvents: { name: string; size: number }[] = [];
|
|
112
|
+
await connect(transport.boundPort!, (data, isBinary) => {
|
|
113
|
+
if (!isBinary) {
|
|
114
|
+
serverMessages.push(JSON.parse(data.toString('utf8')) as TServerMessage);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const decoded = decodeChannelFrame(new Uint8Array(data));
|
|
118
|
+
if (!decoded.ok) throw new Error(decoded.error);
|
|
119
|
+
if (decoded.frame.kind === 'binary') received.push(decoded.frame);
|
|
120
|
+
else customEvents.push(decoded.frame.payload as { name: string; size: number });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A payload the transport must NOT understand: random bytes, invalid UTF-8, JSON-hostile.
|
|
124
|
+
const blob = randomBytes(64 * 1024);
|
|
125
|
+
const CHUNK = 4096;
|
|
126
|
+
|
|
127
|
+
channel.sendEvent('manifest', { name: 'payload.bin', size: blob.byteLength });
|
|
128
|
+
for (let offset = 0; offset < blob.byteLength; offset += CHUNK) {
|
|
129
|
+
channel.sendBinary(new Uint8Array(blob.subarray(offset, offset + CHUNK)));
|
|
130
|
+
// Interleave the text-agent profile on the SAME connection.
|
|
131
|
+
session.emit('text_delta', `chunk@${offset} `);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const expectedChunks = Math.ceil(blob.byteLength / CHUNK);
|
|
135
|
+
await until(() => received.length === expectedChunks, 'all binary chunks');
|
|
136
|
+
await until(
|
|
137
|
+
() => serverMessages.filter((m) => m.type === 'text_delta').length === expectedChunks,
|
|
138
|
+
'all text deltas',
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// 1. Custom (consumer-declared) event arrived.
|
|
142
|
+
expect(customEvents).toEqual([{ name: 'payload.bin', size: blob.byteLength }]);
|
|
143
|
+
|
|
144
|
+
// 2. Frames arrived in order (WS preserves per-connection frame order) and seq is monotonic.
|
|
145
|
+
expect(received.map((f) => f.seq)).toEqual(
|
|
146
|
+
Array.from({ length: expectedChunks }, (_, i) => i + 1), // seq 0 was the manifest event
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// 3. Byte-identical reassembly.
|
|
150
|
+
const reassembled = Buffer.concat(received.map((f) => Buffer.from(f.payload)));
|
|
151
|
+
expect(reassembled.byteLength).toBe(blob.byteLength);
|
|
152
|
+
expect(reassembled.equals(blob)).toBe(true);
|
|
153
|
+
|
|
154
|
+
// 4. The text-agent profile is untouched: the connect handshake + every delta came through.
|
|
155
|
+
expect(serverMessages[0]?.type).toBe('messages');
|
|
156
|
+
expect(serverMessages.filter((m) => m.type === 'text_delta')).toHaveLength(expectedChunks);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('reassembles an opaque upload sent by the client, byte-identically and in order', async () => {
|
|
160
|
+
const transport = new WsTransport({
|
|
161
|
+
port: 17860,
|
|
162
|
+
maxRetries: 40,
|
|
163
|
+
open: true,
|
|
164
|
+
openReason: 'SEC-008: this case is about payload channels, not admission',
|
|
165
|
+
});
|
|
166
|
+
const channel = transport.registerChannel({ name: 'upload', events: [], binary: true });
|
|
167
|
+
transport.attach(emittingSession());
|
|
168
|
+
await transport.start();
|
|
169
|
+
started.push(transport);
|
|
170
|
+
|
|
171
|
+
const inbound: IBinaryFrame[] = [];
|
|
172
|
+
channel.onBinary((frame) => inbound.push(frame));
|
|
173
|
+
|
|
174
|
+
const ws = await connect(transport.boundPort!, () => {});
|
|
175
|
+
const blob = randomBytes(20_000);
|
|
176
|
+
const CHUNK = 3000;
|
|
177
|
+
const expectedChunks = Math.ceil(blob.byteLength / CHUNK);
|
|
178
|
+
for (let offset = 0, seq = 0; offset < blob.byteLength; offset += CHUNK, seq += 1) {
|
|
179
|
+
ws.send(
|
|
180
|
+
encodeBinaryFrame({
|
|
181
|
+
kind: 'binary',
|
|
182
|
+
channel: 'upload',
|
|
183
|
+
seq,
|
|
184
|
+
payload: new Uint8Array(blob.subarray(offset, offset + CHUNK)),
|
|
185
|
+
}),
|
|
186
|
+
{ binary: true },
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
await until(() => inbound.length === expectedChunks, 'all uploaded chunks');
|
|
191
|
+
expect(inbound.map((f) => f.seq)).toEqual(Array.from({ length: expectedChunks }, (_, i) => i));
|
|
192
|
+
const reassembled = Buffer.concat(inbound.map((f) => Buffer.from(f.payload)));
|
|
193
|
+
expect(reassembled.equals(blob)).toBe(true);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it('answers an unroutable inbound frame with a protocol_error instead of dropping it', async () => {
|
|
197
|
+
const transport = new WsTransport({
|
|
198
|
+
port: 17880,
|
|
199
|
+
maxRetries: 40,
|
|
200
|
+
open: true,
|
|
201
|
+
openReason: 'SEC-008: this case is about payload channels, not admission',
|
|
202
|
+
});
|
|
203
|
+
transport.attach(emittingSession());
|
|
204
|
+
await transport.start();
|
|
205
|
+
started.push(transport);
|
|
206
|
+
|
|
207
|
+
const errors: TServerMessage[] = [];
|
|
208
|
+
const ws = await connect(transport.boundPort!, (data, isBinary) => {
|
|
209
|
+
if (isBinary) return;
|
|
210
|
+
const msg = JSON.parse(data.toString('utf8')) as TServerMessage;
|
|
211
|
+
if (msg.type === 'protocol_error') errors.push(msg);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
ws.send(
|
|
215
|
+
encodeBinaryFrame({
|
|
216
|
+
kind: 'binary',
|
|
217
|
+
channel: 'never-registered',
|
|
218
|
+
seq: 0,
|
|
219
|
+
payload: Uint8Array.from([1, 2, 3]),
|
|
220
|
+
}),
|
|
221
|
+
{ binary: true },
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
await until(() => errors.length === 1, 'a protocol_error for the unknown channel');
|
|
225
|
+
expect((errors[0] as { message: string }).message).toMatch(/never-registered/);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('keeps the text-agent profile working when no channel is registered at all', async () => {
|
|
229
|
+
const transport = new WsTransport({
|
|
230
|
+
port: 17900,
|
|
231
|
+
maxRetries: 40,
|
|
232
|
+
open: true,
|
|
233
|
+
openReason: 'SEC-008: this case is about payload channels, not admission',
|
|
234
|
+
});
|
|
235
|
+
const session = emittingSession();
|
|
236
|
+
transport.attach(session);
|
|
237
|
+
await transport.start();
|
|
238
|
+
started.push(transport);
|
|
239
|
+
|
|
240
|
+
const messages: TServerMessage[] = [];
|
|
241
|
+
const ws = await connect(transport.boundPort!, (data, isBinary) => {
|
|
242
|
+
if (!isBinary) messages.push(JSON.parse(data.toString('utf8')) as TServerMessage);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
ws.send(JSON.stringify({ type: 'submit', prompt: 'hello' }));
|
|
246
|
+
await until(
|
|
247
|
+
() => (session.submit as ReturnType<typeof vi.fn>).mock.calls.length === 1,
|
|
248
|
+
'submit',
|
|
249
|
+
);
|
|
250
|
+
session.emit('text_delta', 'hi');
|
|
251
|
+
await until(() => messages.some((m) => m.type === 'text_delta'), 'a text delta');
|
|
252
|
+
|
|
253
|
+
expect(messages[0]?.type).toBe('messages');
|
|
254
|
+
});
|
|
255
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
import { createSessionMessageHandler } from '@robota-sdk/agent-transport';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { WebSocket } from 'ws';
|
|
5
|
+
|
|
6
|
+
import { WsSessionDelivery } from '../ws-session-delivery.js';
|
|
7
|
+
|
|
8
|
+
import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
|
|
9
|
+
|
|
10
|
+
function createSessionHarness(): {
|
|
11
|
+
session: IInteractiveSession;
|
|
12
|
+
fireBranchEvent: () => void;
|
|
13
|
+
handlers: Map<string, (value: unknown) => void>;
|
|
14
|
+
} {
|
|
15
|
+
const handlers = new Map<string, (value: unknown) => void>();
|
|
16
|
+
const session = createTestInteractiveSession({
|
|
17
|
+
on: ((event: string, handler: (value: unknown) => void) => {
|
|
18
|
+
handlers.set(event, handler);
|
|
19
|
+
}) as IInteractiveSession['on'],
|
|
20
|
+
off: ((event: string) => {
|
|
21
|
+
handlers.delete(event);
|
|
22
|
+
}) as IInteractiveSession['off'],
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
session,
|
|
26
|
+
handlers,
|
|
27
|
+
fireBranchEvent: () =>
|
|
28
|
+
handlers.get('branch_event')?.({
|
|
29
|
+
kind: 'checkpoint_created',
|
|
30
|
+
checkpointId: 'turn-0001',
|
|
31
|
+
branchId: 'main',
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('WsSessionDelivery', () => {
|
|
37
|
+
it('routes a non-open synchronous send through one protocol and sink cleanup', () => {
|
|
38
|
+
const socket = {
|
|
39
|
+
readyState: WebSocket.CLOSED,
|
|
40
|
+
send: vi.fn(),
|
|
41
|
+
close: vi.fn(),
|
|
42
|
+
} as unknown as WebSocket;
|
|
43
|
+
const delivery = new WsSessionDelivery(socket);
|
|
44
|
+
const detachSink = vi.fn();
|
|
45
|
+
delivery.bindSinkDetach(detachSink);
|
|
46
|
+
const harness = createSessionHarness();
|
|
47
|
+
// ARCH-030: the carrier's own boundary is what the handler receives — there is no raw sink to pass.
|
|
48
|
+
const handler = createSessionMessageHandler({
|
|
49
|
+
session: harness.session,
|
|
50
|
+
deliver: delivery.deliver,
|
|
51
|
+
});
|
|
52
|
+
delivery.bindProtocolCleanup(handler.cleanup);
|
|
53
|
+
|
|
54
|
+
expect(harness.fireBranchEvent).not.toThrow();
|
|
55
|
+
expect(detachSink).toHaveBeenCalledTimes(1);
|
|
56
|
+
expect(harness.handlers.size).toBe(0);
|
|
57
|
+
expect(socket.close).not.toHaveBeenCalled();
|
|
58
|
+
delivery.close();
|
|
59
|
+
expect(detachSink).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('routes an asynchronous ws.send callback error through the same idempotent cleanup', () => {
|
|
63
|
+
let sendCallback: ((error?: Error) => void) | undefined;
|
|
64
|
+
const socket = {
|
|
65
|
+
readyState: WebSocket.OPEN,
|
|
66
|
+
send: vi.fn((_data: string, callback: (error?: Error) => void) => {
|
|
67
|
+
sendCallback = callback;
|
|
68
|
+
}),
|
|
69
|
+
close: vi.fn(),
|
|
70
|
+
} as unknown as WebSocket;
|
|
71
|
+
const delivery = new WsSessionDelivery(socket);
|
|
72
|
+
const cleanup = vi.fn();
|
|
73
|
+
const detachSink = vi.fn();
|
|
74
|
+
delivery.bindProtocolCleanup(cleanup);
|
|
75
|
+
delivery.bindSinkDetach(detachSink);
|
|
76
|
+
|
|
77
|
+
delivery.deliver({ type: 'history_cleared' });
|
|
78
|
+
expect(sendCallback).toBeDefined();
|
|
79
|
+
sendCallback?.(new Error('asynchronous send failed'));
|
|
80
|
+
sendCallback?.(new Error('duplicate callback'));
|
|
81
|
+
expect(cleanup).toHaveBeenCalledTimes(1);
|
|
82
|
+
expect(detachSink).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(socket.close).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(socket.close).toHaveBeenCalledWith(1011, 'session event delivery failed');
|
|
85
|
+
});
|
|
86
|
+
});
|