diff --git a/spec/lib/captain/base_editor_service_spec.rb b/spec/lib/captain/base_editor_service_spec.rb new file mode 100644 index 000000000..98bd2bf33 --- /dev/null +++ b/spec/lib/captain/base_editor_service_spec.rb @@ -0,0 +1,245 @@ +require 'rails_helper' + +RSpec.describe Captain::BaseEditorService do + let(:account) { create(:account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:event) do + { + 'name' => 'test_event', + 'data' => { + 'conversation_display_id' => conversation.display_id, + 'content' => 'Test content' + } + } + end + + # Create a concrete test service class since BaseEditorService is abstract + let(:test_service_class) do + Class.new(described_class) do + def test_event_message + { message: 'Test response' } + end + end + end + + let(:service) { test_service_class.new(account: account, event: event) } + + before do + create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + end + + describe '#perform' do + it 'calls the correct service method based on event name' do + expect(service).to receive(:test_event_message).and_call_original + result = service.perform + expect(result).to eq({ message: 'Test response' }) + end + end + + describe '#conversation' do + it 'finds conversation by display_id' do + expect(service.send(:conversation)).to eq(conversation) + end + + it 'memoizes the conversation' do + expect(account.conversations).to receive(:find_by).once.and_return(conversation) + service.send(:conversation) + service.send(:conversation) + end + end + + describe '#conversation_messages' do + let(:message1) { create(:message, conversation: conversation, message_type: :incoming, content: 'Hello', created_at: 1.hour.ago) } + let(:message2) { create(:message, conversation: conversation, message_type: :outgoing, content: 'Hi there', created_at: 30.minutes.ago) } + let(:message3) { create(:message, conversation: conversation, message_type: :incoming, content: 'How are you?', created_at: 10.minutes.ago) } + let(:private_message) { create(:message, conversation: conversation, message_type: :incoming, content: 'Private', private: true) } + + before do + message1 + message2 + message3 + private_message + end + + it 'returns messages in array format with role and content' do + messages = service.send(:conversation_messages) + + expect(messages).to be_an(Array) + expect(messages.length).to eq(3) + expect(messages[0]).to eq({ role: 'user', content: 'Hello' }) + expect(messages[1]).to eq({ role: 'assistant', content: 'Hi there' }) + expect(messages[2]).to eq({ role: 'user', content: 'How are you?' }) + end + + it 'excludes private messages' do + messages = service.send(:conversation_messages) + contents = messages.pluck(:content) + expect(contents).not_to include('Private') + end + + it 'respects token limit' do + # Create messages that collectively exceed token limit + # Message validation max is 150000, so create multiple large messages + 10.times do |i| + create(:message, conversation: conversation, message_type: :incoming, + content: 'a' * 100_000, created_at: i.minutes.ago) + end + + messages = service.send(:conversation_messages) + total_length = messages.sum { |m| m[:content].length } + expect(total_length).to be <= Captain::BaseEditorService::TOKEN_LIMIT + end + + it 'respects start_from offset for token counting' do + # With a start_from offset, fewer messages should fit + start_from = Captain::BaseEditorService::TOKEN_LIMIT - 100 + messages = service.send(:conversation_messages, start_from: start_from) + + total_length = messages.sum { |m| m[:content].length } + expect(total_length).to be <= 100 + end + end + + describe '#make_api_call' do + let(:model) { 'gpt-4' } + let(:messages) { [{ role: 'system', content: 'Test' }, { role: 'user', content: 'Hello' }] } + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) } + let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) } + + before do + allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_chat).to receive(:with_instructions) + allow(mock_chat).to receive(:ask).and_return(mock_response) + end + + it 'calls execute_ruby_llm_request with correct parameters' do + expect(service).to receive(:execute_ruby_llm_request).with(model: model, messages: messages).and_call_original + service.send(:make_api_call, model: model, messages: messages) + end + + it 'instruments the LLM call' do + expect(service).to receive(:instrument_llm_call).and_call_original + service.send(:make_api_call, model: model, messages: messages) + end + + it 'returns formatted response with tokens' do + result = service.send(:make_api_call, model: model, messages: messages) + + expect(result[:message]).to eq('Response') + expect(result[:usage]['prompt_tokens']).to eq(10) + expect(result[:usage]['completion_tokens']).to eq(20) + expect(result[:usage]['total_tokens']).to eq(30) + end + end + + describe 'chat setup' do + let(:model) { 'gpt-4' } + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) } + let(:mock_response) { instance_double(RubyLLM::Message, content: 'Response', input_tokens: 10, output_tokens: 20) } + + before do + allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) + allow(mock_response).to receive(:input_tokens).and_return(10) + allow(mock_response).to receive(:output_tokens).and_return(20) + end + + context 'with system instructions' do + let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] } + + it 'applies system instructions to chat' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + + context 'with conversation history' do + let(:messages) do + [ + { role: 'system', content: 'You are helpful' }, + { role: 'user', content: 'First message' }, + { role: 'assistant', content: 'First response' }, + { role: 'user', content: 'Second message' } + ] + end + + it 'adds conversation history before asking' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).to receive(:add_message).with(role: :user, content: 'First message').ordered + expect(mock_chat).to receive(:add_message).with(role: :assistant, content: 'First response').ordered + expect(mock_chat).to receive(:ask).with('Second message').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + + context 'with single message' do + let(:messages) { [{ role: 'system', content: 'You are helpful' }, { role: 'user', content: 'Hello' }] } + + it 'does not add conversation history' do + expect(mock_chat).to receive(:with_instructions).with('You are helpful') + expect(mock_chat).not_to receive(:add_message) + expect(mock_chat).to receive(:ask).with('Hello').and_return(mock_response) + + service.send(:make_api_call, model: model, messages: messages) + end + end + end + + describe 'error handling' do + let(:model) { 'gpt-4' } + let(:messages) { [{ role: 'user', content: 'Hello' }] } + let(:error) { StandardError.new('API Error') } + let(:exception_tracker) { instance_double(ChatwootExceptionTracker) } + + before do + allow(Llm::Config).to receive(:with_api_key).and_raise(error) + allow(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) + allow(exception_tracker).to receive(:capture_exception) + end + + it 'tracks exceptions' do + expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker) + expect(exception_tracker).to receive(:capture_exception) + + service.send(:make_api_call, model: model, messages: messages) + end + + it 'returns error response' do + expect(exception_tracker).to receive(:capture_exception) + result = service.send(:make_api_call, model: model, messages: messages) + + expect(result[:error]).to eq('API Error') + expect(result[:request_messages]).to eq(messages) + end + end + + describe '#api_key' do + context 'when openai hook is configured' do + let(:hook) { create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' }) } + + before { hook } + + it 'uses api key from hook' do + expect(service.send(:api_key)).to eq('hook-key') + end + end + + context 'when openai hook is not configured' do + it 'uses system api key' do + expect(service.send(:api_key)).to eq('test-key') + end + end + end + + describe '#prompt_from_file' do + it 'reads prompt from file' do + allow(Rails.root).to receive(:join).and_return(instance_double(Pathname, read: 'Test prompt content')) + expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content') + end + end +end