diff --git a/app/models/message.rb b/app/models/message.rb index 20b9a756d..cf03c9502 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -310,6 +310,7 @@ class Message < ApplicationRecord def execute_after_create_commit_callbacks # rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911 reopen_conversation + mark_pending_conversation_as_open_for_human_response set_conversation_activity dispatch_create_events send_reply @@ -390,6 +391,18 @@ class Message < ApplicationRecord reopen_resolved_conversation if conversation.resolved? end + def mark_pending_conversation_as_open_for_human_response + return unless captain_pending_conversation? + return unless human_response? + return if private? + + conversation.open! + end + + def captain_pending_conversation? + false + end + def reopen_resolved_conversation # mark resolved bot conversation as pending to be reopened by bot processor service if conversation.inbox.active_bot? diff --git a/config/locales/en.yml b/config/locales/en.yml index 9cab941c2..0c89f3e7d 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -236,6 +236,7 @@ en: 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}' + 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.' status: diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index c4723f6b9..0fc146b12 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -8,6 +8,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob @inbox = conversation.inbox @assistant = assistant + return unless conversation_pending? + Current.executed_by = @assistant if captain_v2_enabled? @@ -15,9 +17,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob else generate_and_process_response end + rescue ActiveStorage::FileNotFoundError, Faraday::BadRequestError => e + handle_error(e) + raise e rescue StandardError => e - raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError) - handle_error(e) ensure Current.executed_by = nil @@ -42,6 +45,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def process_response + return unless conversation_pending? + if handoff_requested? process_action('handoff') else @@ -144,4 +149,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def captain_v2_enabled? account.feature_enabled?('captain_integration_v2') end + + def conversation_pending? + status = Conversation.where(id: @conversation.id).pick(:status) + status == 'pending' || status == Conversation.statuses[:pending] + end end diff --git a/enterprise/app/models/enterprise/message.rb b/enterprise/app/models/enterprise/message.rb new file mode 100644 index 000000000..bee6c2f0e --- /dev/null +++ b/enterprise/app/models/enterprise/message.rb @@ -0,0 +1,40 @@ +module Enterprise::Message + private + + def mark_pending_conversation_as_open_for_human_response + return unless captain_pending_conversation? + return unless human_response? + return if private? + + previous_user = Current.user + previous_executed_by = Current.executed_by + Current.user = nil + Current.executed_by = nil + + begin + conversation.open! + return unless conversation.saved_change_to_status? + + create_captain_auto_open_activity_message + ensure + Current.user = previous_user + Current.executed_by = previous_executed_by + end + end + + def captain_pending_conversation? + return false unless conversation.pending? + + ::CaptainInbox.exists?(inbox_id: conversation.inbox_id) + end + + def create_captain_auto_open_activity_message + ::Conversations::ActivityMessageJob.perform_later( + conversation, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: I18n.t('conversations.activity.captain.auto_opened_after_agent_reply', locale: conversation.account.locale) + ) + end +end diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index 4e48eb355..3efa69e34 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -7,7 +7,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do let(:captain_inbox_association) { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) } describe '#perform' do - let(:conversation) { create(:conversation, inbox: inbox, account: account) } + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) } @@ -47,6 +47,15 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do account.reload expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1) end + + it 'does not send a response when the conversation is no longer pending' do + conversation.open! + + expect(mock_llm_chat_service).not_to receive(:generate_response) + expect do + described_class.perform_now(conversation, assistant) + end.not_to(change { conversation.messages.outgoing.count }) + end end context 'when captain_v2 is enabled' do @@ -157,7 +166,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do end describe 'retry mechanisms for image processing' do - let(:conversation) { create(:conversation, inbox: inbox, account: account) } + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) } let(:mock_message_builder) { instance_double(Captain::OpenAiMessageBuilderService) } diff --git a/spec/enterprise/models/message_spec.rb b/spec/enterprise/models/message_spec.rb index aa1537e65..36311a567 100644 --- a/spec/enterprise/models/message_spec.rb +++ b/spec/enterprise/models/message_spec.rb @@ -23,4 +23,69 @@ RSpec.describe Message do expect(conversation.first_reply_created_at).not_to be_nil expect(conversation.waiting_since).to be_nil end + + describe '#mark_pending_conversation_as_open_for_human_response' do + let(:conversation) { create(:conversation, status: :pending) } + let(:captain_assistant) { create(:captain_assistant, account: conversation.account) } + let(:auto_open_activity_content) { I18n.t('conversations.activity.captain.auto_opened_after_agent_reply', locale: conversation.account.locale) } + + before do + create(:captain_inbox, inbox: conversation.inbox, captain_assistant: captain_assistant) + end + + it 'marks the conversation open when a human sends a public outgoing message' do + create(:message, message_type: :outgoing, conversation: conversation) + + expect(conversation.reload.open?).to be true + end + + it 'creates an activity message when a human sends a public outgoing message' do + expect do + create(:message, message_type: :outgoing, conversation: conversation) + end.to have_enqueued_job(Conversations::ActivityMessageJob).with( + conversation, + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: auto_open_activity_content + } + ) + end + + it 'creates an activity message for external echo replies' do + message = build( + :message, + message_type: :outgoing, + conversation: conversation, + content_attributes: { external_echo: true } + ) + message.sender = nil + + expect do + message.save! + end.to have_enqueued_job(Conversations::ActivityMessageJob).with( + conversation, + { + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + message_type: :activity, + content: auto_open_activity_content + } + ) + end + + it 'does not mark the conversation open for private outgoing messages' do + create(:message, message_type: :outgoing, conversation: conversation, private: true) + + expect(conversation.reload.pending?).to be true + end + + it 'does not mark the conversation open for bot outgoing messages' do + agent_bot = create(:agent_bot, account: conversation.account) + create(:message, message_type: :outgoing, conversation: conversation, sender: agent_bot) + + expect(conversation.reload.pending?).to be true + end + end end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index d606c266d..64a488dcb 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -271,6 +271,15 @@ RSpec.describe Message do end end + describe '#mark_pending_conversation_as_open_for_human_response' do + let(:conversation) { create(:conversation, status: :pending) } + + it 'does not mark the conversation open when pending is used without captain' do + create(:message, message_type: :outgoing, conversation: conversation) + expect(conversation.reload.pending?).to be true + end + end + describe '#waiting since' do let(:conversation) { create(:conversation) } let(:agent) { create(:user, account: conversation.account) }