// ***************************************************************************** // Copyright (C) 2025 EclipseSource GmbH. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; let disableJSDOM = enableJSDOM(); import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; FrontendApplicationConfigProvider.set({}); import { expect } from 'chai'; import * as sinon from 'sinon'; import { Container } from '@theia/core/shared/inversify'; import { ChatSessionStoreImpl } from './chat-session-store-impl'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { WorkspaceService, WorkspaceMetadataStorageService, WorkspaceMetadataStore } from '@theia/workspace/lib/browser'; import { EnvVariablesServer } from '@theia/core/lib/common/env-variables'; import { PreferenceService } from '@theia/core/lib/common'; import { StorageService } from '@theia/core/lib/browser'; import { URI, Emitter, ILogger } from '@theia/core'; import { BinaryBuffer } from '@theia/core/lib/common/buffer'; import { ChatSessionIndex, ChatSessionMetadata } from '../common/chat-session-store'; import { PERSISTED_SESSION_LIMIT_PREF, SESSION_STORAGE_PREF, SessionStorageScope } from '../common/ai-chat-preferences'; import { ChatAgentLocation } from '../common/chat-agents'; import { FileStat } from '@theia/filesystem/lib/common/files'; disableJSDOM(); describe('ChatSessionStoreImpl', () => { let sandbox: sinon.SinonSandbox; let container: Container; let chatSessionStore: ChatSessionStoreImpl; let mockFileService: sinon.SinonStubbedInstance; let mockPreferenceService: sinon.SinonStubbedInstance; let mockEnvServer: sinon.SinonStubbedInstance; let mockWorkspaceService: { tryGetRoots: () => FileStat[]; }; let deletedFiles: string[]; let preferenceChangeCallback: ((event: { preferenceName: string }) => void) | undefined; // Use obviously fake paths that will not exist on real systems to prevent any accidental // interaction with actual user data if mocking were to misconfigured const STORAGE_ROOT = 'file:///__test__/mock-config/chatSessions'; const WORKSPACE_METADATA_STORAGE = 'file:///__test__/mock-config/workspace-metadata/test-uuid/chatSessions'; const WORKSPACE_ROOT = 'file:///__test__/mock-workspace/my-project'; const GLOBAL_CONFIG_DIR = 'file:///__test__/mock-config'; const DEFAULT_STORAGE_SCOPE: SessionStorageScope = 'workspace'; function createMockSessionMetadata(id: string, saveDate: number, rootSessionId?: string): ChatSessionMetadata { return { sessionId: id, title: `Session ${id}`, saveDate, location: ChatAgentLocation.Panel, rootSessionId }; } function createMockIndex(sessions: ChatSessionMetadata[]): ChatSessionIndex { const index: ChatSessionIndex = {}; for (const session of sessions) { index[session.sessionId] = session; } return index; } before(() => { disableJSDOM = enableJSDOM(); }); after(() => { disableJSDOM(); }); beforeEach(() => { sandbox = sinon.createSandbox(); deletedFiles = []; preferenceChangeCallback = undefined; container = new Container(); mockFileService = { readFile: sandbox.stub(), writeFile: sandbox.stub().resolves(), delete: sandbox.stub().callsFake(async (uri: URI) => { deletedFiles.push(uri.toString()); }), createFolder: sandbox.stub().resolves(), exists: sandbox.stub().resolves(true), copy: sandbox.stub().resolves(), resolve: sandbox.stub().resolves({ children: [] }) } as unknown as sinon.SinonStubbedInstance; mockPreferenceService = { ready: Promise.resolve(), get: sandbox.stub(), onPreferenceChanged: sandbox.stub().callsFake((callback: (event: { preferenceName: string }) => void) => { preferenceChangeCallback = callback; return { dispose: () => { preferenceChangeCallback = undefined; } }; }) } as unknown as sinon.SinonStubbedInstance; mockEnvServer = { getConfigDirUri: sandbox.stub().resolves(GLOBAL_CONFIG_DIR) } as unknown as sinon.SinonStubbedInstance; mockWorkspaceService = { tryGetRoots: () => [] }; const mockStorageService = {} as StorageService; const mockLogger = { debug: sandbox.stub(), info: sandbox.stub(), warn: sandbox.stub(), error: sandbox.stub() } as unknown as ILogger; // Create a mock WorkspaceMetadataStore const locationChangeEmitter = new Emitter(); const mockMetadataStore: WorkspaceMetadataStore = { key: 'chatSessions', location: new URI(WORKSPACE_METADATA_STORAGE), onDidChangeLocation: locationChangeEmitter.event, ensureExists: sandbox.stub().resolves(), delete: sandbox.stub().resolves(), dispose: () => locationChangeEmitter.dispose() }; // Create a mock for WorkspaceMetadataStorageService const mockMetadataStorageService = { getOrCreateStore: sandbox.stub().resolves(mockMetadataStore) }; container.bind(FileService).toConstantValue(mockFileService as unknown as FileService); container.bind(PreferenceService).toConstantValue(mockPreferenceService as unknown as PreferenceService); container.bind(EnvVariablesServer).toConstantValue(mockEnvServer as unknown as EnvVariablesServer); container.bind(WorkspaceService).toConstantValue(mockWorkspaceService as unknown as WorkspaceService); container.bind(WorkspaceMetadataStorageService).toConstantValue(mockMetadataStorageService as unknown as WorkspaceMetadataStorageService); container.bind(StorageService).toConstantValue(mockStorageService); container.bind('ChatSessionStore').toConstantValue(mockLogger); container.bind(ILogger).toConstantValue(mockLogger); container.bind(ChatSessionStoreImpl).toSelf().inSingletonScope(); chatSessionStore = container.get(ChatSessionStoreImpl); // Set up default storage preferences for all tests mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns(DEFAULT_STORAGE_SCOPE); }); afterEach(() => { sandbox.restore(); }); describe('trimSessions', () => { describe('when persistedSessionLimit is -1 (unlimited)', () => { beforeEach(() => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1); }); it('should not delete any sessions regardless of count', async () => { const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000), createMockSessionMetadata('session-4', 4000), createMockSessionMetadata('session-5', 5000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // Access protected method via any // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; expect(mockFileService.delete.called).to.be.false; }); it('should not delete sessions even with 100 sessions', async () => { const sessions = Array.from({ length: 100 }, (_, i) => createMockSessionMetadata(`session-${i}`, (i + 1) * 1000) ); const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; }); }); describe('when persistedSessionLimit is 0 (no persistence)', () => { beforeEach(() => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0); }); it('should delete all sessions and clear index', async () => { const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(3); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-3.json`); const savedIndexCall = mockFileService.writeFile.lastCall; expect(savedIndexCall).to.not.be.null; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(Object.keys(savedIndex)).to.have.lengthOf(0); }); it('should handle empty index gracefully', async () => { const index: ChatSessionIndex = {}; mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; }); }); describe('when persistedSessionLimit is positive', () => { it('should not trim when session count is within limit', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; }); it('should trim oldest sessions when count exceeds limit', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000), createMockSessionMetadata('session-4', 4000), createMockSessionMetadata('session-5', 5000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(2); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-4.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-5.json`); }); it('should delete sessions in order of saveDate (oldest first)', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2); const sessions = [ createMockSessionMetadata('session-newest', 5000), createMockSessionMetadata('session-middle', 3000), createMockSessionMetadata('session-oldest', 1000), createMockSessionMetadata('session-second-oldest', 2000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(2); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-oldest.json`); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-second-oldest.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-newest.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-middle.json`); }); it('should update index after trimming', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000), createMockSessionMetadata('session-4', 4000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); const savedIndexCall = mockFileService.writeFile.lastCall; expect(savedIndexCall).to.not.be.null; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(Object.keys(savedIndex)).to.have.lengthOf(2); expect(savedIndex['session-3']).to.not.be.undefined; expect(savedIndex['session-4']).to.not.be.undefined; expect(savedIndex['session-1']).to.be.undefined; expect(savedIndex['session-2']).to.be.undefined; }); it('should trim to exactly the limit (42 session bug scenario)', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25); const sessions = Array.from({ length: 42 }, (_, i) => createMockSessionMetadata(`session-${i}`, (i + 1) * 1000) ); const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(17); for (let i = 0; i < 17; i++) { expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-${i}.json`); } for (let i = 17; i < 42; i++) { expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-${i}.json`); } }); }); describe('edge cases', () => { it('should handle empty session index', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(5); const index: ChatSessionIndex = {}; mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; }); it('should handle sessions exactly at limit', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(3); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.be.empty; }); it('should handle limit of 1', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(1); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(2); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-1.json`); expect(deletedFiles).to.include(`${STORAGE_ROOT}/session-2.json`); expect(deletedFiles).to.not.include(`${STORAGE_ROOT}/session-3.json`); }); it('should handle file deletion errors gracefully', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2); const sessions = [ createMockSessionMetadata('session-1', 1000), createMockSessionMetadata('session-2', 2000), createMockSessionMetadata('session-3', 3000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); mockFileService.delete.rejects(new Error('File not found')); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); const savedIndexCall = mockFileService.writeFile.lastCall; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(Object.keys(savedIndex)).to.have.lengthOf(2); }); it('should handle sessions with equal save dates', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(2); const sessions = [ createMockSessionMetadata('session-a', 1000), createMockSessionMetadata('session-b', 1000), createMockSessionMetadata('session-c', 2000), createMockSessionMetadata('session-d', 2000) ]; const index = createMockIndex(sessions); mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).trimSessions(); expect(deletedFiles).to.have.lengthOf(2); const savedIndexCall = mockFileService.writeFile.lastCall; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(Object.keys(savedIndex)).to.have.lengthOf(2); }); }); }); describe('getPersistedSessionLimit', () => { it('should return -1 for unlimited sessions', () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(-1); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = (chatSessionStore as any).getPersistedSessionLimit(); expect(result).to.equal(-1); }); it('should return 0 for no persistence', () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(0); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = (chatSessionStore as any).getPersistedSessionLimit(); expect(result).to.equal(0); }); it('should return default value of 25', () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = (chatSessionStore as any).getPersistedSessionLimit(); expect(result).to.equal(25); }); it('should return custom positive value', () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(100); // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = (chatSessionStore as any).getPersistedSessionLimit(); expect(result).to.equal(100); }); }); describe('resolveStorageRoot', () => { describe('when scope is workspace', () => { it('should use workspace storage path when workspace is open', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (chatSessionStore as any).resolveStorageRoot(); expect(result.toString()).to.equal(WORKSPACE_METADATA_STORAGE); }); it('should use workspace metadata storage', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (chatSessionStore as any).resolveStorageRoot(); expect(result.toString()).to.equal(WORKSPACE_METADATA_STORAGE); }); it('should fall back to global storage when no workspace is open', async () => { mockWorkspaceService.tryGetRoots = () => []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (chatSessionStore as any).resolveStorageRoot(); expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); }); }); describe('when scope is global', () => { beforeEach(() => { mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global'); }); it('should use global storage path', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (chatSessionStore as any).resolveStorageRoot(); expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); }); it('should ignore workspace even when open', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any const result = await (chatSessionStore as any).resolveStorageRoot(); expect(result.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); }); }); }); describe('cache invalidation', () => { it('should invalidate cache when storage preference changes scope', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // First call to establish cache // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstResult = await (chatSessionStore as any).getStorageRoot(); expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE); // Change scope to global mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global'); // Trigger preference change expect(preferenceChangeCallback).to.not.be.undefined; preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF }); // Next call should resolve new path // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondResult = await (chatSessionStore as any).getStorageRoot(); expect(secondResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); }); it('should invalidate cache when workspace path preference changes', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // First call to establish cache // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstResult = await (chatSessionStore as any).getStorageRoot(); expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE); // Workspace path changes are no longer applicable as we use WorkspaceMetadataStore // Trigger preference change event (scope remains 'workspace') mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('workspace'); // Trigger preference change expect(preferenceChangeCallback).to.not.be.undefined; preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF }); // Path should remain the same since WorkspaceMetadataStore manages the location // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondResult = await (chatSessionStore as any).getStorageRoot(); expect(secondResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE); }); it('should maintain same global path since paths are no longer customizable', async () => { mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global'); // First call to establish cache // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstResult = await (chatSessionStore as any).getStorageRoot(); expect(firstResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); // Trigger preference change (but scope remains 'global') expect(preferenceChangeCallback).to.not.be.undefined; preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF }); // Path should remain the same since global path is fixed // eslint-disable-next-line @typescript-eslint/no-explicit-any const secondResult = await (chatSessionStore as any).getStorageRoot(); expect(secondResult.toString()).to.equal(`${GLOBAL_CONFIG_DIR}/chatSessions`); }); it('should use workspace metadata storage for workspace scope', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // First call to establish cache // eslint-disable-next-line @typescript-eslint/no-explicit-any const firstResult = await (chatSessionStore as any).getStorageRoot(); expect(firstResult.toString()).to.equal(WORKSPACE_METADATA_STORAGE); // Workspace location changes are now handled by WorkspaceMetadataStore.onDidChangeLocation // The store will emit this event and the chat store will invalidate its cache automatically }); it('should invalidate index cache along with storage root', async () => { mockWorkspaceService.tryGetRoots = () => [ { resource: new URI(WORKSPACE_ROOT), isDirectory: true } as FileStat ]; // Setup index file response const index = { 'session-1': { sessionId: 'session-1', title: 'Test', saveDate: 1000, location: 'panel' } }; mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // Load index (establishes cache) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).loadIndex(); // Change scope to trigger invalidation mockPreferenceService.get.withArgs(SESSION_STORAGE_PREF, 'workspace').returns('global'); preferenceChangeCallback!({ preferenceName: SESSION_STORAGE_PREF }); // Load index again - should read from file again // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).loadIndex(); // readFile should have been called twice expect(mockFileService.readFile.callCount).to.equal(2); }); }); describe('rootSessionId persistence', () => { const mockModel = (id: string) => ({ id, isEmpty: () => false, location: ChatAgentLocation.Panel, toSerializable: () => ({ location: ChatAgentLocation.Panel, requests: [], responses: [], hierarchy: { rootBranchId: '', branches: {} } }) }); it('should include rootSessionId in the session index when updating', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25); const sessions = [ { model: mockModel('parent-session') as never, title: 'Parent Session', saveDate: 1000, pinnedAgentId: undefined, rootSessionId: undefined }, { model: mockModel('child-session') as never, title: 'Child Session', saveDate: 2000, pinnedAgentId: undefined, rootSessionId: 'parent-session' } ] as never[]; // Setup index file response const index = { 'parent-session': { sessionId: 'parent-session', title: 'Parent Session', saveDate: 1000, location: 'panel' } }; mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // Access protected method via any // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).updateIndex(sessions); const savedIndexCall = mockFileService.writeFile.lastCall; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(savedIndex['child-session']).to.not.be.undefined; expect(savedIndex['child-session'].rootSessionId).to.equal('parent-session'); }); it('should handle sessions without rootSessionId', async () => { mockPreferenceService.get.withArgs(PERSISTED_SESSION_LIMIT_PREF, 25).returns(25); const sessions = [ { model: mockModel('standalone-session') as never, title: 'Standalone Session', saveDate: 1000, pinnedAgentId: undefined, rootSessionId: undefined } ] as never[]; const index = { 'standalone-session': { sessionId: 'standalone-session', title: 'Standalone Session', saveDate: 1000, location: 'panel' } }; mockFileService.readFile.resolves({ value: BinaryBuffer.fromString(JSON.stringify(index)) } as never); // Access protected method via any // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatSessionStore as any).updateIndex(sessions); const savedIndexCall = mockFileService.writeFile.lastCall; const savedIndex = JSON.parse(savedIndexCall.args[1].toString()); expect(savedIndex['standalone-session'].rootSessionId).to.be.undefined; }); }); });