Skip to content

Commit 8f08454

Browse files
authored
Merge branch 'main' into joh/private-fields-async-fix
2 parents b10dae2 + b5dd5ac commit 8f08454

File tree

11 files changed

+225
-52
lines changed

11 files changed

+225
-52
lines changed

build/gulpfile.vscode.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
393393
}));
394394

395395

396-
const isInsiderOrExploration = quality === 'insider' || quality === 'exploration';
397-
const embedded = isInsiderOrExploration
396+
const embedded = quality
398397
? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded
399398
: undefined;
400399

@@ -556,8 +555,8 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d
556555
'**',
557556
'!LICENSE',
558557
'!version',
559-
...(platform === 'darwin' && !isInsiderOrExploration ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []),
560-
...(platform === 'win32' && !isInsiderOrExploration ? ['!**/electron_proxy.exe'] : []),
558+
...(platform === 'darwin' && !quality ? ['!**/Contents/Applications', '!**/Contents/Applications/**'] : []),
559+
...(platform === 'win32' && !quality ? ['!**/electron_proxy.exe'] : []),
561560
], { dot: true }));
562561

563562
if (platform === 'linux') {

build/gulpfile.vscode.win32.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,7 @@ function buildWin32Setup(arch: string, target: string): task.CallbackTask {
112112
Quality: quality
113113
};
114114

115-
const isInsiderOrExploration = quality === 'insider' || quality === 'exploration';
116-
const embedded = isInsiderOrExploration
115+
const embedded = quality
117116
? (product as typeof product & { embedded?: EmbeddedProductInfo }).embedded
118117
: undefined;
119118

extensions/copilot/src/extension/prompt/node/pseudoStartStopConversationCallback.ts

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
3232
private nonReportedDeltas: IResponseDelta[] = [];
3333
private thinkingActive: boolean = false;
3434

35+
private static readonly _toolStreamThrottleMs = 100;
36+
private readonly _lastToolStreamUpdate = new Map<string, number>();
37+
private readonly _pendingToolStreamUpdates = new Map<string, { id: string; arguments: string | undefined }>();
38+
3539
constructor(
3640
private readonly stopStartMappings: readonly StartStopMapping[],
3741
private readonly processNonReportedDelta: ((deltas: IResponseDelta[]) => string[]) | undefined,
@@ -43,14 +47,34 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
4347
}
4448

4549
async doProcessResponse(responseStream: AsyncIterable<IResponsePart>, progress: ChatResponseStream, token: CancellationToken): Promise<void> {
46-
for await (const { delta } of responseStream) {
50+
try {
51+
for await (const { delta } of responseStream) {
52+
if (token.isCancellationRequested) {
53+
return;
54+
}
55+
this.applyDelta(delta, progress);
56+
}
57+
} finally {
4758
if (token.isCancellationRequested) {
48-
return;
59+
this._clearPendingToolStreamUpdates();
60+
} else {
61+
this._flushPendingToolStreamUpdates(progress);
4962
}
50-
this.applyDelta(delta, progress);
5163
}
5264
}
5365

66+
private _clearPendingToolStreamUpdates(): void {
67+
this._pendingToolStreamUpdates.clear();
68+
this._lastToolStreamUpdate.clear();
69+
}
70+
71+
private _flushPendingToolStreamUpdates(progress: ChatResponseStream): void {
72+
for (const update of this._pendingToolStreamUpdates.values()) {
73+
progress.updateToolInvocation(update.id, { partialInput: tryParsePartialToolInput(update.arguments) });
74+
}
75+
this._clearPendingToolStreamUpdates();
76+
}
77+
5478
protected applyDeltaToProgress(delta: IResponseDelta, progress: ChatResponseStream) {
5579
if (delta.thinking) {
5680
// Don't send parts that are only encrypted content
@@ -79,11 +103,20 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
79103
}
80104

81105
if (delta.copilotToolCallStreamUpdates?.length) {
106+
const now = Date.now();
82107
for (const update of delta.copilotToolCallStreamUpdates) {
83108
if (!update.name) {
84109
continue;
85110
}
86-
progress.updateToolInvocation(update.id ?? '', { partialInput: tryParsePartialToolInput(update.arguments) });
111+
const toolId = update.id ?? '';
112+
const lastUpdate = this._lastToolStreamUpdate.get(toolId) ?? 0;
113+
if (now - lastUpdate >= PseudoStopStartResponseProcessor._toolStreamThrottleMs) {
114+
this._lastToolStreamUpdate.set(toolId, now);
115+
this._pendingToolStreamUpdates.delete(toolId);
116+
progress.updateToolInvocation(toolId, { partialInput: tryParsePartialToolInput(update.arguments) });
117+
} else {
118+
this._pendingToolStreamUpdates.set(toolId, { id: toolId, arguments: update.arguments });
119+
}
87120
}
88121
}
89122
}
@@ -176,6 +209,7 @@ export class PseudoStopStartResponseProcessor implements IResponseProcessor {
176209
this.currentStartStop = undefined;
177210
this.nonReportedDeltas = [];
178211
this.thinkingActive = false;
212+
this._clearPendingToolStreamUpdates();
179213
if (delta.retryReason === 'network_error' || delta.retryReason === 'server_error') {
180214
progress.clearToPreviousToolInvocation(ChatResponseClearToPreviousToolInvocationReason.NoReason);
181215
} else if (delta.retryReason === FilterReason.Copyright) {

extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts

Lines changed: 159 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66
import assert from 'assert';
77
import * as sinon from 'sinon';
88
import { afterEach, beforeEach, suite, test } from 'vitest';
9-
import type { ChatVulnerability } from 'vscode';
9+
import type { ChatToolInvocationStreamData, ChatVulnerability } from 'vscode';
1010
import { IResponsePart } from '../../../platform/chat/common/chatMLFetcher';
1111
import { IResponseDelta } from '../../../platform/networking/common/fetch';
1212
import { createPlatformServices } from '../../../platform/test/node/services';
13+
import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl';
1314
import { SpyChatResponseStream } from '../../../util/common/test/mockChatResponseStream';
1415
import { AsyncIterableSource } from '../../../util/vs/base/common/async';
15-
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
16+
import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
1617
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
1718
import { ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart } from '../../../vscodeTypes';
1819
import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartStopConversationCallback';
@@ -169,3 +170,159 @@ suite('Post Report Conversation Callback', () => {
169170

170171
afterEach(() => sinon.restore());
171172
});
173+
174+
suite('Tool stream throttling', () => {
175+
let clock: sinon.SinonFakeTimers;
176+
let updateCalls: { toolCallId: string; streamData: ChatToolInvocationStreamData }[];
177+
let stream: ChatResponseStreamImpl;
178+
179+
beforeEach(() => {
180+
clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] });
181+
updateCalls = [];
182+
stream = new ChatResponseStreamImpl(
183+
() => { },
184+
() => { },
185+
undefined,
186+
undefined,
187+
(toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }),
188+
);
189+
});
190+
191+
afterEach(() => {
192+
clock.restore();
193+
sinon.restore();
194+
});
195+
196+
test('first update is emitted immediately', async () => {
197+
const responseSource = new AsyncIterableSource<IResponsePart>();
198+
const processor = new PseudoStopStartResponseProcessor([], undefined);
199+
200+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
201+
responseSource.resolve();
202+
203+
await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);
204+
205+
assert.strictEqual(updateCalls.length, 1);
206+
assert.strictEqual(updateCalls[0].toolCallId, 'tool1');
207+
});
208+
209+
test('rapid updates within throttle window are throttled', async () => {
210+
const responseSource = new AsyncIterableSource<IResponsePart>();
211+
const processor = new PseudoStopStartResponseProcessor([], undefined);
212+
213+
// First update goes through immediately
214+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
215+
// These arrive within the 100ms throttle window — should be buffered
216+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });
217+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":3}' }] } });
218+
responseSource.resolve();
219+
220+
await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);
221+
222+
// 1 immediate + 1 flush of the last buffered update = 2 total
223+
assert.strictEqual(updateCalls.length, 2);
224+
assert.strictEqual(updateCalls[0].toolCallId, 'tool1');
225+
assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 3 });
226+
});
227+
228+
test('update after throttle window elapses is emitted immediately', async () => {
229+
const responseSource = new AsyncIterableSource<IResponsePart>();
230+
const processor = new PseudoStopStartResponseProcessor([], undefined);
231+
232+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
233+
clock.tick(100);
234+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });
235+
responseSource.resolve();
236+
237+
await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);
238+
239+
// Both emitted immediately (no pending flush needed)
240+
assert.strictEqual(updateCalls.length, 2);
241+
assert.deepStrictEqual(updateCalls[0].streamData.partialInput, { a: 1 });
242+
assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 2 });
243+
});
244+
245+
test('different tool IDs are throttled independently', async () => {
246+
const responseSource = new AsyncIterableSource<IResponsePart>();
247+
const processor = new PseudoStopStartResponseProcessor([], undefined);
248+
249+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
250+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool2', name: 'myTool', arguments: '{"b":1}' }] } });
251+
// These are within the throttle window for their respective tools
252+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });
253+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool2', name: 'myTool', arguments: '{"b":2}' }] } });
254+
responseSource.resolve();
255+
256+
await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);
257+
258+
// 2 immediate (one per tool) + 2 flushed (one per tool) = 4
259+
assert.strictEqual(updateCalls.length, 4);
260+
});
261+
262+
test('pending updates are not flushed on cancellation', async () => {
263+
const cts = new CancellationTokenSource();
264+
const responseSource = new AsyncIterableSource<IResponsePart>();
265+
const processor = new PseudoStopStartResponseProcessor([], undefined);
266+
267+
// Start processing, then emit items so the for-await loop consumes them
268+
const promise = processor.doProcessResponse(responseSource.asyncIterable, stream, cts.token);
269+
270+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
271+
await new Promise(r => setTimeout(r, 0));
272+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });
273+
await new Promise(r => setTimeout(r, 0));
274+
275+
// Cancel after items are processed but before stream ends
276+
cts.cancel();
277+
responseSource.resolve();
278+
279+
await promise;
280+
281+
// Only the first immediate update — buffered update should NOT be flushed
282+
assert.strictEqual(updateCalls.length, 1);
283+
assert.strictEqual(updateCalls[0].toolCallId, 'tool1');
284+
});
285+
286+
test('retry clears pending throttle state', async () => {
287+
const responseSource = new AsyncIterableSource<IResponsePart>();
288+
const clearCalls: number[] = [];
289+
const clearStream = new ChatResponseStreamImpl(
290+
() => { },
291+
() => clearCalls.push(1),
292+
undefined,
293+
undefined,
294+
(toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }),
295+
);
296+
const processor = new PseudoStopStartResponseProcessor([], undefined);
297+
298+
// Buffer a pending update
299+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });
300+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });
301+
// Retry clears everything
302+
responseSource.emitOne({ delta: { text: '', retryReason: 'network_error' } });
303+
// New update after retry should go through immediately
304+
clock.tick(100);
305+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":3}' }] } });
306+
responseSource.resolve();
307+
308+
await processor.doProcessResponse(responseSource.asyncIterable, clearStream, CancellationToken.None);
309+
310+
// 1 immediate before retry + 1 immediate after retry = 2
311+
// The buffered {"a":2} should have been cleared by retry, not flushed
312+
assert.strictEqual(updateCalls.length, 2);
313+
assert.deepStrictEqual(updateCalls[0].streamData.partialInput, { a: 1 });
314+
assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 3 });
315+
});
316+
317+
test('updates without name are skipped', async () => {
318+
const responseSource = new AsyncIterableSource<IResponsePart>();
319+
const processor = new PseudoStopStartResponseProcessor([], undefined);
320+
321+
responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: undefined as any, arguments: '{"a":1}' }] } });
322+
responseSource.resolve();
323+
324+
await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);
325+
326+
assert.strictEqual(updateCalls.length, 0);
327+
});
328+
});

