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
<img width="1228" height="438" alt="image"
src="https://github.com/user-attachments/assets/fb2cf1e9-4b2b-458b-a1e2-45c53d6a0158"
/>

LLM call decides conversation is still open as query was not resolved
<img width="1215" height="573" alt="image"
src="https://github.com/user-attachments/assets/2d1d5322-f567-487e-954e-11ab0798d11c"
/>


## 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 <sojan@pepalo.com>
This commit is contained in:
Aakash Bakhle
2026-03-13 10:03:58 +05:30
committed by GitHub
parent 199dcd382e
commit d6d38cdd7d
22 changed files with 949 additions and 109 deletions

View File

@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
def conversation_resolved(event) def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0] 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( reporting_event = ReportingEvent.new(
name: 'conversation_resolved', name: 'conversation_resolved',
value: time_to_resolve, value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at), event_end_time),
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id, user_id: conversation.assignee_id,
conversation_id: conversation.id, conversation_id: conversation.id,
event_start_time: conversation.created_at, 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) create_bot_resolved_event(conversation, reporting_event)
@@ -69,41 +70,51 @@ class ReportingEventListener < BaseListener
def conversation_bot_handoff(event) def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# check if a conversation_bot_handoff event exists for this conversation # 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') bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
return if bot_handoff_event.present? 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( reporting_event = ReportingEvent.new(
name: 'conversation_bot_handoff', name: 'conversation_bot_handoff',
value: time_to_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, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id, user_id: conversation.assignee_id,
conversation_id: conversation.id, conversation_id: conversation.id,
event_start_time: conversation.created_at, event_start_time: conversation.created_at,
event_end_time: conversation.updated_at event_end_time: event_end_time
) )
reporting_event.save! reporting_event.save!
end 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) def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# Find the most recent resolved event for this conversation # Find the most recent resolved event for this conversation
last_resolved_event = ReportingEvent.where( last_resolved_event = ReportingEvent.where(
conversation_id: conversation.id, conversation_id: conversation.id,
name: 'conversation_resolved' 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 first-time openings, value is 0
# For reopenings, calculate time since resolution # For reopenings, calculate time since resolution
if last_resolved_event if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i 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, conversation.updated_at) business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
start_time = last_resolved_event.event_end_time start_time = last_resolved_event.event_end_time
else else
time_since_resolved = 0 time_since_resolved = 0
@@ -111,12 +122,12 @@ class ReportingEventListener < BaseListener
start_time = conversation.created_at start_time = conversation.created_at
end 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 end
private 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( reporting_event = ReportingEvent.new(
name: 'conversation_opened', name: 'conversation_opened',
value: time_since_resolved, value: time_since_resolved,
@@ -126,11 +137,27 @@ class ReportingEventListener < BaseListener
user_id: conversation.assignee_id, user_id: conversation.assignee_id,
conversation_id: conversation.id, conversation_id: conversation.id,
event_start_time: start_time, event_start_time: start_time,
event_end_time: conversation.updated_at event_end_time: event_end_time
) )
reporting_event.save! reporting_event.save!
end 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) def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot? return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation # We don't want to create a bot_resolved event if there is user interaction on the conversation

View File

@@ -81,6 +81,8 @@ class Message < ApplicationRecord
# when you have a temperory id in your frontend and want it echoed back via action cable # when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id 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 message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum content_type: { enum content_type: {
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
end end
def update_waiting_since 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 def clear_waiting_since_on_outgoing_response
if human_response? if human_response?
Rails.configuration.dispatcher.dispatch( Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
) )
conversation.update(waiting_since: nil) conversation.update(waiting_since: nil)
elsif bot_response? return
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
end 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) # Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank? conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end end

View File

@@ -235,8 +235,10 @@ en:
activity: activity:
captain: captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity' 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}' resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
open: 'Conversation was marked open by %{user_name}' 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' auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
agent_bot: agent_bot:
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.' error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'

View File

