Merge branch 'main' into tyriar/284593_npm_spec__actual_spec

This commit is contained in:
Daniel Imms
2025-12-20 16:32:29 -08:00
committed by GitHub
27 changed files with 2718 additions and 2223 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -114,8 +114,6 @@ export const upstreamSpecs = [
// JavaScript / TypeScript
'node',
'nvm',
'pnpm',
'yarn',
'yo',
// Python

View File

@@ -18,7 +18,9 @@ import gitCompletionSpec from './completions/git';
import ghCompletionSpec from './completions/gh';
import npmCompletionSpec from './completions/npm';
import npxCompletionSpec from './completions/npx';
import pnpmCompletionSpec from './completions/pnpm';
import setLocationSpec from './completions/set-location';
import yarnCompletionSpec from './completions/yarn';
import { upstreamSpecs } from './constants';
import { ITerminalEnvironment, PathExecutableCache } from './env/pathExecutableCache';
import { executeCommand, executeCommandTimeout, IFigExecuteExternals } from './fig/execute';
@@ -72,7 +74,9 @@ export const availableSpecs: Fig.Spec[] = [
ghCompletionSpec,
npmCompletionSpec,
npxCompletionSpec,
pnpmCompletionSpec,
setLocationSpec,
yarnCompletionSpec,
];
for (const spec of upstreamSpecs) {
availableSpecs.push(require(`./completions/upstream/${spec}`).default);

View File

@@ -148,7 +148,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA
this._agents.deleteAndDispose(handle);
}
$transferActiveChatSession(toWorkspace: UriComponents): void {
async $transferActiveChatSession(toWorkspace: UriComponents): Promise<void> {
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<void> {

View File

@@ -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<void> {
checkProposedApiEnabled(extension, 'interactive');
return extHostChatAgents2.transferActiveChat(toWorkspace);
}

View File

@@ -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<void>;
}
export interface ICodeMapperTextEdit {

View File

@@ -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<void> {
await this._proxy.$transferActiveChatSession(newWorkspace);
}
createChatAgent(extension: IExtensionDescription, id: string, handler: vscode.ChatExtendedRequestHandler): vscode.ChatParticipant {

View File

@@ -310,13 +310,15 @@ abstract class OpenChatGlobalAction extends Action2 {
let resp: Promise<IChatResponseModel | undefined> | 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();
}
}

View File

@@ -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<void> {
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');

View File

@@ -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<IChatResponseModel>;
responseCompletePromise: Promise<void>;
@@ -1006,7 +1000,7 @@ export const IChatService = createDecorator<IChatService>('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<void>;
activateDefaultAgent(location: ChatAgentLocation): Promise<void>;

View File

@@ -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<void> {
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()}`);
}

View File

@@ -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<void> {
// 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<void> {
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<ISerializableChatData | undefined> {
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<void> {
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<void> {
try {
@@ -359,45 +450,49 @@ export class ChatSessionStore extends Disposable {
public async readSession(sessionId: string): Promise<ISerializableChatData | undefined> {
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<ISerializableChatData | undefined> {
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<string | undefined> {
@@ -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<IChatTransfer>;
type IChatTransferDto = Dto<IChatTransfer>;
/**
* Map of destination workspace URI to chat transfer data
*/
// type IChatTransferIndex = Record<string, IChatTransferDto>;
type IChatTransferIndex = Record<string, IChatTransferDto>;

View File

@@ -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<string[]>(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;
}

View File

@@ -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;

View File

@@ -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<string, IChatModel>();
@@ -144,7 +144,7 @@ class MockChatService implements IChatService {
notifyUserAction(_event: any): void { }
transferChatSession(): void { }
async transferChatSession(): Promise<void> { }
setChatSessionTitle(): void { }

View File

@@ -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());

View File

@@ -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());
});
});
});

View File

@@ -14,12 +14,16 @@ import { ChatAgentLocation } from '../../common/constants.js';
export class MockChatModel extends Disposable implements IChatModel {
readonly onDidDispose = this._register(new Emitter<void>()).event;
readonly onDidChange = this._register(new Emitter<IChatChangeEvent>()).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<IChatRequestNeedsInputInfo | undefined>('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: '',

View File

@@ -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<IChatModel>();
@@ -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<void> {
throw new Error('Method not implemented.');
}

View File

@@ -260,6 +260,22 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
find: true,
'/^find\\b.*-(delete|exec|execdir|fprint|fprintf|fls|ok|okdir)\\b/': false,
// sed
// - `-e`/`--expression`: Add the commands in script to the set of commands to be run
// while processing the input.
// - `-f`/`--file`: Add the commands contained in the file script-file to the set of
// commands to be run while processing the input.
// - `-i`/`--in-place`: This option specifies that files are to be edited in-place.
// - `w`/`W` commands: Write to files (blocked by `-i` check + agent typically won't use).
// - `s///e` flag: Executes substitution result as shell command
// - `s///w` flag: Write substitution result to file
// - `;W` Write first line of pattern space to file
// - Note that `--sandbox` exists which blocks unsafe commands that could potentially be
// leveraged to auto approve
sed: true,
'/^sed\\b.*(-[a-zA-Z]*(e|i|f)[a-zA-Z]*|--expression|--file|--in-place)\\b/': false,
'/^sed\\b.*(\/e|\/w|;W)/': false,
// sort
// - `-o`: Output redirection can write files (`sort -o /etc/something file`) which are
// blocked currently

View File

@@ -243,6 +243,8 @@ suite('RunInTerminalTool', () => {
'date +%Y-%m-%d',
'find . -name "*.txt"',
'grep pattern file.txt',
'sed "s/foo/bar/g"',
'sed -n "1,10p" file.txt',
'sort file.txt',
'tree directory'
];
@@ -295,6 +297,16 @@ suite('RunInTerminalTool', () => {
'find . -exec rm {} \\;',
'find . -execdir rm {} \\;',
'find . -fprint output.txt',
'sed -i "s/foo/bar/g" file.txt',
'sed -i.bak "s/foo/bar/" file.txt',
'sed --in-place "s/foo/bar/" file.txt',
'sed -e "s/a/b/" file.txt',
'sed -f script.sed file.txt',
'sed --expression "s/a/b/" file.txt',
'sed --file script.sed file.txt',
'sed "s/foo/bar/e" file.txt',
'sed "s/foo/bar/w output.txt" file.txt',
'sed ";W output.txt" file.txt',
'sort -o /etc/passwd file.txt',
'sort -S 100G file.txt',
'tree -o output.txt',

View File

@@ -5,6 +5,7 @@
import { $, Dimension, addDisposableListener, append, clearNode, reset } from '../../../../base/browser/dom.js';
import { renderFormattedText } from '../../../../base/browser/formattedTextRenderer.js';
import { status } from '../../../../base/browser/ui/aria/aria.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { renderLabelWithIcons } from '../../../../base/browser/ui/iconLabel/iconLabels.js';
@@ -295,6 +296,9 @@ export class GettingStartedPage extends EditorPane {
badgeelement.setAttribute('aria-label', localize('stepNotDone', "Checkbox for Step {0}: Not completed", step.title));
}
});
if (step.done) {
status(localize('stepAutoCompleted', "Step {0} completed", step.title));
}
}
this.updateCategoryProgress();
}));

View File

@@ -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<void>();
private readonly whenReady = new DeferredPromise<void>();
private readonly whenRestored = new DeferredPromise<void>();
private readonly whenEventually = new DeferredPromise<void>();
async when(phase: LifecyclePhase): Promise<void> {
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<InternalBeforeShutdownEvent>());
get onBeforeShutdown(): Event<InternalBeforeShutdownEvent> { return this._onBeforeShutdown.event; }
private readonly _onBeforeShutdownError = this._register(new Emitter<BeforeShutdownErrorEvent>());
get onBeforeShutdownError(): Event<BeforeShutdownErrorEvent> { return this._onBeforeShutdownError.event; }
private readonly _onShutdownVeto = this._register(new Emitter<void>());
get onShutdownVeto(): Event<void> { return this._onShutdownVeto.event; }
private readonly _onWillShutdown = this._register(new Emitter<WillShutdownEvent>());
get onWillShutdown(): Event<WillShutdownEvent> { return this._onWillShutdown.event; }
private readonly _onDidShutdown = this._register(new Emitter<void>());
get onDidShutdown(): Event<void> { return this._onDidShutdown.event; }
shutdownJoiners: Promise<void>[] = [];
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<void> {
this.fireShutdown();
}
}
export class TestBeforeShutdownEvent implements InternalBeforeShutdownEvent {
value: boolean | Promise<boolean> | undefined;

View File

@@ -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<string, VSBuffer>();
private files = new ResourceMap<VSBuffer>();
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<void> {
this.files.delete(resource);
this.notExistsSet.set(resource, true);
}
override async exists(resource: URI): Promise<boolean> {
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<void>();
private readonly whenReady = new DeferredPromise<void>();
private readonly whenRestored = new DeferredPromise<void>();
private readonly whenEventually = new DeferredPromise<void>();
async when(phase: LifecyclePhase): Promise<void> {
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<InternalBeforeShutdownEvent>());
get onBeforeShutdown(): Event<InternalBeforeShutdownEvent> { return this._onBeforeShutdown.event; }
private readonly _onBeforeShutdownError = this._register(new Emitter<BeforeShutdownErrorEvent>());
get onBeforeShutdownError(): Event<BeforeShutdownErrorEvent> { return this._onBeforeShutdownError.event; }
private readonly _onShutdownVeto = this._register(new Emitter<void>());
get onShutdownVeto(): Event<void> { return this._onShutdownVeto.event; }
private readonly _onWillShutdown = this._register(new Emitter<WillShutdownEvent>());
get onWillShutdown(): Event<WillShutdownEvent> { return this._onWillShutdown.event; }
private readonly _onDidShutdown = this._register(new Emitter<void>());
get onDidShutdown(): Event<void> { return this._onDidShutdown.event; }
shutdownJoiners: Promise<void>[] = [];
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<void> {
this.fireShutdown();
}
}

View File

@@ -6,6 +6,6 @@
declare module 'vscode' {
export namespace interactive {
export function transferActiveChat(toWorkspace: Uri): void;
export function transferActiveChat(toWorkspace: Uri): Thenable<void>;
}
}