extensions/copilot/src/platform/telemetry/common/baseTelemetryService.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import { ICopilotTokenStore } from '../../authentication/common/copilotTokenStor
88
import { ICAPIClientService } from '../../endpoint/common/capiClient';
99
import { BaseGHTelemetrySender } from './ghTelemetrySender';
1010
import { BaseMsftTelemetrySender } from './msftTelemetrySender';
11-
import { ITelemetryService, TelemetryDestination, TelemetryEventMeasurements, TelemetryEventProperties } from './telemetry';
11+
import { ITelemetryService, TelemetryDestination, TelemetryEventMeasurements, TelemetryEventProperties, TelemetryTrustedValue } from './telemetry';
1212

1313
export class BaseTelemetryService implements ITelemetryService {
1414
declare readonly _serviceBrand: undefined;
1515
// Properties that are applied to all telemetry events (currently only used by the exp service
1616
// TODO @lramos15 extend further to include more
17-
private _sharedProperties: Record<string, string> = {};
17+
private _sharedProperties: Record<string, string | TelemetryTrustedValue<string>> = {};
1818
private _originalExpAssignments: string | undefined;
1919
private _additionalExpAssignments: string[] = [];
2020
private _disposables: IDisposable[] = [];
@@ -143,7 +143,7 @@ export class BaseTelemetryService implements ITelemetryService {
143143
}
144144
}
145145
this._capiClientService.abExpContext = value;
146-
this._sharedProperties['abexp.assignmentcontext'] = value;
146+
this._sharedProperties['abexp.assignmentcontext'] = new TelemetryTrustedValue(value);
147147
}
148148

