Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8aa49f69d2 | ||
|
|
550b408656 | ||
|
|
b103747584 | ||
|
|
d6d38cdd7d | ||
|
|
199dcd382e | ||
|
|
c6f82783ba | ||
|
|
9b3f0029a4 | ||
|
|
6e46be36c8 | ||
|
|
87f5af4caa | ||
|
|
dbe35252bc | ||
|
|
de8aa48b83 |
@@ -104,7 +104,7 @@ class ContactIdentifyAction
|
||||
# blank identifier or email will throw unique index error
|
||||
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
|
||||
@contact.discard_invalid_attrs if discard_invalid_attrs
|
||||
@contact.save!
|
||||
@contact.save! if @contact.changed?
|
||||
enqueue_avatar_job
|
||||
end
|
||||
|
||||
|
||||
@@ -18,10 +18,18 @@ const newMessage = ref('');
|
||||
const isLoading = ref(false);
|
||||
|
||||
const formatMessagesForApi = () => {
|
||||
return messages.value.map(message => ({
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
}));
|
||||
return messages.value.map(message => {
|
||||
const payload = {
|
||||
role: message.sender,
|
||||
content: message.content,
|
||||
};
|
||||
|
||||
if (message.sender === 'assistant' && message.agentName) {
|
||||
payload.agent_name = message.agentName;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
};
|
||||
|
||||
const resetConversation = () => {
|
||||
@@ -62,6 +70,7 @@ const sendMessage = async () => {
|
||||
messages.value.push({
|
||||
content: data.response,
|
||||
sender: 'assistant',
|
||||
agentName: data.agent_name,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -71,6 +80,12 @@ const sendMessage = async () => {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +119,7 @@ const sendMessage = async () => {
|
||||
v-model="newMessage"
|
||||
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
|
||||
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
|
||||
@keyup.enter="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<NextButton
|
||||
ghost
|
||||
|
||||
@@ -29,6 +29,12 @@ const handleInput = () => {
|
||||
nextTick(adjustHeight);
|
||||
};
|
||||
|
||||
const handleEnterKey = event => {
|
||||
if (event.isComposing) return;
|
||||
event.preventDefault();
|
||||
sendMessage();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(adjustHeight);
|
||||
});
|
||||
@@ -43,7 +49,7 @@ onMounted(() => {
|
||||
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
|
||||
rows="1"
|
||||
@input="handleInput"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
@keydown.enter.exact="handleEnterKey"
|
||||
/>
|
||||
<button
|
||||
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
|
||||
|
||||
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
|
||||
emit('keydown');
|
||||
|
||||
// Handle Enter key to send message (Shift+Enter for new line)
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
// Skip if IME composition is active (CJK character confirmation)
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
handleSubmit();
|
||||
return true; // Prevent ProseMirror's default Enter handling
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'agents/getVerifiedAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
|
||||
@@ -111,7 +111,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
'agents/getVerifiedAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('useMacros', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
'agents/getVerifiedAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
export default function useAutomationValues() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const agents = useMapGetter('agents/getVerifiedAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
|
||||
@@ -13,7 +13,7 @@ export const useMacros = () => {
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
|
||||
@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_resolved(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
event_end_time = event.timestamp
|
||||
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_resolved',
|
||||
value: time_to_resolve,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
|
||||
conversation.updated_at),
|
||||
event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
|
||||
create_bot_resolved_event(conversation, reporting_event)
|
||||
@@ -69,41 +70,51 @@ class ReportingEventListener < BaseListener
|
||||
|
||||
def conversation_bot_handoff(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# check if a conversation_bot_handoff event exists for this conversation
|
||||
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
return if bot_handoff_event.present?
|
||||
|
||||
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
|
||||
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
|
||||
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_bot_handoff',
|
||||
value: time_to_handoff,
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
|
||||
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def conversation_captain_inference_resolved(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
|
||||
end
|
||||
|
||||
def conversation_captain_inference_handoff(event)
|
||||
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
|
||||
end
|
||||
|
||||
def conversation_opened(event)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
event_end_time = event.timestamp
|
||||
|
||||
# Find the most recent resolved event for this conversation
|
||||
last_resolved_event = ReportingEvent.where(
|
||||
conversation_id: conversation.id,
|
||||
name: 'conversation_resolved'
|
||||
).order(event_end_time: :desc).first
|
||||
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
|
||||
|
||||
# For first-time openings, value is 0
|
||||
# For reopenings, calculate time since resolution
|
||||
if last_resolved_event
|
||||
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
|
||||
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
|
||||
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
|
||||
start_time = last_resolved_event.event_end_time
|
||||
else
|
||||
time_since_resolved = 0
|
||||
@@ -111,12 +122,12 @@ class ReportingEventListener < BaseListener
|
||||
start_time = conversation.created_at
|
||||
end
|
||||
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
|
||||
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
|
||||
reporting_event = ReportingEvent.new(
|
||||
name: 'conversation_opened',
|
||||
value: time_since_resolved,
|
||||
@@ -126,11 +137,27 @@ class ReportingEventListener < BaseListener
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: start_time,
|
||||
event_end_time: conversation.updated_at
|
||||
event_end_time: event_end_time
|
||||
)
|
||||
reporting_event.save!
|
||||
end
|
||||
|
||||
def create_captain_inference_event(event, event_name)
|
||||
conversation = extract_conversation_and_account(event)[0]
|
||||
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
|
||||
|
||||
ReportingEvent.create!(
|
||||
name: event_name,
|
||||
value: time_to_event,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
user_id: conversation.assignee_id,
|
||||
conversation_id: conversation.id,
|
||||
event_start_time: conversation.created_at,
|
||||
event_end_time: event.timestamp
|
||||
)
|
||||
end
|
||||
|
||||
def create_bot_resolved_event(conversation, reporting_event)
|
||||
return unless conversation.inbox.active_bot?
|
||||
# We don't want to create a bot_resolved event if there is user interaction on the conversation
|
||||
|
||||
+19
-13
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
|
||||
|
||||
# when you have a temperory id in your frontend and want it echoed back via action cable
|
||||
attr_accessor :echo_id
|
||||
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
|
||||
attr_accessor :preserve_waiting_since
|
||||
|
||||
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
|
||||
enum content_type: {
|
||||
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :conversation, touch: true
|
||||
belongs_to :conversation
|
||||
belongs_to :sender, polymorphic: true, optional: true
|
||||
|
||||
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
|
||||
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
|
||||
end
|
||||
|
||||
def update_waiting_since
|
||||
waiting_present = conversation.waiting_since.present?
|
||||
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
|
||||
set_waiting_since_on_incoming_message
|
||||
end
|
||||
|
||||
if waiting_present && !private
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
elsif bot_response?
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil)
|
||||
end
|
||||
def clear_waiting_since_on_outgoing_response
|
||||
if human_response?
|
||||
Rails.configuration.dispatcher.dispatch(
|
||||
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
|
||||
)
|
||||
conversation.update(waiting_since: nil)
|
||||
return
|
||||
end
|
||||
|
||||
# Bot responses also clear waiting_since (simpler than checking on next customer message)
|
||||
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
|
||||
end
|
||||
|
||||
def set_waiting_since_on_incoming_message
|
||||
# Set waiting_since when customer sends a message (if currently blank)
|
||||
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
|
||||
end
|
||||
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
|
||||
|
||||
def set_conversation_activity
|
||||
# rubocop:disable Rails/SkipsModelValidations
|
||||
conversation.update_columns(last_activity_at: created_at)
|
||||
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
|
||||
# rubocop:enable Rails/SkipsModelValidations
|
||||
end
|
||||
|
||||
|
||||
@@ -47,7 +47,9 @@ class ActionService
|
||||
|
||||
@agent = @account.users.find_by(id: agent_ids)
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id) if @agent.present?
|
||||
return unless @agent.present? && @agent.confirmed?
|
||||
|
||||
@conversation.update!(assignee_id: @agent.id)
|
||||
end
|
||||
|
||||
def remove_label(labels)
|
||||
|
||||
@@ -191,7 +191,6 @@
|
||||
- name: assignment_v2
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
|
||||
@@ -235,8 +235,10 @@ en:
|
||||
activity:
|
||||
captain:
|
||||
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
|
||||
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
|
||||
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
|
||||
open: 'Conversation was marked open by %{user_name}'
|
||||
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
|
||||
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
|
||||
agent_bot:
|
||||
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class SamlUserBuilder
|
||||
class AuthenticationFailed < StandardError; end
|
||||
|
||||
def initialize(auth_hash, account_id)
|
||||
@auth_hash = auth_hash
|
||||
@account_id = account_id
|
||||
@@ -16,13 +18,20 @@ class SamlUserBuilder
|
||||
def find_or_create_user
|
||||
user = User.from_email(auth_attribute('email'))
|
||||
|
||||
if user
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
return user
|
||||
end
|
||||
return create_user unless user
|
||||
return existing_user_for_account(user) if user_belongs_to_account?(user)
|
||||
|
||||
create_user
|
||||
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
|
||||
end
|
||||
|
||||
def existing_user_for_account(user)
|
||||
confirm_user_if_required(user)
|
||||
convert_existing_user_to_saml(user)
|
||||
user
|
||||
end
|
||||
|
||||
def user_belongs_to_account?(user)
|
||||
user.account_users.exists?(account_id: @account_id)
|
||||
end
|
||||
|
||||
def confirm_user_if_required(user)
|
||||
|
||||
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground
|
||||
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
additional_message: params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
message_history: playground_message_history
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
|
||||
additional_message: playground_params[:message_content],
|
||||
message_history: message_history
|
||||
)
|
||||
end
|
||||
|
||||
render json: response
|
||||
end
|
||||
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def playground_params
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
|
||||
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
|
||||
end
|
||||
|
||||
def message_history
|
||||
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
|
||||
(playground_params[:message_history] || []).map do |message|
|
||||
{
|
||||
role: message[:role],
|
||||
content: message[:content],
|
||||
agent_name: message[:agent_name]
|
||||
}.compact
|
||||
end
|
||||
end
|
||||
|
||||
def playground_message_history
|
||||
history = message_history
|
||||
current_message = playground_params[:message_content]
|
||||
return history if current_message.blank?
|
||||
|
||||
current_user_message = { role: 'user', content: current_message }
|
||||
return history if history.last == current_user_message
|
||||
|
||||
history + [current_user_message]
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
@assistant.account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
+18
-14
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
error = params[:message] || 'authentication-failed'
|
||||
|
||||
if for_mobile?(relay_state)
|
||||
redirect_to_mobile_error(error, relay_state)
|
||||
redirect_to_mobile_error(error)
|
||||
else
|
||||
redirect_to login_page_url(error: "saml-#{error}")
|
||||
end
|
||||
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
account_id = extract_saml_account_id
|
||||
relay_state = saml_relay_state
|
||||
|
||||
unless saml_enabled_for_account?(account_id)
|
||||
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
|
||||
|
||||
return redirect_to login_page_url(error: 'saml-not-enabled')
|
||||
end
|
||||
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
|
||||
|
||||
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
|
||||
|
||||
if @resource.persisted?
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
return sign_in_saml_user(relay_state) if @resource.persisted?
|
||||
|
||||
sign_in_user
|
||||
else
|
||||
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: 'saml-authentication-failed')
|
||||
end
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
|
||||
end
|
||||
|
||||
def extract_saml_account_id
|
||||
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
relay_state.to_s.casecmp('mobile').zero?
|
||||
end
|
||||
|
||||
def sign_in_saml_user(relay_state)
|
||||
return sign_in_user_on_mobile if for_mobile?(relay_state)
|
||||
|
||||
sign_in_user
|
||||
end
|
||||
|
||||
def handle_saml_auth_error(relay_state, error)
|
||||
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
|
||||
|
||||
redirect_to login_page_url(error: error)
|
||||
end
|
||||
|
||||
def redirect_to_mobile_error(error)
|
||||
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
|
||||
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
|
||||
|
||||
@@ -5,7 +5,6 @@ module Captain::ChatHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
chat = build_chat
|
||||
|
||||
add_messages_to_chat(chat)
|
||||
@@ -86,7 +85,8 @@ module Captain::ChatHelper
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id,
|
||||
channel_type: resolved_channel_type
|
||||
channel_type: resolved_channel_type,
|
||||
source: @source
|
||||
}.compact
|
||||
}
|
||||
end
|
||||
@@ -130,7 +130,6 @@ module Captain::ChatHelper
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
|
||||
"for messages #{@messages} with #{@tools&.length || 0} tools")
|
||||
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
|
||||
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
|
||||
|
||||
queue_as :low
|
||||
|
||||
def perform(inbox)
|
||||
return if inbox.account.captain_disable_auto_resolve
|
||||
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
resolvable_conversations.each do |conversation|
|
||||
create_outgoing_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
if inbox.account.feature_enabled?('captain_tasks')
|
||||
perform_with_evaluation(inbox)
|
||||
else
|
||||
perform_time_based(inbox)
|
||||
end
|
||||
ensure
|
||||
Current.reset
|
||||
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def create_outgoing_message(conversation, inbox)
|
||||
def perform_time_based(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.resolved!
|
||||
end
|
||||
end
|
||||
|
||||
def perform_with_evaluation(inbox)
|
||||
Current.executed_by = inbox.captain_assistant
|
||||
|
||||
resolvable_pending_conversations(inbox).each do |conversation|
|
||||
evaluation = evaluate_conversation(conversation, inbox)
|
||||
next unless still_resolvable_after_evaluation?(conversation)
|
||||
|
||||
if evaluation[:complete]
|
||||
resolve_conversation(conversation, inbox, evaluation[:reason])
|
||||
else
|
||||
handoff_conversation(conversation, inbox, evaluation[:reason])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def evaluate_conversation(conversation, inbox)
|
||||
Captain::ConversationCompletionService.new(
|
||||
account: inbox.account,
|
||||
conversation_display_id: conversation.display_id
|
||||
).perform
|
||||
end
|
||||
|
||||
def resolvable_pending_conversations(inbox)
|
||||
inbox.conversations.pending
|
||||
.where('last_activity_at < ?', auto_resolve_cutoff_time)
|
||||
.limit(Limits::BULK_ACTIONS_LIMIT)
|
||||
end
|
||||
|
||||
def still_resolvable_after_evaluation?(conversation)
|
||||
conversation.reload
|
||||
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
false
|
||||
end
|
||||
|
||||
def auto_resolve_cutoff_time
|
||||
Time.now.utc - 1.hour
|
||||
end
|
||||
|
||||
def resolve_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
|
||||
create_resolution_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.resolved! }
|
||||
conversation.dispatch_captain_inference_resolved_event
|
||||
end
|
||||
|
||||
def handoff_conversation(conversation, inbox, reason)
|
||||
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
|
||||
create_handoff_message(conversation, inbox)
|
||||
conversation.with_captain_activity_context(
|
||||
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
|
||||
reason_type: :inference
|
||||
) { conversation.bot_handoff! }
|
||||
conversation.dispatch_captain_inference_handoff_event
|
||||
send_out_of_office_message_if_applicable(conversation.reload)
|
||||
end
|
||||
|
||||
def send_out_of_office_message_if_applicable(conversation)
|
||||
# Campaign conversations should never receive OOO templates — the campaign itself
|
||||
# serves as the initial outreach, and OOO would be confusing in that context.
|
||||
return if conversation.campaign.present?
|
||||
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
|
||||
end
|
||||
|
||||
def create_private_note(conversation, inbox, content)
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
private: true,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: content
|
||||
)
|
||||
end
|
||||
|
||||
def create_resolution_message(conversation, inbox)
|
||||
I18n.with_locale(inbox.account.locale) do
|
||||
resolution_message = inbox.captain_assistant.config['resolution_message']
|
||||
conversation.messages.create!(
|
||||
{
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
}
|
||||
message_type: :outgoing,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
|
||||
sender: inbox.captain_assistant
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def create_handoff_message(conversation, inbox)
|
||||
handoff_message = inbox.captain_assistant.config['handoff_message']
|
||||
return if handoff_message.blank?
|
||||
|
||||
conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
sender: inbox.captain_assistant,
|
||||
account_id: conversation.account_id,
|
||||
inbox_id: conversation.inbox_id,
|
||||
content: handoff_message,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
|
||||
scenarios: scenarios.enabled.map do |scenario|
|
||||
{
|
||||
title: scenario.title,
|
||||
key: scenario.title.parameterize.underscore,
|
||||
key: scenario.handoff_key,
|
||||
description: scenario.description
|
||||
}
|
||||
end,
|
||||
|
||||
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
include Concerns::Agentable
|
||||
|
||||
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
|
||||
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
|
||||
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
|
||||
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
|
||||
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
|
||||
# based on the available length budget.
|
||||
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
|
||||
HANDOFF_KEY_PREFIX = 'scenario'.freeze
|
||||
HANDOFF_KEY_SUFFIX = 'agent'.freeze
|
||||
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
|
||||
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
|
||||
MAX_HANDOFF_SLUG_LENGTH = 24
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
|
||||
|
||||
before_save :resolve_tool_references
|
||||
|
||||
def handoff_key
|
||||
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
|
||||
end
|
||||
|
||||
def prompt_context
|
||||
{
|
||||
title: title,
|
||||
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
|
||||
private
|
||||
|
||||
def agent_name
|
||||
"#{title} Agent".parameterize(separator: '_')
|
||||
handoff_key
|
||||
end
|
||||
|
||||
def handoff_id_key
|
||||
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
|
||||
|
||||
"#{HANDOFF_KEY_PREFIX}_draft"
|
||||
end
|
||||
|
||||
def compact_handoff_slug
|
||||
slug = title.to_s.parameterize(separator: '_').presence
|
||||
return nil if slug.blank?
|
||||
|
||||
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
|
||||
return nil if max_slug_length <= 0
|
||||
|
||||
slug.first(max_slug_length).sub(/_+\z/, '').presence
|
||||
end
|
||||
|
||||
def dynamic_slug_max_length
|
||||
# handoff_to_#{scenario_<id>_<slug>_agent}
|
||||
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
|
||||
end
|
||||
|
||||
def agent_tools
|
||||
|
||||
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
|
||||
key = captain_activity_key
|
||||
return unless key
|
||||
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
|
||||
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def captain_status_reason
|
||||
captain_activity_reason.presence
|
||||
end
|
||||
|
||||
def captain_activity_key
|
||||
if resolved? && Current.captain_resolve_reason.present?
|
||||
'conversations.activity.captain.resolved_by_tool'
|
||||
elsif resolved?
|
||||
'conversations.activity.captain.resolved'
|
||||
elsif open?
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
return captain_resolved_activity_key if resolved?
|
||||
return captain_open_activity_key if open?
|
||||
end
|
||||
|
||||
def captain_resolved_activity_key
|
||||
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
|
||||
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.resolved'
|
||||
end
|
||||
|
||||
def captain_open_activity_key
|
||||
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
|
||||
|
||||
'conversations.activity.captain.open'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
module Enterprise::Conversation
|
||||
attr_accessor :captain_activity_reason, :captain_activity_reason_type
|
||||
|
||||
def dispatch_captain_inference_resolved_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
|
||||
end
|
||||
|
||||
def dispatch_captain_inference_handoff_event
|
||||
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
|
||||
end
|
||||
|
||||
def list_of_keys
|
||||
super + %w[sla_policy_id]
|
||||
end
|
||||
|
||||
def with_captain_activity_context(reason:, reason_type:)
|
||||
previous_reason = captain_activity_reason
|
||||
previous_reason_type = captain_activity_reason_type
|
||||
|
||||
self.captain_activity_reason = reason
|
||||
self.captain_activity_reason_type = reason_type
|
||||
yield
|
||||
ensure
|
||||
self.captain_activity_reason = previous_reason
|
||||
self.captain_activity_reason_type = previous_reason_type
|
||||
end
|
||||
|
||||
# Include select additional_attributes keys (call related) for update events
|
||||
def allowed_keys?
|
||||
return true if super
|
||||
@@ -13,4 +35,10 @@ module Enterprise::Conversation
|
||||
changed_attr_keys = attrs_change[1].keys
|
||||
changed_attr_keys.intersect?(%w[call_status])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def dispatch_captain_inference_event(event_name)
|
||||
dispatcher_dispatch(event_name)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
def initialize(assistant:, conversation: nil, callbacks: {})
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@callbacks = callbacks
|
||||
@source = source
|
||||
end
|
||||
|
||||
def generate_response(message_history: [])
|
||||
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
process_agent_result(result)
|
||||
rescue StandardError => e
|
||||
# when running the agent runner service in a rake task, the conversation might not have an account associated
|
||||
# for regular production usage, it will run just fine
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
return unless @conversation.contact_inbox
|
||||
|
||||
state[:contact_inbox] =
|
||||
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
|
||||
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
|
||||
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
|
||||
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
|
||||
}.compact.transform_values(&:to_s)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil, source: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@source = source
|
||||
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
class Captain::ConversationCompletionSchema < RubyLLM::Schema
|
||||
boolean :complete, description: 'Whether the conversation is complete and can be closed'
|
||||
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
|
||||
end
|
||||
@@ -0,0 +1,76 @@
|
||||
# Evaluates whether a conversation is complete and can be auto-resolved.
|
||||
# Used by InboxPendingConversationsResolutionJob to determine if inactive
|
||||
# conversations should be resolved or handed off to human agents.
|
||||
#
|
||||
# NOTE: This service intentionally does NOT count toward Captain usage limits.
|
||||
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
|
||||
# checks for usage tracking. This is an internal operational evaluation,
|
||||
# not a customer-facing value-add, so we don't charge for it.
|
||||
class Captain::ConversationCompletionService < Captain::BaseTaskService
|
||||
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
|
||||
|
||||
pattr_initialize [:account!, :conversation_display_id!]
|
||||
|
||||
def perform
|
||||
content = format_messages_as_string
|
||||
return default_incomplete_response('No messages found') if content.blank?
|
||||
|
||||
response = make_api_call(
|
||||
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
|
||||
messages: [
|
||||
{ role: 'system', content: prompt_from_file('conversation_completion') },
|
||||
{ role: 'user', content: content }
|
||||
],
|
||||
schema: RESPONSE_SCHEMA
|
||||
)
|
||||
|
||||
return default_incomplete_response(response[:error]) if response[:error].present?
|
||||
|
||||
parse_response(response[:message])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def prompt_from_file(file_name)
|
||||
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
|
||||
end
|
||||
|
||||
def format_messages_as_string
|
||||
messages = conversation_messages(start_from: 0)
|
||||
messages.map do |msg|
|
||||
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
|
||||
"#{sender_type}: #{msg[:content]}"
|
||||
end.join("\n")
|
||||
end
|
||||
|
||||
def parse_response(message)
|
||||
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
|
||||
|
||||
{
|
||||
complete: message['complete'] == true,
|
||||
reason: message['reason'] || 'No reason provided'
|
||||
}
|
||||
end
|
||||
|
||||
def default_incomplete_response(reason)
|
||||
{ complete: false, reason: reason }
|
||||
end
|
||||
|
||||
# Prefer the system API key over the account's OpenAI hook key.
|
||||
# This is an internal operational evaluation, not a customer-triggered feature,
|
||||
# so it should not consume the customer's OpenAI credits on hosted platforms.
|
||||
# Falls back to the account hook for self-hosted deployments without a system key.
|
||||
def api_key
|
||||
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
|
||||
end
|
||||
|
||||
def event_name
|
||||
'captain.conversation_completion'
|
||||
end
|
||||
|
||||
def build_follow_up_context?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
|
||||
@@ -0,0 +1,21 @@
|
||||
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
|
||||
|
||||
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
|
||||
|
||||
A conversation is INCOMPLETE (keep open) if ANY of these apply:
|
||||
- The assistant suggested the customer try something or take an action — they may still be attempting it
|
||||
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
|
||||
- The assistant asked a question or requested information that the customer hasn't provided
|
||||
- The customer asked a question that wasn't fully answered
|
||||
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
|
||||
- The customer raised multiple questions or issues and not all were addressed
|
||||
|
||||
A conversation is COMPLETE only if ALL of these are true:
|
||||
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
|
||||
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
|
||||
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
|
||||
|
||||
Analyze the conversation and respond with ONLY a JSON object (no other text):
|
||||
{"complete": true, "reason": "brief explanation"}
|
||||
or
|
||||
{"complete": false, "reason": "brief explanation"}
|
||||
@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
|
||||
|
||||
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
|
||||
|
||||
Current.captain_resolve_reason = reason
|
||||
begin
|
||||
conversation.resolved!
|
||||
ensure
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
|
||||
|
||||
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# Overrides the quota check from Enterprise::Captain::BaseTaskService
|
||||
# so that conversation completion evaluation always runs regardless of
|
||||
# the customer's Captain usage quota. This is an internal operational
|
||||
# check, not a customer-facing feature — it should never be blocked
|
||||
# by quota exhaustion.
|
||||
module Enterprise::Captain::ConversationCompletionService
|
||||
private
|
||||
|
||||
def responses_available?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
|
||||
"#{endpoint}/v1"
|
||||
end
|
||||
|
||||
def make_api_call(model:, messages:, tools: [])
|
||||
def make_api_call(model:, messages:, schema: nil, tools: [])
|
||||
# Community edition prerequisite checks
|
||||
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
|
||||
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
|
||||
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
|
||||
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
|
||||
|
||||
response = send(instrumentation_method, instrumentation_params) do
|
||||
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
|
||||
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
|
||||
end
|
||||
|
||||
return response unless build_follow_up_context? && response[:message].present?
|
||||
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
|
||||
response.merge(follow_up_context: build_follow_up_context(messages, response))
|
||||
end
|
||||
|
||||
def execute_ruby_llm_request(model:, messages:, tools: [])
|
||||
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
|
||||
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
|
||||
chat = build_chat(context, model: model, messages: messages, tools: tools)
|
||||
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
|
||||
|
||||
conversation_messages = messages.reject { |m| m[:role] == 'system' }
|
||||
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
|
||||
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
|
||||
{ error: e.message, request_messages: messages }
|
||||
end
|
||||
|
||||
def build_chat(context, model:, messages:, tools: [])
|
||||
def build_chat(context, model:, messages:, schema: nil, tools: [])
|
||||
chat = context.chat(model: model)
|
||||
system_msg = messages.find { |m| m[:role] == 'system' }
|
||||
chat.with_instructions(system_msg[:content]) if system_msg
|
||||
chat.with_schema(schema) if schema
|
||||
|
||||
if tools.any?
|
||||
tools.each { |tool| chat = chat.with_tool(tool) }
|
||||
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
|
||||
.reorder('id desc')
|
||||
.each do |message|
|
||||
content = message.content_for_llm
|
||||
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
|
||||
next if content.blank?
|
||||
break if character_count + content.length > TOKEN_LIMIT
|
||||
|
||||
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
|
||||
character_count += content.length
|
||||
|
||||
@@ -4,7 +4,6 @@ module Current
|
||||
thread_mattr_accessor :account_user
|
||||
thread_mattr_accessor :executed_by
|
||||
thread_mattr_accessor :contact
|
||||
thread_mattr_accessor :captain_resolve_reason
|
||||
|
||||
def self.reset
|
||||
Current.user = nil
|
||||
@@ -12,6 +11,5 @@ module Current
|
||||
Current.account_user = nil
|
||||
Current.executed_by = nil
|
||||
Current.contact = nil
|
||||
Current.captain_resolve_reason = nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,8 @@ module Events::Types
|
||||
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
|
||||
CONVERSATION_OPENED = 'conversation.opened'
|
||||
CONVERSATION_RESOLVED = 'conversation.resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
|
||||
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
|
||||
|
||||
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
|
||||
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
|
||||
|
||||
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
|
||||
+3
-1
@@ -160,7 +160,9 @@
|
||||
"overrides": {
|
||||
"vite-node": "2.0.1",
|
||||
"vite": "5.4.21",
|
||||
"vitest": "3.0.5"
|
||||
"vitest": "3.0.5",
|
||||
"minimatch@<4": "3.1.5",
|
||||
"minimatch@>=9.0.0 <9.0.7": "9.0.9"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
|
||||
Generated
+21
-27
@@ -8,6 +8,8 @@ overrides:
|
||||
vite-node: 2.0.1
|
||||
vite: 5.4.21
|
||||
vitest: 3.0.5
|
||||
minimatch@<4: 3.1.5
|
||||
minimatch@>=9.0.0 <9.0.7: 9.0.9
|
||||
|
||||
importers:
|
||||
|
||||
@@ -1731,8 +1733,8 @@ packages:
|
||||
brace-expansion@1.1.11:
|
||||
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
|
||||
|
||||
brace-expansion@2.0.1:
|
||||
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
|
||||
brace-expansion@2.0.2:
|
||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
||||
|
||||
braces@3.0.3:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
@@ -3255,15 +3257,11 @@ packages:
|
||||
min-document@2.19.0:
|
||||
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
|
||||
|
||||
minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
minimatch@3.1.5:
|
||||
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
|
||||
|
||||
minimatch@9.0.1:
|
||||
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimatch@9.0.5:
|
||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minimist@1.2.8:
|
||||
@@ -5312,7 +5310,7 @@ snapshots:
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5326,7 +5324,7 @@ snapshots:
|
||||
ignore: 5.2.4
|
||||
import-fresh: 3.3.0
|
||||
js-yaml: 4.1.0
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -5463,7 +5461,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@humanwhocodes/object-schema': 2.0.3
|
||||
debug: 4.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -6373,7 +6371,7 @@ snapshots:
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
|
||||
brace-expansion@2.0.1:
|
||||
brace-expansion@2.0.2:
|
||||
dependencies:
|
||||
balanced-match: 1.0.2
|
||||
|
||||
@@ -6817,7 +6815,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
commander: 10.0.1
|
||||
minimatch: 9.0.1
|
||||
minimatch: 9.0.9
|
||||
semver: 7.6.3
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
@@ -7084,7 +7082,7 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.15.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.0
|
||||
@@ -7164,7 +7162,7 @@ snapshots:
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
levn: 0.4.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.3
|
||||
strip-ansi: 6.0.1
|
||||
@@ -7394,7 +7392,7 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.0
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.9
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.0
|
||||
path-scurry: 1.11.1
|
||||
@@ -7404,7 +7402,7 @@ snapshots:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
@@ -8143,17 +8141,13 @@ snapshots:
|
||||
dependencies:
|
||||
dom-walk: 0.1.2
|
||||
|
||||
minimatch@3.1.2:
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 1.1.11
|
||||
|
||||
minimatch@9.0.1:
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
|
||||
minimatch@9.0.5:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.1
|
||||
brace-expansion: 2.0.2
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9251,7 +9245,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.4.5
|
||||
minimatch: 9.0.5
|
||||
minimatch: 9.0.9
|
||||
|
||||
text-segmentation@1.0.3:
|
||||
dependencies:
|
||||
|
||||
@@ -145,5 +145,24 @@ describe ContactIdentifyAction do
|
||||
expect(contact.phone_number).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when params have not changed' do
|
||||
it 'skips save and does not issue an UPDATE query' do
|
||||
contact.update!(name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' })
|
||||
params = { name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' } }
|
||||
|
||||
# any_instance is needed because merge lookup can reassign @contact to a different Ruby object
|
||||
expect_any_instance_of(Contact).not_to receive(:save!) # rubocop:disable RSpec/AnyInstance
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
|
||||
it 'still enqueues avatar job even when attributes have not changed' do
|
||||
contact.update!(name: 'test')
|
||||
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
|
||||
|
||||
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
|
||||
described_class.new(contact: contact, params: params).perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -74,7 +74,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user already exists' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account) }
|
||||
|
||||
it 'does not create a new user' do
|
||||
expect { builder.perform }.not_to change(User, :count)
|
||||
@@ -85,7 +85,7 @@ RSpec.describe SamlUserBuilder do
|
||||
expect(user).to eq(existing_user)
|
||||
end
|
||||
|
||||
it 'adds existing user to the account if not already added' do
|
||||
it 'keeps the existing user in the account' do
|
||||
user = builder.perform
|
||||
expect(user.accounts).to include(account)
|
||||
end
|
||||
@@ -105,11 +105,38 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
it 'does not duplicate account association' do
|
||||
existing_user.account_users.create!(account: account, role: 'agent')
|
||||
|
||||
expect { builder.perform }.not_to change(AccountUser, :count)
|
||||
end
|
||||
|
||||
context 'when the user does not belong to the target account' do
|
||||
let!(:other_account) { create(:account) }
|
||||
let!(:existing_user) { create(:user, email: email, account: other_account) }
|
||||
|
||||
it 'raises an authentication failure' do
|
||||
expect { builder.perform }.to raise_error do |error|
|
||||
expect(error.class.name).to eq('SamlUserBuilder::AuthenticationFailed')
|
||||
expect(error.message).to eq(I18n.t('auth.saml.authentication_failed'))
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not add the user to the target account' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to change(AccountUser, :count)
|
||||
expect(existing_user.reload.accounts).not_to include(account)
|
||||
end
|
||||
|
||||
it 'does not convert the user provider to saml' do
|
||||
expect do
|
||||
builder.perform
|
||||
rescue SamlUserBuilder::AuthenticationFailed
|
||||
nil
|
||||
end.not_to(change { existing_user.reload.provider })
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is not confirmed' do
|
||||
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
|
||||
let(:unconfirmed_auth_hash) do
|
||||
@@ -131,7 +158,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
|
||||
let!(:existing_user) do
|
||||
user = build(:user, email: unconfirmed_email)
|
||||
user = build(:user, email: unconfirmed_email, account: account)
|
||||
user.confirmed_at = nil
|
||||
user.save!(validate: false)
|
||||
user
|
||||
@@ -147,7 +174,7 @@ RSpec.describe SamlUserBuilder do
|
||||
end
|
||||
|
||||
context 'when user is already confirmed' do
|
||||
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
|
||||
let!(:existing_user) { create(:user, email: email, account: account, confirmed_at: Time.current) }
|
||||
|
||||
it 'keeps already confirmed user confirmed' do
|
||||
expect(existing_user.confirmed?).to be true
|
||||
|
||||
@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [
|
||||
{ role: 'user', content: 'Previous message' },
|
||||
{ role: 'assistant', content: 'Previous response' }
|
||||
{ role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
|
||||
]
|
||||
}
|
||||
end
|
||||
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
|
||||
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
|
||||
|
||||
context 'when it is an un-authenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an agent' do
|
||||
it 'generates a response' do
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
context 'when captain v2 is disabled' do
|
||||
it 'generates a response with the legacy assistant chat service' do
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
expect(json_response[:content]).to eq('Assistant response')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message_history is not provided' do
|
||||
it 'uses empty array as default' do
|
||||
params_without_history = { message_content: 'Hello assistant' }
|
||||
chat_service = instance_double(Captain::Llm::AssistantChatService)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
|
||||
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(chat_service)
|
||||
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
|
||||
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_without_history,
|
||||
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain v2 is enabled' do
|
||||
before do
|
||||
account.enable_features('captain_integration_v2')
|
||||
end
|
||||
|
||||
it 'generates a response with the agent runner service' do
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: valid_params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
|
||||
)
|
||||
expect(json_response[:response]).to eq('Assistant response')
|
||||
end
|
||||
|
||||
it 'does not duplicate the latest user message if it is already in history' do
|
||||
params_with_latest_message = {
|
||||
message_content: 'Hello assistant',
|
||||
message_history: [{ role: 'user', content: 'Hello assistant' }]
|
||||
}
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
|
||||
assistant: assistant,
|
||||
source: 'playground'
|
||||
).and_return(agent_runner_service)
|
||||
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
|
||||
params: params_with_latest_message,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(agent_runner_service).to have_received(:generate_response).with(
|
||||
message_history: params_with_latest_message[:message_history]
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
+17
@@ -57,5 +57,22 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
|
||||
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
|
||||
end
|
||||
end
|
||||
|
||||
it 'rejects an existing user from another account' do
|
||||
with_modified_env FRONTEND_URL: 'http://www.example.com' do
|
||||
other_account = create(:account)
|
||||
existing_user = create(:user, email: 'existing@example.com', account: other_account, provider: 'email')
|
||||
set_saml_config('existing@example.com')
|
||||
|
||||
get "/omniauth/saml/callback?account_id=#{account.id}"
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
|
||||
follow_redirect!
|
||||
|
||||
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
|
||||
expect(existing_user.reload.provider).to eq('email')
|
||||
expect(existing_user.accounts).not_to include(account)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,16 +2,14 @@ require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
let!(:inbox) { create(:inbox) }
|
||||
|
||||
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
|
||||
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.minute.ago, status: :pending) }
|
||||
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
|
||||
|
||||
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
|
||||
|
||||
before do
|
||||
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
|
||||
stub_const('Limits::BULK_ACTIONS_LIMIT', 3)
|
||||
inbox.reload
|
||||
end
|
||||
|
||||
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
|
||||
.to have_enqueued_job.on_queue('low')
|
||||
end
|
||||
|
||||
it 'resolves only the eligible pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates exactly one outgoing message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
|
||||
expect do
|
||||
context 'when captain_tasks is disabled' do
|
||||
it 'resolves pending conversations inactive for over 1 hour' do
|
||||
described_class.perform_now(inbox)
|
||||
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
|
||||
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(custom_message)
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'does not resolve recent pending conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
end
|
||||
|
||||
it 'does not affect open conversations' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'does not call ConversationCompletionService' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(Captain::ConversationCompletionService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
it 'creates an outgoing message with default auto resolution message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
context 'when captain_tasks is enabled' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
|
||||
expect(outgoing_message.content).to eq(
|
||||
I18n.t('conversations.activity.auto_resolution_message')
|
||||
)
|
||||
end
|
||||
it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
|
||||
|
||||
it 'adds the correct activity message after resolution by Captain' do
|
||||
described_class.perform_now(inbox)
|
||||
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
# Mock the service to return complete for all conversations
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
# Only resolvable conversation should be evaluated (not recent or open)
|
||||
expect(Captain::ConversationCompletionService).to have_received(:new).with(
|
||||
account: inbox.account,
|
||||
conversation_display_id: resolvable_pending_conversation.display_id
|
||||
)
|
||||
expect(recent_pending_conversation.reload.status).to eq('pending')
|
||||
expect(open_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'skips auto-action if conversation receives new activity after evaluation' do
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform) do
|
||||
resolvable_pending_conversation.update!(last_activity_at: Time.current)
|
||||
{ complete: true, reason: 'Customer question was answered' }
|
||||
end
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('pending')
|
||||
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns complete' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Customer question was answered' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'resolves the conversation' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq('Auto-resolved: Customer question was answered')
|
||||
end
|
||||
|
||||
it 'creates resolution message with configured content' do
|
||||
custom_message = 'This is a custom resolution message.'
|
||||
captain_assistant.update!(config: { 'resolution_message' => custom_message })
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(custom_message)
|
||||
end
|
||||
|
||||
it 'creates resolution message with default if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(I18n.t('conversations.activity.auto_resolution_message'))
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after resolution' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.resolved_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'no outstanding questions'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference resolved reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_resolved'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation returns incomplete' do
|
||||
let(:handoff_reason) { 'Assistant asked for order number but customer did not respond' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off the conversation to agents (status becomes open)' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'creates a private note with the reason' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
private_note = resolvable_pending_conversation.messages.where(private: true).last
|
||||
expect(private_note.content).to eq("Auto-handoff: #{handoff_reason}")
|
||||
end
|
||||
|
||||
it 'creates handoff message with configured content' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
|
||||
expect(public_message.content).to eq(handoff_message)
|
||||
expect(public_message.additional_attributes['preserve_waiting_since']).to be_nil
|
||||
end
|
||||
|
||||
it 'preserves existing waiting_since when handoff message is configured' do
|
||||
handoff_message = 'Connecting you to a human agent...'
|
||||
original_waiting_since = 3.hours.ago
|
||||
|
||||
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
|
||||
resolvable_pending_conversation.update!(waiting_since: original_waiting_since)
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:perform_if_applicable)
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
|
||||
it 'does not create handoff message if not configured' do
|
||||
captain_assistant.update!(config: {})
|
||||
inbox.reload
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
|
||||
expect do
|
||||
described_class.perform_now(inbox)
|
||||
end.not_to(change { resolvable_pending_conversation.messages.where(private: false).count })
|
||||
end
|
||||
|
||||
it 'adds the correct activity message after handoff' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expected_content = I18n.t(
|
||||
'conversations.activity.captain.open_with_reason',
|
||||
user_name: captain_assistant.name,
|
||||
reason: 'pending clarification from customer'
|
||||
)
|
||||
expect(Conversations::ActivityMessageJob)
|
||||
.to have_been_enqueued.with(
|
||||
resolvable_pending_conversation,
|
||||
{
|
||||
account_id: resolvable_pending_conversation.account_id,
|
||||
inbox_id: resolvable_pending_conversation.inbox_id,
|
||||
message_type: :activity,
|
||||
content: expected_content
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates a captain inference handoff reporting event' do
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_now(inbox)
|
||||
end
|
||||
|
||||
inference_event = ReportingEvent.find_by(
|
||||
conversation_id: resolvable_pending_conversation.id,
|
||||
name: 'conversation_captain_inference_handoff'
|
||||
)
|
||||
expect(inference_event).to be_present
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handoff occurs outside business hours' do
|
||||
let(:handoff_reason) { 'Customer has not responded to clarifying question' }
|
||||
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently unavailable.')
|
||||
end
|
||||
|
||||
it 'sends OOO message for non-campaign conversations' do
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
ooo_message = resolvable_pending_conversation.messages.template.last
|
||||
expect(ooo_message).to be_present
|
||||
expect(ooo_message.content).to eq('We are currently unavailable.')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message for campaign conversations' do
|
||||
campaign = create(:campaign, account: inbox.account, inbox: inbox)
|
||||
resolvable_pending_conversation.update!(campaign: campaign)
|
||||
|
||||
travel_to '01.11.2020 13:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not send OOO message during business hours' do
|
||||
travel_to '26.10.2020 10:00'.to_datetime do
|
||||
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.messages.template).to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM evaluation fails' do
|
||||
before do
|
||||
allow(inbox.account).to receive(:feature_enabled?).and_call_original
|
||||
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
mock_service = instance_double(Captain::ConversationCompletionService)
|
||||
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: 'API Error' })
|
||||
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
|
||||
end
|
||||
|
||||
it 'hands off as safe default' do
|
||||
described_class.perform_now(inbox)
|
||||
|
||||
expect(resolvable_pending_conversation.reload.status).to eq('open')
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::ConversationCompletionService do
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
|
||||
let(:mock_chat) { instance_double(RubyLLM::Chat) }
|
||||
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:with_instructions)
|
||||
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
|
||||
allow(account).to receive(:feature_enabled?).and_call_original
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when conversation is complete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Thanks!')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: true with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation is incomplete' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => false, 'reason' => 'Assistant asked for order number but customer did not respond' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Where is my order?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'Can you please share your order number?')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns complete: false with reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Assistant asked for order number but customer did not respond')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conversation has no messages' do
|
||||
it 'returns incomplete with appropriate reason' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('No messages found')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when LLM returns non-hash response' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: 'unexpected string response',
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'returns incomplete as safe default' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('Invalid response format')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when API call fails' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
allow(mock_chat).to receive(:ask).and_raise(StandardError.new('API Error'))
|
||||
end
|
||||
|
||||
it 'returns incomplete with error message' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).to be false
|
||||
expect(result[:reason]).to eq('API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when captain_tasks feature is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
end
|
||||
|
||||
it 'does not evaluate the conversation as complete' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:complete]).not_to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has its own OpenAI hook' do
|
||||
before do
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
|
||||
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
|
||||
end
|
||||
|
||||
it 'uses the system API key instead of the account hook key' do
|
||||
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'falls back to the account hook key when no system key exists' do
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
|
||||
|
||||
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context)
|
||||
allow(mock_chat).to receive(:ask).and_return(
|
||||
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
|
||||
)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when customer quota is exhausted' do
|
||||
let(:mock_response) do
|
||||
instance_double(
|
||||
RubyLLM::Message,
|
||||
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
|
||||
input_tokens: 100,
|
||||
output_tokens: 20
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
allow(account).to receive(:usage_limits).and_return({
|
||||
captain: { responses: { current_available: 0 } }
|
||||
})
|
||||
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
|
||||
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
|
||||
allow(mock_chat).to receive(:ask).and_return(mock_response)
|
||||
end
|
||||
|
||||
it 'still runs the evaluation bypassing quota check' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:error]).to be_nil
|
||||
expect(result[:complete]).to be true
|
||||
expect(result[:reason]).to eq('Customer question was fully answered')
|
||||
end
|
||||
|
||||
it 'does not increment usage' do
|
||||
expect(account).not_to receive(:increment_response_usage)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
|
||||
tool.perform(tool_context, reason: 'Test reason')
|
||||
end
|
||||
|
||||
it 'creates a conversation_bot_handoff reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
Current.executed_by = assistant
|
||||
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Customer needs specialized support')
|
||||
end
|
||||
|
||||
reporting_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
|
||||
expect(reporting_event).to be_present
|
||||
ensure
|
||||
Current.reset
|
||||
end
|
||||
|
||||
it 'logs tool usage with reason' do
|
||||
reason = 'Customer needs help'
|
||||
expect(tool).to receive(:log_tool_usage).with(
|
||||
|
||||
@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
|
||||
)
|
||||
end
|
||||
|
||||
it 'clears captain_resolve_reason after execution' do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
it 'creates a conversation_resolved reporting event' do
|
||||
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
|
||||
|
||||
expect(Current.captain_resolve_reason).to be_nil
|
||||
expect do
|
||||
perform_enqueued_jobs do
|
||||
tool.perform(tool_context, reason: 'Possible spam')
|
||||
end
|
||||
end.to change { ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_resolved').count }.by(1)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -8,8 +8,7 @@ describe CaptainListener do
|
||||
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(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
|
||||
|
||||
let(:event_name) { :conversation_resolved }
|
||||
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
|
||||
|
||||
@@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#handoff_key' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
it 'uses id plus readable slug for persisted scenarios' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Handle complex refund requests requiring manager approval steps')
|
||||
|
||||
expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_")
|
||||
expect(scenario.handoff_key).to end_with('_agent')
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'uses a truncated slug key for unsaved scenarios' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'Troubleshoot payment gateway errors for recurring subscription charges')
|
||||
|
||||
expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/)
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'stays within length budget even for large ids' do
|
||||
scenario = build(:captain_scenario, assistant: assistant, account: account,
|
||||
title: 'A very long scenario title used only for budget verification')
|
||||
allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789)
|
||||
|
||||
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
|
||||
end
|
||||
|
||||
it 'exposes handoff keys in assistant prompt context' do
|
||||
scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
|
||||
prompt_context = assistant.send(:prompt_context)
|
||||
scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title }
|
||||
|
||||
expect(scenario_config[:key]).to eq(scenario.handoff_key)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'tool validation and population' do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
@@ -27,12 +27,24 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_resolved event with business hour value' do
|
||||
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.resolved', updated_at, conversation: new_conversation)
|
||||
listener.conversation_resolved(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
resolved_at = conversation.created_at + 20.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(resolved_at + 10.minutes)
|
||||
event = Events::Base.new('conversation.resolved', resolved_at, conversation: conversation)
|
||||
|
||||
listener.conversation_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_resolved').first
|
||||
expect(reporting_event.value).to eq 1200
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(resolved_at)
|
||||
end
|
||||
|
||||
describe 'conversation_bot_resolved' do
|
||||
# create an agent bot
|
||||
let!(:agent_bot_inbox) { create(:inbox, account: account) }
|
||||
@@ -261,11 +273,55 @@ describe ReportingEventListener do
|
||||
end
|
||||
|
||||
it 'creates conversation_bot_handoff event with business hour value' do
|
||||
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: new_conversation)
|
||||
event = Events::Base.new('conversation.bot_handoff', updated_at, conversation: new_conversation)
|
||||
listener.conversation_bot_handoff(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
handoff_at = conversation.created_at + 10.minutes
|
||||
allow(conversation).to receive(:updated_at).and_return(handoff_at + 15.minutes)
|
||||
event = Events::Base.new('conversation.bot_handoff', handoff_at, conversation: conversation)
|
||||
|
||||
listener.conversation_bot_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_bot_handoff').first
|
||||
expect(reporting_event.value).to eq 600
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(handoff_at)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_resolved' do
|
||||
it 'creates conversation_captain_inference_resolved event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_resolved').count).to be 0
|
||||
decision_time = conversation.created_at + 60.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_resolved', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_resolved(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_resolved').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 60
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_captain_inference_handoff' do
|
||||
it 'creates conversation_captain_inference_handoff event' do
|
||||
expect(account.reporting_events.where(name: 'conversation_captain_inference_handoff').count).to be 0
|
||||
decision_time = conversation.created_at + 90.seconds
|
||||
event = Events::Base.new('conversation.captain_inference_handoff', decision_time, conversation: conversation)
|
||||
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
|
||||
|
||||
listener.conversation_captain_inference_handoff(event)
|
||||
|
||||
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_handoff').first
|
||||
expect(reporting_event).to be_present
|
||||
expect(reporting_event.value).to eq 90
|
||||
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#conversation_opened' do
|
||||
@@ -274,7 +330,8 @@ describe ReportingEventListener do
|
||||
|
||||
it 'creates conversation_opened event with value 0' do
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
|
||||
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
|
||||
opened_at = Time.zone.now
|
||||
event = Events::Base.new('conversation.opened', opened_at, conversation: new_conversation)
|
||||
listener.conversation_opened(event)
|
||||
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
|
||||
|
||||
@@ -282,7 +339,7 @@ describe ReportingEventListener do
|
||||
expect(opened_event.value).to eq 0
|
||||
expect(opened_event.value_in_business_hours).to eq 0
|
||||
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
|
||||
expect(opened_event.event_end_time).to be_within(1.second).of(opened_at)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -334,6 +391,17 @@ describe ReportingEventListener do
|
||||
expect(reopened_event.user_id).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'uses event timestamp even when conversation updated_at changes later' do
|
||||
allow(reopened_conversation).to receive(:updated_at).and_return(reopened_time + 20.minutes)
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
|
||||
context 'when business hours enabled for inbox' do
|
||||
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
|
||||
@@ -406,6 +474,45 @@ describe ReportingEventListener do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when latest resolved event is after the conversation opened event timestamp' do
|
||||
let(:previous_resolved_time) { Time.zone.parse('March 22, 2022 09:00') }
|
||||
let(:future_resolved_time) { Time.zone.parse('March 22, 2022 11:00') }
|
||||
let(:reopened_time) { Time.zone.parse('March 22, 2022 10:00') }
|
||||
let(:reopened_conversation) do
|
||||
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
|
||||
end
|
||||
|
||||
before do
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: reopened_conversation.created_at,
|
||||
event_end_time: previous_resolved_time)
|
||||
|
||||
create(:reporting_event,
|
||||
name: 'conversation_resolved',
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
conversation_id: reopened_conversation.id,
|
||||
user_id: user.id,
|
||||
event_start_time: previous_resolved_time,
|
||||
event_end_time: future_resolved_time)
|
||||
end
|
||||
|
||||
it 'ignores future resolved events when computing reopen duration' do
|
||||
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
|
||||
listener.conversation_opened(event)
|
||||
|
||||
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
|
||||
expect(reopened_event.value).to be_within(1).of(3600)
|
||||
expect(reopened_event.event_start_time).to be_within(1.second).of(previous_resolved_time)
|
||||
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent bot resolves and conversation is reopened' do
|
||||
# This implicitly tests that the first_response time is correctly calculated
|
||||
# By checking that a conversation reopened event is created with the correct values
|
||||
|
||||
@@ -366,6 +366,25 @@ RSpec.describe Message do
|
||||
expect(conversation.waiting_since).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'when bot response should preserve waiting_since' do
|
||||
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
|
||||
|
||||
it 'does not clear waiting_since when preserve_waiting_since is set' do
|
||||
original_waiting_since = 45.minutes.ago
|
||||
conversation.update!(waiting_since: original_waiting_since)
|
||||
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
message_type: :outgoing,
|
||||
sender: agent_bot,
|
||||
preserve_waiting_since: true
|
||||
)
|
||||
|
||||
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with webhook_data' do
|
||||
|
||||
@@ -50,6 +50,26 @@ describe ActionService do
|
||||
action_service.assign_agent(['nil'])
|
||||
expect(conversation.reload.assignee).to be_nil
|
||||
end
|
||||
|
||||
context 'when agent is confirmed' do
|
||||
it 'assigns the agent to the conversation' do
|
||||
inbox_member
|
||||
action_service.assign_agent([agent.id])
|
||||
expect(conversation.reload.assignee).to eq(agent)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when agent is unconfirmed' do
|
||||
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
|
||||
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
|
||||
|
||||
it 'does not assign unconfirmed agent to the conversation' do
|
||||
unconfirmed_inbox_member
|
||||
original_assignee = conversation.assignee
|
||||
action_service.assign_agent([unconfirmed_agent.id])
|
||||
expect(conversation.reload.assignee).to eq(original_assignee)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#assign_team' do
|
||||
|
||||
Reference in New Issue
Block a user