@@ -1,15 +1,16 @@
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob 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 queue_as :low
def perform(inbox) def perform(inbox)
return if inbox.account.captain_disable_auto_resolve return if inbox.account.captain_disable_auto_resolve
Current.executed_by = inbox.captain_assistant if inbox.account.feature_enabled?('captain_tasks')
perform_with_evaluation(inbox)
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT) else
resolvable_conversations.each do |conversation| perform_time_based(inbox)
create_outgoing_message(conversation, inbox)
conversation.resolved!
end end
ensure ensure
Current.reset Current.reset
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
private 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 I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message'] resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!( conversation.messages.create!(
{ message_type: :outgoing,
message_type: :outgoing, account_id: conversation.account_id,
account_id: conversation.account_id, inbox_id: conversation.inbox_id,
inbox_id: conversation.inbox_id, content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'), sender: inbox.captain_assistant
sender: inbox.captain_assistant
}
) )
end end
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 end

View File

@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
key = captain_activity_key key = captain_activity_key
return unless 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 end
private private
def captain_status_reason
captain_activity_reason.presence
end
def captain_activity_key def captain_activity_key
if resolved? && Current.captain_resolve_reason.present? return captain_resolved_activity_key if resolved?
'conversations.activity.captain.resolved_by_tool' return captain_open_activity_key if open?
elsif resolved? end
'conversations.activity.captain.resolved'
elsif open? def captain_resolved_activity_key
'conversations.activity.captain.open' return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
end 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
end end

View File

@@ -1,8 +1,30 @@
module Enterprise::Conversation 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 def list_of_keys
super + %w[sla_policy_id] super + %w[sla_policy_id]
end 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 # Include select additional_attributes keys (call related) for update events
def allowed_keys? def allowed_keys?
return true if super return true if super
@@ -13,4 +35,10 @@ module Enterprise::Conversation
changed_attr_keys = attrs_change[1].keys changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status]) changed_attr_keys.intersect?(%w[call_status])
end end
private
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
end end

View File

@@ -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

View File

@@ -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')

View File

@@ -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"}

View File

@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason }) log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
Current.captain_resolve_reason = reason conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
begin
conversation.resolved!
ensure
Current.captain_resolve_reason = nil
end
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}" "Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
end end

View File

@@ -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

View File

@@ -36,7 +36,7 @@ class Captain::BaseTaskService
"#{endpoint}/v1" "#{endpoint}/v1"
end end
def make_api_call(model:, messages:, tools: []) def make_api_call(model:, messages:, schema: nil, tools: [])
# Community edition prerequisite checks # Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted) # 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? 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 instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do 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 end
return response unless build_follow_up_context? && response[:message].present? 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)) response.merge(follow_up_context: build_follow_up_context(messages, response))
end 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| 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' } conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty? 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 } { error: e.message, request_messages: messages }
end end
def build_chat(context, model:, messages:, tools: []) def build_chat(context, model:, messages:, schema: nil, tools: [])
chat = context.chat(model: model) chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' } system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema
if tools.any? if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) } tools.each { |tool| chat = chat.with_tool(tool) }
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
.reorder('id desc') .reorder('id desc')
.each do |message| .each do |message|
content = message.content_for_llm 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 }) messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length character_count += content.length

View File

@@ -4,7 +4,6 @@ module Current
thread_mattr_accessor :account_user thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by thread_mattr_accessor :executed_by
thread_mattr_accessor :contact thread_mattr_accessor :contact
thread_mattr_accessor :captain_resolve_reason
def self.reset def self.reset
Current.user = nil Current.user = nil
@@ -12,6 +11,5 @@ module Current
Current.account_user = nil Current.account_user = nil
Current.executed_by = nil Current.executed_by = nil
Current.contact = nil Current.contact = nil
Current.captain_resolve_reason = nil
end end
end end

View File

@@ -21,6 +21,8 @@ module Events::Types
# FIXME: deprecate the opened and resolved events in future in favor of status changed event. # FIXME: deprecate the opened and resolved events in future in favor of status changed event.
CONVERSATION_OPENED = 'conversation.opened' CONVERSATION_OPENED = 'conversation.opened'
CONVERSATION_RESOLVED = 'conversation.resolved' 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_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed' CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'

View File

@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
return if message.blank? return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant') 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 end
def set_usage_metrics(span, result) def set_usage_metrics(span, result)

View File

