@robota-sdk/agent-transport-ws 3.0.0-beta.100
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/CHANGELOG.md +564 -0
- package/LICENSE +661 -0
- package/README.md +85 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +180 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +180 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/package.json +83 -0
- 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-binder.test.ts +271 -0
- package/src/__tests__/ws-session-delivery.test.ts +86 -0
- package/src/__tests__/ws-session-directory.test.ts +97 -0
- package/src/__tests__/ws-transport-auth.test.ts +253 -0
- package/src/__tests__/ws-transport-lifecycle.test.ts +135 -0
- package/src/__tests__/ws-transport.test.ts +117 -0
- package/src/index.ts +7 -0
- 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 +133 -0
- package/src/ws-transport-configurable.ts +337 -0
- package/src/ws-transport.ts +94 -0
|
@@ -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,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3189: with a session binder, each WebSocket connection is bound to a session of its own. One
|
|
3
|
+
* connection's switch — and every event after it — stays on that connection, and the binding is
|
|
4
|
+
* released when the connection closes. Without a binder every connection shares the attached session.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
8
|
+
import { WebSocket } from 'ws';
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
10
|
+
|
|
11
|
+
import { WsTransport } from '../ws-transport-configurable.js';
|
|
12
|
+
|
|
13
|
+
import type { IWsTransportConfig } from '../ws-transport-config.js';
|
|
14
|
+
import type {
|
|
15
|
+
IInteractiveSession,
|
|
16
|
+
ISessionBinder,
|
|
17
|
+
ISessionBinding,
|
|
18
|
+
ISessionDirectory,
|
|
19
|
+
TSessionBindingRole,
|
|
20
|
+
} from '@robota-sdk/agent-interface-session';
|
|
21
|
+
import type { IProtocolSession, TServerMessage } from '@robota-sdk/agent-transport';
|
|
22
|
+
|
|
23
|
+
type TListener = (...args: unknown[]) => void;
|
|
24
|
+
|
|
25
|
+
interface IEmittingSession extends IInteractiveSession {
|
|
26
|
+
emit(event: string, payload?: unknown): void;
|
|
27
|
+
listenerCount(): number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function emittingSession(label: string): IEmittingSession {
|
|
31
|
+
const listeners = new Map<string, Set<TListener>>();
|
|
32
|
+
const session = createTestInteractiveSession({
|
|
33
|
+
getMessages: () => [{ role: 'user', content: label }] as never,
|
|
34
|
+
getExecutionWorkspaceSnapshot: () => ({ entries: [] }) as never,
|
|
35
|
+
on: ((event: string, handler: TListener) => {
|
|
36
|
+
if (!listeners.has(event)) listeners.set(event, new Set());
|
|
37
|
+
listeners.get(event)!.add(handler);
|
|
38
|
+
}) as IInteractiveSession['on'],
|
|
39
|
+
off: ((event: string, handler: TListener) => {
|
|
40
|
+
listeners.get(event)?.delete(handler);
|
|
41
|
+
}) as IInteractiveSession['off'],
|
|
42
|
+
});
|
|
43
|
+
return Object.assign(session, {
|
|
44
|
+
emit: (event: string, payload?: unknown) =>
|
|
45
|
+
listeners.get(event)?.forEach((handler) => handler(payload)),
|
|
46
|
+
listenerCount: () => [...listeners.values()].reduce((sum, set) => sum + set.size, 0),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface IFakeBinding extends ISessionBinding<IProtocolSession> {
|
|
51
|
+
readonly session: IEmittingSession;
|
|
52
|
+
readonly role: TSessionBindingRole;
|
|
53
|
+
readonly release: ReturnType<typeof vi.fn>;
|
|
54
|
+
/** Listeners on the session when `release` ran: the handler must have unsubscribed first. */
|
|
55
|
+
listenersAtRelease?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A binder like the host's: its own session per connection, and a switch that moves only that one. */
|
|
59
|
+
function fakeBinder(overrides: Partial<ISessionDirectory> = {}): {
|
|
60
|
+
binder: ISessionBinder<IProtocolSession>;
|
|
61
|
+
bindings: IFakeBinding[];
|
|
62
|
+
} {
|
|
63
|
+
const bindings: IFakeBinding[] = [];
|
|
64
|
+
const binder: ISessionBinder<IProtocolSession> = {
|
|
65
|
+
bind: (role) => {
|
|
66
|
+
const session = emittingSession(`connection-${bindings.length + 1}`);
|
|
67
|
+
const directory: ISessionDirectory = {
|
|
68
|
+
listSessions: () => ({ currentSessionId: 's', sessions: [], unreadableSessionIds: [] }),
|
|
69
|
+
switchSession: async (sessionId) => session.emit('session_switched', { sessionId }),
|
|
70
|
+
newSession: async () => session.emit('session_switched', { sessionId: 'new' }),
|
|
71
|
+
renameSession: async () => undefined,
|
|
72
|
+
deleteSession: async () => undefined,
|
|
73
|
+
...overrides,
|
|
74
|
+
};
|
|
75
|
+
const binding: IFakeBinding = {
|
|
76
|
+
session,
|
|
77
|
+
directory,
|
|
78
|
+
role,
|
|
79
|
+
release: vi.fn(() => {
|
|
80
|
+
binding.listenersAtRelease = session.listenerCount();
|
|
81
|
+
}),
|
|
82
|
+
};
|
|
83
|
+
bindings.push(binding);
|
|
84
|
+
return binding;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
return { binder, bindings };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const started: WsTransport[] = [];
|
|
91
|
+
afterEach(async () => {
|
|
92
|
+
while (started.length) await started.pop()!.stop();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
async function startTransport(
|
|
96
|
+
config: IWsTransportConfig,
|
|
97
|
+
attached: IProtocolSession = emittingSession('attached'),
|
|
98
|
+
): Promise<WsTransport> {
|
|
99
|
+
const transport = new WsTransport({
|
|
100
|
+
port: 17760,
|
|
101
|
+
maxRetries: 30,
|
|
102
|
+
open: true,
|
|
103
|
+
openReason: 'session-binder regression test',
|
|
104
|
+
...config,
|
|
105
|
+
});
|
|
106
|
+
transport.attach(attached);
|
|
107
|
+
await transport.start();
|
|
108
|
+
started.push(transport);
|
|
109
|
+
return transport;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface IClient {
|
|
113
|
+
ws: WebSocket;
|
|
114
|
+
received: TServerMessage[];
|
|
115
|
+
send(message: object): void;
|
|
116
|
+
close(): Promise<void>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function connect(transport: WsTransport): Promise<IClient> {
|
|
120
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
121
|
+
const received: TServerMessage[] = [];
|
|
122
|
+
ws.on('message', (data) => received.push(JSON.parse(String(data)) as TServerMessage));
|
|
123
|
+
await new Promise<void>((resolve, reject) => {
|
|
124
|
+
ws.on('open', resolve);
|
|
125
|
+
ws.on('error', reject);
|
|
126
|
+
});
|
|
127
|
+
// The connection's initial frames arrive before anything the test does.
|
|
128
|
+
await vi.waitFor(() => expect(received.some((m) => m.type === 'messages')).toBe(true));
|
|
129
|
+
return {
|
|
130
|
+
ws,
|
|
131
|
+
received,
|
|
132
|
+
send: (message) => ws.send(JSON.stringify(message)),
|
|
133
|
+
close: async () => {
|
|
134
|
+
const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
|
|
135
|
+
ws.close();
|
|
136
|
+
await closed;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ofType = <T extends TServerMessage['type']>(
|
|
142
|
+
client: IClient,
|
|
143
|
+
type: T,
|
|
144
|
+
): Array<Extract<TServerMessage, { type: T }>> =>
|
|
145
|
+
client.received.filter((m): m is Extract<TServerMessage, { type: T }> => m.type === type);
|
|
146
|
+
|
|
147
|
+
/** Frames already in flight have landed: a round trip on the same socket answers after them. */
|
|
148
|
+
async function settle(client: IClient, requestId: string): Promise<void> {
|
|
149
|
+
client.send({ type: 'list-sessions', requestId });
|
|
150
|
+
await vi.waitFor(() =>
|
|
151
|
+
expect(client.received.some((m) => 'requestId' in m && m.requestId === requestId)).toBe(true),
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
describe('WsTransport session binder (#3189)', () => {
|
|
156
|
+
it('binds each connection to its own session and keeps a switch on the connection that made it', async () => {
|
|
157
|
+
const { binder, bindings } = fakeBinder();
|
|
158
|
+
const transport = await startTransport({ sessionBinder: binder });
|
|
159
|
+
const a = await connect(transport);
|
|
160
|
+
const b = await connect(transport);
|
|
161
|
+
|
|
162
|
+
expect(bindings.map((binding) => binding.role)).toEqual(['drive', 'drive']);
|
|
163
|
+
expect(ofType(a, 'messages')[0]?.messages).toEqual([{ role: 'user', content: 'connection-1' }]);
|
|
164
|
+
expect(ofType(b, 'messages')[0]?.messages).toEqual([{ role: 'user', content: 'connection-2' }]);
|
|
165
|
+
|
|
166
|
+
a.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'switch-1' });
|
|
167
|
+
await vi.waitFor(() =>
|
|
168
|
+
expect(ofType(a, 'session_switched')).toEqual([
|
|
169
|
+
{ type: 'session_switched', event: { sessionId: 'session-2' } },
|
|
170
|
+
]),
|
|
171
|
+
);
|
|
172
|
+
bindings[0]!.session.emit('text_delta', 'only for a');
|
|
173
|
+
await vi.waitFor(() =>
|
|
174
|
+
expect(ofType(a, 'text_delta')).toEqual([{ type: 'text_delta', delta: 'only for a' }]),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await settle(b, 'b-settled');
|
|
178
|
+
expect(ofType(b, 'session_switched')).toEqual([]);
|
|
179
|
+
expect(ofType(b, 'text_delta')).toEqual([]);
|
|
180
|
+
|
|
181
|
+
await a.close();
|
|
182
|
+
await b.close();
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('releases the binding when its connection closes, after the handler unsubscribed', async () => {
|
|
186
|
+
const { binder, bindings } = fakeBinder();
|
|
187
|
+
const transport = await startTransport({ sessionBinder: binder });
|
|
188
|
+
const a = await connect(transport);
|
|
189
|
+
const b = await connect(transport);
|
|
190
|
+
|
|
191
|
+
await a.close();
|
|
192
|
+
await vi.waitFor(() => expect(bindings[0]!.release).toHaveBeenCalledTimes(1));
|
|
193
|
+
expect(bindings[0]!.listenersAtRelease).toBe(0);
|
|
194
|
+
expect(bindings[1]!.release).not.toHaveBeenCalled();
|
|
195
|
+
|
|
196
|
+
await b.close();
|
|
197
|
+
await vi.waitFor(() => expect(bindings[1]!.release).toHaveBeenCalledTimes(1));
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('answers a refusal from the binding directory with session_change_failed and the requestId', async () => {
|
|
201
|
+
const { binder } = fakeBinder({
|
|
202
|
+
switchSession: async () => {
|
|
203
|
+
throw Object.assign(new Error('Answer the pending prompt first.'), {
|
|
204
|
+
name: 'SessionChangeRefusal',
|
|
205
|
+
code: 'prompt_pending',
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
const transport = await startTransport({ sessionBinder: binder });
|
|
210
|
+
const a = await connect(transport);
|
|
211
|
+
|
|
212
|
+
a.send({ type: 'switch-session', sessionId: 'session-2', requestId: 'switch-1' });
|
|
213
|
+
await vi.waitFor(() =>
|
|
214
|
+
expect(ofType(a, 'session_change_failed')).toEqual([
|
|
215
|
+
{
|
|
216
|
+
type: 'session_change_failed',
|
|
217
|
+
code: 'prompt_pending',
|
|
218
|
+
message: 'Answer the pending prompt first.',
|
|
219
|
+
requestId: 'switch-1',
|
|
220
|
+
},
|
|
221
|
+
]),
|
|
222
|
+
);
|
|
223
|
+
await a.close();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('closes the connection before any session data when the host will not bind it', async () => {
|
|
227
|
+
const binder: ISessionBinder<IProtocolSession> = {
|
|
228
|
+
bind: () => {
|
|
229
|
+
throw new Error('host is stopping');
|
|
230
|
+
},
|
|
231
|
+
};
|
|
232
|
+
const transport = await startTransport({ sessionBinder: binder });
|
|
233
|
+
const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
|
|
234
|
+
const received: string[] = [];
|
|
235
|
+
ws.on('message', (data) => received.push(String(data)));
|
|
236
|
+
const code = await new Promise<number>((resolve) => ws.once('close', resolve));
|
|
237
|
+
expect(code).toBe(1013);
|
|
238
|
+
expect(received).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('without a binder, every connection shares the attached session and hears its switch', async () => {
|
|
242
|
+
const attached = emittingSession('attached');
|
|
243
|
+
const transport = await startTransport({}, attached);
|
|
244
|
+
const a = await connect(transport);
|
|
245
|
+
const b = await connect(transport);
|
|
246
|
+
|
|
247
|
+
expect(ofType(a, 'messages')[0]?.messages).toEqual([{ role: 'user', content: 'attached' }]);
|
|
248
|
+
attached.emit('session_switched', { sessionId: 'session-2' });
|
|
249
|
+
for (const client of [a, b]) {
|
|
250
|
+
await vi.waitFor(() =>
|
|
251
|
+
expect(ofType(client, 'session_switched')).toEqual([
|
|
252
|
+
{ type: 'session_switched', event: { sessionId: 'session-2' } },
|
|
253
|
+
]),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
// No directory either: a switch is answered, not dropped.
|
|
257
|
+
a.send({ type: 'switch-session', sessionId: 'session-3', requestId: 'switch-1' });
|
|
258
|
+
await vi.waitFor(() =>
|
|
259
|
+
expect(ofType(a, 'session_change_failed')).toEqual([
|
|
260
|
+
{
|
|
261
|
+
type: 'session_change_failed',
|
|
262
|
+
code: 'not_available',
|
|
263
|
+
message: 'Sessions cannot be switched on this host.',
|
|
264
|
+
requestId: 'switch-1',
|
|
265
|
+
},
|
|
266
|
+
]),
|
|
267
|
+
);
|
|
268
|
+
await a.close();
|
|
269
|
+
await b.close();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
@@ -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
|
+
});
|