diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts index bbe0c69bbe3..72a0df70f1a 100644 --- a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionModel.ts @@ -92,13 +92,19 @@ export interface IInteractiveSessionModel { getRequests(): IInteractiveRequestModel[]; } -export interface IDeserializedInteractiveSessionData { +export interface IDeserializedInteractiveSessionItem { requests: InteractiveRequestModel[]; + providerId: string; providerState: any; } +export interface IDeserializedInteractiveSessionsData { + [providerId: string]: IDeserializedInteractiveSessionItem[]; +} + export interface ISerializableInteractiveSessionData { requests: { message: string; response: string | undefined }[]; + providerId: string; providerState: any; } @@ -128,7 +134,7 @@ export class InteractiveSessionModel extends Disposable implements IInteractiveS private _requests: InteractiveRequestModel[]; private _providerState: any; - static deserialize(obj: ISerializableInteractiveSessionData): IDeserializedInteractiveSessionData { + static deserialize(obj: ISerializableInteractiveSessionData): IDeserializedInteractiveSessionItem { const requests = obj.requests; if (!Array.isArray(requests)) { throw new Error(`Malformed session data: ${obj}`); @@ -141,14 +147,14 @@ export class InteractiveSessionModel extends Disposable implements IInteractiveS } return request; }); - return { requests: requestModels, providerState: obj.providerState }; + return { requests: requestModels, providerState: obj.providerState, providerId: obj.providerId }; } get sessionId(): number { return this.session.id; } - constructor(public readonly session: IInteractiveSession, public readonly providerId: string, initialData?: IDeserializedInteractiveSessionData) { + constructor(public readonly session: IInteractiveSession, public readonly providerId: string, initialData?: IDeserializedInteractiveSessionItem) { super(); this._requests = initialData ? initialData.requests : []; this._providerState = initialData ? initialData.providerState : undefined; @@ -202,6 +208,7 @@ export class InteractiveSessionModel extends Disposable implements IInteractiveS response: r.response ? r.response.response.value : undefined, }; }), + providerId: this.providerId, providerState: this._providerState }; } diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts index ae14a628e9d..d3e1510a2a6 100644 --- a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionService.ts @@ -33,7 +33,7 @@ export interface IInteractiveProvider { id: string; prepareSession(initialState: IPersistedInteractiveState | undefined, token: CancellationToken): ProviderResult; resolveRequest?(session: IInteractiveSession, context: any, token: CancellationToken): ProviderResult; - provideSuggestions(token: CancellationToken): ProviderResult; + provideSuggestions?(token: CancellationToken): ProviderResult; provideReply(request: IInteractiveRequest, progress: (progress: IInteractiveProgress) => void, token: CancellationToken): ProviderResult; } diff --git a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts index 4f0d124325c..2dcb01c22ac 100644 --- a/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts +++ b/src/vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl.ts @@ -4,12 +4,13 @@ *--------------------------------------------------------------------------------------------*/ import { CancellationToken } from 'vs/base/common/cancellation'; +import { groupBy } from 'vs/base/common/collections'; import { Iterable } from 'vs/base/common/iterator'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { withNullAsUndefined } from 'vs/base/common/types'; import { ILogService } from 'vs/platform/log/common/log'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; -import { InteractiveRequestModel, InteractiveSessionModel, IDeserializedInteractiveSessionData } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; +import { IDeserializedInteractiveSessionsData, InteractiveRequestModel, InteractiveSessionModel } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionModel'; import { IInteractiveProgress, IInteractiveProvider, IInteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; @@ -21,7 +22,7 @@ export class InteractiveSessionService extends Disposable implements IInteractiv private readonly _providers = new Map(); private readonly _sessionModels = new Map(); private readonly _pendingRequestSessions = new Set(); - private readonly _unprocessedPersistedSessions: IDeserializedInteractiveSessionData[]; + private readonly _unprocessedPersistedSessions: IDeserializedInteractiveSessionsData; constructor( @IStorageService storageService: IStorageService, @@ -31,10 +32,11 @@ export class InteractiveSessionService extends Disposable implements IInteractiv super(); const sessionData = storageService.get(serializedInteractiveSessionKey, StorageScope.WORKSPACE, ''); if (sessionData) { - this._unprocessedPersistedSessions = this.restoreInteractiveSessions(sessionData); - this.trace('constructor', `Restored ${this._unprocessedPersistedSessions.length} persisted sessions`); + this._unprocessedPersistedSessions = this.deserializeInteractiveSessions(sessionData); + const countsForLog = Object.keys(this._unprocessedPersistedSessions).map(key => `${key}: ${this._unprocessedPersistedSessions[key].length}`).join(', '); + this.trace('constructor', `Restored persisted sessions: ${countsForLog}`); } else { - this._unprocessedPersistedSessions = []; + this._unprocessedPersistedSessions = {}; this.trace('constructor', 'No persisted sessions'); } @@ -53,17 +55,18 @@ export class InteractiveSessionService extends Disposable implements IInteractiv this.logService.error(`[InteractiveSessionService#${method}] ${message}`); } - private restoreInteractiveSessions(sessionData: string): IDeserializedInteractiveSessionData[] { + private deserializeInteractiveSessions(sessionData: string): IDeserializedInteractiveSessionsData { try { const obj = JSON.parse(sessionData); if (!Array.isArray(obj)) { throw new Error('Expected array'); } - return obj.map(item => InteractiveSessionModel.deserialize(item)); + const items = obj.map(item => InteractiveSessionModel.deserialize(item)); + return groupBy(items, item => item.providerId); } catch (err) { - this.error('restoreInteractiveSessions', `Malformed session data: ${err}. [${sessionData.substring(0, 20)}...]`); - return []; + this.error('deserializeInteractiveSessions', `Malformed session data: ${err}. [${sessionData.substring(0, 20)}${sessionData.length > 20 ? '...' : ''}]`); + return {}; } } @@ -76,12 +79,13 @@ export class InteractiveSessionService extends Disposable implements IInteractiv throw new Error(`Unknown provider: ${providerId}`); } - const someSessionHistory = allowRestoringSession ? this._unprocessedPersistedSessions.shift() : undefined; + const providerData = this._unprocessedPersistedSessions[providerId] ?? []; + const someSessionHistory = allowRestoringSession ? providerData.shift() : undefined; this.trace('startSession', `Has history: ${!!someSessionHistory}. Including provider state: ${!!someSessionHistory?.providerState}`); const session = await provider.prepareSession(someSessionHistory?.providerState, token); if (!session) { if (someSessionHistory) { - this._unprocessedPersistedSessions.unshift(someSessionHistory); + providerData.unshift(someSessionHistory); } this.trace('startSession', 'Provider returned no session'); @@ -95,7 +99,7 @@ export class InteractiveSessionService extends Disposable implements IInteractiv } sendRequest(sessionId: number, message: string, token: CancellationToken): boolean { - this.trace('sendRequest', `sessionId: ${sessionId}, message: ${message.substring(0, 20)}[...]`); + this.trace('sendRequest', `sessionId: ${sessionId}, message: ${message.substring(0, 20)}${message.length > 20 ? '[...]' : ''}}`); if (!message.trim()) { this.trace('sendRequest', 'Rejected empty message'); return false; @@ -208,6 +212,7 @@ export class InteractiveSessionService extends Disposable implements IInteractiv } async provideSuggestions(providerId: string, token: CancellationToken): Promise { + this.trace('provideSuggestions', `Called for provider ${providerId}`); await this.extensionService.activateByEvent(`onInteractiveSession:${providerId}`); const provider = this._providers.get(providerId); @@ -215,6 +220,10 @@ export class InteractiveSessionService extends Disposable implements IInteractiv throw new Error(`Unknown provider: ${providerId}`); } + if (!provider.provideSuggestions) { + return; + } + const suggestions = await provider.provideSuggestions(token); this.trace('provideSuggestions', `Provider returned ${suggestions?.length} suggestions`); return withNullAsUndefined(suggestions); diff --git a/src/vs/workbench/contrib/interactiveSession/test/common/interactiveSessionService.test.ts b/src/vs/workbench/contrib/interactiveSession/test/common/interactiveSessionService.test.ts new file mode 100644 index 00000000000..5b62767d5c1 --- /dev/null +++ b/src/vs/workbench/contrib/interactiveSession/test/common/interactiveSessionService.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; +import { ILogService, NullLogService } from 'vs/platform/log/common/log'; +import { IStorageService } from 'vs/platform/storage/common/storage'; +import { IInteractiveProvider, IInteractiveRequest } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionService'; +import { InteractiveSessionService } from 'vs/workbench/contrib/interactiveSession/common/interactiveSessionServiceImpl'; +import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions'; +import { TestExtensionService, TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; + +suite('InteractiveSession', () => { + const testDisposables = new DisposableStore(); + + let storageService: IStorageService; + let instantiationService: TestInstantiationService; + + suiteSetup(async () => { + instantiationService = new TestInstantiationService(); + instantiationService.stub(IStorageService, storageService = new TestStorageService()); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IExtensionService, new TestExtensionService()); + }); + + teardown(() => { + testDisposables.clear(); + }); + + test('Restores state for the correct provider', async () => { + let sessionId = 0; + function getTestProvider(providerId: string) { + return new class implements IInteractiveProvider { + readonly id = providerId; + + lastInitialState = undefined; + + prepareSession(initialState: any) { + this.lastInitialState = initialState; + return Promise.resolve({ id: sessionId++ }); + } + + async provideReply(request: IInteractiveRequest) { + return { session: request.session, followups: [] }; + } + }; + } + + const testService = instantiationService.createInstance(InteractiveSessionService); + const provider1 = getTestProvider('provider1'); + const provider2 = getTestProvider('provider2'); + testService.registerProvider(provider1); + testService.registerProvider(provider2); + + let session1 = await testService.startSession('provider1', true, CancellationToken.None); + let session2 = await testService.startSession('provider2', true, CancellationToken.None); + assert.strictEqual(provider1.lastInitialState, undefined); + assert.strictEqual(provider2.lastInitialState, undefined); + testService.acceptNewSessionState(session1!.sessionId, { state: 'provider1_state' }); + testService.acceptNewSessionState(session2!.sessionId, { state: 'provider2_state' }); + storageService.flush(); + + const testService2 = instantiationService.createInstance(InteractiveSessionService); + testService2.registerProvider(provider1); + testService2.registerProvider(provider2); + session1 = await testService2.startSession('provider1', true, CancellationToken.None); + session2 = await testService2.startSession('provider2', true, CancellationToken.None); + assert.deepStrictEqual(provider1.lastInitialState, { state: 'provider1_state' }); + assert.deepStrictEqual(provider2.lastInitialState, { state: 'provider2_state' }); + }); +}); + +