@@ -2,16 +2,14 @@ require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
let!(:inbox) { create(:inbox) } let!(:inbox) { create(:inbox) }
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) } 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!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) } let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
before do before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant) 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 inbox.reload
end end
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
.to have_enqueued_job.on_queue('low') .to have_enqueued_job.on_queue('low')
end end
it 'resolves only the eligible pending conversations' do context 'when captain_tasks is disabled' do
described_class.perform_now(inbox) it 'resolves pending conversations inactive for over 1 hour' do
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
described_class.perform_now(inbox) 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(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(outgoing_message.content).to eq(custom_message) 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 end
it 'creates an outgoing message with default auto resolution message if not configured' do context 'when captain_tasks is enabled' do
captain_assistant.update!(config: {}) 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) it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
outgoing_message = resolvable_pending_conversation.messages.outgoing.last allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
)
end
it 'adds the correct activity message after resolution by Captain' do # Mock the service to return complete for all conversations
described_class.perform_now(inbox) mock_service = instance_double(Captain::ConversationCompletionService)
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name) allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
expect(Conversations::ActivityMessageJob) allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
.to have_been_enqueued.with(
resolvable_pending_conversation, described_class.perform_now(inbox)
{
account_id: resolvable_pending_conversation.account_id, # Only resolvable conversation should be evaluated (not recent or open)
inbox_id: resolvable_pending_conversation.inbox_id, expect(Captain::ConversationCompletionService).to have_received(:new).with(
message_type: :activity, account: inbox.account,
content: expected_content 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 end
it 'does not resolve conversations when auto-resolve is disabled at execution time' do it 'does not resolve conversations when auto-resolve is disabled at execution time' do

View File

@@ -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

View File

@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: 'Test reason') tool.perform(tool_context, reason: 'Test reason')
end 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 it 'logs tool usage with reason' do
reason = 'Customer needs help' reason = 'Customer needs help'
expect(tool).to receive(:log_tool_usage).with( expect(tool).to receive(:log_tool_usage).with(

View File

@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
) )
end end
it 'clears captain_resolve_reason after execution' do it 'creates a conversation_resolved reporting event' do
tool.perform(tool_context, reason: 'Possible spam') 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
end end

View File

@@ -8,8 +8,7 @@ describe CaptainListener do
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) } let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) } let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:event_name) { :conversation_resolved } let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }

View File

@@ -27,12 +27,24 @@ describe ReportingEventListener do
end end
it 'creates conversation_resolved event with business hour value' do 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) listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0 expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
end end
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 describe 'conversation_bot_resolved' do
# create an agent bot # create an agent bot
let!(:agent_bot_inbox) { create(:inbox, account: account) } let!(:agent_bot_inbox) { create(:inbox, account: account) }
@@ -261,11 +273,55 @@ describe ReportingEventListener do
end end
it 'creates conversation_bot_handoff event with business hour value' do 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) listener.conversation_bot_handoff(event)
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0 expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
end end
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 end
describe '#conversation_opened' do describe '#conversation_opened' do
@@ -274,7 +330,8 @@ describe ReportingEventListener do
it 'creates conversation_opened event with value 0' do it 'creates conversation_opened event with value 0' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0 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) listener.conversation_opened(event)
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1 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).to eq 0
expect(opened_event.value_in_business_hours).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_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
end end
@@ -334,6 +391,17 @@ describe ReportingEventListener do
expect(reopened_event.user_id).to eq(user.id) expect(reopened_event.user_id).to eq(user.id)
end 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 context 'when business hours enabled for inbox' do
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') } let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') } let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
@@ -406,6 +474,45 @@ describe ReportingEventListener do
end end
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 context 'when agent bot resolves and conversation is reopened' do
# This implicitly tests that the first_response time is correctly calculated # This implicitly tests that the first_response time is correctly calculated
# By checking that a conversation reopened event is created with the correct values # By checking that a conversation reopened event is created with the correct values

View File

@@ -366,6 +366,25 @@ RSpec.describe Message do
expect(conversation.waiting_since).to be_nil expect(conversation.waiting_since).to be_nil
end end
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 end
context 'with webhook_data' do context 'with webhook_data' do