// ***************************************************************************** // 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 { URI } from '@theia/core'; import { Position } from '@theia/core/shared/vscode-languageserver-protocol'; import { ILogger } from '@theia/core/lib/common'; import { ChatContentDeserializerRegistryImpl, DefaultChatContentDeserializerContribution } from './chat-content-deserializer'; import { CodeChatResponseContentImpl, ErrorChatResponseContentImpl, HorizontalLayoutChatResponseContentImpl, InformationalChatResponseContentImpl, MarkdownChatResponseContentImpl, ProgressChatResponseContentImpl, QuestionResponseContentImpl, TextChatResponseContentImpl, ThinkingChatResponseContentImpl, ToolCallChatResponseContent, ToolCallChatResponseContentImpl } from './chat-model'; class MockLogger { error(): void { } warn(): void { } info(): void { } debug(): void { } } describe('Chat Content Serialization', () => { let registry: ChatContentDeserializerRegistryImpl; beforeEach(() => { registry = new ChatContentDeserializerRegistryImpl(); // eslint-disable-next-line @typescript-eslint/no-explicit-any (registry as any).logger = new MockLogger() as unknown as ILogger; const contribution = new DefaultChatContentDeserializerContribution(); contribution.registerDeserializers(registry); }); describe('TextChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new TextChatResponseContentImpl('Hello, World!'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('text'); expect(serialized!.data).to.deep.equal({ content: 'Hello, World!' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('text'); expect(deserialized.asString?.()).to.equal('Hello, World!'); }); }); describe('ThinkingChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new ThinkingChatResponseContentImpl('Thinking...', 'sig123'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('thinking'); expect(serialized!.data).to.deep.equal({ content: 'Thinking...', signature: 'sig123' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('thinking'); }); }); describe('MarkdownChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new MarkdownChatResponseContentImpl('# Title\n\nContent'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('markdownContent'); expect(serialized!.data).to.deep.equal({ content: '# Title\n\nContent' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('markdownContent'); expect(deserialized.asString?.()).to.equal('# Title\n\nContent'); }); }); describe('InformationalChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new InformationalChatResponseContentImpl('Info message'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('informational'); expect(serialized!.data).to.deep.equal({ content: 'Info message' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('informational'); }); }); describe('CodeChatResponseContentImpl', () => { it('should serialize and deserialize code without location', async () => { const original = new CodeChatResponseContentImpl('console.log("test")', 'typescript'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('code'); expect(serialized!.data).to.deep.equal({ code: 'console.log("test")', language: 'typescript', location: undefined }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('code'); }); it('should serialize and deserialize code with location', async () => { const location = { uri: new URI('file:///test.ts'), position: Position.create(1, 0) }; const original = new CodeChatResponseContentImpl('code', 'typescript', location); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('code'); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('code'); }); }); describe('ToolCallChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new ToolCallChatResponseContentImpl( 'id123', 'toolName', '{"arg": "value"}', true, 'result' ); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('toolCall'); expect(serialized!.data).to.deep.equal({ id: 'id123', name: 'toolName', arguments: '{"arg": "value"}', result: 'result' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('toolCall'); }); it('should mark an unfinished tool call as interrupted when restored', async () => { const original = new ToolCallChatResponseContentImpl('id', 'toolName', '{}', false); const serialized = original.toSerializable?.(); const withFallback = { ...serialized!, fallbackMessage: '' }; const deserialized = await registry.deserialize(withFallback) as ToolCallChatResponseContentImpl; expect(deserialized.finished).to.be.true; expect(ToolCallChatResponseContent.isErrorResult(deserialized.result)).to.be.true; expect(ToolCallChatResponseContent.getErrorMessage(deserialized.result)).to.include('interrupted'); const [, toolResultMessage] = deserialized.toLanguageModelMessage(); expect(ToolCallChatResponseContent.isErrorResult(toolResultMessage.content)).to.be.true; }); it('should preserve a real tool result on restore (not overwrite as interrupted)', async () => { const original = new ToolCallChatResponseContentImpl('id', 'toolName', '{}', true, 'real-result'); const serialized = original.toSerializable?.(); const withFallback = { ...serialized!, fallbackMessage: '' }; const deserialized = await registry.deserialize(withFallback) as ToolCallChatResponseContentImpl; expect(deserialized.finished).to.be.true; expect(deserialized.result).to.equal('real-result'); }); it('should preserve a partial result written via updateResult and finalize the tool call', async () => { // Simulates a still-pending tool that persisted intermediate state // (e.g. user-interaction wizard) before the chat session was reloaded. const original = new ToolCallChatResponseContentImpl('id', 'toolName', '{}', false); original.updateResult('{"completed":false,"steps":[{"title":"S1","value":"a"}]}'); const serialized = original.toSerializable?.(); const withFallback = { ...serialized!, fallbackMessage: '' }; const deserialized = await registry.deserialize(withFallback) as ToolCallChatResponseContentImpl; expect(deserialized.finished).to.be.true; expect(deserialized.result).to.equal('{"completed":false,"steps":[{"title":"S1","value":"a"}]}'); expect(ToolCallChatResponseContent.isErrorResult(deserialized.result)).to.be.false; }); }); describe('ErrorChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const error = new Error('Test error'); const original = new ErrorChatResponseContentImpl(error); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('error'); expect(serialized!.data).to.have.property('message', 'Test error'); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('error'); }); }); describe('ProgressChatResponseContentImpl', () => { it('should serialize and deserialize correctly', async () => { const original = new ProgressChatResponseContentImpl('Processing...'); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('progress'); expect(serialized!.data).to.deep.equal({ message: 'Processing...' }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('progress'); }); }); describe('HorizontalLayoutChatResponseContentImpl', () => { it('should serialize and deserialize nested content', async () => { const child1 = new TextChatResponseContentImpl('Text 1'); const child2 = new TextChatResponseContentImpl('Text 2'); const original = new HorizontalLayoutChatResponseContentImpl([child1, child2]); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('horizontal'); expect(serialized!.data).to.have.property('content'); // eslint-disable-next-line @typescript-eslint/no-explicit-any expect((serialized!.data as any).content).to.be.an('array').with.length(2); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('horizontal'); }); }); describe('QuestionResponseContentImpl', () => { it('should serialize and deserialize question with selected option', async () => { const options = [ { text: 'Blue' }, { text: 'Green' }, { text: 'Lavender' } ]; const original = new QuestionResponseContentImpl( 'Which color do you find most calming?', options, undefined, // request undefined, // handler { selectedOption: { text: 'Blue' } } ); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('question'); expect(serialized!.data).to.deep.equal({ question: 'Which color do you find most calming?', options: options, selectedOption: { text: 'Blue' } }); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('question'); expect(deserialized.asString?.()).to.include('Question: Which color do you find most calming?'); expect(deserialized.asString?.()).to.include('Answer: Blue'); }); it('should serialize and deserialize multi-select question with selected options', async () => { const options = [ { text: 'TypeScript' }, { text: 'Python' }, { text: 'Rust' } ]; const original = new QuestionResponseContentImpl( 'Which languages do you use?', options, undefined, // request undefined, // handler { multiSelect: true, selectedOptions: [{ text: 'TypeScript' }, { text: 'Rust' }] } ); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('question'); expect(serialized!.data).to.deep.equal({ question: 'Which languages do you use?', options: options, multiSelect: true, selectedOptions: [{ text: 'TypeScript' }, { text: 'Rust' }] }); const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('question'); expect(deserialized.asString?.()).to.include('Answer: TypeScript, Rust'); const q = deserialized as QuestionResponseContentImpl; expect(q.multiSelect).to.be.true; expect(q.selectedOptions).to.deep.equal([{ text: 'TypeScript' }, { text: 'Rust' }]); }); it('should serialize and deserialize multi-select question with header and no selected options', async () => { const options = [ { text: 'Option A' }, { text: 'Option B' } ]; const original = new QuestionResponseContentImpl( 'Pick any that apply', options, undefined, // request undefined, // handler { multiSelect: true, header: 'Configuration' } ); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('question'); expect(serialized!.data).to.deep.equal({ question: 'Pick any that apply', options: options, multiSelect: true, header: 'Configuration', selectedOptions: undefined }); const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('question'); const q = deserialized as QuestionResponseContentImpl; expect(q.multiSelect).to.be.true; expect(q.header).to.equal('Configuration'); expect(q.selectedOptions).to.be.undefined; }); it('should serialize and deserialize question without selected option', async () => { const options = [ { text: 'Option 1' }, { text: 'Option 2' } ]; const original = new QuestionResponseContentImpl( 'What is your choice?', options, undefined, // request undefined // handler // no selectedOption ); const serialized = original.toSerializable?.(); expect(serialized).to.not.be.undefined; expect(serialized!.kind).to.equal('question'); // Simulate caller populating fallbackMessage const withFallback = { ...serialized!, fallbackMessage: original.asString?.() || original.toString() }; const deserialized = await registry.deserialize(withFallback); expect(deserialized.kind).to.equal('question'); expect(deserialized.asString?.()).to.include('Question: What is your choice?'); expect(deserialized.asString?.()).to.include('No answer'); }); }); describe('ChatContentDeserializerRegistry', () => { it('should handle unknown content types with fallback', async () => { const unknownContent = { kind: 'unknown-type', fallbackMessage: 'Fallback text', data: { some: 'data' } }; const deserialized = await registry.deserialize(unknownContent); expect(deserialized.kind).to.equal('unknown'); expect(deserialized.asString?.()).to.equal('Fallback text'); }); it('should use fallback message when deserializer not found', async () => { const unknownContent = { kind: 'custom-extension-type', fallbackMessage: 'Custom content not available', data: undefined }; const deserialized = await registry.deserialize(unknownContent); expect(deserialized.asString?.()).to.equal('Custom content not available'); }); }); });