feat: Add support for realtime-events in copilot-threads and copilot-messages (#11557)
- Add API support for creating a thread - Add API support for creating a message - Remove uuid from thread (no longer required, we will use existing websocket connection to send messages) - Update message_type to a column (user, assistant, assistant_thinking)
This commit is contained in:
@@ -4,12 +4,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
|
||||
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, user: user, account: account) }
|
||||
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.uuid}/copilot_messages' do
|
||||
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'returns all messages' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.uuid}/copilot_messages",
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -20,9 +20,9 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread uuid is invalid' do
|
||||
context 'when thread id is invalid' do
|
||||
it 'returns not found error' do
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/invalid-uuid/copilot_messages",
|
||||
get "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
@@ -30,4 +30,49 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'creates a new message' do
|
||||
message_content = { 'content' => 'This is a test message' }
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{copilot_thread.id}/copilot_messages",
|
||||
params: { message: message_content },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(CopilotMessage, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(CopilotMessage.last.message).to eq(message_content)
|
||||
expect(CopilotMessage.last.message_type).to eq('user')
|
||||
expect(CopilotMessage.last.copilot_thread_id).to eq(copilot_thread.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread does not exist' do
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/999999999/copilot_messages",
|
||||
params: { message: { text: 'Test message' } },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when thread belongs to another user' do
|
||||
let(:another_user) { create(:user, account: account) }
|
||||
let(:another_thread) { create(:captain_copilot_thread, account: account, user: another_user) }
|
||||
|
||||
it 'returns not found error' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads/#{another_thread.id}/copilot_messages",
|
||||
params: { message: { text: 'Test message' } },
|
||||
headers: user.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
context 'when it is an authenticated user' do
|
||||
it 'fetches copilot threads for the current user' do
|
||||
# Create threads for the current agent
|
||||
create_list(:captain_copilot_thread, 3, account: account, user: agent)
|
||||
@@ -47,4 +47,65 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/captain/copilot_threads' do
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:valid_params) { { message: 'Hello, how can you help me?', assistant_id: assistant.id } }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
context 'with invalid params' do
|
||||
it 'returns error when message is blank' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: { message: '', assistant_id: assistant.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(json_response[:error]).to eq('Message is required')
|
||||
end
|
||||
|
||||
it 'returns error when assistant_id is invalid' do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: { message: 'Hello', assistant_id: 0 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid params' do
|
||||
it 'creates a new copilot thread with initial message' do
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/captain/copilot_threads",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end.to change(CopilotThread, :count).by(1)
|
||||
.and change(CopilotMessage, :count).by(1)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
thread = CopilotThread.last
|
||||
expect(thread.title).to eq(valid_params[:message])
|
||||
expect(thread.user_id).to eq(agent.id)
|
||||
expect(thread.assistant_id).to eq(assistant.id)
|
||||
|
||||
message = thread.copilot_messages.last
|
||||
expect(message.message_type).to eq('user')
|
||||
expect(message.message).to eq(valid_params[:message])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
24
spec/enterprise/listeners/action_cable_listener_spec.rb
Normal file
24
spec/enterprise/listeners/action_cable_listener_spec.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe ActionCableListener do
|
||||
describe '#copilot_message_created' do
|
||||
let(:event_name) { :copilot_message_created }
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
let(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread) }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, copilot_message: copilot_message) }
|
||||
let(:listener) { described_class.instance }
|
||||
|
||||
it 'broadcasts message to the user' do
|
||||
expect(ActionCableBroadcastJob).to receive(:perform_later).with(
|
||||
[user.pubsub_token],
|
||||
'copilot.message.created',
|
||||
copilot_message.push_event_data.merge(account_id: account.id)
|
||||
)
|
||||
|
||||
listener.copilot_message_created(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
57
spec/enterprise/listeners/captain_listener_spec.rb
Normal file
57
spec/enterprise/listeners/captain_listener_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe CaptainListener do
|
||||
let(:listener) { described_class.instance }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
|
||||
|
||||
describe '#conversation_resolved' do
|
||||
let(:agent) { create(:user, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
|
||||
|
||||
let(:event_name) { :conversation_resolved }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
end
|
||||
|
||||
context 'when feature_memory is enabled' do
|
||||
before do
|
||||
assistant.config['feature_memory'] = true
|
||||
assistant.config['feature_faq'] = false
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'generates and updates notes' do
|
||||
expect(Captain::Llm::ContactNotesService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ContactNotesService, generate_and_update_notes: nil))
|
||||
expect(Captain::Llm::ConversationFaqService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when feature_faq is enabled' do
|
||||
before do
|
||||
assistant.config['feature_faq'] = true
|
||||
assistant.config['feature_memory'] = false
|
||||
assistant.save!
|
||||
end
|
||||
|
||||
it 'generates and deduplicates FAQs' do
|
||||
expect(Captain::Llm::ConversationFaqService)
|
||||
.to receive(:new)
|
||||
.with(assistant, conversation)
|
||||
.and_return(instance_double(Captain::Llm::ConversationFaqService, generate_and_deduplicate: false))
|
||||
expect(Captain::Llm::ContactNotesService).not_to receive(:new)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
64
spec/enterprise/models/copilot_message_spec.rb
Normal file
64
spec/enterprise/models/copilot_message_spec.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CopilotMessage, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:copilot_thread) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:message_type) }
|
||||
it { is_expected.to validate_presence_of(:message) }
|
||||
it { is_expected.to validate_inclusion_of(:message_type).in_array(described_class.message_types.keys) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
describe '#ensure_account' do
|
||||
it 'sets the account from the copilot thread before validation' do
|
||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread, account: nil)
|
||||
message.valid?
|
||||
expect(message.account).to eq(copilot_thread.account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#broadcast_message' do
|
||||
it 'dispatches COPILOT_MESSAGE_CREATED event after create' do
|
||||
message = build(:captain_copilot_message, copilot_thread: copilot_thread)
|
||||
|
||||
expect(Rails.configuration.dispatcher).to receive(:dispatch)
|
||||
.with(COPILOT_MESSAGE_CREATED, anything, copilot_message: message)
|
||||
|
||||
message.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#push_event_data' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
let(:message_content) { { 'content' => 'Test message' } }
|
||||
let(:copilot_message) do
|
||||
create(:captain_copilot_message,
|
||||
copilot_thread: copilot_thread,
|
||||
message_type: 'user',
|
||||
message: message_content)
|
||||
end
|
||||
|
||||
it 'returns the correct event data' do
|
||||
event_data = copilot_message.push_event_data
|
||||
|
||||
expect(event_data[:id]).to eq(copilot_message.id)
|
||||
expect(event_data[:message]).to eq(message_content)
|
||||
expect(event_data[:message_type]).to eq('user')
|
||||
expect(event_data[:created_at]).to eq(copilot_message.created_at.to_i)
|
||||
expect(event_data[:copilot_thread]).to eq(copilot_thread.push_event_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
62
spec/enterprise/models/copilot_thread_spec.rb
Normal file
62
spec/enterprise/models/copilot_thread_spec.rb
Normal file
@@ -0,0 +1,62 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe CopilotThread, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to belong_to(:account) }
|
||||
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
|
||||
it { is_expected.to have_many(:copilot_messages).dependent(:destroy_async) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:title) }
|
||||
end
|
||||
|
||||
describe '#push_event_data' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant, title: 'Test Thread') }
|
||||
|
||||
it 'returns the correct event data' do
|
||||
event_data = copilot_thread.push_event_data
|
||||
|
||||
expect(event_data[:id]).to eq(copilot_thread.id)
|
||||
expect(event_data[:title]).to eq('Test Thread')
|
||||
expect(event_data[:created_at]).to eq(copilot_thread.created_at.to_i)
|
||||
expect(event_data[:user]).to eq(user.push_event_data)
|
||||
expect(event_data[:account_id]).to eq(account.id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#previous_history' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user, assistant: assistant) }
|
||||
|
||||
context 'when there are messages in the thread' do
|
||||
before do
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'user', message: { 'content' => 'User message' })
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant_thinking', message: { 'content' => 'Thinking...' })
|
||||
create(:captain_copilot_message, copilot_thread: copilot_thread, message_type: 'assistant', message: { 'content' => 'Assistant message' })
|
||||
end
|
||||
|
||||
it 'returns only user and assistant messages in chronological order' do
|
||||
history = copilot_thread.previous_history
|
||||
|
||||
expect(history.length).to eq(2)
|
||||
expect(history[0][:role]).to eq('user')
|
||||
expect(history[0][:content]).to eq({ 'content' => 'User message' })
|
||||
expect(history[1][:role]).to eq('assistant')
|
||||
expect(history[1][:content]).to eq({ 'content' => 'Assistant message' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when there are no messages in the thread' do
|
||||
it 'returns an empty array' do
|
||||
expect(copilot_thread.previous_history).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user