Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts
13406 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 { describe, expect, test } from 'vitest';
7
import { BackgroundSummarizationState, BackgroundSummarizationThresholds, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../backgroundSummarizer';
8
9
describe('BackgroundSummarizer', () => {
10
11
test('initial state is Idle', () => {
12
const summarizer = new BackgroundSummarizer(100_000);
13
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
14
expect(summarizer.error).toBeUndefined();
15
expect(summarizer.token).toBeUndefined();
16
});
17
18
test('start transitions to InProgress', async () => {
19
const summarizer = new BackgroundSummarizer(100_000);
20
summarizer.start(async _token => {
21
return { summary: 'test', toolCallRoundId: 'r1' };
22
});
23
expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);
24
expect(summarizer.token).toBeDefined();
25
await summarizer.waitForCompletion();
26
});
27
28
test('successful work transitions to Completed', async () => {
29
const summarizer = new BackgroundSummarizer(100_000);
30
const result: IBackgroundSummarizationResult = { summary: 'test summary', toolCallRoundId: 'round1' };
31
summarizer.start(async _token => result);
32
await summarizer.waitForCompletion();
33
expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);
34
});
35
36
test('failed work transitions to Failed', async () => {
37
const summarizer = new BackgroundSummarizer(100_000);
38
summarizer.start(async _token => {
39
throw new Error('summarization failed');
40
});
41
await summarizer.waitForCompletion();
42
expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);
43
expect(summarizer.error).toBeInstanceOf(Error);
44
});
45
46
test('consumeAndReset returns result and resets to Idle', async () => {
47
const summarizer = new BackgroundSummarizer(100_000);
48
const expected: IBackgroundSummarizationResult = { summary: 'test summary', toolCallRoundId: 'round1' };
49
summarizer.start(async _token => expected);
50
await summarizer.waitForCompletion();
51
52
const result = summarizer.consumeAndReset();
53
expect(result).toEqual(expected);
54
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
55
expect(summarizer.token).toBeUndefined();
56
});
57
58
test('consumeAndReset returns undefined while InProgress', async () => {
59
const summarizer = new BackgroundSummarizer(100_000);
60
let resolveFn: () => void;
61
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
62
summarizer.start(async _token => {
63
await gate;
64
return { summary: 'test', toolCallRoundId: 'r1' };
65
});
66
expect(summarizer.consumeAndReset()).toBeUndefined();
67
expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);
68
summarizer.cancel();
69
resolveFn!();
70
await new Promise<void>(resolve => setTimeout(resolve, 0));
71
});
72
73
test('consumeAndReset returns undefined after failure', async () => {
74
const summarizer = new BackgroundSummarizer(100_000);
75
summarizer.start(async _token => {
76
throw new Error('fail');
77
});
78
await summarizer.waitForCompletion();
79
expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);
80
81
const result = summarizer.consumeAndReset();
82
expect(result).toBeUndefined();
83
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
84
});
85
86
test('start is a no-op when already InProgress', async () => {
87
const summarizer = new BackgroundSummarizer(100_000);
88
let callCount = 0;
89
let resolveFn: () => void;
90
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
91
summarizer.start(async _token => {
92
callCount++;
93
await gate;
94
return { summary: 'first', toolCallRoundId: 'r1' };
95
});
96
// Second start should be ignored
97
summarizer.start(async _token => {
98
callCount++;
99
return { summary: 'second', toolCallRoundId: 'r2' };
100
});
101
resolveFn!();
102
await summarizer.waitForCompletion();
103
expect(callCount).toBe(1);
104
expect(summarizer.consumeAndReset()?.summary).toBe('first');
105
});
106
107
test('start is a no-op when already Completed', async () => {
108
const summarizer = new BackgroundSummarizer(100_000);
109
summarizer.start(async _token => ({ summary: 'first', toolCallRoundId: 'r1' }));
110
await summarizer.waitForCompletion();
111
expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);
112
113
// Second start should be ignored because state is Completed
114
summarizer.start(async _token => ({ summary: 'second', toolCallRoundId: 'r2' }));
115
expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);
116
expect(summarizer.consumeAndReset()?.summary).toBe('first');
117
});
118
119
test('start retries after Failed state', async () => {
120
const summarizer = new BackgroundSummarizer(100_000);
121
summarizer.start(async _token => {
122
throw new Error('fail');
123
});
124
await summarizer.waitForCompletion();
125
expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);
126
127
// Should be allowed to retry
128
summarizer.start(async _token => ({ summary: 'retry', toolCallRoundId: 'r2' }));
129
await summarizer.waitForCompletion();
130
expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);
131
expect(summarizer.consumeAndReset()?.summary).toBe('retry');
132
});
133
134
test('cancel resets state to Idle', async () => {
135
const summarizer = new BackgroundSummarizer(100_000);
136
let resolveFn: () => void;
137
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
138
summarizer.start(async _token => {
139
await gate;
140
return { summary: 'test', toolCallRoundId: 'r1' };
141
});
142
expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);
143
144
summarizer.cancel();
145
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
146
expect(summarizer.token).toBeUndefined();
147
expect(summarizer.error).toBeUndefined();
148
resolveFn!();
149
await new Promise<void>(resolve => setTimeout(resolve, 0));
150
});
151
152
test('cancel prevents .then() from setting state to Completed', async () => {
153
const summarizer = new BackgroundSummarizer(100_000);
154
let resolveFn: () => void;
155
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
156
157
summarizer.start(async _token => {
158
await gate;
159
return { summary: 'test', toolCallRoundId: 'r1' };
160
});
161
expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);
162
163
// Cancel before the work completes
164
summarizer.cancel();
165
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
166
167
// Let the work complete — the .then() should NOT overwrite the Idle state.
168
// Use setTimeout to yield to the macrotask queue, guaranteeing all
169
// microtasks (including the .then() handler) have drained first.
170
resolveFn!();
171
await new Promise<void>(resolve => setTimeout(resolve, 0));
172
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
173
});
174
175
test('cancel prevents .catch() from setting state to Failed', async () => {
176
const summarizer = new BackgroundSummarizer(100_000);
177
let rejectFn: (err: Error) => void;
178
const gate = new Promise<void>((_, reject) => { rejectFn = reject; });
179
180
summarizer.start(async _token => {
181
await gate;
182
return { summary: 'unreachable', toolCallRoundId: 'r1' };
183
});
184
185
summarizer.cancel();
186
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
187
188
// Let the work fail — the .catch() should NOT overwrite the Idle state.
189
// Use setTimeout to yield to the macrotask queue, guaranteeing all
190
// microtasks (including the .catch() handler) have drained first.
191
rejectFn!(new Error('fail'));
192
await new Promise<void>(resolve => setTimeout(resolve, 0));
193
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
194
expect(summarizer.error).toBeUndefined();
195
});
196
197
test('waitForCompletion is a no-op when nothing started', async () => {
198
const summarizer = new BackgroundSummarizer(100_000);
199
await summarizer.waitForCompletion();
200
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
201
});
202
203
test('multiple waitForCompletion calls resolve correctly', async () => {
204
const summarizer = new BackgroundSummarizer(100_000);
205
let resolveFn: () => void;
206
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
207
summarizer.start(async _token => {
208
await gate;
209
return { summary: 'test', toolCallRoundId: 'r1' };
210
});
211
resolveFn!();
212
// Both should resolve without error
213
await Promise.all([
214
summarizer.waitForCompletion(),
215
summarizer.waitForCompletion(),
216
]);
217
expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);
218
});
219
220
test('waitForCompletion resolves without error even when work fails', async () => {
221
// agentIntent.ts calls waitForCompletion without try/catch in the
222
// blocking paths — verify it swallows the error.
223
const summarizer = new BackgroundSummarizer(100_000);
224
summarizer.start(async _token => {
225
throw new Error('network timeout');
226
});
227
// Should not throw
228
await summarizer.waitForCompletion();
229
expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);
230
});
231
232
test('cancel during waitForCompletion leaves state Idle with no result', async () => {
233
// Tests the race where a caller is awaiting completion and cancellation happens
234
const summarizer = new BackgroundSummarizer(100_000);
235
let resolveFn: () => void;
236
const gate = new Promise<void>(resolve => { resolveFn = resolve; });
237
summarizer.start(async _token => {
238
await gate;
239
return { summary: 'test', toolCallRoundId: 'r1' };
240
});
241
// Start awaiting completion (captures the promise but doesn't resolve yet)
242
const completionPromise = summarizer.waitForCompletion();
243
// Cancel while waitForCompletion is pending
244
summarizer.cancel();
245
// Let the work resolve so the promise settles
246
resolveFn!();
247
await completionPromise;
248
await new Promise<void>(resolve => setTimeout(resolve, 0));
249
250
// State should be Idle (cancel resets) and no result available
251
const result = summarizer.consumeAndReset();
252
expect(result).toBeUndefined();
253
expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);
254
});
255
});
256
257
const { base, warmJitterMin, warmJitterSpan, emergency } = BackgroundSummarizationThresholds;
258
259
// rng that always returns 0.5 -> threshold sits exactly at the center of the
260
// jitter range. With [0.78, 0.82) that's 0.80.
261
const midRng = () => 0.5;
262
// rng that forces the maximum of the jitter range.
263
const maxRng = () => 1 - Number.EPSILON;
264
// rng that should never be called on cold/non-inline branches.
265
const unusedRng = () => {
266
throw new Error('rng should not be consumed');
267
};
268
269
describe('shouldKickOffBackgroundSummarization', () => {
270
describe('inline + cold cache', () => {
271
test('defers kick-off below the emergency threshold', () => {
272
// Cold turn sitting in the old 0.80 trigger band — must not fire.
273
expect(shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).toBe(false);
274
});
275
276
test('kicks off at the emergency threshold', () => {
277
expect(shouldKickOffBackgroundSummarization(emergency, true, false, unusedRng)).toBe(true);
278
expect(shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).toBe(true);
279
});
280
281
test('does not consume the rng on the cold branch', () => {
282
// The unusedRng would throw if consumed — asserting no throw is the check.
283
expect(() => shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).not.toThrow();
284
expect(() => shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).not.toThrow();
285
});
286
});
287
288
describe('inline + warm cache', () => {
289
test('kicks off at the jittered midpoint (0.80) when ratio meets it', () => {
290
expect(shouldKickOffBackgroundSummarization(0.80, true, true, midRng)).toBe(true);
291
expect(shouldKickOffBackgroundSummarization(0.81, true, true, midRng)).toBe(true);
292
});
293
294
test('defers when ratio is under the jittered threshold', () => {
295
// midRng -> 0.80; 0.77 is below the entire jitter window.
296
expect(shouldKickOffBackgroundSummarization(0.77, true, true, midRng)).toBe(false);
297
// Also below the minimum of the window regardless of rng.
298
expect(shouldKickOffBackgroundSummarization(warmJitterMin - 0.0001, true, true, () => 0)).toBe(false);
299
});
300
301
test('respects the top of the jitter range', () => {
302
// With maxRng, threshold approaches warmJitterMin + warmJitterSpan = 0.82.
303
// 0.81 lands below it, so we defer.
304
expect(shouldKickOffBackgroundSummarization(0.81, true, true, maxRng)).toBe(false);
305
// 0.82 meets it.
306
expect(shouldKickOffBackgroundSummarization(warmJitterMin + warmJitterSpan, true, true, maxRng)).toBe(true);
307
});
308
});
309
310
describe('non-inline path', () => {
311
test('uses the fixed base threshold and ignores cache warmth', () => {
312
// Warm, cold — both behave the same on non-inline.
313
expect(shouldKickOffBackgroundSummarization(base, false, false, unusedRng)).toBe(true);
314
expect(shouldKickOffBackgroundSummarization(base, false, true, unusedRng)).toBe(true);
315
expect(shouldKickOffBackgroundSummarization(base - 0.0001, false, true, unusedRng)).toBe(false);
316
});
317
});
318
});
319
320