blob: c8d4fc002157f7f2e79d81c43676805f1d37b37d [file]
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../test/common-test-setup';
import {assert} from '@open-wc/testing';
import {ChatModel, Turn, UserType} from './chat-model';
import {PluginsModel} from '../plugins/plugins-model';
import {ChangeModel} from '../change/change-model';
import {FilesModel} from '../change/files-model';
import {UserModel} from '../user/user-model';
import {BehaviorSubject} from 'rxjs';
import {createParsedChange} from '../../test/test-data-generators';
import {AiCodeReviewProvider, ChatRequest} from '../../api/ai-code-review';
import sinon from 'sinon';
import {ParsedChangeInfo} from '../../types/types';
import {getAppContext} from '../../services/app-context';
import {Interaction, Timing} from '../../constants/reporting';
suite('chat-model tests', () => {
let model: ChatModel;
let pluginsModel: PluginsModel;
let changeModel: ChangeModel;
let filesModel: FilesModel;
let userModel: UserModel;
let updatePreferencesStub: sinon.SinonStub;
let provider: AiCodeReviewProvider;
setup(() => {
pluginsModel = new PluginsModel();
changeModel = {
change$: new BehaviorSubject(undefined),
} as unknown as ChangeModel;
changeModel.updateStateChange = (change?: ParsedChangeInfo) => {
(
changeModel.change$ as BehaviorSubject<ParsedChangeInfo | undefined>
).next(change);
};
filesModel = {
files$: new BehaviorSubject([]),
} as unknown as FilesModel;
updatePreferencesStub = sinon.stub();
userModel = {
getState: () => {
return {preferences: {}};
},
preferences$: new BehaviorSubject({}),
updatePreferences: updatePreferencesStub,
} as unknown as UserModel;
provider = {
chat: sinon.stub(),
listChatConversations: sinon.stub().resolves([]),
getChatConversation: sinon.stub().resolves([]),
getModels: sinon.stub().resolves({models: [], default_model_id: ''}),
getActions: sinon.stub().resolves({actions: [], default_action_id: ''}),
getContextItemTypes: sinon.stub().resolves([]),
};
sinon
.stub(pluginsModel, 'aiCodeReviewPlugins$')
.get(() => new BehaviorSubject([{pluginName: 'test-plugin', provider}]));
model = new ChatModel(pluginsModel, changeModel, filesModel, userModel);
});
test('initial state', () => {
const state = model.getState();
assert.isObject(state);
assert.isEmpty(state.turns);
});
test('change subscription triggers API calls', () => {
changeModel.updateStateChange(createParsedChange());
assert.isTrue((provider.getModels as sinon.SinonStub).called);
assert.isTrue((provider.getActions as sinon.SinonStub).called);
assert.isTrue((provider.getContextItemTypes as sinon.SinonStub).called);
assert.isTrue((provider.listChatConversations as sinon.SinonStub).called);
});
test('updateUserInput', () => {
model.updateUserInput('test input');
const state = model.getState();
assert.equal(state.draftUserMessage.content, 'test input');
});
test('addContextItem', () => {
const item = {
type_id: 'file',
link: 'link',
title: 'title',
identifier: 'id',
};
model.addContextItem(item);
let state = model.getState();
assert.lengthOf(state.draftUserMessage.contextItems, 1);
assert.deepEqual(state.draftUserMessage.contextItems[0], item);
// Adding the same item again should not change the state.
model.addContextItem(item);
state = model.getState();
assert.lengthOf(state.draftUserMessage.contextItems, 1);
});
test('removeContextItem', () => {
const item = {
type_id: 'file',
link: 'link',
title: 'title',
identifier: 'id',
};
model.addContextItem(item);
let state = model.getState();
assert.lengthOf(state.draftUserMessage.contextItems, 1);
model.removeContextItem(item);
state = model.getState();
assert.isEmpty(state.draftUserMessage.contextItems);
});
test('getModels with custom_actions updates actions', async () => {
const customActions = [{id: 'custom', display_text: 'Custom'}];
(provider.getModels as sinon.SinonStub).resolves({
models: [],
default_model_id: '',
custom_actions: customActions,
});
changeModel.updateStateChange(createParsedChange());
// Wait for the promise to resolve
await new Promise(resolve => setTimeout(resolve, 0));
const state = model.getState();
assert.isDefined(state.customActions);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.deepEqual(state.customActions, customActions as any);
});
test('chat uses selected model', async () => {
// Mock getModels to return multiple models
const models = {
models: [
{
model_id: 'default-model',
full_display_text: 'Default Model',
short_text: 'Default',
},
{
model_id: 'advanced-model',
full_display_text: 'Advanced Model',
short_text: 'Advanced',
},
],
default_model_id: 'default-model',
};
const actions = {
actions: [],
default_action_id: 'default-action',
};
(provider.getActions as sinon.SinonStub).resolves(actions);
(provider.getModels as sinon.SinonStub).resolves(models);
changeModel.updateStateChange(createParsedChange());
await new Promise(resolve => setTimeout(resolve, 0));
// Select the non-default model
model.selectModel('advanced-model');
assert.isTrue(
updatePreferencesStub.calledWith({
ai_chat_selected_model: 'advanced-model',
})
);
// Trigger a chat
model.updateUserInput('hello');
// We need an action to be defined. Since we defined default_action_id above,
// getAction will fallback to it if we assume it exists in actions list.
// However, our mocked actions list is empty. Let's add the default action.
actions.actions = [
{
id: 'default-action',
display_text: 'Default Action',
initial_user_prompt: 'Hello',
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any;
model.chat('hello', undefined, 0);
// Verify provider.chat was called with correct model_name
assert.isTrue((provider.chat as sinon.SinonStub).called);
const request = (provider.chat as sinon.SinonStub).lastCall
.args[0] as ChatRequest;
assert.equal(request.model_name, 'advanced-model');
});
test('selectedModelId$ falls back when preferred model is unavailable', async () => {
const models = {
models: [
{
model_id: 'default-model',
},
],
default_model_id: 'default-model',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider.getModels as sinon.SinonStub).resolves(models as any);
changeModel.updateStateChange(createParsedChange());
await new Promise(resolve => setTimeout(resolve, 0));
model.selectModel('removed-model');
let selectedModelId;
const sub = model.selectedModelId$.subscribe(id => (selectedModelId = id));
assert.equal(selectedModelId, 'default-model');
sub.unsubscribe();
});
test('chat falls back to default model when selected model is unavailable', async () => {
const models = {
models: [
{
model_id: 'default-model',
},
],
default_model_id: 'default-model',
};
const actions = {
actions: [
{
id: 'default-action',
display_text: 'Default Action',
initial_user_prompt: 'Hello',
},
],
default_action_id: 'default-action',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider.getActions as sinon.SinonStub).resolves(actions as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(provider.getModels as sinon.SinonStub).resolves(models as any);
changeModel.updateStateChange(createParsedChange());
await new Promise(resolve => setTimeout(resolve, 0));
model.selectModel('removed-model');
model.updateUserInput('hello');
model.chat('hello', undefined, 0);
const request = (provider.chat as sinon.SinonStub).lastCall
.args[0] as ChatRequest;
assert.equal(request.model_name, 'default-model');
});
test('change navigation resets state', () => {
model.updateUserInput('some input');
model.selectModel('some-model');
let state = model.getState();
assert.equal(state.draftUserMessage.content, 'some input');
assert.equal(state.selectedModelId, 'some-model');
changeModel.updateStateChange(createParsedChange());
state = model.getState();
assert.equal(state.draftUserMessage.content, '');
assert.isUndefined(state.selectedModelId);
assert.isEmpty(state.turns);
});
test('change property update does not trigger API calls', () => {
const change = {
...createParsedChange(),
_number: 123,
} as unknown as ParsedChangeInfo;
changeModel.updateStateChange(change);
assert.isTrue((provider.getModels as sinon.SinonStub).calledOnce);
// Update some property but keep _number the same
const updatedChange = {
...change,
subject: 'updated subject',
} as unknown as ParsedChangeInfo;
changeModel.updateStateChange(updatedChange);
// API calls should not be triggered again
assert.isTrue((provider.getModels as sinon.SinonStub).calledOnce);
assert.isTrue((provider.getActions as sinon.SinonStub).calledOnce);
assert.isTrue((provider.getContextItemTypes as sinon.SinonStub).calledOnce);
});
test('regenerateMessage increments regenerationIndex when no error', () => {
const turn: Turn = {
userMessage: {
content: 'hello',
userType: UserType.USER,
contextItems: [],
},
geminiMessage: {
userType: UserType.GEMINI,
responseParts: [],
regenerationIndex: 0,
references: [],
citations: [],
},
};
model.updateState({
...model.getState(),
turns: [turn],
});
sinon.stub(model, 'sendChatRequest');
model.regenerateMessage({turnIndex: 0, regenerationIndex: 0});
const state = model.getState();
assert.equal(state.turns[0].geminiMessage.regenerationIndex, 1);
});
test('regenerateMessage does not increment regenerationIndex when error exists', () => {
const turn: Turn = {
userMessage: {
content: 'hello',
userType: UserType.USER,
contextItems: [],
},
geminiMessage: {
userType: UserType.GEMINI,
responseParts: [],
regenerationIndex: 0,
references: [],
citations: [],
errorMessage: 'error',
},
};
model.updateState({
...model.getState(),
turns: [turn],
});
sinon.stub(model, 'sendChatRequest');
model.regenerateMessage({turnIndex: 0, regenerationIndex: 0});
const state = model.getState();
assert.equal(state.turns[0].geminiMessage.regenerationIndex, 0);
});
test('processChatRequest delegates to startNewChatWithUserInput', () => {
const startNewChatStub = sinon.stub(model, 'startNewChatWithUserInput');
model.processChatRequest({prompt: 'Explain this code'});
assert.isTrue(
startNewChatStub.calledOnceWith('Explain this code', undefined, [], false)
);
});
suite('telemetry reporting', () => {
let timeStub: sinon.SinonStub;
let timeEndStub: sinon.SinonStub;
let reportInteractionStub: sinon.SinonStub;
setup(() => {
timeStub = sinon.stub(getAppContext().reportingService, 'time');
timeEndStub = sinon.stub(getAppContext().reportingService, 'timeEnd');
reportInteractionStub = sinon.stub(
getAppContext().reportingService,
'reportInteraction'
);
// Set up a change, models, and actions
const models = {
models: [
{
model_id: 'test-model',
full_display_text: 'Test Model',
short_text: 'Test',
},
],
default_model_id: 'test-model',
};
const actions = {
actions: [
{
id: 'test-action',
display_text: 'Test Action',
initial_user_prompt: 'Test Prompt',
},
],
default_action_id: 'test-action',
};
(provider.getActions as sinon.SinonStub).resolves(actions);
(provider.getModels as sinon.SinonStub).resolves(models);
changeModel.updateStateChange(createParsedChange());
});
test('chat request starts a timer', async () => {
await new Promise(resolve => setTimeout(resolve, 0));
model.updateUserInput('hello');
model.chat('hello', 'test-action', 0);
assert.isTrue(timeStub.calledOnceWith(Timing.AI_CHAT_REQUEST));
});
test('chat request success stops the timer', async () => {
await new Promise(resolve => setTimeout(resolve, 0));
(provider.chat as sinon.SinonStub).callsFake((_, listener) => {
listener.done();
});
model.updateUserInput('hello');
model.chat('hello', 'test-action', 0);
assert.isTrue(
timeEndStub.calledOnceWith(Timing.AI_CHAT_REQUEST, {
modelName: 'test-model',
actionId: 'test-action',
})
);
});
test('chat request failure stops the timer and logs interaction', async () => {
await new Promise(resolve => setTimeout(resolve, 0));
(provider.chat as sinon.SinonStub).callsFake((_, listener) => {
listener.emitError('some error');
});
model.updateUserInput('hello');
model.chat('hello', 'test-action', 0);
assert.isTrue(
timeEndStub.calledOnceWith(Timing.AI_CHAT_REQUEST, {
modelName: 'test-model',
actionId: 'test-action',
error: 'some error',
})
);
assert.isTrue(
reportInteractionStub.calledOnceWith(Interaction.AI_CHAT_FAILURE, {
modelName: 'test-model',
actionId: 'test-action',
error: 'some error',
})
);
});
});
});