Path: blob/main/extensions/copilot/src/extension/prompts/node/agent/test/backgroundSummarizer.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { describe, expect, test } from 'vitest';6import { BackgroundSummarizationState, BackgroundSummarizationThresholds, BackgroundSummarizer, IBackgroundSummarizationResult, shouldKickOffBackgroundSummarization } from '../backgroundSummarizer';78describe('BackgroundSummarizer', () => {910test('initial state is Idle', () => {11const summarizer = new BackgroundSummarizer(100_000);12expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);13expect(summarizer.error).toBeUndefined();14expect(summarizer.token).toBeUndefined();15});1617test('start transitions to InProgress', async () => {18const summarizer = new BackgroundSummarizer(100_000);19summarizer.start(async _token => {20return { summary: 'test', toolCallRoundId: 'r1' };21});22expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);23expect(summarizer.token).toBeDefined();24await summarizer.waitForCompletion();25});2627test('successful work transitions to Completed', async () => {28const summarizer = new BackgroundSummarizer(100_000);29const result: IBackgroundSummarizationResult = { summary: 'test summary', toolCallRoundId: 'round1' };30summarizer.start(async _token => result);31await summarizer.waitForCompletion();32expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);33});3435test('failed work transitions to Failed', async () => {36const summarizer = new BackgroundSummarizer(100_000);37summarizer.start(async _token => {38throw new Error('summarization failed');39});40await summarizer.waitForCompletion();41expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);42expect(summarizer.error).toBeInstanceOf(Error);43});4445test('consumeAndReset returns result and resets to Idle', async () => {46const summarizer = new BackgroundSummarizer(100_000);47const expected: IBackgroundSummarizationResult = { summary: 'test summary', toolCallRoundId: 'round1' };48summarizer.start(async _token => expected);49await summarizer.waitForCompletion();5051const result = summarizer.consumeAndReset();52expect(result).toEqual(expected);53expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);54expect(summarizer.token).toBeUndefined();55});5657test('consumeAndReset returns undefined while InProgress', async () => {58const summarizer = new BackgroundSummarizer(100_000);59let resolveFn: () => void;60const gate = new Promise<void>(resolve => { resolveFn = resolve; });61summarizer.start(async _token => {62await gate;63return { summary: 'test', toolCallRoundId: 'r1' };64});65expect(summarizer.consumeAndReset()).toBeUndefined();66expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);67summarizer.cancel();68resolveFn!();69await new Promise<void>(resolve => setTimeout(resolve, 0));70});7172test('consumeAndReset returns undefined after failure', async () => {73const summarizer = new BackgroundSummarizer(100_000);74summarizer.start(async _token => {75throw new Error('fail');76});77await summarizer.waitForCompletion();78expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);7980const result = summarizer.consumeAndReset();81expect(result).toBeUndefined();82expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);83});8485test('start is a no-op when already InProgress', async () => {86const summarizer = new BackgroundSummarizer(100_000);87let callCount = 0;88let resolveFn: () => void;89const gate = new Promise<void>(resolve => { resolveFn = resolve; });90summarizer.start(async _token => {91callCount++;92await gate;93return { summary: 'first', toolCallRoundId: 'r1' };94});95// Second start should be ignored96summarizer.start(async _token => {97callCount++;98return { summary: 'second', toolCallRoundId: 'r2' };99});100resolveFn!();101await summarizer.waitForCompletion();102expect(callCount).toBe(1);103expect(summarizer.consumeAndReset()?.summary).toBe('first');104});105106test('start is a no-op when already Completed', async () => {107const summarizer = new BackgroundSummarizer(100_000);108summarizer.start(async _token => ({ summary: 'first', toolCallRoundId: 'r1' }));109await summarizer.waitForCompletion();110expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);111112// Second start should be ignored because state is Completed113summarizer.start(async _token => ({ summary: 'second', toolCallRoundId: 'r2' }));114expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);115expect(summarizer.consumeAndReset()?.summary).toBe('first');116});117118test('start retries after Failed state', async () => {119const summarizer = new BackgroundSummarizer(100_000);120summarizer.start(async _token => {121throw new Error('fail');122});123await summarizer.waitForCompletion();124expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);125126// Should be allowed to retry127summarizer.start(async _token => ({ summary: 'retry', toolCallRoundId: 'r2' }));128await summarizer.waitForCompletion();129expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);130expect(summarizer.consumeAndReset()?.summary).toBe('retry');131});132133test('cancel resets state to Idle', async () => {134const summarizer = new BackgroundSummarizer(100_000);135let resolveFn: () => void;136const gate = new Promise<void>(resolve => { resolveFn = resolve; });137summarizer.start(async _token => {138await gate;139return { summary: 'test', toolCallRoundId: 'r1' };140});141expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);142143summarizer.cancel();144expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);145expect(summarizer.token).toBeUndefined();146expect(summarizer.error).toBeUndefined();147resolveFn!();148await new Promise<void>(resolve => setTimeout(resolve, 0));149});150151test('cancel prevents .then() from setting state to Completed', async () => {152const summarizer = new BackgroundSummarizer(100_000);153let resolveFn: () => void;154const gate = new Promise<void>(resolve => { resolveFn = resolve; });155156summarizer.start(async _token => {157await gate;158return { summary: 'test', toolCallRoundId: 'r1' };159});160expect(summarizer.state).toBe(BackgroundSummarizationState.InProgress);161162// Cancel before the work completes163summarizer.cancel();164expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);165166// Let the work complete — the .then() should NOT overwrite the Idle state.167// Use setTimeout to yield to the macrotask queue, guaranteeing all168// microtasks (including the .then() handler) have drained first.169resolveFn!();170await new Promise<void>(resolve => setTimeout(resolve, 0));171expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);172});173174test('cancel prevents .catch() from setting state to Failed', async () => {175const summarizer = new BackgroundSummarizer(100_000);176let rejectFn: (err: Error) => void;177const gate = new Promise<void>((_, reject) => { rejectFn = reject; });178179summarizer.start(async _token => {180await gate;181return { summary: 'unreachable', toolCallRoundId: 'r1' };182});183184summarizer.cancel();185expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);186187// Let the work fail — the .catch() should NOT overwrite the Idle state.188// Use setTimeout to yield to the macrotask queue, guaranteeing all189// microtasks (including the .catch() handler) have drained first.190rejectFn!(new Error('fail'));191await new Promise<void>(resolve => setTimeout(resolve, 0));192expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);193expect(summarizer.error).toBeUndefined();194});195196test('waitForCompletion is a no-op when nothing started', async () => {197const summarizer = new BackgroundSummarizer(100_000);198await summarizer.waitForCompletion();199expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);200});201202test('multiple waitForCompletion calls resolve correctly', async () => {203const summarizer = new BackgroundSummarizer(100_000);204let resolveFn: () => void;205const gate = new Promise<void>(resolve => { resolveFn = resolve; });206summarizer.start(async _token => {207await gate;208return { summary: 'test', toolCallRoundId: 'r1' };209});210resolveFn!();211// Both should resolve without error212await Promise.all([213summarizer.waitForCompletion(),214summarizer.waitForCompletion(),215]);216expect(summarizer.state).toBe(BackgroundSummarizationState.Completed);217});218219test('waitForCompletion resolves without error even when work fails', async () => {220// agentIntent.ts calls waitForCompletion without try/catch in the221// blocking paths — verify it swallows the error.222const summarizer = new BackgroundSummarizer(100_000);223summarizer.start(async _token => {224throw new Error('network timeout');225});226// Should not throw227await summarizer.waitForCompletion();228expect(summarizer.state).toBe(BackgroundSummarizationState.Failed);229});230231test('cancel during waitForCompletion leaves state Idle with no result', async () => {232// Tests the race where a caller is awaiting completion and cancellation happens233const summarizer = new BackgroundSummarizer(100_000);234let resolveFn: () => void;235const gate = new Promise<void>(resolve => { resolveFn = resolve; });236summarizer.start(async _token => {237await gate;238return { summary: 'test', toolCallRoundId: 'r1' };239});240// Start awaiting completion (captures the promise but doesn't resolve yet)241const completionPromise = summarizer.waitForCompletion();242// Cancel while waitForCompletion is pending243summarizer.cancel();244// Let the work resolve so the promise settles245resolveFn!();246await completionPromise;247await new Promise<void>(resolve => setTimeout(resolve, 0));248249// State should be Idle (cancel resets) and no result available250const result = summarizer.consumeAndReset();251expect(result).toBeUndefined();252expect(summarizer.state).toBe(BackgroundSummarizationState.Idle);253});254});255256const { base, warmJitterMin, warmJitterSpan, emergency } = BackgroundSummarizationThresholds;257258// rng that always returns 0.5 -> threshold sits exactly at the center of the259// jitter range. With [0.78, 0.82) that's 0.80.260const midRng = () => 0.5;261// rng that forces the maximum of the jitter range.262const maxRng = () => 1 - Number.EPSILON;263// rng that should never be called on cold/non-inline branches.264const unusedRng = () => {265throw new Error('rng should not be consumed');266};267268describe('shouldKickOffBackgroundSummarization', () => {269describe('inline + cold cache', () => {270test('defers kick-off below the emergency threshold', () => {271// Cold turn sitting in the old 0.80 trigger band — must not fire.272expect(shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).toBe(false);273});274275test('kicks off at the emergency threshold', () => {276expect(shouldKickOffBackgroundSummarization(emergency, true, false, unusedRng)).toBe(true);277expect(shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).toBe(true);278});279280test('does not consume the rng on the cold branch', () => {281// The unusedRng would throw if consumed — asserting no throw is the check.282expect(() => shouldKickOffBackgroundSummarization(0.85, true, false, unusedRng)).not.toThrow();283expect(() => shouldKickOffBackgroundSummarization(0.91, true, false, unusedRng)).not.toThrow();284});285});286287describe('inline + warm cache', () => {288test('kicks off at the jittered midpoint (0.80) when ratio meets it', () => {289expect(shouldKickOffBackgroundSummarization(0.80, true, true, midRng)).toBe(true);290expect(shouldKickOffBackgroundSummarization(0.81, true, true, midRng)).toBe(true);291});292293test('defers when ratio is under the jittered threshold', () => {294// midRng -> 0.80; 0.77 is below the entire jitter window.295expect(shouldKickOffBackgroundSummarization(0.77, true, true, midRng)).toBe(false);296// Also below the minimum of the window regardless of rng.297expect(shouldKickOffBackgroundSummarization(warmJitterMin - 0.0001, true, true, () => 0)).toBe(false);298});299300test('respects the top of the jitter range', () => {301// With maxRng, threshold approaches warmJitterMin + warmJitterSpan = 0.82.302// 0.81 lands below it, so we defer.303expect(shouldKickOffBackgroundSummarization(0.81, true, true, maxRng)).toBe(false);304// 0.82 meets it.305expect(shouldKickOffBackgroundSummarization(warmJitterMin + warmJitterSpan, true, true, maxRng)).toBe(true);306});307});308309describe('non-inline path', () => {310test('uses the fixed base threshold and ignores cache warmth', () => {311// Warm, cold — both behave the same on non-inline.312expect(shouldKickOffBackgroundSummarization(base, false, false, unusedRng)).toBe(true);313expect(shouldKickOffBackgroundSummarization(base, false, true, unusedRng)).toBe(true);314expect(shouldKickOffBackgroundSummarization(base - 0.0001, false, true, unusedRng)).toBe(false);315});316});317});318319320