diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index e5a45b43d65..1099251114a 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -148,7 +148,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._agents.deleteAndDispose(handle); } - $transferActiveChatSession(toWorkspace: UriComponents): void { + async $transferActiveChatSession(toWorkspace: UriComponents): Promise { const widget = this._chatWidgetService.lastFocusedWidget; const model = widget?.viewModel?.model; if (!model) { @@ -156,8 +156,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA return; } - const location = widget.location; - this._chatService.transferChatSession({ sessionId: model.sessionId, inputState: model.inputModel.state.get(), location }, URI.revive(toWorkspace)); + await this._chatService.transferChatSession(model.sessionResource, URI.revive(toWorkspace)); } async $registerAgent(handle: number, extension: ExtensionIdentifier, id: string, metadata: IExtensionChatAgentMetadata, dynamicProps: IDynamicChatAgentProps | undefined): Promise { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 8fc90fe1cb9..ccfd8731f6e 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1469,7 +1469,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I // namespace: interactive const interactive: typeof vscode.interactive = { - transferActiveChat(toWorkspace: vscode.Uri) { + transferActiveChat(toWorkspace: vscode.Uri): Thenable { checkProposedApiEnabled(extension, 'interactive'); return extHostChatAgents2.transferActiveChat(toWorkspace); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index d24a2d7b932..11941ad8369 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1402,7 +1402,7 @@ export interface MainThreadChatAgentsShape2 extends IChatAgentProgressShape, IDi $updateAgent(handle: number, metadataUpdate: IExtensionChatAgentMetadata): void; $unregisterAgent(handle: number): void; - $transferActiveChatSession(toWorkspace: UriComponents): void; + $transferActiveChatSession(toWorkspace: UriComponents): Promise; } export interface ICodeMapperTextEdit { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 5aa9eaa3398..047976cf458 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -437,8 +437,8 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS }); } - transferActiveChat(newWorkspace: vscode.Uri): void { - this._proxy.$transferActiveChatSession(newWorkspace); + async transferActiveChat(newWorkspace: vscode.Uri): Promise { + await this._proxy.$transferActiveChatSession(newWorkspace); } createChatAgent(extension: IExtensionDescription, id: string, handler: vscode.ChatExtendedRequestHandler): vscode.ChatParticipant { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index fa0a9b4bdb4..0095bf738f0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -310,13 +310,15 @@ abstract class OpenChatGlobalAction extends Action2 { let resp: Promise | undefined; if (opts?.query) { - chatWidget.setInput(opts.query); - if (!opts.isPartialQuery) { + if (opts.isPartialQuery) { + chatWidget.setInput(opts.query); + } else { if (!chatWidget.viewModel) { await Event.toPromise(chatWidget.onDidChangeViewModel); } await waitForDefaultAgent(chatAgentService, chatWidget.input.currentModeKind); + chatWidget.setInput(opts.query); // wait until the model is restored before setting the input, or it will be cleared when the model is restored resp = chatWidget.acceptInput(); } } diff --git a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts index b6a683b7ef2..02e29f23337 100644 --- a/src/vs/workbench/contrib/chat/browser/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/chatViewPane.ts @@ -215,9 +215,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { private onDidChangeAgents(): void { if (this.chatAgentService.getDefaultAgent(ChatAgentLocation.Chat)) { if (!this._widget?.viewModel && !this.restoringSession) { - const info = this.getTransferredOrPersistedSessionInfo(); + const sessionResource = this.getTransferredOrPersistedSessionInfo(); this.restoringSession = - (info.sessionId ? this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(info.sessionId)) : Promise.resolve(undefined)).then(async modelRef => { + (sessionResource ? this.chatService.getOrRestoreSession(sessionResource) : Promise.resolve(undefined)).then(async modelRef => { if (!this._widget) { return; // renderBody has not been called yet } @@ -228,9 +228,6 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { const wasVisible = this._widget.visible; try { this._widget.setVisible(false); - if (info.inputState && modelRef) { - modelRef.object.inputModel.setState(info.inputState); - } await this.showModel(modelRef); } finally { @@ -245,16 +242,12 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { this._onDidChangeViewWelcomeState.fire(); } - private getTransferredOrPersistedSessionInfo(): { sessionId?: string; inputState?: IChatModelInputState; mode?: ChatModeKind } { - if (this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat) { - const sessionId = this.chatService.transferredSessionData.sessionId; - return { - sessionId, - inputState: this.chatService.transferredSessionData.inputState, - }; + private getTransferredOrPersistedSessionInfo(): URI | undefined { + if (this.chatService.transferredSessionResource) { + return this.chatService.transferredSessionResource; } - return { sessionId: this.viewState.sessionId }; + return this.viewState.sessionId ? LocalChatSessionUri.forSession(this.viewState.sessionId) : undefined; } protected override renderBody(parent: HTMLElement): void { @@ -658,12 +651,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { //#region Model Management private async applyModel(): Promise { - const info = this.getTransferredOrPersistedSessionInfo(); - const modelRef = info.sessionId ? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(info.sessionId)) : undefined; - if (modelRef && info.inputState) { - modelRef.object.inputModel.setState(info.inputState); - } - + const sessionResource = this.getTransferredOrPersistedSessionInfo(); + const modelRef = sessionResource ? await this.chatService.getOrRestoreSession(sessionResource) : undefined; await this.showModel(modelRef); } @@ -673,8 +662,8 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { let ref: IChatModelReference | undefined; if (startNewSession) { - ref = modelRef ?? (this.chatService.transferredSessionData?.sessionId && this.chatService.transferredSessionData?.location === ChatAgentLocation.Chat - ? await this.chatService.getOrRestoreSession(LocalChatSessionUri.forSession(this.chatService.transferredSessionData.sessionId)) + ref = modelRef ?? (this.chatService.transferredSessionResource + ? await this.chatService.getOrRestoreSession(this.chatService.transferredSessionResource) : this.chatService.startSession(ChatAgentLocation.Chat)); if (!ref) { throw new Error('Could not start chat session'); diff --git a/src/vs/workbench/contrib/chat/common/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService.ts index 289e4bcb22f..21f972a109b 100644 --- a/src/vs/workbench/contrib/chat/common/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService.ts @@ -23,7 +23,7 @@ import { ICellEditOperation } from '../../notebook/common/notebookCommon.js'; import { IWorkspaceSymbol } from '../../search/common/search.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentResult, UserSelectedTools } from './chatAgents.js'; import { IChatEditingSession } from './chatEditingService.js'; -import { IChatModel, IChatModelInputState, IChatRequestModeInfo, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData } from './chatModel.js'; +import { IChatModel, IChatRequestModeInfo, IChatRequestModel, IChatRequestVariableData, IChatResponseModel, IExportableChatData, ISerializableChatData } from './chatModel.js'; import { IParsedChatRequest } from './chatParserTypes.js'; import { IChatParserContext } from './chatRequestParser.js'; import { IChatRequestVariableEntry } from './chatVariableEntries.js'; @@ -934,12 +934,6 @@ export interface IChatProviderInfo { id: string; } -export interface IChatTransferredSessionData { - sessionId: string; - location: ChatAgentLocation; - inputState: IChatModelInputState | undefined; -} - export interface IChatSendRequestResponseState { responseCreatedPromise: Promise; responseCompletePromise: Promise; @@ -1006,7 +1000,7 @@ export const IChatService = createDecorator('IChatService'); export interface IChatService { _serviceBrand: undefined; - transferredSessionData: IChatTransferredSessionData | undefined; + transferredSessionResource: URI | undefined; readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }>; @@ -1066,7 +1060,7 @@ export interface IChatService { notifyUserAction(event: IChatUserActionEvent): void; readonly onDidDisposeSession: Event<{ readonly sessionResource: URI[]; readonly reason: 'cleared' }>; - transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void; + transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise; activateDefaultAgent(location: ChatAgentLocation): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts index db08afa3591..da263d91534 100644 --- a/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatServiceImpl.ts @@ -14,6 +14,7 @@ import { Disposable, DisposableResourceMap, DisposableStore, IDisposable, Mutabl import { revive } from '../../../../base/common/marshalling.js'; import { Schemas } from '../../../../base/common/network.js'; import { autorun, derived, IObservable } from '../../../../base/common/observable.js'; +import { isEqual } from '../../../../base/common/resources.js'; import { StopWatch } from '../../../../base/common/stopwatch.js'; import { isDefined } from '../../../../base/common/types.js'; import { URI } from '../../../../base/common/uri.js'; @@ -24,7 +25,7 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { Progress } from '../../../../platform/progress/common/progress.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { InlineChatConfigKeys } from '../../inlineChat/common/inlineChat.js'; @@ -36,10 +37,10 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from './chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from './chatParserTypes.js'; import { ChatRequestParser } from './chatRequestParser.js'; -import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatTransferredSessionData, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from './chatSessionsService.js'; -import { ChatSessionStore, IChatSessionEntryMetadata, IChatTransfer2 } from './chatSessionStore.js'; +import { ChatSessionStore, IChatSessionEntryMetadata } from './chatSessionStore.js'; import { IChatSlashCommandService } from './chatSlashCommands.js'; import { IChatTransferService } from './chatTransferService.js'; import { LocalChatSessionUri } from './chatUri.js'; @@ -50,10 +51,6 @@ import { ILanguageModelToolsService } from './languageModelToolsService.js'; const serializedChatKey = 'interactive.sessions'; -const TransferredGlobalChatKey = 'chat.workspaceTransfer'; - -const SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS = 1000 * 60; - class CancellableRequest implements IDisposable { constructor( public readonly cancellationTokenSource: CancellationTokenSource, @@ -82,9 +79,9 @@ export class ChatService extends Disposable implements IChatService { private _persistedSessions: ISerializableChatsData; private _saveModelsEnabled = true; - private _transferredSessionData: IChatTransferredSessionData | undefined; - public get transferredSessionData(): IChatTransferredSessionData | undefined { - return this._transferredSessionData; + private _transferredSessionResource: URI | undefined; + public get transferredSessionResource(): URI | undefined { + return this._transferredSessionResource; } private readonly _onDidSubmitRequest = this._register(new Emitter<{ readonly chatSessionResource: URI }>()); @@ -128,7 +125,7 @@ export class ChatService extends Disposable implements IChatService { } constructor( - @IStorageService private readonly storageService: IStorageService, + @IStorageService storageService: IStorageService, @ILogService private readonly logService: ILogService, @IExtensionService private readonly extensionService: IExtensionService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -175,21 +172,15 @@ export class ChatService extends Disposable implements IChatService { this._persistedSessions = {}; } - const transferredData = this.getTransferredSessionData(); - const transferredChat = transferredData?.chat; - if (transferredChat) { - this.trace('constructor', `Transferred session ${transferredChat.sessionId}`); - this._persistedSessions[transferredChat.sessionId] = transferredChat; - this._transferredSessionData = { - sessionId: transferredChat.sessionId, - location: transferredData.location, - inputState: transferredData.inputState - }; - } - this._chatSessionStore = this._register(this.instantiationService.createInstance(ChatSessionStore)); this._chatSessionStore.migrateDataIfNeeded(() => this._persistedSessions); + const transferredData = this._chatSessionStore.getTransferredSessionData(); + if (transferredData) { + this.trace('constructor', `Transferred session ${transferredData}`); + this._transferredSessionResource = transferredData; + } + // When using file storage, populate _persistedSessions with session metadata from the index // This ensures that getPersistedSessionTitle() can find titles for inactive sessions this.initializePersistedSessionsFromFileStorage().then(() => { @@ -309,23 +300,6 @@ export class ChatService extends Disposable implements IChatService { } } - private getTransferredSessionData(): IChatTransfer2 | undefined { - const data: IChatTransfer2[] = this.storageService.getObject(TransferredGlobalChatKey, StorageScope.PROFILE, []); - const workspaceUri = this.workspaceContextService.getWorkspace().folders[0]?.uri; - if (!workspaceUri) { - return; - } - - const thisWorkspace = workspaceUri.toString(); - const currentTime = Date.now(); - // Only use transferred data if it was created recently - const transferred = data.find(item => URI.revive(item.toWorkspace).toString() === thisWorkspace && (currentTime - item.timestampInMilliseconds < SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS)); - // Keep data that isn't for the current workspace and that hasn't expired yet - const filtered = data.filter(item => URI.revive(item.toWorkspace).toString() !== thisWorkspace && (currentTime - item.timestampInMilliseconds < SESSION_TRANSFER_EXPIRATION_IN_MILLISECONDS)); - this.storageService.store(TransferredGlobalChatKey, JSON.stringify(filtered), StorageScope.PROFILE, StorageTarget.MACHINE); - return transferred; - } - /** * todo@connor4312 This will be cleaned up with the globalization of edits. */ @@ -540,8 +514,9 @@ export class ChatService extends Disposable implements IChatService { } let sessionData: ISerializableChatData | undefined; - if (this.transferredSessionData?.sessionId === sessionId) { - sessionData = revive(this._persistedSessions[sessionId]); + if (isEqual(this.transferredSessionResource, sessionResource)) { + this._transferredSessionResource = undefined; + sessionData = revive(await this._chatSessionStore.readTransferredSession(sessionResource)); } else { sessionData = revive(await this._chatSessionStore.readSession(sessionId)); } @@ -558,11 +533,6 @@ export class ChatService extends Disposable implements IChatService { canUseTools: true, }); - const isTransferred = this.transferredSessionData?.sessionId === sessionId; - if (isTransferred) { - this._transferredSessionData = undefined; - } - return sessionRef; } @@ -1309,22 +1279,25 @@ export class ChatService extends Disposable implements IChatService { return this._chatSessionStore.hasSessions(); } - transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void { - const model = Iterable.find(this._sessionModels.values(), model => model.sessionId === transferredSessionData.sessionId); - if (!model) { - throw new Error(`Failed to transfer session. Unknown session ID: ${transferredSessionData.sessionId}`); + async transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise { + if (!LocalChatSessionUri.isLocalSession(transferredSessionResource)) { + throw new Error(`Can only transfer local chat sessions. Invalid session: ${transferredSessionResource}`); } - const existingRaw: IChatTransfer2[] = this.storageService.getObject(TransferredGlobalChatKey, StorageScope.PROFILE, []); - existingRaw.push({ - chat: model.toJSON(), + const model = this._sessionModels.get(transferredSessionResource) as ChatModel | undefined; + if (!model) { + throw new Error(`Failed to transfer session. Unknown session: ${transferredSessionResource}`); + } + + if (model.initialLocation !== ChatAgentLocation.Chat) { + throw new Error(`Can only transfer chat sessions located in the Chat view. Session ${transferredSessionResource} has location=${model.initialLocation}`); + } + + await this._chatSessionStore.storeTransferSession({ + sessionResource: model.sessionResource, timestampInMilliseconds: Date.now(), toWorkspace: toWorkspace, - inputState: transferredSessionData.inputState, - location: transferredSessionData.location, - }); - - this.storageService.store(TransferredGlobalChatKey, JSON.stringify(existingRaw), StorageScope.PROFILE, StorageTarget.MACHINE); + }, model); this.chatTransferService.addWorkspaceToTransferred(toWorkspace); this.trace('transferChatSession', `Transferred session ${model.sessionResource} to workspace ${toWorkspace.toString()}`); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts index 1c52d67f4b8..47218ece5c1 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionStore.ts @@ -19,10 +19,11 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { ILifecycleService } from '../../../services/lifecycle/common/lifecycle.js'; import { awaitStatsForSession } from './chat.js'; import { ModifiedFileEntryState } from './chatEditingService.js'; -import { ChatModel, IChatModelInputState, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js'; +import { ChatModel, ISerializableChatData, ISerializableChatDataIn, ISerializableChatsData, normalizeSerializableChatData } from './chatModel.js'; import { IChatSessionStats, IChatSessionTiming, ResponseModelState } from './chatService.js'; import { LocalChatSessionUri } from './chatUri.js'; import { ChatAgentLocation } from './constants.js'; @@ -30,12 +31,12 @@ import { ChatAgentLocation } from './constants.js'; const maxPersistedSessions = 25; const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; -// const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; +const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; export class ChatSessionStore extends Disposable { private readonly storageRoot: URI; private readonly previousEmptyWindowStorageRoot: URI | undefined; - // private readonly transferredSessionStorageRoot: URI; + private readonly transferredSessionStorageRoot: URI; private readonly storeQueue = new Sequencer(); @@ -65,8 +66,7 @@ export class ChatSessionStore extends Disposable { joinPath(this.environmentService.workspaceStorageHome, 'no-workspace', 'chatSessions') : undefined; - // TODO tmpdir - // this.transferredSessionStorageRoot = joinPath(this.environmentService.workspaceStorageHome, 'transferredChatSessions'); + this.transferredSessionStorageRoot = joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'transferredChatSessions'); this._register(this.lifecycleService.onWillShutdown(e => { this.shuttingDown = true; @@ -124,33 +124,124 @@ export class ChatSessionStore extends Disposable { } } - // async storeTransferSession(transferData: IChatTransfer, session: ISerializableChatData): Promise { - // try { - // const content = JSON.stringify(session, undefined, 2); - // await this.fileService.writeFile(this.transferredSessionStorageRoot, VSBuffer.fromString(content)); - // } catch (e) { - // this.reportError('sessionWrite', 'Error writing chat session', e); - // return; - // } + async storeTransferSession(transferData: IChatTransfer, session: ChatModel): Promise { + const index = this.getTransferredSessionIndex(); + const workspaceKey = transferData.toWorkspace.toString(); - // const index = this.getTransferredSessionIndex(); - // index[transferData.toWorkspace.toString()] = transferData; - // try { - // this.storageService.store(ChatTransferIndexStorageKey, index, StorageScope.PROFILE, StorageTarget.MACHINE); - // } catch (e) { - // this.reportError('storeTransferSession', 'Error storing chat transfer session', e); - // } - // } + // Clean up any preexisting transferred session for this workspace + const existingTransfer = index[workspaceKey]; + if (existingTransfer) { + try { + const existingSessionResource = URI.revive(existingTransfer.sessionResource); + if (existingSessionResource && LocalChatSessionUri.parseLocalSessionId(existingSessionResource)) { + const existingStorageLocation = this.getTransferredSessionStorageLocation(existingSessionResource); + await this.fileService.del(existingStorageLocation); + } + } catch (e) { + if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) { + this.reportError('storeTransferSession', 'Error deleting old transferred session file', e); + } + } + } - // private getTransferredSessionIndex(): IChatTransferIndex { - // try { - // const data: IChatTransferIndex = this.storageService.getObject(ChatTransferIndexStorageKey, StorageScope.PROFILE, {}); - // return data; - // } catch (e) { - // this.reportError('getTransferredSessionIndex', 'Error reading chat transfer index', e); - // return {}; - // } - // } + try { + const content = JSON.stringify(session, undefined, 2); + const storageLocation = this.getTransferredSessionStorageLocation(session.sessionResource); + await this.fileService.writeFile(storageLocation, VSBuffer.fromString(content)); + } catch (e) { + this.reportError('sessionWrite', 'Error writing chat session', e); + return; + } + + index[workspaceKey] = transferData; + try { + this.storageService.store(ChatTransferIndexStorageKey, index, StorageScope.PROFILE, StorageTarget.MACHINE); + } catch (e) { + this.reportError('storeTransferSession', 'Error storing chat transfer session', e); + } + } + + private getTransferredSessionIndex(): IChatTransferIndex { + try { + const data: IChatTransferIndex = this.storageService.getObject(ChatTransferIndexStorageKey, StorageScope.PROFILE, {}); + return data; + } catch (e) { + this.reportError('getTransferredSessionIndex', 'Error reading chat transfer index', e); + return {}; + } + } + + private static readonly TRANSFER_EXPIRATION_MS = 60 * 1000 * 5; + + getTransferredSessionData(): URI | undefined { + try { + const index = this.getTransferredSessionIndex(); + const workspaceFolders = this.workspaceContextService.getWorkspace().folders; + if (workspaceFolders.length !== 1) { + // Can only transfer sessions to single-folder workspaces + return undefined; + } + + const workspaceKey = workspaceFolders[0].uri.toString(); + const transferredSessionForWorkspace: IChatTransferDto = index[workspaceKey]; + if (!transferredSessionForWorkspace) { + return undefined; + } + + // Check if the transfer has expired + const revivedTransferData = revive(transferredSessionForWorkspace); + if (Date.now() - transferredSessionForWorkspace.timestampInMilliseconds > ChatSessionStore.TRANSFER_EXPIRATION_MS) { + this.logService.info('ChatSessionStore: Transferred session has expired'); + this.cleanupTransferredSession(revivedTransferData.sessionResource); + return undefined; + } + return !!LocalChatSessionUri.parseLocalSessionId(revivedTransferData.sessionResource) && revivedTransferData.sessionResource; + } catch (e) { + this.reportError('getTransferredSession', 'Error getting transferred chat session URI', e); + return undefined; + } + } + + async readTransferredSession(sessionResource: URI): Promise { + try { + const storageLocation = this.getTransferredSessionStorageLocation(sessionResource); + const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + if (!sessionId) { + return undefined; + } + + const sessionData = await this.readSessionFromLocation(storageLocation, sessionId); + + // Clean up the transferred session after reading + await this.cleanupTransferredSession(sessionResource); + + return sessionData; + } catch (e) { + this.reportError('getTransferredSession', 'Error getting transferred chat session', e); + return undefined; + } + } + + private async cleanupTransferredSession(sessionResource: URI): Promise { + try { + // Remove from index + const index = this.getTransferredSessionIndex(); + const workspaceFolders = this.workspaceContextService.getWorkspace().folders; + if (workspaceFolders.length === 1) { + const workspaceKey = workspaceFolders[0].uri.toString(); + delete index[workspaceKey]; + this.storageService.store(ChatTransferIndexStorageKey, index, StorageScope.PROFILE, StorageTarget.MACHINE); + } + + // Delete the transferred session file + const storageLocation = this.getTransferredSessionStorageLocation(sessionResource); + await this.fileService.del(storageLocation); + } catch (e) { + if (toFileOperationResult(e) !== FileOperationResult.FILE_NOT_FOUND) { + this.reportError('cleanupTransferredSession', 'Error cleaning up transferred session', e); + } + } + } private async writeSession(session: ChatModel | ISerializableChatData): Promise { try { @@ -359,45 +450,49 @@ export class ChatSessionStore extends Disposable { public async readSession(sessionId: string): Promise { return await this.storeQueue.queue(async () => { - let rawData: string | undefined; const storageLocation = this.getStorageLocation(sessionId); - try { - rawData = (await this.fileService.readFile(storageLocation)).value.toString(); - } catch (e) { - this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e); + return this.readSessionFromLocation(storageLocation, sessionId); + }); + } - if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) { - rawData = await this.readSessionFromPreviousLocation(sessionId); - } + private async readSessionFromLocation(storageLocation: URI, sessionId: string): Promise { + let rawData: string | undefined; + try { + rawData = (await this.fileService.readFile(storageLocation)).value.toString(); + } catch (e) { + this.reportError('sessionReadFile', `Error reading chat session file ${sessionId}`, e); - if (!rawData) { - return undefined; - } + if (toFileOperationResult(e) === FileOperationResult.FILE_NOT_FOUND && this.previousEmptyWindowStorageRoot) { + rawData = await this.readSessionFromPreviousLocation(sessionId); } - try { - // TODO Copied from ChatService.ts, cleanup - const session: ISerializableChatDataIn = revive(JSON.parse(rawData)); // Revive serialized URIs in session data - // Revive serialized markdown strings in response data - for (const request of session.requests) { - if (Array.isArray(request.response)) { - request.response = request.response.map((response) => { - if (typeof response === 'string') { - return new MarkdownString(response); - } - return response; - }); - } else if (typeof request.response === 'string') { - request.response = [new MarkdownString(request.response)]; - } - } - - return normalizeSerializableChatData(session); - } catch (err) { - this.reportError('malformedSession', `Malformed session data in ${storageLocation.fsPath}: [${rawData.substring(0, 20)}${rawData.length > 20 ? '...' : ''}]`, err); + if (!rawData) { return undefined; } - }); + } + + try { + // TODO Copied from ChatService.ts, cleanup + const session: ISerializableChatDataIn = revive(JSON.parse(rawData)); // Revive serialized URIs in session data + // Revive serialized markdown strings in response data + for (const request of session.requests) { + if (Array.isArray(request.response)) { + request.response = request.response.map((response) => { + if (typeof response === 'string') { + return new MarkdownString(response); + } + return response; + }); + } else if (typeof request.response === 'string') { + request.response = [new MarkdownString(request.response)]; + } + } + + return normalizeSerializableChatData(session); + } catch (err) { + this.reportError('malformedSession', `Malformed session data in ${storageLocation.fsPath}: [${rawData.substring(0, 20)}${rawData.length > 20 ? '...' : ''}]`, err); + return undefined; + } } private async readSessionFromPreviousLocation(sessionId: string): Promise { @@ -421,6 +516,11 @@ export class ChatSessionStore extends Disposable { return joinPath(this.storageRoot, `${chatSessionId}.json`); } + private getTransferredSessionStorageLocation(sessionResource: URI): URI { + const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + return joinPath(this.transferredSessionStorageRoot, `${sessionId}.json`); + } + public getChatStorageFolder(): URI { return this.storageRoot; } @@ -525,18 +625,17 @@ async function getSessionMetadata(session: ChatModel | ISerializableChatData): P export interface IChatTransfer { toWorkspace: URI; + sessionResource: URI; timestampInMilliseconds: number; - inputState: IChatModelInputState | undefined; - location: ChatAgentLocation; } export interface IChatTransfer2 extends IChatTransfer { chat: ISerializableChatData; } -// type IChatTransferDto = Dto; +type IChatTransferDto = Dto; /** * Map of destination workspace URI to chat transfer data */ -// type IChatTransferIndex = Record; +type IChatTransferIndex = Record; diff --git a/src/vs/workbench/contrib/chat/common/chatTransferService.ts b/src/vs/workbench/contrib/chat/common/chatTransferService.ts index bbc21070343..2bd380085b2 100644 --- a/src/vs/workbench/contrib/chat/common/chatTransferService.ts +++ b/src/vs/workbench/contrib/chat/common/chatTransferService.ts @@ -30,7 +30,7 @@ export class ChatTransferService implements IChatTransferService { @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService ) { } - deleteWorkspaceFromTransferredList(workspace: URI): void { + private deleteWorkspaceFromTransferredList(workspace: URI): void { const transferredWorkspaces = this.storageService.getObject(transferredWorkspacesKey, StorageScope.PROFILE, []); const updatedWorkspaces = transferredWorkspaces.filter(uri => uri !== workspace.toString()); this.storageService.store(transferredWorkspacesKey, updatedWorkspaces, StorageScope.PROFILE, StorageTarget.MACHINE); @@ -54,7 +54,7 @@ export class ChatTransferService implements IChatTransferService { } } - isChatTransferredWorkspace(workspace: URI, storageService: IStorageService): boolean { + private isChatTransferredWorkspace(workspace: URI, storageService: IStorageService): boolean { if (!workspace) { return false; } diff --git a/src/vs/workbench/contrib/chat/common/chatUri.ts b/src/vs/workbench/contrib/chat/common/chatUri.ts index 596cf2606ca..792a10caa5a 100644 --- a/src/vs/workbench/contrib/chat/common/chatUri.ts +++ b/src/vs/workbench/contrib/chat/common/chatUri.ts @@ -28,6 +28,10 @@ export namespace LocalChatSessionUri { return parsed?.chatSessionType === localChatSessionType ? parsed.sessionId : undefined; } + export function isLocalSession(resource: URI): boolean { + return !!parseLocalSessionId(resource); + } + function parse(resource: URI): ChatSessionIdentifier | undefined { if (resource.scheme !== scheme) { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts index c08e11d1651..a3358e2782d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/localAgentSessionsProvider.test.ts @@ -30,7 +30,7 @@ class MockChatService implements IChatService { edits2Enabled: boolean = false; _serviceBrand: undefined; editingSessions = []; - transferredSessionData = undefined; + transferredSessionResource = undefined; readonly onDidSubmitRequest = Event.None; private sessions = new Map(); @@ -144,7 +144,7 @@ class MockChatService implements IChatService { notifyUserAction(_event: any): void { } - transferChatSession(): void { } + async transferChatSession(): Promise { } setChatSessionTitle(): void { } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts index 7ae67169f9e..e8b59b9fbbe 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService.test.ts @@ -24,6 +24,7 @@ import { ILogService, NullLogService } from '../../../../../platform/log/common/ import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IUserDataProfilesService, toUserDataProfile } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { NullWorkbenchAssignmentService } from '../../../../services/assignment/test/common/nullAssignmentService.js'; @@ -158,6 +159,7 @@ suite('ChatService', () => { ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(ITelemetryService, NullTelemetryService); instantiationService.stub(IExtensionService, new TestExtensionService()); instantiationService.stub(IContextKeyService, new MockContextKeyService()); diff --git a/src/vs/workbench/contrib/chat/test/common/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/chatSessionStore.test.ts new file mode 100644 index 00000000000..f347bfc1604 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/chatSessionStore.test.ts @@ -0,0 +1,374 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ServiceCollection } from '../../../../../platform/instantiation/common/serviceCollection.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../platform/telemetry/common/telemetryUtils.js'; +import { IUserDataProfilesService, toUserDataProfile } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; +import { IWorkspaceContextService, WorkspaceFolder } from '../../../../../platform/workspace/common/workspace.js'; +import { TestWorkspace, Workspace } from '../../../../../platform/workspace/test/common/testWorkspace.js'; +import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { InMemoryTestFileService, TestContextService, TestLifecycleService, TestStorageService } from '../../../../test/common/workbenchTestServices.js'; +import { ChatModel } from '../../common/chatModel.js'; +import { ChatSessionStore, IChatTransfer } from '../../common/chatSessionStore.js'; +import { LocalChatSessionUri } from '../../common/chatUri.js'; +import { MockChatModel } from './mockChatModel.js'; + +function createMockChatModel(sessionResource: URI, options?: { customTitle?: string }): ChatModel { + const sessionId = LocalChatSessionUri.parseLocalSessionId(sessionResource); + if (!sessionId) { + throw new Error('createMockChatModel requires a local session URI'); + } + const model = new MockChatModel(sessionResource); + model.sessionId = sessionId; + if (options?.customTitle) { + model.customTitle = options.customTitle; + } + // Cast to ChatModel - the mock implements enough of the interface for testing + return model as unknown as ChatModel; +} + +suite('ChatSessionStore', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + + function createChatSessionStore(isEmptyWindow: boolean = false): ChatSessionStore { + const workspace = isEmptyWindow ? new Workspace('empty-window-id', []) : TestWorkspace; + instantiationService.stub(IWorkspaceContextService, new TestContextService(workspace)); + return testDisposables.add(instantiationService.createInstance(ChatSessionStore)); + } + + setup(() => { + instantiationService = testDisposables.add(new TestInstantiationService(new ServiceCollection())); + instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(ILogService, NullLogService); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IFileService, testDisposables.add(new InMemoryTestFileService())); + instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/workspaceStorage') }); + instantiationService.stub(ILifecycleService, testDisposables.add(new TestLifecycleService())); + instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); + }); + + test('hasSessions returns false when no sessions exist', () => { + const store = createChatSessionStore(); + + assert.strictEqual(store.hasSessions(), false); + }); + + test('getIndex returns empty index initially', async () => { + const store = createChatSessionStore(); + + const index = await store.getIndex(); + assert.deepStrictEqual(index, {}); + }); + + test('getChatStorageFolder returns correct path for workspace', () => { + const store = createChatSessionStore(false); + + const storageFolder = store.getChatStorageFolder(); + assert.ok(storageFolder.path.includes('workspaceStorage')); + assert.ok(storageFolder.path.includes('chatSessions')); + }); + + test('getChatStorageFolder returns correct path for empty window', () => { + const store = createChatSessionStore(true); + + const storageFolder = store.getChatStorageFolder(); + assert.ok(storageFolder.path.includes('emptyWindowChatSessions')); + }); + + test('isSessionEmpty returns true for non-existent session', () => { + const store = createChatSessionStore(); + + assert.strictEqual(store.isSessionEmpty('non-existent-session'), true); + }); + + test('readSession returns undefined for non-existent session', async () => { + const store = createChatSessionStore(); + + const session = await store.readSession('non-existent-session'); + assert.strictEqual(session, undefined); + }); + + test('deleteSession handles non-existent session gracefully', async () => { + const store = createChatSessionStore(); + + // Should not throw + await store.deleteSession('non-existent-session'); + + assert.strictEqual(store.hasSessions(), false); + }); + + test('storeSessions persists session to index', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + await store.storeSessions([model]); + + assert.strictEqual(store.hasSessions(), true); + const index = await store.getIndex(); + assert.ok(index['session-1']); + assert.strictEqual(index['session-1'].sessionId, 'session-1'); + }); + + test('storeSessions persists custom title', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'), { customTitle: 'My Custom Title' })); + + await store.storeSessions([model]); + + const index = await store.getIndex(); + assert.strictEqual(index['session-1'].title, 'My Custom Title'); + }); + + test('readSession returns stored session data', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + await store.storeSessions([model]); + const session = await store.readSession('session-1'); + + assert.ok(session); + assert.strictEqual(session.sessionId, 'session-1'); + }); + + test('deleteSession removes session from index', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + await store.storeSessions([model]); + assert.strictEqual(store.hasSessions(), true); + + await store.deleteSession('session-1'); + + assert.strictEqual(store.hasSessions(), false); + const index = await store.getIndex(); + assert.strictEqual(index['session-1'], undefined); + }); + + test('clearAllSessions removes all sessions', async () => { + const store = createChatSessionStore(); + const model1 = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + const model2 = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-2'))); + + await store.storeSessions([model1, model2]); + assert.strictEqual(Object.keys(await store.getIndex()).length, 2); + + await store.clearAllSessions(); + + const index = await store.getIndex(); + assert.deepStrictEqual(index, {}); + }); + + test('setSessionTitle updates existing session title', async () => { + const store = createChatSessionStore(); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'), { customTitle: 'Original Title' })); + + await store.storeSessions([model]); + await store.setSessionTitle('session-1', 'New Title'); + + const index = await store.getIndex(); + assert.strictEqual(index['session-1'].title, 'New Title'); + }); + + test('setSessionTitle does nothing for non-existent session', async () => { + const store = createChatSessionStore(); + + // Should not throw + await store.setSessionTitle('non-existent', 'Title'); + + const index = await store.getIndex(); + assert.strictEqual(index['non-existent'], undefined); + }); + + test('multiple stores can be created with different workspaces', async () => { + const store1 = createChatSessionStore(false); + const store2 = createChatSessionStore(true); + + const folder1 = store1.getChatStorageFolder(); + const folder2 = store2.getChatStorageFolder(); + + assert.notStrictEqual(folder1.toString(), folder2.toString()); + }); + + suite('transferred sessions', () => { + function createSingleFolderWorkspace(folderUri: URI): Workspace { + const folder = new WorkspaceFolder({ uri: folderUri, index: 0, name: 'test' }); + return new Workspace('single-folder-id', [folder]); + } + + function createChatSessionStoreWithSingleFolder(folderUri: URI): ChatSessionStore { + instantiationService.stub(IWorkspaceContextService, new TestContextService(createSingleFolderWorkspace(folderUri))); + return testDisposables.add(instantiationService.createInstance(ChatSessionStore)); + } + + function createTransferData(toWorkspace: URI, sessionResource: URI, timestampInMilliseconds?: number): IChatTransfer { + return { + toWorkspace, + sessionResource, + timestampInMilliseconds: timestampInMilliseconds ?? Date.now(), + }; + } + + test('getTransferredSessionData returns undefined for empty window', () => { + const store = createChatSessionStore(true); // empty window + + const result = store.getTransferredSessionData(); + + assert.strictEqual(result, undefined); + }); + + test('getTransferredSessionData returns undefined when no transfer exists', () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + + const result = store.getTransferredSessionData(); + + assert.strictEqual(result, undefined); + }); + + test('storeTransferSession stores and retrieves transfer data', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const sessionResource = LocalChatSessionUri.forSession('transfer-session'); + const model = testDisposables.add(createMockChatModel(sessionResource)); + + const transferData = createTransferData(folderUri, sessionResource); + await store.storeTransferSession(transferData, model); + + const result = store.getTransferredSessionData(); + assert.ok(result); + assert.strictEqual(result.toString(), sessionResource.toString()); + }); + + test('readTransferredSession returns session data', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const sessionResource = LocalChatSessionUri.forSession('transfer-session'); + const model = testDisposables.add(createMockChatModel(sessionResource)); + + const transferData = createTransferData(folderUri, sessionResource); + await store.storeTransferSession(transferData, model); + + const sessionData = await store.readTransferredSession(sessionResource); + assert.ok(sessionData); + assert.strictEqual(sessionData.sessionId, 'transfer-session'); + }); + + test('readTransferredSession cleans up after reading', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const sessionResource = LocalChatSessionUri.forSession('transfer-session'); + const model = testDisposables.add(createMockChatModel(sessionResource)); + + const transferData = createTransferData(folderUri, sessionResource); + await store.storeTransferSession(transferData, model); + + // Read the session + await store.readTransferredSession(sessionResource); + + // Transfer should be cleaned up + const result = store.getTransferredSessionData(); + assert.strictEqual(result, undefined); + }); + + test('getTransferredSessionData returns undefined for expired transfer', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const sessionResource = LocalChatSessionUri.forSession('transfer-session'); + const model = testDisposables.add(createMockChatModel(sessionResource)); + + // Create transfer with timestamp 10 minutes in the past (expired) + const expiredTimestamp = Date.now() - (10 * 60 * 1000); + const transferData = createTransferData(folderUri, sessionResource, expiredTimestamp); + await store.storeTransferSession(transferData, model); + + const result = store.getTransferredSessionData(); + assert.strictEqual(result, undefined); + }); + + test('expired transfer cleans up index and file', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const sessionResource = LocalChatSessionUri.forSession('transfer-session'); + const model = testDisposables.add(createMockChatModel(sessionResource)); + + // Create transfer with timestamp 100 minutes in the past (expired) + const expiredTimestamp = Date.now() - (100 * 60 * 1000); + const transferData = createTransferData(folderUri, sessionResource, expiredTimestamp); + await store.storeTransferSession(transferData, model); + + // Assert cleaned up + const data = store.getTransferredSessionData(); + assert.strictEqual(data, undefined); + }); + + test('readTransferredSession returns undefined for invalid session resource', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + + // Use a non-local session URI + const invalidResource = URI.parse('file:///invalid/session'); + + const result = await store.readTransferredSession(invalidResource); + assert.strictEqual(result, undefined); + }); + + test('storeTransferSession deletes preexisting transferred session file', async () => { + const folderUri = URI.file('/test/workspace'); + const store = createChatSessionStoreWithSingleFolder(folderUri); + const fileService = instantiationService.get(IFileService); + + // Store first session + const session1Resource = LocalChatSessionUri.forSession('transfer-session-1'); + const model1 = testDisposables.add(createMockChatModel(session1Resource)); + const transferData1 = createTransferData(folderUri, session1Resource); + await store.storeTransferSession(transferData1, model1); + + // Verify first session file exists + const userDataProfile = instantiationService.get(IUserDataProfilesService).defaultProfile; + const storageLocation1 = URI.joinPath( + userDataProfile.globalStorageHome, + 'transferredChatSessions', + 'transfer-session-1.json' + ); + const exists1 = await fileService.exists(storageLocation1); + assert.strictEqual(exists1, true, 'First session file should exist'); + + // Store second session for the same workspace + const session2Resource = LocalChatSessionUri.forSession('transfer-session-2'); + const model2 = testDisposables.add(createMockChatModel(session2Resource)); + const transferData2 = createTransferData(folderUri, session2Resource); + await store.storeTransferSession(transferData2, model2); + + // Verify first session file is deleted + const exists1After = await fileService.exists(storageLocation1); + assert.strictEqual(exists1After, false, 'First session file should be deleted'); + + // Verify second session file exists + const storageLocation2 = URI.joinPath( + userDataProfile.globalStorageHome, + 'transferredChatSessions', + 'transfer-session-2.json' + ); + const exists2 = await fileService.exists(storageLocation2); + assert.strictEqual(exists2, true, 'Second session file should exist'); + + // Verify only the second session is retrievable + const result = store.getTransferredSessionData(); + assert.ok(result); + assert.strictEqual(result.toString(), session2Resource.toString()); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts index 3ffac4bc092..851ad51d5c5 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatModel.ts @@ -14,12 +14,16 @@ import { ChatAgentLocation } from '../../common/constants.js'; export class MockChatModel extends Disposable implements IChatModel { readonly onDidDispose = this._register(new Emitter()).event; readonly onDidChange = this._register(new Emitter()).event; - readonly sessionId = ''; + sessionId = ''; readonly timestamp = 0; readonly timing = { startTime: 0 }; readonly initialLocation = ChatAgentLocation.Chat; readonly title = ''; readonly hasCustomTitle = false; + customTitle: string | undefined; + lastMessageDate = Date.now(); + creationDate = Date.now(); + requests: IChatRequestModel[] = []; readonly requestInProgress = observableValue('requestInProgress', false); readonly requestNeedsInput = observableValue('requestNeedsInput', undefined); readonly inputPlaceholder = undefined; @@ -66,8 +70,8 @@ export class MockChatModel extends Disposable implements IChatModel { version: 3, sessionId: this.sessionId, creationDate: this.timestamp, - lastMessageDate: this.timestamp, - customTitle: undefined, + lastMessageDate: this.lastMessageDate, + customTitle: this.customTitle, initialLocation: this.initialLocation, requests: [], responderUsername: '', diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts index 3a56512be9f..ae582b3b4b7 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatService.ts @@ -10,7 +10,7 @@ import { IObservable, observableValue } from '../../../../../base/common/observa import { URI } from '../../../../../base/common/uri.js'; import { IChatModel, IChatRequestModel, IChatRequestVariableData, ISerializableChatData } from '../../common/chatModel.js'; import { IParsedChatRequest } from '../../common/chatParserTypes.js'; -import { IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatTransferredSessionData, IChatUserActionEvent } from '../../common/chatService.js'; +import { IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../common/chatService.js'; import { ChatAgentLocation } from '../../common/constants.js'; export class MockChatService implements IChatService { @@ -19,7 +19,7 @@ export class MockChatService implements IChatService { edits2Enabled: boolean = false; _serviceBrand: undefined; editingSessions = []; - transferredSessionData: IChatTransferredSessionData | undefined; + transferredSessionResource: URI | undefined; readonly onDidSubmitRequest: Event<{ readonly chatSessionResource: URI }> = Event.None; private sessions = new ResourceMap(); @@ -104,7 +104,7 @@ export class MockChatService implements IChatService { } readonly onDidDisposeSession: Event<{ sessionResource: URI[]; reason: 'cleared' }> = undefined!; - transferChatSession(transferredSessionData: IChatTransferredSessionData, toWorkspace: URI): void { + async transferChatSession(transferredSessionResource: URI, toWorkspace: URI): Promise { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 57ba23f9975..577232d67e5 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -7,7 +7,7 @@ import { IContextMenuDelegate } from '../../../base/browser/contextmenu.js'; import { IDimension } from '../../../base/browser/dom.js'; import { Direction, IViewSize } from '../../../base/browser/ui/grid/grid.js'; import { mainWindow } from '../../../base/browser/window.js'; -import { DeferredPromise, timeout } from '../../../base/common/async.js'; +import { timeout } from '../../../base/common/async.js'; import { VSBuffer, VSBufferReadable, VSBufferReadableStream } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Codicon } from '../../../base/common/codicons.js'; @@ -26,7 +26,6 @@ import { assertReturnsDefined, upcast } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { ICodeEditor } from '../../../editor/browser/editorBrowser.js'; import { ICodeEditorService } from '../../../editor/browser/services/codeEditorService.js'; -import { IMarkdownRendererService, MarkdownRendererService } from '../../../platform/markdown/browser/markdownRenderer.js'; import { Position as EditorPosition, IPosition } from '../../../editor/common/core/position.js'; import { Range } from '../../../editor/common/core/range.js'; import { Selection } from '../../../editor/common/core/selection.js'; @@ -83,6 +82,7 @@ import { ILabelService } from '../../../platform/label/common/label.js'; import { ILayoutOffsetInfo } from '../../../platform/layout/browser/layoutService.js'; import { IListService } from '../../../platform/list/browser/listService.js'; import { ILoggerService, ILogService, NullLogService } from '../../../platform/log/common/log.js'; +import { IMarkdownRendererService, MarkdownRendererService } from '../../../platform/markdown/browser/markdownRenderer.js'; import { IMarkerService } from '../../../platform/markers/common/markers.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { TestNotificationService } from '../../../platform/notification/test/common/testNotificationService.js'; @@ -161,7 +161,7 @@ import { IHostService } from '../../services/host/browser/host.js'; import { LabelService } from '../../services/label/common/labelService.js'; import { ILanguageDetectionService } from '../../services/languageDetection/common/languageDetectionWorkerService.js'; import { IWorkbenchLayoutService, PanelAlignment, Position as PartPosition, Parts } from '../../services/layout/browser/layoutService.js'; -import { BeforeShutdownErrorEvent, ILifecycleService, InternalBeforeShutdownEvent, IWillShutdownEventJoiner, LifecyclePhase, ShutdownReason, StartupKind, WillShutdownEvent } from '../../services/lifecycle/common/lifecycle.js'; +import { ILifecycleService, InternalBeforeShutdownEvent, IWillShutdownEventJoiner, ShutdownReason, WillShutdownEvent } from '../../services/lifecycle/common/lifecycle.js'; import { IPaneCompositePartService } from '../../services/panecomposite/browser/panecomposite.js'; import { IPathService } from '../../services/path/common/pathService.js'; import { QuickInputService } from '../../services/quickinput/browser/quickInputService.js'; @@ -185,10 +185,10 @@ import { InMemoryWorkingCopyBackupService } from '../../services/workingCopy/com import { IWorkingCopyEditorService, WorkingCopyEditorService } from '../../services/workingCopy/common/workingCopyEditorService.js'; import { IWorkingCopyFileService, WorkingCopyFileService } from '../../services/workingCopy/common/workingCopyFileService.js'; import { IWorkingCopyService, WorkingCopyService } from '../../services/workingCopy/common/workingCopyService.js'; -import { TestChatEntitlementService, TestContextService, TestExtensionService, TestFileService, TestHistoryService, TestLoggerService, TestMarkerService, TestProductService, TestStorageService, TestTextResourcePropertiesService, TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../common/workbenchTestServices.js'; +import { TestChatEntitlementService, TestContextService, TestExtensionService, TestFileService, TestHistoryService, TestLifecycleService, TestLoggerService, TestMarkerService, TestProductService, TestStorageService, TestTextResourcePropertiesService, TestWorkspaceTrustManagementService, TestWorkspaceTrustRequestService } from '../common/workbenchTestServices.js'; // Backcompat export -export { TestFileService }; +export { TestFileService, TestLifecycleService }; export function createFileEditorInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { return instantiationService.createInstance(FileEditorInput, resource, undefined, undefined, undefined, undefined, undefined, undefined); @@ -1187,88 +1187,6 @@ export class InMemoryTestWorkingCopyBackupService extends BrowserWorkingCopyBack } } -export class TestLifecycleService extends Disposable implements ILifecycleService { - - declare readonly _serviceBrand: undefined; - - usePhases = false; - _phase!: LifecyclePhase; - get phase(): LifecyclePhase { return this._phase; } - set phase(value: LifecyclePhase) { - this._phase = value; - if (value === LifecyclePhase.Starting) { - this.whenStarted.complete(); - } else if (value === LifecyclePhase.Ready) { - this.whenReady.complete(); - } else if (value === LifecyclePhase.Restored) { - this.whenRestored.complete(); - } else if (value === LifecyclePhase.Eventually) { - this.whenEventually.complete(); - } - } - - private readonly whenStarted = new DeferredPromise(); - private readonly whenReady = new DeferredPromise(); - private readonly whenRestored = new DeferredPromise(); - private readonly whenEventually = new DeferredPromise(); - async when(phase: LifecyclePhase): Promise { - if (!this.usePhases) { - return; - } - if (phase === LifecyclePhase.Starting) { - await this.whenStarted.p; - } else if (phase === LifecyclePhase.Ready) { - await this.whenReady.p; - } else if (phase === LifecyclePhase.Restored) { - await this.whenRestored.p; - } else if (phase === LifecyclePhase.Eventually) { - await this.whenEventually.p; - } - } - - startupKind!: StartupKind; - willShutdown = false; - - private readonly _onBeforeShutdown = this._register(new Emitter()); - get onBeforeShutdown(): Event { return this._onBeforeShutdown.event; } - - private readonly _onBeforeShutdownError = this._register(new Emitter()); - get onBeforeShutdownError(): Event { return this._onBeforeShutdownError.event; } - - private readonly _onShutdownVeto = this._register(new Emitter()); - get onShutdownVeto(): Event { return this._onShutdownVeto.event; } - - private readonly _onWillShutdown = this._register(new Emitter()); - get onWillShutdown(): Event { return this._onWillShutdown.event; } - - private readonly _onDidShutdown = this._register(new Emitter()); - get onDidShutdown(): Event { return this._onDidShutdown.event; } - - shutdownJoiners: Promise[] = []; - - fireShutdown(reason = ShutdownReason.QUIT): void { - this.shutdownJoiners = []; - - this._onWillShutdown.fire({ - join: p => { - this.shutdownJoiners.push(typeof p === 'function' ? p() : p); - }, - joiners: () => [], - force: () => { /* No-Op in tests */ }, - token: CancellationToken.None, - reason - }); - } - - fireBeforeShutdown(event: InternalBeforeShutdownEvent): void { this._onBeforeShutdown.fire(event); } - - fireWillShutdown(event: WillShutdownEvent): void { this._onWillShutdown.fire(event); } - - async shutdown(): Promise { - this.fireShutdown(); - } -} - export class TestBeforeShutdownEvent implements InternalBeforeShutdownEvent { value: boolean | Promise | undefined; diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index c5c6e145e08..0000856e22c 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { timeout } from '../../../base/common/async.js'; +import { DeferredPromise, timeout } from '../../../base/common/async.js'; import { bufferToStream, readableToBuffer, VSBuffer, VSBufferReadable } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../base/common/event.js'; @@ -36,6 +36,7 @@ import { ChatEntitlement, IChatEntitlementService } from '../../services/chat/co import { NullExtensionService } from '../../services/extensions/common/extensions.js'; import { IAutoSaveConfiguration, IAutoSaveMode, IFilesConfigurationService } from '../../services/filesConfiguration/common/filesConfigurationService.js'; import { IHistoryService } from '../../services/history/common/history.js'; +import { BeforeShutdownErrorEvent, ILifecycleService, InternalBeforeShutdownEvent, LifecyclePhase, ShutdownReason, StartupKind, WillShutdownEvent } from '../../services/lifecycle/common/lifecycle.js'; import { IResourceEncoding } from '../../services/textfile/common/textfiles.js'; import { IUserDataProfileService } from '../../services/userDataProfile/common/userDataProfile.js'; import { IStoredFileWorkingCopySaveEvent } from '../../services/workingCopy/common/storedFileWorkingCopy.js'; @@ -698,7 +699,7 @@ export class TestFileService implements IFileService { */ export class InMemoryTestFileService extends TestFileService { - private files = new Map(); + private files = new ResourceMap(); override clearTracking(): void { super.clearTracking(); @@ -714,7 +715,7 @@ export class InMemoryTestFileService extends TestFileService { this.readOperations.push({ resource }); // Check if we have content in our in-memory store - const content = this.files.get(resource.toString()); + const content = this.files.get(resource); if (content) { return { ...createFileStat(resource, this.readonly), @@ -743,11 +744,25 @@ export class InMemoryTestFileService extends TestFileService { } // Store in memory and track - this.files.set(resource.toString(), content); + this.files.set(resource, content); this.writeOperations.push({ resource, content: content.toString() }); return createFileStat(resource, this.readonly); } + + override async del(resource: URI, _options?: { useTrash?: boolean; recursive?: boolean }): Promise { + this.files.delete(resource); + this.notExistsSet.set(resource, true); + } + + override async exists(resource: URI): Promise { + const inMemory = this.files.has(resource); + if (inMemory) { + return true; + } + + return super.exists(resource); + } } export class TestChatEntitlementService implements IChatEntitlementService { @@ -779,3 +794,84 @@ export class TestChatEntitlementService implements IChatEntitlementService { readonly anonymousObs = observableValue({}, false); } +export class TestLifecycleService extends Disposable implements ILifecycleService { + + declare readonly _serviceBrand: undefined; + + usePhases = false; + _phase!: LifecyclePhase; + get phase(): LifecyclePhase { return this._phase; } + set phase(value: LifecyclePhase) { + this._phase = value; + if (value === LifecyclePhase.Starting) { + this.whenStarted.complete(); + } else if (value === LifecyclePhase.Ready) { + this.whenReady.complete(); + } else if (value === LifecyclePhase.Restored) { + this.whenRestored.complete(); + } else if (value === LifecyclePhase.Eventually) { + this.whenEventually.complete(); + } + } + + private readonly whenStarted = new DeferredPromise(); + private readonly whenReady = new DeferredPromise(); + private readonly whenRestored = new DeferredPromise(); + private readonly whenEventually = new DeferredPromise(); + async when(phase: LifecyclePhase): Promise { + if (!this.usePhases) { + return; + } + if (phase === LifecyclePhase.Starting) { + await this.whenStarted.p; + } else if (phase === LifecyclePhase.Ready) { + await this.whenReady.p; + } else if (phase === LifecyclePhase.Restored) { + await this.whenRestored.p; + } else if (phase === LifecyclePhase.Eventually) { + await this.whenEventually.p; + } + } + + startupKind!: StartupKind; + willShutdown = false; + + private readonly _onBeforeShutdown = this._register(new Emitter()); + get onBeforeShutdown(): Event { return this._onBeforeShutdown.event; } + + private readonly _onBeforeShutdownError = this._register(new Emitter()); + get onBeforeShutdownError(): Event { return this._onBeforeShutdownError.event; } + + private readonly _onShutdownVeto = this._register(new Emitter()); + get onShutdownVeto(): Event { return this._onShutdownVeto.event; } + + private readonly _onWillShutdown = this._register(new Emitter()); + get onWillShutdown(): Event { return this._onWillShutdown.event; } + + private readonly _onDidShutdown = this._register(new Emitter()); + get onDidShutdown(): Event { return this._onDidShutdown.event; } + + shutdownJoiners: Promise[] = []; + + fireShutdown(reason = ShutdownReason.QUIT): void { + this.shutdownJoiners = []; + + this._onWillShutdown.fire({ + join: p => { + this.shutdownJoiners.push(typeof p === 'function' ? p() : p); + }, + joiners: () => [], + force: () => { /* No-Op in tests */ }, + token: CancellationToken.None, + reason + }); + } + + fireBeforeShutdown(event: InternalBeforeShutdownEvent): void { this._onBeforeShutdown.fire(event); } + + fireWillShutdown(event: WillShutdownEvent): void { this._onWillShutdown.fire(event); } + + async shutdown(): Promise { + this.fireShutdown(); + } +} diff --git a/src/vscode-dts/vscode.proposed.interactive.d.ts b/src/vscode-dts/vscode.proposed.interactive.d.ts index d6c4c7b5296..19eae8d7f37 100644 --- a/src/vscode-dts/vscode.proposed.interactive.d.ts +++ b/src/vscode-dts/vscode.proposed.interactive.d.ts @@ -6,6 +6,6 @@ declare module 'vscode' { export namespace interactive { - export function transferActiveChat(toWorkspace: Uri): void; + export function transferActiveChat(toWorkspace: Uri): Thenable; } }