Skip to content

Commit f8201f7

Browse files
committed
Refactor chat to chat service and chat plugin
Signed-off-by: Anan Zhuang <[email protected]>
1 parent a47de88 commit f8201f7

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

48 files changed

+2230
-797
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { BehaviorSubject } from 'rxjs';
7+
import { ChatServiceSetup, ChatServiceStart } from './types';
8+
9+
const createSetupContractMock = (): jest.Mocked<ChatServiceSetup> => ({
10+
setImplementation: jest.fn(),
11+
setSuggestedActionsService: jest.fn(),
12+
suggestedActionsService: undefined,
13+
});
14+
15+
const createStartContractMock = (): jest.Mocked<ChatServiceStart> => ({
16+
isAvailable: jest.fn().mockReturnValue(false),
17+
isWindowOpen: jest.fn().mockReturnValue(false),
18+
getThreadId$: jest.fn().mockReturnValue(new BehaviorSubject<string>('')),
19+
getThreadId: jest.fn().mockReturnValue(''),
20+
openWindow: jest.fn(),
21+
closeWindow: jest.fn(),
22+
getWindowState: jest.fn().mockReturnValue({
23+
isWindowOpen: false,
24+
windowMode: 'sidecar',
25+
paddingSize: 400,
26+
}),
27+
sendMessage: jest.fn().mockResolvedValue({
28+
observable: null,
29+
userMessage: { id: 'mock-id', role: 'user', content: 'mock-content' },
30+
}),
31+
sendMessageWithWindow: jest.fn().mockResolvedValue({
32+
observable: null,
33+
userMessage: { id: 'mock-id', role: 'user', content: 'mock-content' },
34+
}),
35+
getWindowState$: jest.fn().mockReturnValue(
36+
new BehaviorSubject({
37+
isWindowOpen: false,
38+
windowMode: 'sidecar',
39+
paddingSize: 400,
40+
})
41+
),
42+
onWindowOpen: jest.fn().mockReturnValue(() => {}),
43+
onWindowClose: jest.fn().mockReturnValue(() => {}),
44+
suggestedActionsService: undefined,
45+
});
46+
47+
export const coreChatServiceMock = {
48+
createSetupContract: createSetupContractMock,
49+
createStartContract: createStartContractMock,
50+
};
Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { BehaviorSubject } from 'rxjs';
7+
import { ChatService } from './chat_service';
8+
import { ChatImplementationFunctions, ChatWindowState, UserMessage } from './types';
9+
10+
describe('ChatService', () => {
11+
let service: ChatService;
12+
let mockImplementation: ChatImplementationFunctions;
13+
let mockThreadId$: BehaviorSubject<string>;
14+
let mockWindowState$: BehaviorSubject<ChatWindowState>;
15+
16+
beforeEach(() => {
17+
service = new ChatService();
18+
mockThreadId$ = new BehaviorSubject<string>('test-thread-id');
19+
mockWindowState$ = new BehaviorSubject<ChatWindowState>({
20+
isWindowOpen: false,
21+
windowMode: 'sidecar',
22+
paddingSize: 400,
23+
});
24+
25+
// Create complete mock implementation
26+
mockImplementation = {
27+
sendMessage: jest.fn().mockResolvedValue({
28+
observable: null,
29+
userMessage: { id: '1', role: 'user', content: 'test' },
30+
}),
31+
sendMessageWithWindow: jest.fn().mockResolvedValue({
32+
observable: null,
33+
userMessage: { id: '1', role: 'user', content: 'test' },
34+
}),
35+
getThreadId: jest.fn().mockReturnValue('test-thread-id'),
36+
getThreadId$: jest.fn().mockReturnValue(mockThreadId$.asObservable()),
37+
isWindowOpen: jest.fn().mockReturnValue(false),
38+
openWindow: jest.fn().mockResolvedValue(undefined),
39+
closeWindow: jest.fn().mockResolvedValue(undefined),
40+
getWindowState: jest.fn().mockReturnValue({
41+
isWindowOpen: false,
42+
windowMode: 'sidecar',
43+
paddingSize: 400,
44+
}),
45+
getWindowState$: jest.fn().mockReturnValue(mockWindowState$.asObservable()),
46+
onWindowOpen: jest.fn().mockReturnValue(() => {}),
47+
onWindowClose: jest.fn().mockReturnValue(() => {}),
48+
};
49+
});
50+
51+
describe('setup', () => {
52+
it('returns valid setup contract', () => {
53+
const setupContract = service.setup();
54+
55+
expect(setupContract).toEqual({
56+
setImplementation: expect.any(Function),
57+
setFallbackImplementation: expect.any(Function),
58+
setSuggestedActionsService: expect.any(Function),
59+
suggestedActionsService: undefined,
60+
});
61+
});
62+
63+
it('setImplementation stores the implementation functions', () => {
64+
const setupContract = service.setup();
65+
66+
expect(() => setupContract.setImplementation(mockImplementation)).not.toThrow();
67+
});
68+
});
69+
70+
describe('start', () => {
71+
describe('availability logic', () => {
72+
it('should be unavailable when no implementation is registered', () => {
73+
const startContract = service.start();
74+
expect(startContract.isAvailable()).toBe(false);
75+
});
76+
77+
it('should be available after implementation is registered', () => {
78+
const setupContract = service.setup();
79+
setupContract.setImplementation(mockImplementation);
80+
const startContract = service.start();
81+
expect(startContract.isAvailable()).toBe(true);
82+
});
83+
});
84+
85+
describe('service interface', () => {
86+
let startContract: any;
87+
88+
beforeEach(() => {
89+
startContract = service.start();
90+
});
91+
92+
it('provides all required interface methods', () => {
93+
expect(startContract).toEqual({
94+
isAvailable: expect.any(Function),
95+
isWindowOpen: expect.any(Function),
96+
getThreadId$: expect.any(Function),
97+
getThreadId: expect.any(Function),
98+
openWindow: expect.any(Function),
99+
closeWindow: expect.any(Function),
100+
sendMessage: expect.any(Function),
101+
sendMessageWithWindow: expect.any(Function),
102+
getWindowState: expect.any(Function),
103+
getWindowState$: expect.any(Function),
104+
onWindowOpen: expect.any(Function),
105+
onWindowClose: expect.any(Function),
106+
suggestedActionsService: undefined,
107+
});
108+
});
109+
110+
describe('window state management', () => {
111+
beforeEach(() => {
112+
// Set up implementation for these tests
113+
const setupContract = service.setup();
114+
setupContract.setImplementation(mockImplementation);
115+
startContract = service.start();
116+
});
117+
118+
it('should delegate to implementation for window state', () => {
119+
expect(startContract.isWindowOpen()).toBe(false);
120+
expect(mockImplementation.isWindowOpen).toHaveBeenCalled();
121+
});
122+
123+
it('should delegate window open/close to implementation', async () => {
124+
await startContract.openWindow();
125+
expect(mockImplementation.openWindow).toHaveBeenCalled();
126+
127+
await startContract.closeWindow();
128+
expect(mockImplementation.closeWindow).toHaveBeenCalled();
129+
});
130+
131+
it('should provide window state via implementation', () => {
132+
const state = startContract.getWindowState();
133+
expect(mockImplementation.getWindowState).toHaveBeenCalled();
134+
expect(state).toEqual({
135+
isWindowOpen: false,
136+
windowMode: 'sidecar',
137+
paddingSize: 400,
138+
});
139+
});
140+
141+
it('should provide window state observable via implementation', (done) => {
142+
const state$ = startContract.getWindowState$();
143+
expect(mockImplementation.getWindowState$).toHaveBeenCalled();
144+
145+
state$
146+
.subscribe((state: any) => {
147+
expect(state).toEqual({
148+
isWindowOpen: false,
149+
windowMode: 'sidecar',
150+
paddingSize: 400,
151+
});
152+
done();
153+
})
154+
.unsubscribe();
155+
});
156+
157+
it('should delegate window callbacks to implementation', () => {
158+
const openCallback = jest.fn();
159+
const closeCallback = jest.fn();
160+
161+
startContract.onWindowOpen(openCallback);
162+
startContract.onWindowClose(closeCallback);
163+
164+
expect(mockImplementation.onWindowOpen).toHaveBeenCalledWith(openCallback);
165+
expect(mockImplementation.onWindowClose).toHaveBeenCalledWith(closeCallback);
166+
});
167+
});
168+
169+
describe('thread ID management', () => {
170+
beforeEach(() => {
171+
// Set up implementation for these tests
172+
const setupContract = service.setup();
173+
setupContract.setImplementation(mockImplementation);
174+
startContract = service.start();
175+
});
176+
177+
it('should delegate thread ID to implementation', () => {
178+
const threadId = startContract.getThreadId();
179+
expect(mockImplementation.getThreadId).toHaveBeenCalled();
180+
expect(threadId).toBe('test-thread-id');
181+
});
182+
183+
it('should provide thread ID observable via implementation', (done) => {
184+
const threadId$ = startContract.getThreadId$();
185+
expect(mockImplementation.getThreadId$).toHaveBeenCalled();
186+
187+
threadId$
188+
.subscribe((threadId: string) => {
189+
expect(threadId).toBe('test-thread-id');
190+
done();
191+
})
192+
.unsubscribe();
193+
});
194+
});
195+
196+
describe('message handling', () => {
197+
it('should return undefined when no implementation or fallback is set', async () => {
198+
// Use fresh service without implementation or fallback
199+
const freshService = new ChatService();
200+
const freshStartContract = freshService.start();
201+
202+
// Should return undefined when no implementation or fallback provided
203+
const result1 = await freshStartContract.sendMessage('test message', []);
204+
expect(result1).toBeUndefined();
205+
206+
const result2 = await freshStartContract.sendMessageWithWindow('test message', []);
207+
expect(result2).toBeUndefined();
208+
});
209+
210+
it('should delegate to implementation when set', async () => {
211+
const setupContract = service.setup();
212+
setupContract.setImplementation(mockImplementation);
213+
const serviceWithImpl = service.start();
214+
215+
const testMessages: UserMessage[] = [
216+
{ id: '1', role: 'user', content: 'previous message' },
217+
];
218+
219+
await serviceWithImpl.sendMessage('test message', testMessages);
220+
expect(mockImplementation.sendMessage).toHaveBeenCalledWith('test message', testMessages);
221+
222+
await serviceWithImpl.sendMessageWithWindow('test message with window', testMessages, {
223+
clearConversation: true,
224+
});
225+
expect(mockImplementation.sendMessageWithWindow).toHaveBeenCalledWith(
226+
'test message with window',
227+
testMessages,
228+
{
229+
clearConversation: true,
230+
}
231+
);
232+
});
233+
});
234+
});
235+
236+
describe('delegation without implementation or fallback', () => {
237+
it('should return undefined/default values when no implementation or fallback provided', () => {
238+
const startContract = service.start();
239+
240+
// All methods should return undefined or default values when neither implementation nor fallback is available
241+
expect(startContract.getThreadId()).toBeUndefined();
242+
expect(startContract.getWindowState()).toBeUndefined();
243+
expect(startContract.isWindowOpen()).toBeUndefined();
244+
245+
// onWindow methods return functions (unsubscribe callbacks), not undefined
246+
expect(typeof startContract.onWindowOpen(() => {})).toBe('function');
247+
expect(typeof startContract.onWindowClose(() => {})).toBe('function');
248+
249+
// Observable methods should return undefined
250+
expect(startContract.getThreadId$()).toBeUndefined();
251+
expect(startContract.getWindowState$()).toBeUndefined();
252+
});
253+
254+
it('should return undefined for async operations when no fallback', async () => {
255+
const startContract = service.start();
256+
257+
// Message and window operations should return undefined
258+
const result1 = await startContract.sendMessage('test', []);
259+
expect(result1).toBeUndefined();
260+
261+
const result2 = await startContract.sendMessageWithWindow('test', []);
262+
expect(result2).toBeUndefined();
263+
264+
const result3 = await startContract.openWindow();
265+
expect(result3).toBeUndefined();
266+
267+
const result4 = await startContract.closeWindow();
268+
expect(result4).toBeUndefined();
269+
});
270+
});
271+
272+
describe('delegation with fallback implementation', () => {
273+
let fallbackImplementation: ChatImplementationFunctions;
274+
275+
beforeEach(() => {
276+
// Create a fallback implementation (simulating plugin-provided fallback)
277+
fallbackImplementation = {
278+
sendMessage: jest.fn().mockResolvedValue({
279+
observable: null,
280+
userMessage: { id: 'fallback-id', role: 'user', content: 'fallback-content' },
281+
}),
282+
sendMessageWithWindow: jest.fn().mockResolvedValue({
283+
observable: null,
284+
userMessage: { id: 'fallback-id', role: 'user', content: 'fallback-content' },
285+
}),
286+
getThreadId: jest.fn().mockReturnValue('fallback-thread-id'),
287+
getThreadId$: jest.fn().mockReturnValue(mockThreadId$.asObservable()),
288+
isWindowOpen: jest.fn().mockReturnValue(false),
289+
openWindow: jest.fn().mockResolvedValue(undefined),
290+
closeWindow: jest.fn().mockResolvedValue(undefined),
291+
getWindowState: jest.fn().mockReturnValue({
292+
isWindowOpen: false,
293+
windowMode: 'sidecar',
294+
paddingSize: 400,
295+
}),
296+
getWindowState$: jest.fn().mockReturnValue(mockWindowState$.asObservable()),
297+
onWindowOpen: jest.fn().mockReturnValue(() => {}),
298+
onWindowClose: jest.fn().mockReturnValue(() => {}),
299+
};
300+
});
301+
302+
it('should delegate to fallback when no main implementation', () => {
303+
const setupContract = service.setup();
304+
setupContract.setFallbackImplementation(fallbackImplementation);
305+
const startContract = service.start();
306+
307+
// Should delegate to fallback implementation
308+
expect(startContract.getThreadId()).toBe('fallback-thread-id');
309+
expect(fallbackImplementation.getThreadId).toHaveBeenCalled();
310+
311+
expect(startContract.isWindowOpen()).toBe(false);
312+
expect(fallbackImplementation.isWindowOpen).toHaveBeenCalled();
313+
});
314+
315+
it('should prefer main implementation over fallback', () => {
316+
const setupContract = service.setup();
317+
setupContract.setFallbackImplementation(fallbackImplementation);
318+
setupContract.setImplementation(mockImplementation);
319+
const startContract = service.start();
320+
321+
// Should use main implementation, not fallback
322+
expect(startContract.getThreadId()).toBe('test-thread-id');
323+
expect(mockImplementation.getThreadId).toHaveBeenCalled();
324+
expect(fallbackImplementation.getThreadId).not.toHaveBeenCalled();
325+
});
326+
});
327+
});
328+
329+
describe('stop', () => {
330+
it('should not throw when called', () => {
331+
expect(() => service.stop()).not.toThrow();
332+
});
333+
});
334+
});

0 commit comments

Comments
 (0)