diff --git a/config/agents/tools.yml b/config/agents/tools.yml index c2faf75e7..ff4f7d28f 100644 --- a/config/agents/tools.yml +++ b/config/agents/tools.yml @@ -30,6 +30,11 @@ description: 'Search FAQ responses using semantic similarity' icon: 'search' +- id: resolve_conversation + title: 'Resolve Conversation' + description: 'Resolve a conversation when the issue has been addressed' + icon: 'checkmark' + - id: handoff title: 'Handoff to Human' description: 'Hand off the conversation to a human agent' diff --git a/config/locales/en.yml b/config/locales/en.yml index 07d9b0e2f..a058d28c7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -229,6 +229,7 @@ en: activity: captain: resolved: 'Conversation was marked resolved by %{user_name} due to inactivity' + resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}' open: 'Conversation was marked open by %{user_name}' 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/models/enterprise/activity_message_handler.rb b/enterprise/app/models/enterprise/activity_message_handler.rb index e6a93718f..66195ec46 100644 --- a/enterprise/app/models/enterprise/activity_message_handler.rb +++ b/enterprise/app/models/enterprise/activity_message_handler.rb @@ -1,22 +1,23 @@ module Enterprise::ActivityMessageHandler def automation_status_change_activity_content - if Current.executed_by.instance_of?(Captain::Assistant) - locale = Current.executed_by.account.locale - if resolved? - I18n.t( - 'conversations.activity.captain.resolved', - user_name: Current.executed_by.name, - locale: locale - ) - elsif open? - I18n.t( - 'conversations.activity.captain.open', - user_name: Current.executed_by.name, - locale: locale - ) - end - else - super + return super unless Current.executed_by.instance_of?(Captain::Assistant) + + locale = Current.executed_by.account.locale + key = captain_activity_key + return unless key + + I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale) + end + + private + + 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 end end diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb new file mode 100644 index 000000000..0d2563a8b --- /dev/null +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -0,0 +1,27 @@ +class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool + description 'Resolve a conversation when the issue has been addressed or the conversation should be closed' + param :reason, type: 'string', desc: 'Brief reason for resolving the conversation', required: true + + def perform(tool_context, reason:) + conversation = find_conversation(tool_context.state) + return 'Conversation not found' unless conversation + return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved? + + 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 ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}" + end + + private + + def permissions + %w[conversation_manage conversation_unassigned_manage conversation_participating_manage] + end +end diff --git a/lib/current.rb b/lib/current.rb index 3376099f8..5097df369 100644 --- a/lib/current.rb +++ b/lib/current.rb @@ -4,6 +4,7 @@ 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 @@ -11,5 +12,6 @@ module Current Current.account_user = nil Current.executed_by = nil Current.contact = nil + Current.captain_resolve_reason = nil end end diff --git a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb new file mode 100644 index 000000000..f91f430e8 --- /dev/null +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -0,0 +1,52 @@ +require 'rails_helper' + +RSpec.describe Captain::Tools::ResolveConversationTool do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :open) } + let(:tool) { described_class.new(assistant) } + let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } + + before do + Current.executed_by = assistant + end + + after do + Current.reset + end + + describe 'resolving a conversation' do + it 'marks resolved and enqueues an activity message with the reason' do + tool.perform(tool_context, reason: 'Possible spam') + + expect(conversation.reload).to be_resolved + expect(Conversations::ActivityMessageJob).to have_been_enqueued.with( + conversation, + hash_including( + content: I18n.t('conversations.activity.captain.resolved_by_tool', user_name: assistant.name, reason: 'Possible spam') + ) + ) + end + + it 'clears captain_resolve_reason after execution' do + tool.perform(tool_context, reason: 'Possible spam') + + expect(Current.captain_resolve_reason).to be_nil + end + end + + describe 'resolving an already resolved conversation' do + let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :resolved) } + + it 'does not re-resolve and returns an already resolved message' do + queue_adapter = ActiveJob::Base.queue_adapter + queue_adapter.enqueued_jobs.clear + + result = tool.perform(tool_context, reason: 'Possible spam') + + expect(result).to include('already resolved') + expect(Conversations::ActivityMessageJob).not_to have_been_enqueued + end + end +end