diff --git a/app/listeners/reporting_event_listener.rb b/app/listeners/reporting_event_listener.rb index 9f683a97f..eb234c698 100644 --- a/app/listeners/reporting_event_listener.rb +++ b/app/listeners/reporting_event_listener.rb @@ -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 diff --git a/app/models/message.rb b/app/models/message.rb index f98da83a6..c33ab97d6 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -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: { @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index b9fac9586..68bea856e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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.' diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb index ab9ca2ab1..538c03d22 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -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 diff --git a/enterprise/app/models/enterprise/activity_message_handler.rb b/enterprise/app/models/enterprise/activity_message_handler.rb index 66195ec46..2a0ed67d4 100644 --- a/enterprise/app/models/enterprise/activity_message_handler.rb +++ b/enterprise/app/models/enterprise/activity_message_handler.rb @@ -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 diff --git a/enterprise/app/models/enterprise/conversation.rb b/enterprise/app/models/enterprise/conversation.rb index e653ad0ab..48db11869 100644 --- a/enterprise/app/models/enterprise/conversation.rb +++ b/enterprise/app/models/enterprise/conversation.rb @@ -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 diff --git a/enterprise/lib/captain/conversation_completion_schema.rb b/enterprise/lib/captain/conversation_completion_schema.rb new file mode 100644 index 000000000..ab67866b4 --- /dev/null +++ b/enterprise/lib/captain/conversation_completion_schema.rb @@ -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 diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb new file mode 100644 index 000000000..6ac45421c --- /dev/null +++ b/enterprise/lib/captain/conversation_completion_service.rb @@ -0,0 +1,68 @@ +# 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 + + def event_name + 'captain.conversation_completion' + end + + def build_follow_up_context? + false + end +end + +Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService') diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid new file mode 100644 index 000000000..8f8461223 --- /dev/null +++ b/enterprise/lib/captain/prompts/conversation_completion.liquid @@ -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"} diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb index 5d96d3af1..2b71098c9 100644 --- a/enterprise/lib/captain/tools/resolve_conversation_tool.rb +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -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 diff --git a/enterprise/lib/enterprise/captain/conversation_completion_service.rb b/enterprise/lib/enterprise/captain/conversation_completion_service.rb new file mode 100644 index 000000000..ac12f8039 --- /dev/null +++ b/enterprise/lib/enterprise/captain/conversation_completion_service.rb @@ -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 diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb index 7b84a879d..60e6ac579 100644 --- a/lib/captain/base_task_service.rb +++ b/lib/captain/base_task_service.rb @@ -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 diff --git a/lib/current.rb b/lib/current.rb index 5097df369..3376099f8 100644 --- a/lib/current.rb +++ b/lib/current.rb @@ -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 diff --git a/lib/events/types.rb b/lib/events/types.rb index dce6b8b32..d742232c4 100644 --- a/lib/events/types.rb +++ b/lib/events/types.rb @@ -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' diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb index 50e071119..551d0780f 100644 --- a/lib/integrations/llm_instrumentation_completion_helpers.rb +++ b/lib/integrations/llm_instrumentation_completion_helpers.rb @@ -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) diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb index ab8f0296c..63b8c1bca 100644 --- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb +++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb @@ -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 diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb new file mode 100644 index 000000000..51d7fa807 --- /dev/null +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -0,0 +1,163 @@ +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 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 diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb index ef56ae3bb..492d24f32 100644 --- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -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( diff --git a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb index d5792cf78..f676b2974 100644 --- a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -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 diff --git a/spec/enterprise/listeners/captain_listener_spec.rb b/spec/enterprise/listeners/captain_listener_spec.rb index 1363ea033..e076a8b8f 100644 --- a/spec/enterprise/listeners/captain_listener_spec.rb +++ b/spec/enterprise/listeners/captain_listener_spec.rb @@ -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) } diff --git a/spec/listeners/reporting_event_listener_spec.rb b/spec/listeners/reporting_event_listener_spec.rb index 8004349bf..34b65e7ab 100644 --- a/spec/listeners/reporting_event_listener_spec.rb +++ b/spec/listeners/reporting_event_listener_spec.rb @@ -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 diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 64a488dcb..4a59609b3 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -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