diff --git a/AGENTS.md b/AGENTS.md index 82acfcc25..e37f80553 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,7 @@ - Prefer the smallest production-ready change that solves the current problem. - Build for the expected production path first. Do not add speculative guards, fallbacks, retries, or edge-case handling unless the caller can actually hit that case or production has proven it necessary. +- Enforce eligibility and exclusivity rules at the earliest shared entry point. Do not repeat backup guards across downstream jobs, callbacks, services, or writes unless a proven independent path bypasses that point. - When an impossible or misconfigured state would indicate a setup/deployment bug, let it fail loudly instead of silently skipping behavior. - For locked/internal configs that must exist in production, prefer direct reads (`find`, `find_by!`, required hash keys) over silent fallbacks. - Do not add validation or response checks unless the code uses the result or the check changes behavior meaningfully. diff --git a/app/models/concerns/inbox_bot_status.rb b/app/models/concerns/inbox_bot_status.rb new file mode 100644 index 000000000..ba130bfcb --- /dev/null +++ b/app/models/concerns/inbox_bot_status.rb @@ -0,0 +1,17 @@ +module InboxBotStatus + extend ActiveSupport::Concern + + def active_bot? + external_bot_active? + end + + def external_bot_active? + agent_bot_inbox&.active? || dialogflow_active? + end + + private + + def dialogflow_active? + hooks.exists?(app_id: %w[dialogflow], status: 'enabled') + end +end diff --git a/app/models/inbox.rb b/app/models/inbox.rb index 69f41180a..cb9870538 100644 --- a/app/models/inbox.rb +++ b/app/models/inbox.rb @@ -46,6 +46,7 @@ class Inbox < ApplicationRecord include AccountCacheRevalidator include InboxAgentAvailability include InboxBrandedEmailLayoutable + include InboxBotStatus # Not allowing characters: validates :name, presence: true @@ -174,11 +175,6 @@ class Inbox < ApplicationRecord (account.users.where(id: members.select(:user_id)) + account.administrators).uniq end - def active_bot? - agent_bot_inbox&.active? || hooks.where(app_id: %w[dialogflow], - status: 'enabled').count.positive? - end - def inbox_type channel.name end diff --git a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb index 9e578cf4c..0f4053166 100644 --- a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb +++ b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb @@ -12,7 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob inbox = captain_inbox.inbox assistant = captain_inbox.captain_assistant - next if inbox.email? + next if inbox.email? || inbox.external_bot_active? next if assistant.blank? || assistant.inactive_conversation_resolution_disabled? Captain::InboxPendingConversationsResolutionJob.perform_later( diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index b2491607e..b76dca06f 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -82,7 +82,7 @@ module Enterprise::MessageTemplates::HookExecutionService end def should_process_captain_response? - conversation.pending? && message.captain_response_triggering? && inbox.captain_assistant.present? + conversation.pending? && message.captain_response_triggering? && inbox.captain_assistant.present? && !inbox.external_bot_active? end def perform_handoff diff --git a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb index 3b529bb44..1eacd7bbf 100644 --- a/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb +++ b/spec/enterprise/jobs/enterprise/account/conversations_resolution_scheduler_job_spec.rb @@ -46,6 +46,17 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do end end + it 'does not enqueue resolution jobs for inboxes with an external bot' do + regular_inbox = create(:inbox, account: account) + create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox) + create(:agent_bot_inbox, inbox: regular_inbox, agent_bot: create(:agent_bot, account: account)) + + expect do + described_class.perform_now + end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob) + .with(regular_inbox) + end + context 'when an assistant has been deleted before its inbox link is cleaned up' do let!(:orphaned_inbox) { create(:inbox, account: account) } let!(:regular_inbox) { create(:inbox, account: account) } diff --git a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb index b545cd91e..bacb403ec 100644 --- a/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb +++ b/spec/enterprise/services/enterprise/message_templates/hook_execution_service_spec.rb @@ -202,6 +202,16 @@ RSpec.describe MessageTemplates::HookExecutionService do end end + it 'does not schedule Captain for inbox bot integrations' do + expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later) + agent_bot_inbox = create(:agent_bot_inbox, inbox: inbox, agent_bot: create(:agent_bot, account: account)) + create(:message, conversation: conversation, message_type: :incoming, account: account) + + agent_bot_inbox.destroy! + create(:integrations_hook, :dialogflow, inbox: inbox, account: account) + create(:message, conversation: conversation, message_type: :incoming, account: account) + end + context 'when conversation is not pending' do before do conversation.update!(status: :open)