From d6d38cdd7d49762c636b1cf7a0617d742c811d2a Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Fri, 13 Mar 2026 10:03:58 +0530 Subject: [PATCH] feat: captain decides if conversation should be resolved or kept open (#13336) # Pull Request Template ## Description captain decides if conversation should be resolved or open Fixes https://linear.app/chatwoot/issue/AI-91/make-captain-resolution-time-configurable Update: Added 2 entries in reporting events: `conversation_captain_handoff` and `conversation_captain_resolved` ## Type of change Please delete options that are not relevant. - [x] New feature (non-breaking change which adds functionality) - [x] This change requires a documentation update ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. LLM call decides that conversation is resolved, drops a private note image LLM call decides conversation is still open as query was not resolved image ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sojan Jose --- app/listeners/reporting_event_listener.rb | 51 ++- app/models/message.rb | 28 +- config/locales/en.yml | 2 + ...ox_pending_conversations_resolution_job.rb | 129 ++++++- .../enterprise/activity_message_handler.rb | 28 +- .../app/models/enterprise/conversation.rb | 28 ++ .../captain/conversation_completion_schema.rb | 4 + .../conversation_completion_service.rb | 68 ++++ .../prompts/conversation_completion.liquid | 21 ++ .../tools/resolve_conversation_tool.rb | 7 +- .../conversation_completion_service.rb | 12 + lib/captain/base_task_service.rb | 14 +- lib/current.rb | 2 - lib/events/types.rb | 2 + .../llm_instrumentation_completion_helpers.rb | 2 +- ...nding_conversations_resolution_job_spec.rb | 336 +++++++++++++++--- .../conversation_completion_service_spec.rb | 163 +++++++++ .../lib/captain/tools/handoff_tool_spec.rb | 14 + .../tools/resolve_conversation_tool_spec.rb | 10 +- .../listeners/captain_listener_spec.rb | 3 +- .../reporting_event_listener_spec.rb | 115 +++++- spec/models/message_spec.rb | 19 + 22 files changed, 949 insertions(+), 109 deletions(-) create mode 100644 enterprise/lib/captain/conversation_completion_schema.rb create mode 100644 enterprise/lib/captain/conversation_completion_service.rb create mode 100644 enterprise/lib/captain/prompts/conversation_completion.liquid create mode 100644 enterprise/lib/enterprise/captain/conversation_completion_service.rb create mode 100644 spec/enterprise/lib/captain/conversation_completion_service_spec.rb 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