// ***************************************************************************** // 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, DefaultChatAgentId } 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 { ChatAgentLocation } from './chat-agents'; import { ChatContentDeserializerRegistry, ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer'; import { ChangeSetElementDeserializerRegistry, ChangeSetElementDeserializerRegistryImpl } from './change-set-element-deserializer'; import { ChatModel, MutableChatModel } from './chat-model'; import { SerializedChatData } from './chat-model-serialization'; import { ParsedChatRequest, ParsedChatRequestTextPart } from './parsed-chat-request'; describe('Chat Auto-Save Mechanism', () => { let chatService: ChatServiceImpl; let sessionStore: MockChatSessionStore; let container: Container; class MockChatSessionStore implements ChatSessionStore { public saveCount = 0; public savedSessions: Array = []; public lastSaveTimes: Map = new Map(); async storeSessions(...sessions: Array): Promise { this.saveCount++; this.savedSessions = sessions; // Track save times per session sessions.forEach(session => { const id = 'model' in session ? session.model.id : session.id; this.lastSaveTimes.set(id, Date.now()); }); } async readSession(sessionId: string): Promise { return undefined; } async deleteSession(sessionId: string): Promise { // No-op for mock } async clearAllSessions(): Promise { this.savedSessions = []; this.saveCount = 0; this.lastSaveTimes.clear(); } async getSessionIndex(): Promise { return {}; } async hasPersistedSessions(): Promise { return false; } async setSessionTitle(sessionId: string, title: string): Promise { // No-op for mock } reset(): void { this.saveCount = 0; this.savedSessions = []; this.lastSaveTimes.clear(); } } class MockChatAgentService { private testAgent = { id: 'test-agent', name: 'Test Agent', invoke: () => Promise.resolve() }; readonly onDidChangeAgents = { dispose: () => { } }; readonly onDefaultAgentChanged = { dispose: () => { } }; getAgent(id: string): typeof this.testAgent | undefined { return id === 'test-agent' ? this.testAgent : undefined; } getAgents(): typeof this.testAgent[] { return [this.testAgent]; } resolveAgent(): typeof this.testAgent { return this.testAgent; } } class MockChatRequestParser { parseChatRequest(): Promise { return Promise.resolve({ request: { text: 'test' }, parts: [ new ParsedChatRequestTextPart( { start: 0, endExclusive: 4 }, 'test' ) ], toolRequests: new Map(), variables: [] }); } } class MockAIVariableService { resolveVariables(): Promise { return Promise.resolve([]); } resolveVariable(): Promise { return Promise.resolve(undefined); } } class MockLogger { error(): void { } warn(): void { } info(): void { } debug(): void { } } const mockToolInvocationRegistry: ToolInvocationRegistry = { registerTool: () => { }, unregisterTool: () => { }, 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(DefaultChatAgentId).toConstantValue({ id: 'test-agent' }); 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('Auto-save on response completion', () => { it('should auto-save when response is complete', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); const initialSaveCount = sessionStore.saveCount; // Send a request const invocation = await chatService.sendRequest(session.id, { text: 'Test request' }); const responseModel = await invocation!.responseCreated; // Complete the response // eslint-disable-next-line @typescript-eslint/no-explicit-any (responseModel as any).complete(); // Wait for auto-save to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify session was auto-saved expect(sessionStore.saveCount).to.be.greaterThan(initialSaveCount); }); it('should auto-save when response has error', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); const initialSaveCount = sessionStore.saveCount; // Send a request that will error const invocation = await chatService.sendRequest(session.id, { text: 'Test request' }); const responseModel = await invocation!.responseCreated; // Simulate error // eslint-disable-next-line @typescript-eslint/no-explicit-any (responseModel as any).error(new Error('Test error')); // Wait for auto-save to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify session was auto-saved even on error expect(sessionStore.saveCount).to.be.greaterThan(initialSaveCount); }); }); describe('Auto-save on changeset updates', () => { it('should auto-save when changeset elements are updated', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); // Add a request so the session is not empty await chatService.sendRequest(session.id, { text: 'Test request' }); sessionStore.reset(); // Trigger changeset update event via model's internal emitter // eslint-disable-next-line @typescript-eslint/no-explicit-any (session.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] }); // Wait for auto-save to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify session was auto-saved expect(sessionStore.saveCount).to.be.greaterThan(0); }); it('should not auto-save on non-changeset events', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); sessionStore.reset(); // Trigger other kind of event (like 'addRequest') // eslint-disable-next-line @typescript-eslint/no-explicit-any (session.model as any)._onDidChangeEmitter.fire({ kind: 'addRequest' }); // Wait to ensure no save happens await new Promise(resolve => setTimeout(resolve, 10)); // Verify no auto-save occurred expect(sessionStore.saveCount).to.equal(0); }); }); describe('Auto-save on settings changes', () => { it('should auto-save when per-session settings change', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); // Non-empty session so saveSession does not skip it. await chatService.sendRequest(session.id, { text: 'Test request' }); sessionStore.reset(); // A selector-only / dialog-only settings change must trigger persistence. (session.model as MutableChatModel).setSettings({ commonSettings: { modelId: 'test/model' } }); // Wait for auto-save to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); expect(sessionStore.saveCount).to.be.greaterThan(0); }); }); describe('Auto-save for all sessions', () => { it('should save all non-empty sessions', async () => { const session1 = chatService.createSession(ChatAgentLocation.Panel); const session2 = chatService.createSession(ChatAgentLocation.Panel); sessionStore.reset(); // Send requests to both sessions const invocation1 = await chatService.sendRequest(session1.id, { text: 'Request 1' }); const invocation2 = await chatService.sendRequest(session2.id, { text: 'Request 2' }); // Complete both responses // eslint-disable-next-line @typescript-eslint/no-explicit-any (await invocation1!.responseCreated as any).complete(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (await invocation2!.responseCreated as any).complete(); // Wait for auto-save to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify both sessions were saved (check lastSaveTimes since sessions are saved individually) expect(sessionStore.saveCount).to.be.greaterThan(0); expect(sessionStore.lastSaveTimes.has(session1.id)).to.be.true; expect(sessionStore.lastSaveTimes.has(session2.id)).to.be.true; }); it('should not save empty sessions', async () => { // Create session without any requests const session = chatService.createSession(ChatAgentLocation.Panel); sessionStore.reset(); // Manually trigger save // eslint-disable-next-line @typescript-eslint/no-explicit-any await (chatService as any).saveSession(session.id); // Verify empty session was not saved const savedSessionIds = sessionStore.savedSessions.map(s => 'model' in s ? s.model.id : s.id); expect(savedSessionIds).to.not.include(session.id); }); }); describe('Auto-save without session store', () => { it('should handle auto-save gracefully when session store unavailable', async () => { // Create 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(DefaultChatAgentId).toConstantValue({ id: 'test-agent' }); 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 session and send request const session = serviceWithoutStore.createSession(ChatAgentLocation.Panel); const invocation = await serviceWithoutStore.sendRequest(session.id, { text: 'Test' }); // Complete response - should not throw even without session store // eslint-disable-next-line @typescript-eslint/no-explicit-any (await invocation!.responseCreated as any).complete(); // Wait to ensure no errors await new Promise(resolve => setTimeout(resolve, 10)); // No assertion needed - we're just verifying no exception is thrown }); }); describe('Auto-save setup for restored sessions', () => { it('should set up auto-save for restored sessions', async () => { // Create and save a session const session1 = chatService.createSession(ChatAgentLocation.Panel); await chatService.sendRequest(session1.id, { text: 'Test' }); const serialized = session1.model.toSerializable(); // Create new service instance const newContainer = new Container(); const newSessionStore = new MockChatSessionStore(); newContainer.bind(ChatSessionStore).toConstantValue(newSessionStore); newContainer.bind(ChatAgentService).toConstantValue(new MockChatAgentService() as unknown as ChatAgentService); newContainer.bind(ChatRequestParser).toConstantValue(new MockChatRequestParser() as unknown as ChatRequestParser); newContainer.bind(AIVariableService).toConstantValue(new MockAIVariableService() as unknown as AIVariableService); newContainer.bind(ILogger).toConstantValue(new MockLogger() as unknown as ILogger); newContainer.bind(DefaultChatAgentId).toConstantValue({ id: 'test-agent' }); newContainer.bind(ToolInvocationRegistry).toConstantValue(mockToolInvocationRegistry); // Bind deserializer registries const newContentRegistry = new ChatContentDeserializerRegistryImpl(); new DefaultChatContentDeserializerContribution().registerDeserializers(newContentRegistry); newContainer.bind(ChatContentDeserializerRegistry).toConstantValue(newContentRegistry); newContainer.bind(ChangeSetElementDeserializerRegistry).toConstantValue(new ChangeSetElementDeserializerRegistryImpl()); newContainer.bind(ChatServiceImpl).toSelf().inSingletonScope(); const newChatService = newContainer.get(ChatServiceImpl); // Mock readSession to return the serialized data newSessionStore.readSession = async (id: string) => ({ version: 1, model: serialized, pinnedAgentId: undefined, saveDate: Date.now() }); // Restore the session const restoredSession = await newChatService.getOrRestoreSession(serialized.sessionId); expect(restoredSession).to.not.be.undefined; newSessionStore.reset(); // Trigger changeset update on restored session // eslint-disable-next-line @typescript-eslint/no-explicit-any (restoredSession!.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] }); // Wait for auto-save (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify auto-save was set up and triggered expect(newSessionStore.saveCount).to.be.greaterThan(0); }); }); describe('Multiple auto-save triggers', () => { it('should handle multiple rapid auto-save triggers', async () => { const session = chatService.createSession(ChatAgentLocation.Panel); // Add a request so the session is not empty await chatService.sendRequest(session.id, { text: 'Test request' }); sessionStore.reset(); // Trigger multiple changeset updates rapidly for (let i = 0; i < 5; i++) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (session.model as any)._onDidChangeEmitter.fire({ kind: 'updateChangeSet', elements: [] }); } // Wait for all saves to complete (debounce is 500ms + execution time) await new Promise(resolve => setTimeout(resolve, 700)); // Verify saves were triggered (implementation batches them) expect(sessionStore.saveCount).to.be.greaterThan(0); }); }); });