diff --git a/app/models/account.rb b/app/models/account.rb index 4816494fb..eabaa5c26 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,6 +41,7 @@ class Account < ApplicationRecord 'audio_transcriptions': { 'type': %w[boolean null] }, 'auto_resolve_label': { 'type': %w[string null] }, 'keep_pending_on_bot_failure': { 'type': %w[boolean null] }, + 'captain_disable_auto_resolve': { 'type': %w[boolean null] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -90,6 +91,7 @@ class Account < ApplicationRecord store_accessor :settings, :audio_transcriptions, :auto_resolve_label store_accessor :settings, :captain_models, :captain_features store_accessor :settings, :keep_pending_on_bot_failure + store_accessor :settings, :captain_disable_auto_resolve has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/app/models/conversation.rb b/app/models/conversation.rb index ca53238e8..6dd0e9df5 100644 --- a/app/models/conversation.rb +++ b/app/models/conversation.rb @@ -159,6 +159,7 @@ class Conversation < ApplicationRecord end def bot_handoff! + update(waiting_since: Time.current) if waiting_since.blank? open! dispatcher_dispatch(CONVERSATION_BOT_HANDOFF) end diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 698ec56e7..c4723f6b9 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -42,10 +42,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end def process_response - ActiveRecord::Base.transaction do - if handoff_requested? - process_action('handoff') - else + if handoff_requested? + process_action('handoff') + else + ActiveRecord::Base.transaction do create_messages Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}") account.increment_response_usage 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 d3f1f5d96..ab9ca2ab1 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -2,6 +2,8 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob 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) 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 599dee96a..8b6527c93 100644 --- a/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb +++ b/enterprise/app/jobs/enterprise/account/conversations_resolution_scheduler_job.rb @@ -12,6 +12,7 @@ module Enterprise::Account::ConversationsResolutionSchedulerJob inbox = captain_inbox.inbox next if inbox.email? + next if inbox.account.captain_disable_auto_resolve Captain::InboxPendingConversationsResolutionJob.perform_later( inbox diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb index 4aa156f47..0e574cb03 100644 --- a/enterprise/app/services/messages/audio_transcription_service.rb +++ b/enterprise/app/services/messages/audio_transcription_service.rb @@ -19,6 +19,9 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService transcriptions = transcribe_audio Rails.logger.info "Audio transcription successful: #{transcriptions}" { success: true, transcriptions: transcriptions } + rescue Faraday::UnauthorizedError + Rails.logger.warn('Skipping audio transcription: OpenAI configuration is invalid or disabled (401 Unauthorized).') + { error: 'OpenAI configuration is invalid or disabled (401)' } end private diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb index 0d2563a8b..5d96d3af1 100644 --- a/enterprise/lib/captain/tools/resolve_conversation_tool.rb +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -6,6 +6,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool conversation = find_conversation(tool_context.state) return 'Conversation not found' unless conversation return "Conversation ##{conversation.display_id} is already resolved" if conversation.resolved? + return 'Auto-resolve is disabled for this account' if conversation.account.captain_disable_auto_resolve log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason }) 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 777086613..4e48eb355 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -92,6 +92,44 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do end end + # Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the + # same transaction defers the message's after_create_commit until commit, at which + # point it clears waiting_since (bot_response). The handoff path must stay outside + # the transaction so the callback fires before bot_handoff! sets waiting_since. + context 'when handoff is requested' do + let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) } + let(:agent) { create(:user, account: account, role: :agent) } + + before do + allow(account).to receive(:feature_enabled?).and_return(false) + allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false) + allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' }) + end + + it 'sets waiting_since to approximately the handoff time' do + freeze_time do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.status).to eq('open') + expect(conversation.waiting_since).to be_within(1.second).of(Time.current) + end + end + + it 'preserves waiting_since so a human reply consumes it for reply_time tracking' do + described_class.perform_now(conversation, assistant) + + conversation.reload + expect(conversation.waiting_since).to be_present + + # A human reply clears waiting_since (consumed by dispatch_create_events + # to emit FIRST_REPLY_CREATED or REPLY_CREATED for reply_time tracking). + create(:message, conversation: conversation, message_type: :outgoing, + sender: agent, account: account, inbox: inbox) + expect(conversation.reload.waiting_since).to be_nil + end + end + context 'when message contains an image' do let(:message_with_image) { create(:message, conversation: conversation, message_type: :incoming, content: 'Can you help with this error?') } let(:image_attachment) { message_with_image.attachments.create!(account: account, file_type: :image, external_url: 'https://example.com/error.jpg') } 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 1a8a5a342..ab8f0296c 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 @@ -64,4 +64,15 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do } ) end + + it 'does not resolve conversations when auto-resolve is disabled at execution time' do + inbox.account.update!(captain_disable_auto_resolve: true) + + expect do + described_class.perform_now(inbox) + end.not_to(change { resolvable_pending_conversation.reload.status }) + + expect(resolvable_pending_conversation.reload.status).to eq('pending') + expect(resolvable_pending_conversation.messages.outgoing).to be_empty + end end 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 b67877412..343100a50 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 @@ -30,6 +30,22 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do end end + context 'when account has captain_disable_auto_resolve enabled' do + let!(:regular_inbox) { create(:inbox, account: account) } + + before do + create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox) + account.update!(captain_disable_auto_resolve: true) + end + + it 'does not enqueue resolution jobs' do + expect do + described_class.perform_now + end.not_to have_enqueued_job(Captain::InboxPendingConversationsResolutionJob) + .with(regular_inbox) + end + end + context 'when inbox has no captain enabled' do let!(:inbox_without_captain) { create(:inbox, account: create(:account)) } 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 f91f430e8..d5792cf78 100644 --- a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -36,6 +36,17 @@ RSpec.describe Captain::Tools::ResolveConversationTool do end end + describe 'when auto-resolve is disabled for the account' do + before { account.update!(captain_disable_auto_resolve: true) } + + it 'does not resolve and returns a disabled message' do + result = tool.perform(tool_context, reason: 'Possible spam') + + expect(result).to eq('Auto-resolve is disabled for this account') + expect(conversation.reload).not_to be_resolved + end + end + describe 'resolving an already resolved conversation' do let(:conversation) { create(:conversation, account: account, inbox: inbox, status: :resolved) } diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index e1883b54d..89c090207 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -313,6 +313,47 @@ RSpec.describe Conversation do end end + describe '#bot_handoff!' do + let(:conversation) { create(:conversation, status: :pending) } + + before do + allow(Rails.configuration.dispatcher).to receive(:dispatch) + end + + context 'when waiting_since is blank' do + before { conversation.update(waiting_since: nil) } + + it 'sets waiting_since to current time' do + freeze_time do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to eq(Time.current) + end + end + end + + context 'when waiting_since is already set' do + let(:original_time) { 1.hour.ago } + + before { conversation.update(waiting_since: original_time) } + + it 'preserves existing waiting_since' do + conversation.bot_handoff! + expect(conversation.reload.waiting_since).to be_within(1.second).of(original_time) + end + end + + it 'changes status to open' do + conversation.bot_handoff! + expect(conversation.reload.status).to eq('open') + end + + it 'dispatches CONVERSATION_BOT_HANDOFF event' do + expect(Rails.configuration.dispatcher).to receive(:dispatch) + .with(described_class::CONVERSATION_BOT_HANDOFF, anything, hash_including(conversation: conversation)) + conversation.bot_handoff! + end + end + describe '#toggle_priority' do it 'defaults priority to nil when created' do conversation = create(:conversation, status: 'open')