286 lines
13 KiB
TypeScript
286 lines
13 KiB
TypeScript
// *****************************************************************************
|
|
// 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 { expect } from 'chai';
|
|
import { Container } from '@theia/core/shared/inversify';
|
|
import { ChatServiceImpl } from './chat-service';
|
|
import { ChatSessionStore, ChatSessionIndex, ChatModelWithMetadata } from './chat-session-store';
|
|
import { ChatAgentService } from './chat-agent-service';
|
|
import { ChatRequestParser } from './chat-request-parser';
|
|
import { AIVariableService, ToolInvocationRegistry } from '@theia/ai-core';
|
|
import { ILogger } from '@theia/core';
|
|
import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer';
|
|
import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer';
|
|
import { ChatAgentLocation } from './chat-agents';
|
|
import { ChatModel } from './chat-model';
|
|
import { SerializedChatData } from './chat-model-serialization';
|
|
|
|
describe('ChatService Session Deletion', () => {
|
|
let chatService: ChatServiceImpl;
|
|
let sessionStore: MockChatSessionStore;
|
|
let container: Container;
|
|
|
|
class MockChatSessionStore implements ChatSessionStore {
|
|
public deletedSessions: string[] = [];
|
|
public storedSessions: Array<ChatModel | ChatModelWithMetadata> = [];
|
|
|
|
async storeSessions(...sessions: Array<ChatModel | ChatModelWithMetadata>): Promise<void> {
|
|
this.storedSessions = sessions;
|
|
}
|
|
|
|
async readSession(sessionId: string): Promise<SerializedChatData | undefined> {
|
|
return undefined;
|
|
}
|
|
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
this.deletedSessions.push(sessionId);
|
|
}
|
|
|
|
async clearAllSessions(): Promise<void> {
|
|
this.deletedSessions = [];
|
|
this.storedSessions = [];
|
|
}
|
|
|
|
async getSessionIndex(): Promise<ChatSessionIndex> {
|
|
return {};
|
|
}
|
|
|
|
async hasPersistedSessions(): Promise<boolean> {
|
|
return false;
|
|
}
|
|
|
|
async setSessionTitle(sessionId: string, title: string): Promise<void> {
|
|
}
|
|
}
|
|
|
|
class MockChatAgentService {
|
|
getAgent(): undefined {
|
|
return undefined;
|
|
}
|
|
getAgents(): never[] {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
class MockChatRequestParser {
|
|
parseChatRequest(): { parts: never[]; text: string } {
|
|
return { parts: [], text: '' };
|
|
}
|
|
}
|
|
|
|
class MockAIVariableService {
|
|
resolveVariables(): Promise<unknown[]> {
|
|
return Promise.resolve([]);
|
|
}
|
|
}
|
|
|
|
class MockLogger {
|
|
error(): void { }
|
|
warn(): void { }
|
|
info(): void { }
|
|
debug(): void { }
|
|
}
|
|
|
|
const mockToolInvocationRegistry: ToolInvocationRegistry = {
|
|
registerTool: () => { },
|
|
getFunction: () => undefined,
|
|
getFunctions: () => [],
|
|
getAllFunctions: () => [],
|
|
unregisterAllTools: () => { },
|
|
onDidChange: () => ({ dispose: () => { } })
|
|
};
|
|
|
|
beforeEach(() => {
|
|
container = new Container();
|
|
sessionStore = new MockChatSessionStore();
|
|
|
|
container.bind(ChatSessionStore).toConstantValue(sessionStore);
|
|
container.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
|
container.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
|
container.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
|
container.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
|
container.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
|
|
|
// Bind deserializer registries
|
|
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
|
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
|
container.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
|
container.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
|
container.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
|
|
|
chatService = container.get(ChatServiceImpl);
|
|
});
|
|
|
|
describe('deleteSession', () => {
|
|
it('should delete session from memory and persistent storage', async () => {
|
|
// Create a session
|
|
const session = chatService.createSession(ChatAgentLocation.Panel);
|
|
expect(chatService.getSessions()).to.have.lengthOf(1);
|
|
|
|
// Delete the session (now returns a Promise)
|
|
await chatService.deleteSession(session.id);
|
|
|
|
// Verify it's removed from memory
|
|
expect(chatService.getSessions()).to.have.lengthOf(0);
|
|
|
|
// Verify it's deleted from persistent storage
|
|
expect(sessionStore.deletedSessions).to.include(session.id);
|
|
});
|
|
|
|
it('should emit SessionDeletedEvent when session is deleted', done => {
|
|
// Create a session
|
|
const session = chatService.createSession(ChatAgentLocation.Panel);
|
|
|
|
// Listen for deletion event
|
|
chatService.onSessionEvent(event => {
|
|
if (event.type === 'deleted') {
|
|
expect(event.sessionId).to.equal(session.id);
|
|
done();
|
|
}
|
|
});
|
|
|
|
// Delete the session
|
|
chatService.deleteSession(session.id);
|
|
});
|
|
|
|
it('should handle deletion when session store is not available', async () => {
|
|
// Create a new service without session store
|
|
const containerWithoutStore = new Container();
|
|
containerWithoutStore.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
|
containerWithoutStore.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
|
containerWithoutStore.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
|
containerWithoutStore.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
|
containerWithoutStore.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
|
|
|
// Bind deserializer registries
|
|
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
|
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
|
containerWithoutStore.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
|
containerWithoutStore.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
|
|
|
containerWithoutStore.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
|
|
|
const serviceWithoutStore = containerWithoutStore.get(ChatServiceImpl);
|
|
|
|
// Create and delete a session - should not throw
|
|
const session = serviceWithoutStore.createSession(ChatAgentLocation.Panel);
|
|
await serviceWithoutStore.deleteSession(session.id);
|
|
|
|
// Verify session is still removed from memory
|
|
expect(serviceWithoutStore.getSessions()).to.have.lengthOf(0);
|
|
});
|
|
|
|
it('should attempt storage deletion even for non-existent in-memory sessions', async () => {
|
|
const initialDeletedCount = sessionStore.deletedSessions.length;
|
|
const nonExistentId = 'non-existent-id';
|
|
|
|
// Try to delete non-existent session (could be a persisted-only session)
|
|
await chatService.deleteSession(nonExistentId);
|
|
|
|
// Verify storage deletion was attempted even though session not in memory
|
|
expect(sessionStore.deletedSessions).to.include(nonExistentId);
|
|
expect(sessionStore.deletedSessions).to.have.lengthOf(initialDeletedCount + 1);
|
|
});
|
|
|
|
it('should handle deleting active session', () => {
|
|
// Create two sessions
|
|
const session1 = chatService.createSession(ChatAgentLocation.Panel);
|
|
const session2 = chatService.createSession(ChatAgentLocation.Panel);
|
|
|
|
// Ensure session2 is active (it should be by default as the latest)
|
|
expect(chatService.getActiveSession()?.id).to.equal(session2.id);
|
|
|
|
// Delete session1 (not active)
|
|
chatService.deleteSession(session1.id);
|
|
|
|
// Verify session2 is still active
|
|
const activeSession = chatService.getActiveSession();
|
|
expect(activeSession).to.not.be.undefined;
|
|
expect(activeSession?.id).to.equal(session2.id);
|
|
});
|
|
|
|
it('should handle storage deletion errors gracefully', async () => {
|
|
// Create a session store that throws errors
|
|
const errorStore = new MockChatSessionStore();
|
|
errorStore.deleteSession = async () => {
|
|
throw new Error('Storage error');
|
|
};
|
|
|
|
const errorContainer = new Container();
|
|
errorContainer.bind(ChatSessionStore).toConstantValue(errorStore);
|
|
errorContainer.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService);
|
|
errorContainer.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser);
|
|
errorContainer.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService);
|
|
errorContainer.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger);
|
|
errorContainer.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry);
|
|
|
|
// Bind deserializer registries
|
|
const contentRegistry = new ChatContentDeserializerRegistryImpl();
|
|
new DefaultChatContentDeserializerContribution().registerDeserializers(contentRegistry);
|
|
errorContainer.bind(ChatContentDeserializerRegistry).toConstantValue(contentRegistry);
|
|
errorContainer.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl());
|
|
|
|
errorContainer.bind(ChatServiceImpl).toSelf().inSingletonScope();
|
|
|
|
const errorService = errorContainer.get(ChatServiceImpl);
|
|
|
|
// Create and delete a session - should not throw even with storage error
|
|
const session = errorService.createSession(ChatAgentLocation.Panel);
|
|
await errorService.deleteSession(session.id);
|
|
|
|
// Verify session is still removed from memory despite storage error
|
|
expect(errorService.getSessions()).to.have.lengthOf(0);
|
|
});
|
|
|
|
it('should delete persisted-only sessions (not loaded in memory)', async () => {
|
|
// This simulates deleting a session from the "Show Chats..." dialog
|
|
// when the session is persisted but not currently loaded into memory
|
|
const persistedSessionId = 'persisted-session-123';
|
|
|
|
// Verify session is not in memory
|
|
expect(chatService.getSessions().find(s => s.id === persistedSessionId)).to.be.undefined;
|
|
|
|
// Delete the persisted-only session
|
|
await chatService.deleteSession(persistedSessionId);
|
|
|
|
// Verify it was still deleted from storage (even though not in memory)
|
|
expect(sessionStore.deletedSessions).to.include(persistedSessionId);
|
|
});
|
|
|
|
it('should not fire SessionDeletedEvent for persisted-only sessions', async () => {
|
|
// When deleting a persisted-only session (not in memory),
|
|
// we shouldn't fire the event since no in-memory state changed
|
|
const persistedSessionId = 'persisted-session-456';
|
|
let eventFired = false;
|
|
|
|
// Listen for deletion event
|
|
chatService.onSessionEvent(event => {
|
|
if (event.type === 'deleted' && event.sessionId === persistedSessionId) {
|
|
eventFired = true;
|
|
}
|
|
});
|
|
|
|
// Delete the persisted-only session
|
|
await chatService.deleteSession(persistedSessionId);
|
|
|
|
// Event should not have been fired since session wasn't in memory
|
|
expect(eventFired).to.be.false;
|
|
// But storage deletion should still have happened
|
|
expect(sessionStore.deletedSessions).to.include(persistedSessionId);
|
|
});
|
|
});
|
|
});
|