Path: blob/main/extensions/copilot/src/extension/test/node/pseudoStartStopConversationCallback.spec.ts
13399 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 assert from 'assert';6import * as sinon from 'sinon';7import { afterEach, beforeEach, suite, test } from 'vitest';8import type { ChatToolInvocationStreamData, ChatVulnerability } from 'vscode';9import { IResponsePart } from '../../../platform/chat/common/chatMLFetcher';10import { IResponseDelta } from '../../../platform/networking/common/fetch';11import { createPlatformServices } from '../../../platform/test/node/services';12import { ChatResponseStreamImpl } from '../../../util/common/chatResponseStreamImpl';13import { SpyChatResponseStream } from '../../../util/common/test/mockChatResponseStream';14import { AsyncIterableSource } from '../../../util/vs/base/common/async';15import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';16import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';17import { ChatResponseMarkdownPart, ChatResponseMarkdownWithVulnerabilitiesPart } from '../../../vscodeTypes';18import { PseudoStopStartResponseProcessor } from '../../prompt/node/pseudoStartStopConversationCallback';192021suite('Post Report Conversation Callback', () => {22const postReportFn = (deltas: IResponseDelta[]) => {23return ['<processed>', ...deltas.map(d => d.text), '</processed>'];24};25const annotations = [{ id: 123, details: { type: 'type', description: 'description' } }, { id: 456, details: { type: 'type2', description: 'description2' } }];2627let instaService: IInstantiationService;2829beforeEach(() => {30const accessor = createPlatformServices().createTestingAccessor();31instaService = accessor.get(IInstantiationService);32});3334test('Simple post-report', async () => {35const responseSource = new AsyncIterableSource<IResponsePart>();36const stream = new SpyChatResponseStream();37const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,38[{39start: 'end',40stop: 'start'41}],42postReportFn);4344responseSource.emitOne({ delta: { text: 'one' } });45responseSource.emitOne({ delta: { text: ' start ' } });46responseSource.emitOne({ delta: { text: 'two' } });47responseSource.emitOne({ delta: { text: ' end' } });48responseSource.resolve();4950await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);5152assert.deepStrictEqual(53stream.items.map(p => (p as ChatResponseMarkdownPart).value.value),54['one', ' ', '<processed>', ' ', 'two', ' ', '</processed>']);55});5657test('Partial stop word with extra text before', async () => {58const responseSource = new AsyncIterableSource<IResponsePart>();59const stream = new SpyChatResponseStream();60const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,61[{62start: 'end',63stop: 'start'64}],65postReportFn);6667responseSource.emitOne({ delta: { text: 'one sta' } });68responseSource.emitOne({ delta: { text: 'rt' } });69responseSource.emitOne({ delta: { text: ' two end' } });70responseSource.resolve();7172await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);73assert.deepStrictEqual(74stream.items.map(p => (p as ChatResponseMarkdownPart).value.value),75['one ', '<processed>', ' two ', '</processed>']76);77});7879test('Partial stop word with extra text after', async () => {80const responseSource = new AsyncIterableSource<IResponsePart>();81const stream = new SpyChatResponseStream();82const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,83[{84start: 'end',85stop: 'start'86}],87postReportFn);8889responseSource.emitOne({ delta: { text: 'one ', codeVulnAnnotations: annotations } });90responseSource.emitOne({ delta: { text: 'sta' } });91responseSource.emitOne({ delta: { text: 'rt two' } });92responseSource.emitOne({ delta: { text: ' end' } });93responseSource.resolve();9495await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);96assert.deepStrictEqual((stream.items[0] as ChatResponseMarkdownWithVulnerabilitiesPart).vulnerabilities, annotations.map(a => ({ title: a.details.type, description: a.details.description } satisfies ChatVulnerability)));9798assert.deepStrictEqual(99stream.items.map(p => (p as ChatResponseMarkdownPart).value.value),100['one ', '<processed>', ' two', ' ', '</processed>']);101});102103test('no second stop word', async () => {104const responseSource = new AsyncIterableSource<IResponsePart>();105const stream = new SpyChatResponseStream();106const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,107[{108start: 'end',109stop: 'start'110}],111postReportFn,112);113114responseSource.emitOne({ delta: { text: 'one' } });115responseSource.emitOne({ delta: { text: ' start ' } });116responseSource.emitOne({ delta: { text: 'two' } });117responseSource.emitOne({ delta: { text: ' ' } });118responseSource.resolve();119120await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);121assert.deepStrictEqual(122stream.items.map(p => (p as ChatResponseMarkdownPart).value.value),123['one', ' ']);124});125126test('Text on same line as start', async () => {127const responseSource = new AsyncIterableSource<IResponsePart>();128const stream = new SpyChatResponseStream();129const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,130[131{132start: 'end',133stop: 'start'134}135],136postReportFn);137138responseSource.emitOne({ delta: { text: 'this is test text\n\n' } });139responseSource.emitOne({ delta: { text: 'eeep start\n\n' } });140responseSource.emitOne({ delta: { text: 'test test test test 123456' } });141responseSource.emitOne({ delta: { text: 'end\n\nhello' } });142responseSource.resolve();143144await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);145assert.deepStrictEqual(146stream.items.map(p => (p as ChatResponseMarkdownPart).value.value),147['this is test text\n\n', 'eeep ', '<processed>', '\n\n', 'test test test test 123456', '</processed>', '\n\nhello']);148});149150151test('Start word without a stop word', async () => {152const responseSource = new AsyncIterableSource<IResponsePart>();153154const stream = new SpyChatResponseStream();155const testObj = instaService.createInstance(PseudoStopStartResponseProcessor,156[{157start: '[RESPONSE END]',158stop: '[RESPONSE START]'159}],160postReportFn);161162163responseSource.emitOne({ delta: { text: `I'm sorry, but as an AI programming assistant, I'm here to provide assistance with software development topics, specifically related to Visual Studio Code. I'm not equipped to provide a definition of a computer. [RESPONSE END]` } });164responseSource.resolve();165166await testObj.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);167assert.strictEqual((stream.items[0] as ChatResponseMarkdownPart).value.value, `I'm sorry, but as an AI programming assistant, I'm here to provide assistance with software development topics, specifically related to Visual Studio Code. I'm not equipped to provide a definition of a computer. [RESPONSE END]`);168});169170afterEach(() => sinon.restore());171});172173suite('Tool stream throttling', () => {174let clock: sinon.SinonFakeTimers;175let updateCalls: { toolCallId: string; streamData: ChatToolInvocationStreamData }[];176let stream: ChatResponseStreamImpl;177178beforeEach(() => {179clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] });180updateCalls = [];181stream = new ChatResponseStreamImpl(182() => { },183() => { },184undefined,185undefined,186(toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }),187);188});189190afterEach(() => {191clock.restore();192sinon.restore();193});194195test('first update is emitted immediately', async () => {196const responseSource = new AsyncIterableSource<IResponsePart>();197const processor = new PseudoStopStartResponseProcessor([], undefined);198199responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });200responseSource.resolve();201202await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);203204assert.strictEqual(updateCalls.length, 1);205assert.strictEqual(updateCalls[0].toolCallId, 'tool1');206});207208test('rapid updates within throttle window are throttled', async () => {209const responseSource = new AsyncIterableSource<IResponsePart>();210const processor = new PseudoStopStartResponseProcessor([], undefined);211212// First update goes through immediately213responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });214// These arrive within the 100ms throttle window — should be buffered215responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });216responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":3}' }] } });217responseSource.resolve();218219await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);220221// 1 immediate + 1 flush of the last buffered update = 2 total222assert.strictEqual(updateCalls.length, 2);223assert.strictEqual(updateCalls[0].toolCallId, 'tool1');224assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 3 });225});226227test('update after throttle window elapses is emitted immediately', async () => {228const responseSource = new AsyncIterableSource<IResponsePart>();229const processor = new PseudoStopStartResponseProcessor([], undefined);230231responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });232clock.tick(100);233responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });234responseSource.resolve();235236await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);237238// Both emitted immediately (no pending flush needed)239assert.strictEqual(updateCalls.length, 2);240assert.deepStrictEqual(updateCalls[0].streamData.partialInput, { a: 1 });241assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 2 });242});243244test('different tool IDs are throttled independently', async () => {245const responseSource = new AsyncIterableSource<IResponsePart>();246const processor = new PseudoStopStartResponseProcessor([], undefined);247248responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });249responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool2', name: 'myTool', arguments: '{"b":1}' }] } });250// These are within the throttle window for their respective tools251responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });252responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool2', name: 'myTool', arguments: '{"b":2}' }] } });253responseSource.resolve();254255await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);256257// 2 immediate (one per tool) + 2 flushed (one per tool) = 4258assert.strictEqual(updateCalls.length, 4);259});260261test('pending updates are not flushed on cancellation', async () => {262const cts = new CancellationTokenSource();263const responseSource = new AsyncIterableSource<IResponsePart>();264const processor = new PseudoStopStartResponseProcessor([], undefined);265266// Start processing, then emit items so the for-await loop consumes them267const promise = processor.doProcessResponse(responseSource.asyncIterable, stream, cts.token);268269responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });270await new Promise(r => setTimeout(r, 0));271responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });272await new Promise(r => setTimeout(r, 0));273274// Cancel after items are processed but before stream ends275cts.cancel();276responseSource.resolve();277278await promise;279280// Only the first immediate update — buffered update should NOT be flushed281assert.strictEqual(updateCalls.length, 1);282assert.strictEqual(updateCalls[0].toolCallId, 'tool1');283});284285test('retry clears pending throttle state', async () => {286const responseSource = new AsyncIterableSource<IResponsePart>();287const clearCalls: number[] = [];288const clearStream = new ChatResponseStreamImpl(289() => { },290() => clearCalls.push(1),291undefined,292undefined,293(toolCallId, streamData) => updateCalls.push({ toolCallId, streamData }),294);295const processor = new PseudoStopStartResponseProcessor([], undefined);296297// Buffer a pending update298responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":1}' }] } });299responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":2}' }] } });300// Retry clears everything301responseSource.emitOne({ delta: { text: '', retryReason: 'network_error' } });302// New update after retry should go through immediately303clock.tick(100);304responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: 'myTool', arguments: '{"a":3}' }] } });305responseSource.resolve();306307await processor.doProcessResponse(responseSource.asyncIterable, clearStream, CancellationToken.None);308309// 1 immediate before retry + 1 immediate after retry = 2310// The buffered {"a":2} should have been cleared by retry, not flushed311assert.strictEqual(updateCalls.length, 2);312assert.deepStrictEqual(updateCalls[0].streamData.partialInput, { a: 1 });313assert.deepStrictEqual(updateCalls[1].streamData.partialInput, { a: 3 });314});315316test('updates without name are skipped', async () => {317const responseSource = new AsyncIterableSource<IResponsePart>();318const processor = new PseudoStopStartResponseProcessor([], undefined);319320responseSource.emitOne({ delta: { text: '', copilotToolCallStreamUpdates: [{ id: 'tool1', name: undefined as any, arguments: '{"a":1}' }] } });321responseSource.resolve();322323await processor.doProcessResponse(responseSource.asyncIterable, stream, CancellationToken.None);324325assert.strictEqual(updateCalls.length, 0);326});327});328329330