mirror of
https://github.com/microsoft/vscode.git
synced 2026-07-09 00:05:28 +01:00
998 lines
41 KiB
TypeScript
998 lines
41 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import assert from 'assert';
|
|
import * as sinon from 'sinon';
|
|
import type * as vscode from 'vscode';
|
|
import { CancellationToken } from '../../../../base/common/cancellation.js';
|
|
import { DisposableStore } from '../../../../base/common/lifecycle.js';
|
|
import { URI } from '../../../../base/common/uri.js';
|
|
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
|
|
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
|
|
import { TestConfigurationService } from '../../../../platform/configuration/test/common/testConfigurationService.js';
|
|
import { ContextKeyService } from '../../../../platform/contextkey/browser/contextKeyService.js';
|
|
import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
|
|
import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js';
|
|
import { TestInstantiationService } from '../../../../platform/instantiation/test/common/instantiationServiceMock.js';
|
|
import { ILogService, NullLogService } from '../../../../platform/log/common/log.js';
|
|
import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js';
|
|
import { IChatAgentRequest } from '../../../contrib/chat/common/participants/chatAgents.js';
|
|
import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js';
|
|
import { IChatSessionRequestHistoryItem, IChatSessionProviderOptionGroup, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js';
|
|
import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js';
|
|
import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js';
|
|
import { IEditorService } from '../../../services/editor/common/editorService.js';
|
|
import { IExtHostContext } from '../../../services/extensions/common/extHostCustomers.js';
|
|
import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js';
|
|
import { IExtensionService, nullExtensionDescription } from '../../../services/extensions/common/extensions.js';
|
|
import { IViewsService } from '../../../services/views/common/viewsService.js';
|
|
import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js';
|
|
import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js';
|
|
import { ExtHostChatSessions } from '../../common/extHostChatSessions.js';
|
|
import { ExtHostCommands } from '../../common/extHostCommands.js';
|
|
import { ExtHostLanguageModels } from '../../common/extHostLanguageModels.js';
|
|
import * as extHostTypes from '../../common/extHostTypes.js';
|
|
import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionProviderOptions } from '../../common/extHost.protocol.js';
|
|
import { IExtHostAuthentication } from '../../common/extHostAuthentication.js';
|
|
import { IExtHostTelemetry } from '../../common/extHostTelemetry.js';
|
|
import { ILabelService } from '../../../../platform/label/common/label.js';
|
|
import { MockChatService } from '../../../contrib/chat/test/common/chatService/mockChatService.js';
|
|
import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js';
|
|
import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions/agentSessionsModel.js';
|
|
import { Event } from '../../../../base/common/event.js';
|
|
import { AnyCallRPCProtocol } from '../common/testRPCProtocol.js';
|
|
|
|
suite('ObservableChatSession', function () {
|
|
let disposables: DisposableStore;
|
|
let logService: ILogService;
|
|
let dialogService: IDialogService;
|
|
let proxy: ExtHostChatSessionsShape;
|
|
|
|
setup(function () {
|
|
disposables = new DisposableStore();
|
|
logService = new NullLogService();
|
|
|
|
dialogService = new class extends mock<IDialogService>() {
|
|
override async confirm() {
|
|
return { confirmed: true };
|
|
}
|
|
};
|
|
|
|
proxy = {
|
|
$provideChatSessionContent: sinon.stub(),
|
|
$provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise<IChatSessionProviderOptions | undefined>>().resolves(undefined),
|
|
$provideHandleOptionsChange: sinon.stub(),
|
|
$invokeOptionGroupSearch: sinon.stub().resolves([]),
|
|
$interruptChatSessionActiveResponse: sinon.stub(),
|
|
$invokeChatSessionRequestHandler: sinon.stub(),
|
|
$disposeChatSessionContent: sinon.stub(),
|
|
$refreshChatSessionItems: sinon.stub(),
|
|
$onDidChangeChatSessionItemState: sinon.stub(),
|
|
$newChatSessionItem: sinon.stub().resolves(undefined),
|
|
$forkChatSession: sinon.stub().resolves(undefined),
|
|
};
|
|
});
|
|
|
|
teardown(function () {
|
|
disposables.dispose();
|
|
sinon.restore();
|
|
});
|
|
|
|
ensureNoDisposablesAreLeakedInTestSuite();
|
|
|
|
function createSessionContent(options: {
|
|
id?: string;
|
|
title?: string;
|
|
history?: any[];
|
|
hasActiveResponseCallback?: boolean;
|
|
hasRequestHandler?: boolean;
|
|
hasForkHandler?: boolean;
|
|
} = {}) {
|
|
return {
|
|
id: options.id || 'test-id',
|
|
title: options.title,
|
|
history: options.history || [],
|
|
hasActiveResponseCallback: options.hasActiveResponseCallback ?? false,
|
|
hasRequestHandler: options.hasRequestHandler ?? false,
|
|
hasForkHandler: options.hasForkHandler ?? false
|
|
};
|
|
}
|
|
|
|
async function createInitializedSession(sessionContent: any, sessionId = 'test-id'): Promise<ObservableChatSession> {
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = new ObservableChatSession(resource, 1, proxy, logService, dialogService);
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
await session.initialize(CancellationToken.None, { initialSessionOptions: [] });
|
|
return session;
|
|
}
|
|
|
|
test('constructor creates session with proper initial state', function () {
|
|
const sessionId = 'test-id';
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService));
|
|
|
|
assert.strictEqual(session.providerHandle, 1);
|
|
assert.deepStrictEqual(session.history, []);
|
|
assert.ok(session.progressObs);
|
|
assert.ok(session.isCompleteObs);
|
|
|
|
// Initial state should be inactive and incomplete
|
|
assert.deepStrictEqual(session.progressObs.get(), []);
|
|
assert.strictEqual(session.isCompleteObs.get(), false);
|
|
});
|
|
|
|
test('session queues progress before initialization and processes it after', async function () {
|
|
const sessionId = 'test-id';
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService));
|
|
|
|
const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Hello', isTrusted: false } };
|
|
const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'World', isTrusted: false } };
|
|
|
|
// Add progress before initialization - should be queued
|
|
session.handleProgressChunk('req1', [progress1]);
|
|
session.handleProgressChunk('req1', [progress2]);
|
|
|
|
// Progress should be queued, not visible yet
|
|
assert.deepStrictEqual(session.progressObs.get(), []);
|
|
|
|
// Initialize the session
|
|
const sessionContent = createSessionContent();
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
await session.initialize(CancellationToken.None, { initialSessionOptions: [] });
|
|
|
|
// Now progress should be visible
|
|
assert.strictEqual(session.progressObs.get().length, 2);
|
|
assert.deepStrictEqual(session.progressObs.get(), [progress1, progress2]);
|
|
assert.strictEqual(session.isCompleteObs.get(), true); // Should be complete for sessions without active response callback or request handler
|
|
});
|
|
|
|
test('initialization loads session history and sets up capabilities', async function () {
|
|
const sessionHistory = [
|
|
{ type: 'request', prompt: 'Previous question' },
|
|
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Previous answer', isTrusted: false } }] }
|
|
];
|
|
|
|
const sessionContent = createSessionContent({
|
|
history: sessionHistory,
|
|
hasActiveResponseCallback: true,
|
|
hasRequestHandler: true
|
|
});
|
|
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
// Verify history was loaded
|
|
assert.strictEqual(session.history.length, 2);
|
|
assert.strictEqual(session.history[0].type, 'request');
|
|
assert.strictEqual(session.history[0].prompt, 'Previous question');
|
|
assert.strictEqual(session.history[1].type, 'response');
|
|
|
|
// Verify capabilities were set up
|
|
assert.ok(session.interruptActiveResponseCallback);
|
|
assert.ok(session.requestHandler);
|
|
});
|
|
|
|
test('initialization sets forkSession and revives forked items', async function () {
|
|
const session = disposables.add(await createInitializedSession(createSessionContent({ hasForkHandler: true })));
|
|
assert.ok(session.forkSession);
|
|
|
|
const forkedResource = URI.file('/tmp/forked-chat.md');
|
|
const forkedItem = {
|
|
resource: forkedResource,
|
|
label: 'Forked Session',
|
|
timing: {
|
|
created: 123,
|
|
lastRequestStarted: 234,
|
|
lastRequestEnded: 345,
|
|
},
|
|
changes: [{
|
|
uri: URI.file('/tmp/changed.ts'),
|
|
originalUri: URI.file('/tmp/original.ts'),
|
|
insertions: 4,
|
|
deletions: 2,
|
|
}],
|
|
};
|
|
(proxy.$forkChatSession as sinon.SinonStub).resolves(forkedItem);
|
|
|
|
const request: IChatSessionRequestHistoryItem = { type: 'request', id: 'request-1', prompt: 'Previous question', participant: 'participant' };
|
|
const expectedRequestDto = {
|
|
type: 'request',
|
|
id: 'request-1',
|
|
prompt: 'Previous question',
|
|
participant: 'participant',
|
|
command: undefined,
|
|
variableData: undefined,
|
|
modelId: undefined,
|
|
};
|
|
const result = await session.forkSession?.(request, CancellationToken.None);
|
|
|
|
assert.ok((proxy.$forkChatSession as sinon.SinonStub).calledOnceWithExactly(1, session.sessionResource, expectedRequestDto, CancellationToken.None));
|
|
assert.ok(result);
|
|
assert.ok(result.resource instanceof URI);
|
|
assert.ok(Array.isArray(result.changes));
|
|
assert.ok(result.changes[0].uri instanceof URI);
|
|
assert.ok(result.changes[0].originalUri instanceof URI);
|
|
assert.deepStrictEqual(result, forkedItem);
|
|
});
|
|
|
|
test('initialization sets title from session content', async function () {
|
|
const sessionContent = createSessionContent({
|
|
title: 'My Custom Title',
|
|
});
|
|
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
assert.strictEqual(session.title, 'My Custom Title');
|
|
});
|
|
|
|
test('title is undefined when not provided in session content', async function () {
|
|
const sessionContent = createSessionContent();
|
|
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
assert.strictEqual(session.title, undefined);
|
|
});
|
|
|
|
test('initialization is idempotent and returns same promise', async function () {
|
|
const sessionId = 'test-id';
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService));
|
|
|
|
const sessionContent = createSessionContent();
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const promise1 = session.initialize(CancellationToken.None, { initialSessionOptions: [] });
|
|
const promise2 = session.initialize(CancellationToken.None, { initialSessionOptions: [] });
|
|
|
|
assert.strictEqual(promise1, promise2);
|
|
await promise1;
|
|
|
|
// Should only call proxy once even though initialize was called twice
|
|
assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);
|
|
});
|
|
|
|
test('initialization forwards initial session options context', async function () {
|
|
const sessionId = 'test-id';
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService));
|
|
const initialSessionOptions = [{ optionId: 'model', value: { name: 'gpt-4.1', id: 'gpt-4.1' } }];
|
|
|
|
const sessionContent = createSessionContent();
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
await session.initialize(CancellationToken.None, { initialSessionOptions });
|
|
|
|
assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnceWith(
|
|
1,
|
|
resource,
|
|
{ initialSessionOptions },
|
|
CancellationToken.None
|
|
));
|
|
});
|
|
|
|
test('progress handling works correctly after initialization', async function () {
|
|
const sessionContent = createSessionContent();
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'New progress', isTrusted: false } };
|
|
|
|
// Add progress after initialization
|
|
session.handleProgressChunk('req1', [progress]);
|
|
|
|
assert.deepStrictEqual(session.progressObs.get(), [progress]);
|
|
// Session with no capabilities should remain complete
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
});
|
|
|
|
test('progress completion updates session state correctly', async function () {
|
|
const sessionContent = createSessionContent();
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
// Add some progress first
|
|
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };
|
|
session.handleProgressChunk('req1', [progress]);
|
|
|
|
// Session with no capabilities should already be complete
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
session.handleProgressComplete('req1');
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
});
|
|
|
|
test('session with active response callback becomes active when progress is added', async function () {
|
|
const sessionContent = createSessionContent({ hasActiveResponseCallback: true });
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
// Session should start inactive and incomplete (has capabilities but no active progress)
|
|
assert.strictEqual(session.isCompleteObs.get(), false);
|
|
|
|
const progress: IChatProgress = { kind: 'progressMessage', content: { value: 'Processing...', isTrusted: false } };
|
|
session.handleProgressChunk('req1', [progress]);
|
|
|
|
assert.strictEqual(session.isCompleteObs.get(), false);
|
|
session.handleProgressComplete('req1');
|
|
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
});
|
|
|
|
test('request handler forwards requests to proxy', async function () {
|
|
const sessionContent = createSessionContent({ hasRequestHandler: true });
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
assert.ok(session.requestHandler);
|
|
|
|
const request: IChatAgentRequest = {
|
|
requestId: 'req1',
|
|
sessionResource: LocalChatSessionUri.forSession('test-session'),
|
|
agentId: 'test-agent',
|
|
message: 'Test prompt',
|
|
location: ChatAgentLocation.Chat,
|
|
variables: { variables: [] }
|
|
};
|
|
const progressCallback = sinon.stub();
|
|
|
|
await session.requestHandler!(request, progressCallback, [], CancellationToken.None);
|
|
|
|
assert.ok((proxy.$invokeChatSessionRequestHandler as sinon.SinonStubbedMember<typeof proxy.$invokeChatSessionRequestHandler>).calledOnceWith(1, session.sessionResource, request, [], CancellationToken.None));
|
|
});
|
|
|
|
test('request handler forwards progress updates to external callback', async function () {
|
|
const sessionContent = createSessionContent({ hasRequestHandler: true });
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
assert.ok(session.requestHandler);
|
|
|
|
const request: IChatAgentRequest = {
|
|
requestId: 'req1',
|
|
sessionResource: LocalChatSessionUri.forSession('test-session'),
|
|
agentId: 'test-agent',
|
|
message: 'Test prompt',
|
|
location: ChatAgentLocation.Chat,
|
|
variables: { variables: [] }
|
|
};
|
|
const progressCallback = sinon.stub();
|
|
|
|
let resolveRequest: () => void;
|
|
const requestPromise = new Promise<void>(resolve => {
|
|
resolveRequest = resolve;
|
|
});
|
|
|
|
(proxy.$invokeChatSessionRequestHandler as sinon.SinonStub).returns(requestPromise);
|
|
|
|
const requestHandlerPromise = session.requestHandler!(request, progressCallback, [], CancellationToken.None);
|
|
|
|
const progress1: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 1', isTrusted: false } };
|
|
const progress2: IChatProgress = { kind: 'progressMessage', content: { value: 'Progress 2', isTrusted: false } };
|
|
|
|
session.handleProgressChunk('req1', [progress1]);
|
|
session.handleProgressChunk('req1', [progress2]);
|
|
|
|
// Wait a bit for autorun to trigger
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
assert.ok(progressCallback.calledTwice);
|
|
assert.deepStrictEqual(progressCallback.firstCall.args[0], [progress1]);
|
|
assert.deepStrictEqual(progressCallback.secondCall.args[0], [progress2]);
|
|
|
|
// Complete the request
|
|
resolveRequest!();
|
|
await requestHandlerPromise;
|
|
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
});
|
|
|
|
test('dispose properly cleans up resources and notifies listeners', function () {
|
|
const sessionId = 'test-id';
|
|
const resource = LocalChatSessionUri.forSession(sessionId);
|
|
const session = disposables.add(new ObservableChatSession(resource, 1, proxy, logService, dialogService));
|
|
|
|
let disposeEventFired = false;
|
|
const disposable = session.onWillDispose(() => {
|
|
disposeEventFired = true;
|
|
});
|
|
|
|
session.dispose();
|
|
|
|
assert.ok(disposeEventFired);
|
|
assert.ok((proxy.$disposeChatSessionContent as sinon.SinonStubbedMember<typeof proxy.$disposeChatSessionContent>).calledOnceWith(1, resource));
|
|
|
|
disposable.dispose();
|
|
});
|
|
|
|
test('session with multiple request/response pairs in history', async function () {
|
|
const sessionHistory = [
|
|
{ type: 'request', prompt: 'First question' },
|
|
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },
|
|
{ type: 'request', prompt: 'Second question' },
|
|
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }
|
|
];
|
|
|
|
const sessionContent = createSessionContent({
|
|
history: sessionHistory,
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
});
|
|
|
|
const session = disposables.add(await createInitializedSession(sessionContent));
|
|
|
|
// Verify all history was loaded correctly
|
|
assert.strictEqual(session.history.length, 4);
|
|
assert.strictEqual(session.history[0].type, 'request');
|
|
assert.strictEqual(session.history[0].prompt, 'First question');
|
|
assert.strictEqual(session.history[1].type, 'response');
|
|
assert.strictEqual((session.history[1].parts[0] as IChatProgressMessage).content.value, 'First answer');
|
|
assert.strictEqual(session.history[2].type, 'request');
|
|
assert.strictEqual(session.history[2].prompt, 'Second question');
|
|
assert.strictEqual(session.history[3].type, 'response');
|
|
assert.strictEqual((session.history[3].parts[0] as IChatProgressMessage).content.value, 'Second answer');
|
|
|
|
// Session should be complete since it has no capabilities
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
});
|
|
});
|
|
|
|
suite('MainThreadChatSessions', function () {
|
|
let instantiationService: TestInstantiationService;
|
|
let mainThread: MainThreadChatSessions;
|
|
let proxy: ExtHostChatSessionsShape;
|
|
let chatSessionsService: IChatSessionsService;
|
|
let disposables: DisposableStore;
|
|
|
|
setup(function () {
|
|
disposables = new DisposableStore();
|
|
instantiationService = new TestInstantiationService();
|
|
|
|
proxy = {
|
|
$provideChatSessionContent: sinon.stub(),
|
|
$provideChatSessionProviderOptions: sinon.stub<[providerHandle: number, token: CancellationToken], Promise<IChatSessionProviderOptions | undefined>>().resolves(undefined),
|
|
$provideHandleOptionsChange: sinon.stub(),
|
|
$invokeOptionGroupSearch: sinon.stub().resolves([]),
|
|
$interruptChatSessionActiveResponse: sinon.stub(),
|
|
$invokeChatSessionRequestHandler: sinon.stub(),
|
|
$disposeChatSessionContent: sinon.stub(),
|
|
$refreshChatSessionItems: sinon.stub(),
|
|
$onDidChangeChatSessionItemState: sinon.stub(),
|
|
$newChatSessionItem: sinon.stub().resolves(undefined),
|
|
$forkChatSession: sinon.stub().resolves(undefined),
|
|
};
|
|
|
|
const extHostContext = new class implements IExtHostContext {
|
|
remoteAuthority = '';
|
|
extensionHostKind = ExtensionHostKind.LocalProcess;
|
|
dispose() { }
|
|
assertRegistered() { }
|
|
set(v: any): any { return null; }
|
|
getProxy(): any { return proxy; }
|
|
drain(): any { return null; }
|
|
};
|
|
|
|
instantiationService.stub(IConfigurationService, new TestConfigurationService());
|
|
instantiationService.stub(IContextKeyService, disposables.add(instantiationService.createInstance(ContextKeyService)));
|
|
instantiationService.stub(ILogService, new NullLogService());
|
|
instantiationService.stub(IEditorService, new class extends mock<IEditorService>() { });
|
|
instantiationService.stub(IExtensionService, new TestExtensionService());
|
|
instantiationService.stub(IViewsService, new class extends mock<IViewsService>() {
|
|
override async openView() { return null; }
|
|
});
|
|
instantiationService.stub(IDialogService, new class extends mock<IDialogService>() {
|
|
override async confirm() {
|
|
return { confirmed: true };
|
|
}
|
|
});
|
|
instantiationService.stub(ILabelService, new class extends mock<ILabelService>() {
|
|
override registerFormatter() {
|
|
return {
|
|
dispose: () => { }
|
|
};
|
|
}
|
|
});
|
|
instantiationService.stub(IChatService, new MockChatService());
|
|
instantiationService.stub(IAgentSessionsService, new class extends mock<IAgentSessionsService>() {
|
|
override get model(): IAgentSessionsModel {
|
|
return new class extends mock<IAgentSessionsModel>() {
|
|
override onDidChangeSessionArchivedState = Event.None;
|
|
};
|
|
}
|
|
|
|
});
|
|
|
|
chatSessionsService = disposables.add(instantiationService.createInstance(ChatSessionsService));
|
|
instantiationService.stub(IChatSessionsService, chatSessionsService);
|
|
mainThread = disposables.add(instantiationService.createInstance(MainThreadChatSessions, extHostContext));
|
|
});
|
|
|
|
teardown(function () {
|
|
disposables.dispose();
|
|
instantiationService.dispose();
|
|
sinon.restore();
|
|
});
|
|
|
|
ensureNoDisposablesAreLeakedInTestSuite();
|
|
|
|
test('provideChatSessionContent creates and initializes session', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
};
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
const session1 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
assert.ok(session1);
|
|
|
|
const session2 = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
assert.strictEqual(session1, session2);
|
|
|
|
assert.ok((proxy.$provideChatSessionContent as sinon.SinonStub).calledOnce);
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('provideChatSessionContent propagates title', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
title: 'My Session Title',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
};
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
assert.strictEqual(session.title, 'My Session Title');
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('$handleProgressChunk routes to correct session', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession;
|
|
|
|
const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };
|
|
await mainThread.$handleProgressChunk(1, resource, 'req1', [progressDto]);
|
|
|
|
assert.strictEqual(session.progressObs.get().length, 1);
|
|
assert.strictEqual(session.progressObs.get()[0].kind, 'progressMessage');
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('$handleProgressComplete marks session complete', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession;
|
|
|
|
const progressDto: IChatProgressDto = { kind: 'progressMessage', content: { value: 'Test', isTrusted: false } };
|
|
await mainThread.$handleProgressChunk(1, resource, 'req1', [progressDto]);
|
|
mainThread.$handleProgressComplete(1, resource, 'req1');
|
|
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('integration with multiple request/response pairs', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'multi-turn-session',
|
|
history: [
|
|
{ type: 'request', prompt: 'First question' },
|
|
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'First answer', isTrusted: false } }] },
|
|
{ type: 'request', prompt: 'Second question' },
|
|
{ type: 'response', parts: [{ kind: 'progressMessage', content: { value: 'Second answer', isTrusted: false } }] }
|
|
],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/multi-turn-session`);
|
|
const session = await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None) as ObservableChatSession;
|
|
|
|
// Verify the session loaded correctly
|
|
assert.ok(session);
|
|
assert.strictEqual(session.history.length, 4);
|
|
|
|
// Verify all history items are correctly loaded
|
|
assert.strictEqual(session.history[0].type, 'request');
|
|
assert.strictEqual(session.history[0].prompt, 'First question');
|
|
assert.strictEqual(session.history[1].type, 'response');
|
|
assert.strictEqual(session.history[2].type, 'request');
|
|
assert.strictEqual(session.history[2].prompt, 'Second question');
|
|
assert.strictEqual(session.history[3].type, 'response');
|
|
|
|
// Session should be complete since it has no active capabilities
|
|
assert.strictEqual(session.isCompleteObs.get(), true);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('$onDidChangeChatSessionProviderOptions refreshes option groups', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
const handle = 1;
|
|
|
|
const optionGroups1: IChatSessionProviderOptionGroup[] = [{
|
|
id: 'models',
|
|
name: 'Models',
|
|
items: [{ id: 'modelA', name: 'Model A' }]
|
|
}];
|
|
const optionGroups2: IChatSessionProviderOptionGroup[] = [{
|
|
id: 'models',
|
|
name: 'Models',
|
|
items: [{ id: 'modelB', name: 'Model B' }]
|
|
}];
|
|
|
|
const provideOptionsStub = proxy.$provideChatSessionProviderOptions as sinon.SinonStub;
|
|
provideOptionsStub.onFirstCall().resolves({ optionGroups: optionGroups1 } as IChatSessionProviderOptions);
|
|
provideOptionsStub.onSecondCall().resolves({ optionGroups: optionGroups2 } as IChatSessionProviderOptions);
|
|
|
|
mainThread.$registerChatSessionContentProvider(handle, sessionScheme);
|
|
|
|
// Wait for initial options fetch triggered on registration
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
let storedGroups = chatSessionsService.getOptionGroupsForSessionType(sessionScheme);
|
|
assert.ok(storedGroups);
|
|
assert.strictEqual(storedGroups![0].items[0].id, 'modelA');
|
|
|
|
// Simulate extension signaling that provider options have changed
|
|
mainThread.$onDidChangeChatSessionProviderOptions(handle);
|
|
await new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
storedGroups = chatSessionsService.getOptionGroupsForSessionType(sessionScheme);
|
|
assert.ok(storedGroups);
|
|
assert.strictEqual(storedGroups![0].items[0].id, 'modelB');
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(handle);
|
|
});
|
|
|
|
test('getSessionOption returns undefined for unset options', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
// No options provided
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
// getSessionOption should return undefined for unset options
|
|
assert.strictEqual(chatSessionsService.getSessionOption(resource, 'models'), undefined);
|
|
assert.strictEqual(chatSessionsService.getSessionOption(resource, 'anyOption'), undefined);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('getSessionOption returns value for explicitly set options', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
options: {
|
|
'models': 'gpt-4',
|
|
'region': { id: 'us-east', name: 'US East' }
|
|
}
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
// getSessionOption should return the configured values
|
|
assert.strictEqual(chatSessionsService.getSessionOption(resource, 'models'), 'gpt-4');
|
|
assert.deepStrictEqual(chatSessionsService.getSessionOption(resource, 'region'), { id: 'us-east', name: 'US East' });
|
|
|
|
// getSessionOption should return undefined for options not in the session
|
|
assert.strictEqual(chatSessionsService.getSessionOption(resource, 'notConfigured'), undefined);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('option change notifications are sent to the extension', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
const handle = 1;
|
|
|
|
mainThread.$registerChatSessionContentProvider(handle, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
options: {
|
|
'models': 'gpt-4'
|
|
}
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
// Clear the stub call history
|
|
(proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory();
|
|
|
|
// Simulate an option change
|
|
await chatSessionsService.notifySessionOptionsChange(resource, [
|
|
{ optionId: 'models', value: { name: 'gpt-4-turbo', id: 'gpt-4-turbo' } }
|
|
]);
|
|
|
|
// Verify the extension was notified
|
|
assert.ok((proxy.$provideHandleOptionsChange as sinon.SinonStub).calledOnce);
|
|
const call = (proxy.$provideHandleOptionsChange as sinon.SinonStub).firstCall;
|
|
assert.strictEqual(call.args[0], handle);
|
|
assert.deepStrictEqual(call.args[1], resource);
|
|
assert.deepStrictEqual(call.args[2], [{ optionId: 'models', value: { name: 'gpt-4-turbo', id: 'gpt-4-turbo' } }]);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(handle);
|
|
});
|
|
|
|
test('option change notifications fail silently when provider not registered', async function () {
|
|
const sessionScheme = 'unregistered-session-type';
|
|
|
|
// Do NOT register a content provider for this scheme
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
|
|
// Clear any previous calls
|
|
(proxy.$provideHandleOptionsChange as sinon.SinonStub).resetHistory();
|
|
|
|
// Attempt to notify option change for an unregistered scheme
|
|
// This should not throw, but also should not call the proxy
|
|
await chatSessionsService.notifySessionOptionsChange(resource, [
|
|
{ optionId: 'models', value: { name: 'gpt-4-turbo', id: 'gpt-4-turbo' } }
|
|
]);
|
|
|
|
// Verify the extension was NOT notified (no provider registered)
|
|
assert.strictEqual((proxy.$provideHandleOptionsChange as sinon.SinonStub).callCount, 0);
|
|
});
|
|
|
|
test('setSessionOption updates option and getSessionOption reflects change', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
const sessionContent = {
|
|
id: 'test-session',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
// Start with no options
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub).resolves(sessionContent);
|
|
|
|
const resource = URI.parse(`${sessionScheme}:/test-session`);
|
|
await chatSessionsService.getOrCreateChatSession(resource, CancellationToken.None);
|
|
|
|
// Initially no options set
|
|
assert.strictEqual(chatSessionsService.getSessionOption(resource, 'models'), undefined);
|
|
|
|
// Set an option
|
|
chatSessionsService.setSessionOption(resource, 'models', { name: 'gpt-4', id: 'gpt-4' });
|
|
|
|
// Now getSessionOption should return the value
|
|
assert.deepStrictEqual(chatSessionsService.getSessionOption(resource, 'models'), { name: 'gpt-4', id: 'gpt-4' });
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
|
|
test('hasAnySessionOptions returns correct values', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
mainThread.$registerChatSessionContentProvider(1, sessionScheme);
|
|
|
|
// Session with options
|
|
const sessionContentWithOptions = {
|
|
id: 'session-with-options',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
options: { 'models': 'gpt-4' }
|
|
};
|
|
|
|
// Session without options
|
|
const sessionContentWithoutOptions = {
|
|
id: 'session-without-options',
|
|
history: [],
|
|
hasActiveResponseCallback: false,
|
|
hasRequestHandler: false,
|
|
};
|
|
|
|
(proxy.$provideChatSessionContent as sinon.SinonStub)
|
|
.onFirstCall().resolves(sessionContentWithOptions)
|
|
.onSecondCall().resolves(sessionContentWithoutOptions);
|
|
|
|
const resourceWithOptions = URI.parse(`${sessionScheme}:/session-with-options`);
|
|
const resourceWithoutOptions = URI.parse(`${sessionScheme}:/session-without-options`);
|
|
|
|
await chatSessionsService.getOrCreateChatSession(resourceWithOptions, CancellationToken.None);
|
|
await chatSessionsService.getOrCreateChatSession(resourceWithoutOptions, CancellationToken.None);
|
|
|
|
assert.strictEqual(chatSessionsService.hasAnySessionOptions(resourceWithOptions), true);
|
|
assert.strictEqual(chatSessionsService.hasAnySessionOptions(resourceWithoutOptions), false);
|
|
|
|
mainThread.$unregisterChatSessionContentProvider(1);
|
|
});
|
|
});
|
|
|
|
suite('ExtHostChatSessions', function () {
|
|
let disposables: DisposableStore;
|
|
let extHostChatSessions: ExtHostChatSessions;
|
|
let mainThreadChatSessionsProxy: {
|
|
$registerChatSessionItemController: sinon.SinonStub;
|
|
$unregisterChatSessionItemController: sinon.SinonStub;
|
|
$updateChatSessionItems: sinon.SinonStub;
|
|
$addOrUpdateChatSessionItem: sinon.SinonStub;
|
|
$onDidCommitChatSessionItem: sinon.SinonStub;
|
|
$registerChatSessionContentProvider: sinon.SinonStub;
|
|
$unregisterChatSessionContentProvider: sinon.SinonStub;
|
|
$onDidChangeChatSessionOptions: sinon.SinonStub;
|
|
$onDidChangeChatSessionProviderOptions: sinon.SinonStub;
|
|
};
|
|
|
|
setup(function () {
|
|
disposables = new DisposableStore();
|
|
mainThreadChatSessionsProxy = {
|
|
$registerChatSessionItemController: sinon.stub(),
|
|
$unregisterChatSessionItemController: sinon.stub(),
|
|
$updateChatSessionItems: sinon.stub().resolves(),
|
|
$addOrUpdateChatSessionItem: sinon.stub().resolves(),
|
|
$onDidCommitChatSessionItem: sinon.stub(),
|
|
$registerChatSessionContentProvider: sinon.stub(),
|
|
$unregisterChatSessionContentProvider: sinon.stub(),
|
|
$onDidChangeChatSessionOptions: sinon.stub(),
|
|
$onDidChangeChatSessionProviderOptions: sinon.stub(),
|
|
};
|
|
|
|
const rpcProtocol = AnyCallRPCProtocol(mainThreadChatSessionsProxy);
|
|
const commands = new ExtHostCommands(rpcProtocol, new NullLogService(), new class extends mock<IExtHostTelemetry>() { });
|
|
const languageModels = new ExtHostLanguageModels(rpcProtocol, new NullLogService(), new class extends mock<IExtHostAuthentication>() { });
|
|
|
|
extHostChatSessions = disposables.add(new ExtHostChatSessions(commands, languageModels, rpcProtocol, new NullLogService()));
|
|
});
|
|
|
|
teardown(function () {
|
|
disposables.dispose();
|
|
sinon.restore();
|
|
});
|
|
|
|
ensureNoDisposablesAreLeakedInTestSuite();
|
|
|
|
function createContentProvider(session: vscode.ChatSession): vscode.ChatSessionContentProvider {
|
|
return {
|
|
provideChatSessionContent: async () => session,
|
|
};
|
|
}
|
|
|
|
test('advertises controller fork support when only the controller registers a fork handler', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
const sessionResource = URI.parse(`${sessionScheme}:/test-session`);
|
|
const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { }));
|
|
controller.forkHandler = async resource => controller.createChatSessionItem(resource.with({ path: '/forked-session' }), 'Forked Session');
|
|
|
|
disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({
|
|
history: [],
|
|
requestHandler: undefined,
|
|
})));
|
|
|
|
const session = await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None);
|
|
|
|
assert.strictEqual(session.hasForkHandler, true);
|
|
await extHostChatSessions.$disposeChatSessionContent(0, sessionResource);
|
|
});
|
|
|
|
test('prefers controller fork handler over deprecated session fork handler', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
const sessionResource = URI.parse(`${sessionScheme}:/test-session`);
|
|
const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1');
|
|
const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { }));
|
|
const controllerItem = controller.createChatSessionItem(URI.parse(`${sessionScheme}:/forked-by-controller`), 'Forked by Controller');
|
|
const sessionItem = {
|
|
resource: URI.parse(`${sessionScheme}:/forked-by-session`),
|
|
label: 'Forked by Session'
|
|
};
|
|
|
|
const controllerForkHandler = sinon.stub().resolves(controllerItem);
|
|
const deprecatedSessionForkHandler = sinon.stub().resolves(sessionItem);
|
|
controller.forkHandler = controllerForkHandler;
|
|
|
|
disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({
|
|
history: [requestTurn],
|
|
requestHandler: undefined,
|
|
forkHandler: deprecatedSessionForkHandler,
|
|
})));
|
|
|
|
await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None);
|
|
const result = await extHostChatSessions.$forkChatSession(0, sessionResource, {
|
|
type: 'request',
|
|
id: 'request-1',
|
|
prompt: 'prompt',
|
|
participant: 'participant',
|
|
}, CancellationToken.None);
|
|
|
|
assert.ok(controllerForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None));
|
|
assert.strictEqual(deprecatedSessionForkHandler.callCount, 0);
|
|
assert.strictEqual(result.resource.toString(), controllerItem.resource.toString());
|
|
assert.strictEqual(result.label, controllerItem.label);
|
|
await extHostChatSessions.$disposeChatSessionContent(0, sessionResource);
|
|
});
|
|
|
|
test('falls back to deprecated session fork handler when no controller fork handler exists', async function () {
|
|
const sessionScheme = 'test-session-type';
|
|
const sessionResource = URI.parse(`${sessionScheme}:/test-session`);
|
|
const requestTurn = new extHostTypes.ChatRequestTurn('prompt', undefined, [], 'participant', [], undefined, 'request-1');
|
|
const deprecatedSessionForkHandler = sinon.stub().resolves({
|
|
resource: URI.parse(`${sessionScheme}:/forked-by-session`),
|
|
label: 'Forked by Session'
|
|
});
|
|
|
|
disposables.add(extHostChatSessions.registerChatSessionContentProvider(nullExtensionDescription, sessionScheme, undefined!, createContentProvider({
|
|
history: [requestTurn],
|
|
requestHandler: undefined,
|
|
forkHandler: deprecatedSessionForkHandler,
|
|
})));
|
|
|
|
await extHostChatSessions.$provideChatSessionContent(0, sessionResource, { initialSessionOptions: [] }, CancellationToken.None);
|
|
const result = await extHostChatSessions.$forkChatSession(0, sessionResource, {
|
|
type: 'request',
|
|
id: 'request-1',
|
|
prompt: 'prompt',
|
|
participant: 'participant',
|
|
}, CancellationToken.None);
|
|
|
|
assert.ok(deprecatedSessionForkHandler.calledOnceWithExactly(sessionResource, requestTurn, CancellationToken.None));
|
|
assert.strictEqual(result.resource.toString(), `${sessionScheme}:/forked-by-session`);
|
|
assert.strictEqual(result.label, 'Forked by Session');
|
|
await extHostChatSessions.$disposeChatSessionContent(0, sessionResource);
|
|
});
|
|
});
|