Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/agentHost/test/node/protocol/multiClient.integrationTest.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { SubscribeResult } from '../../../common/state/protocol/commands.js';
8
import type { SessionAddedNotification, SessionRemovedNotification } from '../../../common/state/sessionActions.js';
9
import { PROTOCOL_VERSION } from '../../../common/state/sessionCapabilities.js';
10
import type { INotificationBroadcastParams, ReconnectResult } from '../../../common/state/sessionProtocol.js';
11
import type { SessionState } from '../../../common/state/sessionState.js';
12
import {
13
createAndSubscribeSession,
14
dispatchTurnStarted,
15
getActionEnvelope,
16
IServerHandle,
17
isActionNotification,
18
nextSessionUri,
19
startServer,
20
TestProtocolClient,
21
} from './testHelpers.js';
22
23
suite('Protocol WebSocket — Multi-Client', function () {
24
25
let server: IServerHandle;
26
let client: TestProtocolClient;
27
28
suiteSetup(async function () {
29
this.timeout(15_000);
30
server = await startServer();
31
});
32
33
suiteTeardown(function () {
34
server.process.kill();
35
});
36
37
setup(async function () {
38
this.timeout(10_000);
39
client = new TestProtocolClient(server.port);
40
await client.connect();
41
});
42
43
teardown(function () {
44
client.close();
45
});
46
47
test('sessionAdded notification is broadcast to all connected clients', async function () {
48
this.timeout(10_000);
49
50
await client.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-1' });
51
52
const client2 = new TestProtocolClient(server.port);
53
await client2.connect();
54
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-add-2' });
55
56
client.clearReceived();
57
client2.clearReceived();
58
59
await client.call('createSession', { session: nextSessionUri(), provider: 'mock' });
60
61
const n1 = await client.waitForNotification(n =>
62
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
63
);
64
const n2 = await client2.waitForNotification(n =>
65
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionAdded'
66
);
67
assert.ok(n1, 'client 1 should receive sessionAdded');
68
assert.ok(n2, 'client 2 should receive sessionAdded');
69
70
const uri1 = ((n1.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource;
71
const uri2 = ((n2.params as INotificationBroadcastParams).notification as SessionAddedNotification).summary.resource;
72
assert.strictEqual(uri1, uri2, 'both clients should see the same session URI');
73
74
client2.close();
75
});
76
77
test('sessionRemoved notification is broadcast to all connected clients', async function () {
78
this.timeout(10_000);
79
80
const sessionUri = await createAndSubscribeSession(client, 'test-broadcast-remove-1');
81
82
const client2 = new TestProtocolClient(server.port);
83
await client2.connect();
84
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-broadcast-remove-2' });
85
client2.clearReceived();
86
87
await client.call('disposeSession', { session: sessionUri });
88
89
const n1 = await client.waitForNotification(n =>
90
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved'
91
);
92
const n2 = await client2.waitForNotification(n =>
93
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved'
94
);
95
assert.ok(n1, 'client 1 should receive sessionRemoved');
96
assert.ok(n2, 'client 2 should receive sessionRemoved even without subscribing');
97
98
const removed1 = (n1.params as INotificationBroadcastParams).notification as SessionRemovedNotification;
99
const removed2 = (n2.params as INotificationBroadcastParams).notification as SessionRemovedNotification;
100
assert.strictEqual(removed1.session.toString(), sessionUri.toString());
101
assert.strictEqual(removed2.session.toString(), sessionUri.toString());
102
103
client2.close();
104
});
105
106
test('two clients on same session both see actions', async function () {
107
this.timeout(10_000);
108
109
const sessionUri = await createAndSubscribeSession(client, 'test-multi-client-1');
110
111
const client2 = new TestProtocolClient(server.port);
112
await client2.connect();
113
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-multi-client-2' });
114
await client2.call('subscribe', { resource: sessionUri });
115
client2.clearReceived();
116
117
dispatchTurnStarted(client, sessionUri, 'turn-mc', 'hello', 1);
118
119
const d1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
120
const d2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
121
assert.ok(d1);
122
assert.ok(d2);
123
124
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
125
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
126
127
client2.close();
128
});
129
130
test('client B sends message on session created by client A', async function () {
131
this.timeout(10_000);
132
133
const sessionUri = await createAndSubscribeSession(client, 'test-cross-msg-1');
134
135
const client2 = new TestProtocolClient(server.port);
136
await client2.connect();
137
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-msg-2' });
138
await client2.call('subscribe', { resource: sessionUri });
139
client.clearReceived();
140
client2.clearReceived();
141
142
// Client B dispatches the turn
143
dispatchTurnStarted(client2, sessionUri, 'turn-cross', 'hello', 1);
144
145
const r1 = await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
146
const r2 = await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
147
assert.ok(r1, 'client A should see responsePart from client B turn');
148
assert.ok(r2, 'client B should see its own responsePart');
149
150
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
151
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
152
153
client2.close();
154
});
155
156
test('both clients receive full tool progress updates', async function () {
157
this.timeout(10_000);
158
159
const sessionUri = await createAndSubscribeSession(client, 'test-tool-progress-1');
160
161
const client2 = new TestProtocolClient(server.port);
162
await client2.connect();
163
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-tool-progress-2' });
164
await client2.call('subscribe', { resource: sessionUri });
165
client.clearReceived();
166
client2.clearReceived();
167
168
dispatchTurnStarted(client, sessionUri, 'turn-tool-mc', 'use-tool', 1);
169
170
// Both clients should see the full tool lifecycle
171
for (const c of [client, client2]) {
172
await c.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
173
await c.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
174
await c.waitForNotification(n => isActionNotification(n, 'session/toolCallComplete'));
175
await c.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
176
await c.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
177
}
178
179
client2.close();
180
});
181
182
test('unsubscribe stops receiving session actions', async function () {
183
this.timeout(10_000);
184
185
const sessionUri = await createAndSubscribeSession(client, 'test-unsubscribe');
186
client.notify('unsubscribe', { resource: sessionUri });
187
await new Promise(resolve => setTimeout(resolve, 100));
188
client.clearReceived();
189
190
const client2 = new TestProtocolClient(server.port);
191
await client2.connect();
192
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-unsub-helper' });
193
await client2.call('subscribe', { resource: sessionUri });
194
195
dispatchTurnStarted(client2, sessionUri, 'turn-unsub', 'hello', 1);
196
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
197
198
await new Promise(resolve => setTimeout(resolve, 300));
199
const sessionActions = client.receivedNotifications(n => isActionNotification(n, 'session/'));
200
assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should not receive session actions');
201
202
client2.close();
203
});
204
205
test('unsubscribed client receives no actions but still gets notifications', async function () {
206
this.timeout(10_000);
207
208
const sessionUri = await createAndSubscribeSession(client, 'test-scoping-1');
209
210
const client2 = new TestProtocolClient(server.port);
211
await client2.connect();
212
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-scoping-2' });
213
// Client 2 does NOT subscribe to the session
214
client2.clearReceived();
215
216
dispatchTurnStarted(client, sessionUri, 'turn-scoped', 'hello', 1);
217
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
218
219
// Give some time for any stray actions to arrive
220
await new Promise(resolve => setTimeout(resolve, 300));
221
const sessionActions = client2.receivedNotifications(n => n.method === 'action');
222
assert.strictEqual(sessionActions.length, 0, 'unsubscribed client should receive no session actions');
223
224
// But disposing the session should still broadcast a notification
225
client2.clearReceived();
226
await client.call('disposeSession', { session: sessionUri });
227
228
const removed = await client2.waitForNotification(n =>
229
n.method === 'notification' && (n.params as INotificationBroadcastParams).notification.type === 'notify/sessionRemoved'
230
);
231
assert.ok(removed, 'unsubscribed client should still receive sessionRemoved notification');
232
233
client2.close();
234
});
235
236
test('late subscriber gets current state via snapshot', async function () {
237
this.timeout(15_000);
238
239
const sessionUri = await createAndSubscribeSession(client, 'test-late-sub');
240
dispatchTurnStarted(client, sessionUri, 'turn-late', 'hello', 1);
241
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
242
243
// Client 2 joins after the turn has completed
244
const client2 = new TestProtocolClient(server.port);
245
await client2.connect();
246
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-late-sub-2' });
247
248
const result = await client2.call<SubscribeResult>('subscribe', { resource: sessionUri });
249
const state = result.snapshot.state as SessionState;
250
assert.ok(state.turns.length >= 1, `late subscriber should see completed turn, got ${state.turns.length}`);
251
assert.strictEqual(state.turns[0].id, 'turn-late');
252
assert.strictEqual(state.turns[0].state, 'complete');
253
254
client2.close();
255
});
256
257
test('permission flow: client B confirms tool started by client A', async function () {
258
this.timeout(10_000);
259
260
const sessionUri = await createAndSubscribeSession(client, 'test-cross-perm-1');
261
262
const client2 = new TestProtocolClient(server.port);
263
await client2.connect();
264
await client2.call('initialize', { protocolVersion: PROTOCOL_VERSION, clientId: 'test-cross-perm-2' });
265
await client2.call('subscribe', { resource: sessionUri });
266
client.clearReceived();
267
client2.clearReceived();
268
269
// Client A starts the permission turn
270
dispatchTurnStarted(client, sessionUri, 'turn-cross-perm', 'permission', 1);
271
272
// Both clients should see tool_start and tool_ready
273
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
274
await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallStart'));
275
await client.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
276
await client2.waitForNotification(n => isActionNotification(n, 'session/toolCallReady'));
277
278
// Client B confirms the tool call
279
client2.notify('dispatchAction', {
280
clientSeq: 1,
281
action: {
282
type: 'session/toolCallConfirmed',
283
session: sessionUri,
284
turnId: 'turn-cross-perm',
285
toolCallId: 'tc-perm-1',
286
approved: true,
287
},
288
});
289
290
// Both clients should see the response and turn completion
291
await client.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
292
await client2.waitForNotification(n => isActionNotification(n, 'session/responsePart'));
293
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
294
await client2.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
295
296
client2.close();
297
});
298
299
test('reconnect replays missed actions', async function () {
300
this.timeout(15_000);
301
302
const sessionUri = await createAndSubscribeSession(client, 'test-reconnect');
303
dispatchTurnStarted(client, sessionUri, 'turn-recon', 'hello', 1);
304
await client.waitForNotification(n => isActionNotification(n, 'session/turnComplete'));
305
306
const allActions = client.receivedNotifications(n => n.method === 'action');
307
assert.ok(allActions.length > 0);
308
const missedFromSeq = getActionEnvelope(allActions[0]).serverSeq - 1;
309
310
client.close();
311
312
const client2 = new TestProtocolClient(server.port);
313
await client2.connect();
314
const result = await client2.call<ReconnectResult>('reconnect', {
315
clientId: 'test-reconnect',
316
lastSeenServerSeq: missedFromSeq,
317
subscriptions: [sessionUri],
318
});
319
320
assert.ok(result.type === 'replay' || result.type === 'snapshot', 'should receive replay or snapshot');
321
if (result.type === 'replay') {
322
assert.ok(result.actions.length > 0, 'should have replayed actions');
323
}
324
325
client2.close();
326
});
327
});
328
329