@robota-sdk/agent-transport-ws 3.0.0-beta.82 → 3.0.0-beta.84

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.
@@ -0,0 +1,97 @@
1
+ /**
2
+ * #3189: the host hands its session directory to the WebSocket carrier once, and every connection can
3
+ * list, start and switch the host's sessions through it.
4
+ */
5
+
6
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
7
+ import { WebSocket } from 'ws';
8
+ import { afterEach, describe, expect, it, vi } from 'vitest';
9
+
10
+ import { createWsTransport } from '../ws-transport.js';
11
+ import { WsTransport } from '../ws-transport-configurable.js';
12
+
13
+ import type { ISessionDirectory, ISessionListing } from '@robota-sdk/agent-interface-session';
14
+ import type { TServerMessage } from '@robota-sdk/agent-transport';
15
+
16
+ const listing: ISessionListing = {
17
+ currentSessionId: 'session-1',
18
+ sessions: [],
19
+ unreadableSessionIds: [],
20
+ };
21
+
22
+ function createDirectory(): ISessionDirectory {
23
+ return {
24
+ listSessions: vi.fn(() => listing),
25
+ switchSession: vi.fn(async () => undefined),
26
+ newSession: vi.fn(async () => undefined),
27
+ renameSession: vi.fn(async () => undefined),
28
+ deleteSession: vi.fn(async () => undefined),
29
+ };
30
+ }
31
+
32
+ function mockSession(): ReturnType<typeof createTestInteractiveSession> {
33
+ return Object.assign(createTestInteractiveSession(), {
34
+ getMessages: vi.fn().mockReturnValue([]),
35
+ getExecutionWorkspaceSnapshot: vi.fn().mockReturnValue({ entries: [] }),
36
+ on: vi.fn(),
37
+ off: vi.fn(),
38
+ });
39
+ }
40
+
41
+ const started: WsTransport[] = [];
42
+ afterEach(async () => {
43
+ while (started.length) await started.pop()!.stop();
44
+ });
45
+
46
+ describe('WsTransport session directory (#3189)', () => {
47
+ it('lets every connection list and switch the host sessions', async () => {
48
+ const directory = createDirectory();
49
+ const transport = new WsTransport({
50
+ port: 17720,
51
+ maxRetries: 30,
52
+ open: true,
53
+ openReason: 'session-directory regression test',
54
+ sessionDirectory: directory,
55
+ });
56
+ transport.attach(mockSession());
57
+ await transport.start();
58
+ started.push(transport);
59
+
60
+ const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
61
+ const received: TServerMessage[] = [];
62
+ ws.on('message', (data) => received.push(JSON.parse(String(data)) as TServerMessage));
63
+ await new Promise<void>((resolve, reject) => {
64
+ ws.on('open', resolve);
65
+ ws.on('error', reject);
66
+ });
67
+
68
+ ws.send(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
69
+ ws.send(JSON.stringify({ type: 'switch-session', sessionId: 'session-2' }));
70
+ await vi.waitFor(() => expect(directory.switchSession).toHaveBeenCalledWith('session-2'));
71
+ await vi.waitFor(() =>
72
+ expect(received).toContainEqual({ type: 'sessions', requestId: 'r1', listing }),
73
+ );
74
+
75
+ const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
76
+ ws.close();
77
+ await closed;
78
+ });
79
+
80
+ it('passes the directory through the adapter form as well', async () => {
81
+ const directory = createDirectory();
82
+ const sent: TServerMessage[] = [];
83
+ const transport = createWsTransport({
84
+ send: (message) => sent.push(message),
85
+ sessionDirectory: directory,
86
+ });
87
+ transport.attach(mockSession());
88
+ await transport.start();
89
+
90
+ transport.onMessage?.(JSON.stringify({ type: 'list-sessions', requestId: 'r1' }));
91
+ transport.onMessage?.(JSON.stringify({ type: 'new-session' }));
92
+
93
+ expect(sent).toEqual([{ type: 'sessions', requestId: 'r1', listing }]);
94
+ await vi.waitFor(() => expect(directory.newSession).toHaveBeenCalledTimes(1));
95
+ await transport.stop();
96
+ });
97
+ });
@@ -133,6 +133,39 @@ describe('WsTransport loopback auth (GUI-002 TC-03)', () => {
133
133
  await closed;
134
134
  });
135
135
 
136
+ it('carries the driver id on the connection-open messages frame, so a client can learn its own id (#3289 §3)', async () => {
137
+ // The crux of #3289 §3's own-driver-id feature: a client's first `messages` frame is where it
138
+ // learns which of every subsequent `authored` message is its own. This is the one place that
139
+ // sets it — decode-shape validation and the client reducer are covered elsewhere, but neither
140
+ // would catch this specific line being broken or reverted.
141
+ const port = 17660;
142
+ const transport = new WsTransport({
143
+ port,
144
+ maxRetries: 30,
145
+ open: true,
146
+ openReason: 'own-driver-id regression test',
147
+ driverId: 'browser',
148
+ surface: 'browser',
149
+ });
150
+ transport.attach(mockSession());
151
+ await transport.start();
152
+ started.push(transport);
153
+ const ws = new WebSocket(`ws://127.0.0.1:${transport.boundPort}`);
154
+ const firstMessagesFrame = await new Promise<{ type: string; driverId?: string }>(
155
+ (resolve, reject) => {
156
+ ws.on('error', reject);
157
+ ws.on('message', (data) => {
158
+ const msg = JSON.parse(String(data)) as { type: string; driverId?: string };
159
+ if (msg.type === 'messages') resolve(msg);
160
+ });
161
+ },
162
+ );
163
+ expect(firstMessagesFrame.driverId).toBe('browser');
164
+ const closed = new Promise<void>((resolve) => ws.once('close', () => resolve()));
165
+ ws.close();
166
+ await closed;
167
+ });
168
+
136
169
  it('accepts a connection presenting the correct token via query param', async () => {
137
170
  const { port } = await startOn({ token: 'secret-nonce' });
138
171
  expect(await probe(port, { token: 'secret-nonce' })).toBe('messages');
@@ -8,9 +8,9 @@
8
8
  */
9
9
 
10
10
  import type { TUsageSurface } from '@robota-sdk/agent-interface-analytics';
11
- import type { TDriverId } from '@robota-sdk/agent-interface-session';
11
+ import type { ISessionBinder, TDriverId } from '@robota-sdk/agent-interface-session';
12
12
  import type { ITransportLifecycleError } from '@robota-sdk/agent-interface-transport';
13
- import type { ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';
13
+ import type { IProtocolSession, ISessionMessageHandlerOptions } from '@robota-sdk/agent-transport';
14
14
 
15
15
  export const DEFAULT_PORT = 7070;
16
16
  export const DEFAULT_MAX_RETRIES = 20;
@@ -26,6 +26,17 @@ export interface IWsTransportConfig {
26
26
  usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
27
27
  /** Host-owned stored-session trace/cost producer for personal-usage drill-down. */
28
28
  storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
29
+ /** Host-owned session directory (#3189): lets every connection list, start and switch sessions. */
30
+ sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
31
+ /** #3282 §4a: host-owned read/write for the GUI Settings screen, forwarded unchanged. */
32
+ settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
33
+ /**
34
+ * #3189: gives each connection a binding of its own — its own session and its own directory view —
35
+ * so one connection's switch moves only that connection. Released when the connection closes. When
36
+ * set, a connection uses its binding's session and directory instead of the attached session and
37
+ * `sessionDirectory`; without it every connection shares the attached session.
38
+ */
39
+ sessionBinder?: ISessionBinder<IProtocolSession>;
29
40
  port?: number;
30
41
  maxRetries?: number;
31
42
  /**
@@ -84,7 +95,13 @@ function configuredUsageReporters(config: IWsTransportConfig): TUsageReporters {
84
95
 
85
96
  type TConfiguredHandlerOptions = Pick<
86
97
  ISessionMessageHandlerOptions,
87
- 'driverId' | 'surface' | 'personalUsageReporter' | 'usageReporter' | 'storedSessionUsageReporter'
98
+ | 'driverId'
99
+ | 'surface'
100
+ | 'personalUsageReporter'
101
+ | 'usageReporter'
102
+ | 'storedSessionUsageReporter'
103
+ | 'sessionDirectory'
104
+ | 'settingsReporter'
88
105
  >;
89
106
 
90
107
  /** Project immutable transport attribution/reporters into each protocol handler. */
@@ -93,6 +110,8 @@ export function configuredWsHandlerOptions(config: IWsTransportConfig): TConfigu
93
110
  ...(config.driverId ? { driverId: config.driverId } : {}),
94
111
  ...(config.surface ? { surface: config.surface } : {}),
95
112
  ...configuredUsageReporters(config),
113
+ ...(config.sessionDirectory ? { sessionDirectory: config.sessionDirectory } : {}),
114
+ ...(config.settingsReporter ? { settingsReporter: config.settingsReporter } : {}),
96
115
  };
97
116
  }
98
117
 
@@ -28,6 +28,7 @@ import {
28
28
 
29
29
  import type { IWsTransportConfig } from './ws-transport-config.js';
30
30
  import type { TUniversalValue } from '@robota-sdk/agent-core';
31
+ import type { ISessionBinder, ISessionBinding } from '@robota-sdk/agent-interface-session';
31
32
  import type {
32
33
  IChannelDescriptor,
33
34
  IConfigurableTransport,
@@ -51,9 +52,7 @@ const WS_STOP_TERMINATE_DEADLINE_MS = 5000;
51
52
  * TEXT frames go to the text-agent protocol profile (`createSessionMessageHandler`), BINARY frames go to the
52
53
  * consumer-declared channels. The two profiles share one connection and never constrain each other.
53
54
  */
54
- export class WsTransport
55
- implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost
56
- {
55
+ export class WsTransport implements IConfigurableTransport<IProtocolSession>, IPayloadChannelHost {
57
56
  readonly name = 'ws';
58
57
  readonly lifecycle = Object.freeze({ kind: 'service' as const });
59
58
  readonly defaultEnabled = true;
@@ -77,6 +76,7 @@ export class WsTransport
77
76
  private readonly allowedHosts: ReadonlySet<string>;
78
77
  private readonly allowedOrigins: ReadonlySet<string>;
79
78
  private readonly handlerOptions: ReturnType<typeof configuredWsHandlerOptions>;
79
+ private readonly sessionBinder: ISessionBinder<IProtocolSession> | undefined;
80
80
  private readonly channels = new PayloadChannelRegistry();
81
81
  private resolvedPort?: number;
82
82
 
@@ -90,6 +90,7 @@ export class WsTransport
90
90
  this.allowedHosts = new Set(config.allowedHosts ?? []);
91
91
  this.allowedOrigins = new Set(config.allowedOrigins ?? []);
92
92
  this.handlerOptions = configuredWsHandlerOptions(config);
93
+ this.sessionBinder = config.sessionBinder;
93
94
  }
94
95
 
95
96
  attach(session: IProtocolSession): void {
@@ -187,6 +188,22 @@ export class WsTransport
187
188
  return validTransportOptions(options);
188
189
  }
189
190
 
191
+ /**
192
+ * The connection's binding, `{ binding: undefined }` without a binder, or `null` when the host
193
+ * would not bind it (shutting down, say): the socket is then closed before any session data.
194
+ */
195
+ private bindConnection(
196
+ ws: WebSocket,
197
+ ): { binding: ISessionBinding<IProtocolSession> | undefined } | null {
198
+ if (!this.sessionBinder) return { binding: undefined };
199
+ try {
200
+ return { binding: this.sessionBinder.bind('drive') };
201
+ } catch {
202
+ ws.close(1013, 'session unavailable');
203
+ return null;
204
+ }
205
+ }
206
+
190
207
  private bindWithRetry(
191
208
  session: IProtocolSession,
192
209
  port: number,
@@ -244,13 +261,24 @@ export class WsTransport
244
261
  return;
245
262
  }
246
263
 
264
+ // #3189: with a binder, this connection gets its own session and directory, so its
265
+ // switch (and the `session_switched` that follows) stays on this connection. Every WS
266
+ // connection drives: this carrier has no observe role.
267
+ const bound = this.bindConnection(ws);
268
+ if (bound === null) return;
269
+ const { binding } = bound;
270
+ const connectionSession = binding?.session ?? session;
247
271
  const delivery = new WsSessionDelivery(ws);
248
272
  const handler = createSessionMessageHandler({
249
- session,
273
+ session: connectionSession,
250
274
  deliver: delivery.deliver,
251
275
  ...this.handlerOptions,
276
+ ...(binding ? { sessionDirectory: binding.directory } : {}),
277
+ });
278
+ delivery.bindProtocolCleanup(() => {
279
+ handler.cleanup();
280
+ binding?.release();
252
281
  });
253
- delivery.bindProtocolCleanup(handler.cleanup);
254
282
 
255
283
  delivery.bindSinkDetach(
256
284
  // ARCH-030: through the connection's own delivery, so payload frames share the text
@@ -274,10 +302,14 @@ export class WsTransport
274
302
  ws.on('close', delivery.close);
275
303
  ws.on('error', delivery.close);
276
304
 
277
- delivery.deliver({ type: 'messages', messages: session.getMessages() });
305
+ delivery.deliver({
306
+ type: 'messages',
307
+ messages: connectionSession.getMessages(),
308
+ ...(this.handlerOptions.driverId ? { driverId: this.handlerOptions.driverId } : {}),
309
+ });
278
310
  delivery.deliver({
279
311
  type: 'execution_workspace_event',
280
- snapshot: session.getExecutionWorkspaceSnapshot(),
312
+ snapshot: connectionSession.getExecutionWorkspaceSnapshot(),
281
313
  });
282
314
  });
283
315
 
@@ -27,6 +27,8 @@ export interface IWsTransportOptions {
27
27
  personalUsageReporter?: ISessionMessageHandlerOptions['personalUsageReporter'];
28
28
  usageReporter?: ISessionMessageHandlerOptions['usageReporter'];
29
29
  storedSessionUsageReporter?: ISessionMessageHandlerOptions['storedSessionUsageReporter'];
30
+ sessionDirectory?: ISessionMessageHandlerOptions['sessionDirectory'];
31
+ settingsReporter?: ISessionMessageHandlerOptions['settingsReporter'];
30
32
  driverId?: TDriverId;
31
33
  surface?: TUsageSurface;
32
34
  }
@@ -75,6 +77,8 @@ export function createWsTransport(options: IWsTransportOptions): IWsTransport {
75
77
  ...(options.storedSessionUsageReporter
76
78
  ? { storedSessionUsageReporter: options.storedSessionUsageReporter }
77
79
  : {}),
80
+ ...(options.sessionDirectory ? { sessionDirectory: options.sessionDirectory } : {}),
81
+ ...(options.settingsReporter ? { settingsReporter: options.settingsReporter } : {}),
78
82
  });
79
83
  cleanup = handler.cleanup;
80
84
  this.onMessage = handler.onMessage;