149149
setSharedProperty(name: string, value: string): void {
@@ -165,9 +165,11 @@ export class BaseTelemetryService implements ITelemetryService {
165165
}
166166

167167
postEvent(eventName: string, props: Map<string, string>): void {
168-
for (const [key, value] of Object.entries(this._sharedProperties)) {
169-
props.set(key, value);
170-
}
171-
this._microsoftTelemetrySender.postEvent(eventName, props);
168+
const properties: Record<string, string | TelemetryTrustedValue<string>> = {
169+
...Object.fromEntries(props),
170+
...this._sharedProperties
171+
};
172+
this._microsoftTelemetrySender.sendInternalTelemetryEvent(eventName, properties);
173+
this._microsoftTelemetrySender.sendTelemetryEvent(eventName, properties);
172174
}
173175
}

extensions/copilot/src/platform/telemetry/common/msftTelemetrySender.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,6 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
3636
this._disposables.add(copilotTokenStore.onDidStoreUpdate(() => this.processToken(copilotTokenStore.copilotToken)));
3737
}
3838

39-
/**
40-
* **NOTE**: Do not call directly
41-
* This is just used by the experimentation service to log events to the scorecards
42-
* @param eventName
43-
* @param props
44-
*/
45-
postEvent(eventName: string, props: Map<string, string>): void {
46-
const event: Record<string, string> = {};
47-
for (const [key, value] of props) {
48-
event[key] = value;
49-
}
50-
if (this._isInternal) {
51-
this.sendInternalTelemetryEvent(eventName, event);
52-
}
53-
this.sendTelemetryEvent(eventName, event);
54-
}
55-
5639
/**
5740
* Sends a telemetry event regarding internal Microsoft staff only. Will be dropped if telemetry level is below Usage
5841
* @param eventName The name of the event to send
@@ -61,7 +44,7 @@ export class BaseMsftTelemetrySender implements IMSFTTelemetrySender {
6144
* @returns
6245
*/
6346
sendInternalTelemetryEvent(eventName: string, properties?: TelemetryEventProperties, measurements?: TelemetryEventMeasurements): void {
64-
if (!this._internalTelemetryReporter) {
47+
if (!this._internalTelemetryReporter || !this._isInternal) {
6548
return;
6649
}
6750
properties = { ...properties, 'common.tid': this._tid, 'common.userName': this._username ?? 'undefined' };

extensions/copilot/src/platform/telemetry/test/node/telemetry.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ suite('GitHub Telemetry Sender', function () {
126126
const commonTelemetryData = {
127127
properties: {
128128
copilot_build: new TelemetryTrustedValue('1'),
129-
copilot_buildType: new TelemetryTrustedValue('dev'),
129+
copilot_buildType: new TelemetryTrustedValue(!!process.env.BUILD_SOURCEVERSION ? 'prod' : 'dev'),
130130
copilot_trackingId: new TelemetryTrustedValue('testId'),
131131
editor_plugin_version: new TelemetryTrustedValue('simulation-tests-plugin/2'),
132132
client_machineid: new TelemetryTrustedValue('test-machine'),

src/vs/code/electron-main/app.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1354,7 +1354,7 @@ export class CodeApplication extends Disposable {
13541354
const args = this.environmentMainService.args;
13551355

13561356
// Handle agents window first based on context
1357-
if ((process as INodeProcess).isEmbeddedApp || (args['agents'] && this.productService.quality !== 'stable')) {
1357+
if ((process as INodeProcess).isEmbeddedApp || args['agents']) {
13581358
return windowsMainService.openAgentsWindow({
13591359
context,
13601360
cli: args,
@@ -1736,7 +1736,7 @@ export class CodeApplication extends Disposable {
17361736
}
17371737

17381738
private registerEmbeddedAppWithLaunchServices(): void {
1739-
if (!isMacintosh || (process as INodeProcess).isEmbeddedApp || !this.productService.embedded?.nameShort || this.productService.quality === 'stable') {
1739+
if (!isMacintosh || (process as INodeProcess).isEmbeddedApp || !this.productService.embedded?.nameShort) {
17401740
return;
17411741
}
17421742

0 commit comments

Comments
 (0)