From 412b72db7c629d1eb3ca431314ad34d9b0552091 Mon Sep 17 00:00:00 2001 From: Alexander Udovichenko Date: Fri, 13 Mar 2026 12:30:17 +0300 Subject: [PATCH 01/16] fix: Delete double hmac check (#12464) ## Description When hmac identity check is enabled according to [this](https://www.chatwoot.com/hc/user-guide/articles/1677587479-how-to-enable-identity-validation-in-chatwoot) I found out, that it checked twice. If `should_verify_hmac? -> true` then hmac checked in `before_action` and we don't need to do it again later. This perfomance related and PR fixes this. --- app/controllers/api/v1/widget/contacts_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/api/v1/widget/contacts_controller.rb b/app/controllers/api/v1/widget/contacts_controller.rb index 5138fe675..6c595ab59 100644 --- a/app/controllers/api/v1/widget/contacts_controller.rb +++ b/app/controllers/api/v1/widget/contacts_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController contact = @contact end - @contact_inbox.update(hmac_verified: true) if should_verify_hmac? && valid_hmac? + @contact_inbox.update(hmac_verified: true) if should_verify_hmac? identify_contact(contact) end From b8543c09fbb2e7ce2a3ffb34f4c66a963365443d Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Fri, 13 Mar 2026 15:17:46 +0530 Subject: [PATCH 02/16] refactor: unify backfill service to per-dimension query pattern Replace the two-path aggregation (group-then-fan-out for standard events, per-dimension for distinct-count) with a single per-dimension loop for both. This removes the bug-prone fan-out logic and the conceptual split. Strengthen specs to cover all three dimensions (account, agent, inbox), assert total rollup row counts, and add distinct-count deduplication test. --- .../reporting_events/backfill_service.rb | 125 ++++++++---------- .../reporting_events/backfill_service_spec.rb | 73 ++++++++-- 2 files changed, 122 insertions(+), 76 deletions(-) diff --git a/app/services/reporting_events/backfill_service.rb b/app/services/reporting_events/backfill_service.rb index 8ec1c242e..0deef624d 100644 --- a/app/services/reporting_events/backfill_service.rb +++ b/app/services/reporting_events/backfill_service.rb @@ -1,29 +1,20 @@ # frozen_string_literal: true class ReportingEvents::BackfillService - AGGREGATE_SELECTS = [ - :name, - :user_id, - :inbox_id, - Arel.sql('COUNT(*)'), - Arel.sql('COALESCE(SUM(value), 0)'), - Arel.sql('COALESCE(SUM(value_in_business_hours), 0)') - ].freeze - - DISTINCT_AGGREGATE_SELECTS = [ - :name, - :user_id, - :inbox_id, - Arel.sql('COUNT(DISTINCT conversation_id)'), - Arel.sql('COALESCE(SUM(value), 0)'), - Arel.sql('COALESCE(SUM(value_in_business_hours), 0)') + DIMENSIONS = [ + { type: 'account', group_column: nil }, + { type: 'agent', group_column: :user_id }, + { type: 'inbox', group_column: :inbox_id } ].freeze # TODO: Move this to EventMetricRegistry when we expand distinct-counting support. # The live path already guards uniqueness in ReportingEventListener#conversation_bot_handoff, # but historical duplicates can exist since it's not enforced at the DB level. + # These events are queried per-dimension (not group-then-sum) because COUNT(DISTINCT) is not additive. DISTINCT_COUNT_EVENTS = %w[conversation_bot_handoff].freeze + DISTINCT_COUNT_SQL = Arel.sql('COUNT(DISTINCT conversation_id)') + def self.backfill_date(account, date) new(account, date).perform end @@ -74,72 +65,70 @@ class ReportingEvents::BackfillService def build_aggregates(start_utc, end_utc) aggregates = Hash.new { |h, k| h[k] = { count: 0, sum_value: 0.0, sum_value_business_hours: 0.0 } } + standard_names = ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS + base = @account.reporting_events.where(created_at: start_utc...end_utc) - grouped_events(start_utc, end_utc).each { |grouped_event| accumulate_grouped_aggregates(aggregates, grouped_event) } + DIMENSIONS.each do |dimension| + aggregate_standard_events(aggregates, base.where(name: standard_names), dimension) + aggregate_distinct_events(aggregates, base.where(name: DISTINCT_COUNT_EVENTS), dimension) + end aggregates end - def grouped_events(start_utc, end_utc) - standard = fetch_grouped_events(start_utc, end_utc, standard_event_names, AGGREGATE_SELECTS) - distinct = fetch_grouped_events(start_utc, end_utc, DISTINCT_COUNT_EVENTS, DISTINCT_AGGREGATE_SELECTS) + def aggregate_standard_events(aggregates, scope, dimension) + group_cols, selects = dimension_groups_and_selects(dimension) - (standard + distinct).map { |grouped_row| grouped_event_attributes(grouped_row) } - end + scope.group(*group_cols).pluck(*selects).each do |row| + event_name, dimension_id, count, sum_value, sum_value_business_hours = unpack_row(row, dimension) + next if dimension_id.nil? - def standard_event_names - ReportingEvents::EventMetricRegistry.event_names - DISTINCT_COUNT_EVENTS - end - - def fetch_grouped_events(start_utc, end_utc, event_names, selects) - return [] if event_names.empty? - - @account.reporting_events - .where(name: event_names, created_at: start_utc...end_utc) - .group(:name, :user_id, :inbox_id) - .pluck(*selects) - end - - def dimensions(grouped_event) - { - 'account' => @account.id, - 'agent' => grouped_event[:user_id], - 'inbox' => grouped_event[:inbox_id] - } - end - - def accumulate_grouped_aggregates(aggregates, grouped_event) - ReportingEvents::EventMetricRegistry.metrics_for_aggregate( - grouped_event[:event_name], - count: grouped_event[:count], - sum_value: grouped_event[:sum_value], - sum_value_business_hours: grouped_event[:sum_value_business_hours] - ).each do |metric, metric_data| - accumulate_metric_aggregates(aggregates, dimensions(grouped_event), metric, metric_data) + ReportingEvents::EventMetricRegistry.metrics_for_aggregate( + event_name, count: count, sum_value: sum_value, sum_value_business_hours: sum_value_business_hours + ).each do |metric, metric_data| + key = [dimension[:type], dimension_id, metric] + aggregates[key][:count] += metric_data[:count] + aggregates[key][:sum_value] += metric_data[:sum_value].to_f + aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f + end end end - def grouped_event_attributes(grouped_row) - event_name, user_id, inbox_id, count, sum_value, sum_value_business_hours = grouped_row + def aggregate_distinct_events(aggregates, scope, dimension) + return if DISTINCT_COUNT_EVENTS.empty? - { - event_name: event_name, - user_id: user_id, - inbox_id: inbox_id, - count: count, - sum_value: sum_value, - sum_value_business_hours: sum_value_business_hours - } - end + group_cols = dimension[:group_column] ? [:name, dimension[:group_column]] : [:name] - def accumulate_metric_aggregates(aggregates, dimensions, metric, metric_data) - dimensions.each do |dimension_type, dimension_id| + scope.group(*group_cols).pluck(*group_cols, DISTINCT_COUNT_SQL).each do |row| + event_name, dimension_id, count = dimension[:group_column] ? row : [row[0], @account.id, row[1]] next if dimension_id.nil? - key = [dimension_type, dimension_id, metric] - aggregates[key][:count] += metric_data[:count] - aggregates[key][:sum_value] += metric_data[:sum_value].to_f - aggregates[key][:sum_value_business_hours] += metric_data[:sum_value_business_hours].to_f + ReportingEvents::EventMetricRegistry.metrics_for_aggregate( + event_name, count: count, sum_value: 0, sum_value_business_hours: 0 + ).each do |metric, metric_data| + key = [dimension[:type], dimension_id, metric] + aggregates[key][:count] += metric_data[:count] + end + end + end + + def dimension_groups_and_selects(dimension) + agg_selects = [Arel.sql('COUNT(*)'), Arel.sql('COALESCE(SUM(value), 0)'), Arel.sql('COALESCE(SUM(value_in_business_hours), 0)')] + + if dimension[:group_column] + [[:name, dimension[:group_column]], [:name, dimension[:group_column], *agg_selects]] + else + [[:name], [:name, *agg_selects]] + end + end + + def unpack_row(row, dimension) + if dimension[:group_column] + # [name, dimension_id, count, sum_value, sum_value_business_hours] + row + else + # [name, count, sum_value, sum_value_business_hours] → inject account id + [row[0], @account.id, row[1], row[2], row[3]] end end diff --git a/spec/services/reporting_events/backfill_service_spec.rb b/spec/services/reporting_events/backfill_service_spec.rb index c74b32860..8f0572d3b 100644 --- a/spec/services/reporting_events/backfill_service_spec.rb +++ b/spec/services/reporting_events/backfill_service_spec.rb @@ -62,15 +62,72 @@ describe ReportingEvents::BackfillService do expect(reporting_event_instantiations).to eq(0) - first_response_rollup = find_rollup('agent', user.id, 'first_response') - expect(first_response_rollup.count).to eq(2) - expect(first_response_rollup.sum_value).to eq(140) - expect(first_response_rollup.sum_value_business_hours).to eq(80) + rollups = ReportingEventsRollup.where(account_id: account.id, date: date) + # 3 dimensions × first_response + 3 dimensions × resolutions_count + 3 dimensions × resolution_time + expect(rollups.count).to eq(9) - resolution_time_rollup = find_rollup('agent', second_user.id, 'resolution_time') - expect(resolution_time_rollup.count).to eq(1) - expect(resolution_time_rollup.sum_value).to eq(200) - expect(resolution_time_rollup.sum_value_business_hours).to eq(80) + # account dimension + account_first_response = find_rollup('account', account.id, 'first_response') + expect(account_first_response.count).to eq(2) + expect(account_first_response.sum_value).to eq(140) + expect(account_first_response.sum_value_business_hours).to eq(80) + + # agent dimension + agent_first_response = find_rollup('agent', user.id, 'first_response') + expect(agent_first_response.count).to eq(2) + expect(agent_first_response.sum_value).to eq(140) + expect(agent_first_response.sum_value_business_hours).to eq(80) + + agent_resolution_time = find_rollup('agent', second_user.id, 'resolution_time') + expect(agent_resolution_time.count).to eq(1) + expect(agent_resolution_time.sum_value).to eq(200) + expect(agent_resolution_time.sum_value_business_hours).to eq(80) + + # inbox dimension + inbox_first_response = find_rollup('inbox', inbox.id, 'first_response') + expect(inbox_first_response.count).to eq(2) + expect(inbox_first_response.sum_value).to eq(140) + expect(inbox_first_response.sum_value_business_hours).to eq(80) + + inbox_resolution_time = find_rollup('inbox', second_inbox.id, 'resolution_time') + expect(inbox_resolution_time.count).to eq(1) + expect(inbox_resolution_time.sum_value).to eq(200) + expect(inbox_resolution_time.sum_value_business_hours).to eq(80) + end + + it 'deduplicates distinct-count events per dimension' do + second_user = create(:user, account: account) + second_inbox = create(:inbox, account: account) + conversation_b = create(:conversation, account: account, inbox: inbox, assignee: user) + conversation_c = create(:conversation, account: account, inbox: second_inbox, assignee: second_user) + + # Two events for the same conversation — should count as 1 + create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user, + inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 14)) + create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user, + inbox: inbox, conversation: conversation, created_at: Time.utc(2026, 2, 11, 15)) + # Different conversation, same agent/inbox + create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: user, + inbox: inbox, conversation: conversation_b, created_at: Time.utc(2026, 2, 11, 16)) + # Different agent/inbox + create_backfill_event(name: 'conversation_bot_handoff', value: 0, value_in_business_hours: 0, user: second_user, + inbox: second_inbox, conversation: conversation_c, created_at: Time.utc(2026, 2, 11, 17)) + + described_class.backfill_date(account, date) + + rollups = ReportingEventsRollup.where(account_id: account.id, date: date) + expect(rollups.count).to eq(5) + + # account: 3 distinct conversations + expect(find_rollup('account', account.id, 'bot_handoffs_count').count).to eq(3) + + # agent: user has 2 distinct, second_user has 1 + expect(find_rollup('agent', user.id, 'bot_handoffs_count').count).to eq(2) + expect(find_rollup('agent', second_user.id, 'bot_handoffs_count').count).to eq(1) + + # inbox: inbox has 2 distinct, second_inbox has 1 + expect(find_rollup('inbox', inbox.id, 'bot_handoffs_count').count).to eq(2) + expect(find_rollup('inbox', second_inbox.id, 'bot_handoffs_count').count).to eq(1) end def create_backfill_event(**attributes) From a90ffe6264bd2bc614a8a8949ffb05ae8596fe00 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:34:58 +0530 Subject: [PATCH 03/16] feat: Add force legacy auto-resolve flag (#13804) # Pull Request Template ## Description Add account setting and store_accessor for `captain_force_legacy_auto_resolve`. Enterprise job now skips LLM evaluation when this flag is true and falls back to legacy time-based resolution. Add spec to cover the fallback. ## Type of change We recently rolled out Captain deciding if a conversation is resolved or not. While it is an improvement for majority of customers, some still prefer the old way of auto-resolving based on inactivity. This PR adds a check. ## 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. legacy_auto_resolve = true CleanShot 2026-03-13 at 19 55 55@2x legacy_auto_resolve = false CleanShot 2026-03-13 at 20 00 50@2x ## 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 --- app/models/account.rb | 5 ++- .../concerns/account_captain_auto_resolve.rb | 21 ++++++++++ ...ox_pending_conversations_resolution_job.rb | 8 +++- .../conversations_resolution_scheduler_job.rb | 2 +- .../tools/resolve_conversation_tool.rb | 2 +- ...nding_conversations_resolution_job_spec.rb | 22 ++++++++++- ...ersations_resolution_scheduler_job_spec.rb | 20 +++++++++- .../tools/resolve_conversation_tool_spec.rb | 13 ++++++- spec/models/account_spec.rb | 38 +++++++++++++++++++ 9 files changed, 121 insertions(+), 10 deletions(-) create mode 100644 app/models/concerns/account_captain_auto_resolve.rb diff --git a/app/models/account.rb b/app/models/account.rb index eabaa5c26..087226710 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -41,7 +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] }, + 'captain_auto_resolve_mode': { 'type': %w[string null], 'enum': ['evaluated', 'legacy', 'disabled', nil] }, 'conversation_required_attributes': { 'type': %w[array null], 'items': { 'type': 'string' } @@ -91,7 +91,8 @@ 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 + store_accessor :settings, :captain_auto_resolve_mode + include AccountCaptainAutoResolve has_many :account_users, dependent: :destroy_async has_many :agent_bot_inboxes, dependent: :destroy_async diff --git a/app/models/concerns/account_captain_auto_resolve.rb b/app/models/concerns/account_captain_auto_resolve.rb new file mode 100644 index 000000000..5d17e92f9 --- /dev/null +++ b/app/models/concerns/account_captain_auto_resolve.rb @@ -0,0 +1,21 @@ +module AccountCaptainAutoResolve + extend ActiveSupport::Concern + + VALID_CAPTAIN_AUTO_RESOLVE_MODES = %w[evaluated legacy disabled].freeze + + included do + VALID_CAPTAIN_AUTO_RESOLVE_MODES.each do |mode| + define_method("captain_auto_resolve_#{mode}?") do + captain_auto_resolve_mode == mode + end + end + end + + def captain_auto_resolve_mode + mode = settings&.[]('captain_auto_resolve_mode') + return mode if VALID_CAPTAIN_AUTO_RESOLVE_MODES.include?(mode) + return 'disabled' if settings&.[]('captain_disable_auto_resolve') == true + + feature_enabled?('captain_tasks') ? 'evaluated' : 'legacy' + end +end 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 538c03d22..0be179f04 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -5,9 +5,9 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob queue_as :low def perform(inbox) - return if inbox.account.captain_disable_auto_resolve + return if inbox.account.captain_auto_resolve_disabled? - if inbox.account.feature_enabled?('captain_tasks') + if evaluate_conversation_completion?(inbox.account) perform_with_evaluation(inbox) else perform_time_based(inbox) @@ -18,6 +18,10 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob private + def evaluate_conversation_completion?(account) + account.feature_enabled?('captain_tasks') && account.captain_auto_resolve_evaluated? + end + def perform_time_based(inbox) Current.executed_by = inbox.captain_assistant 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 8b6527c93..7ccebf72f 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 next if inbox.email? - next if inbox.account.captain_disable_auto_resolve + next if inbox.account.captain_auto_resolve_disabled? Captain::InboxPendingConversationsResolutionJob.perform_later( inbox diff --git a/enterprise/lib/captain/tools/resolve_conversation_tool.rb b/enterprise/lib/captain/tools/resolve_conversation_tool.rb index 2b71098c9..eeffc3f06 100644 --- a/enterprise/lib/captain/tools/resolve_conversation_tool.rb +++ b/enterprise/lib/captain/tools/resolve_conversation_tool.rb @@ -6,7 +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 + return 'Auto-resolve is disabled for this account' if conversation.account.captain_auto_resolve_disabled? log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason }) 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 63b8c1bca..f432aae62 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 @@ -84,6 +84,16 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do expect(resolvable_pending_conversation.reload.status).to eq('pending') expect(resolvable_pending_conversation.messages.outgoing).to be_empty end + + it 'falls back to legacy time-based resolve when legacy auto-resolve is forced' do + inbox.account.update!(captain_auto_resolve_mode: 'legacy') + allow(Captain::ConversationCompletionService).to receive(:new) + + described_class.perform_now(inbox) + + expect(Captain::ConversationCompletionService).not_to have_received(:new) + expect(resolvable_pending_conversation.reload.status).to eq('resolved') + end end context 'when LLM evaluation returns complete' do @@ -322,7 +332,7 @@ 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) + inbox.account.update!(captain_auto_resolve_mode: 'disabled') expect do described_class.perform_now(inbox) @@ -331,4 +341,14 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do expect(resolvable_pending_conversation.reload.status).to eq('pending') expect(resolvable_pending_conversation.messages.outgoing).to be_empty end + + it 'falls back to disabled mode from legacy settings key' do + inbox.account.update!(settings: inbox.account.settings.merge('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') + 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 343100a50..1988d346c 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,12 +30,28 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do end end - context 'when account has captain_disable_auto_resolve enabled' do + context 'when account has captain auto resolve disabled' 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) + account.update!(captain_auto_resolve_mode: 'disabled') + 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 account uses legacy disabled settings key' do + let!(:regular_inbox) { create(:inbox, account: account) } + + before do + create(:captain_inbox, captain_assistant: assistant, inbox: regular_inbox) + account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true)) end it 'does not enqueue resolution jobs' do 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 f676b2974..2eae2905a 100644 --- a/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/resolve_conversation_tool_spec.rb @@ -41,7 +41,18 @@ RSpec.describe Captain::Tools::ResolveConversationTool do end describe 'when auto-resolve is disabled for the account' do - before { account.update!(captain_disable_auto_resolve: true) } + before { account.update!(captain_auto_resolve_mode: 'disabled') } + + 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 'when auto-resolve is disabled via legacy settings key' do + before { account.update!(settings: account.settings.merge('captain_disable_auto_resolve' => true)) } it 'does not resolve and returns a disabled message' do result = tool.perform(tool_context, reason: 'Possible spam') diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index 5ccff6517..166b4c34b 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -198,6 +198,44 @@ RSpec.describe Account do expect(account.settings['auto_resolve_message']).to eq(message) end + it 'defaults captain_auto_resolve_mode to legacy when captain_tasks is disabled' do + allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false) + + expect(account.captain_auto_resolve_mode).to eq('legacy') + expect(account).to be_captain_auto_resolve_legacy + end + + it 'defaults captain_auto_resolve_mode to evaluated when captain_tasks is enabled' do + allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true) + + expect(account.captain_auto_resolve_mode).to eq('evaluated') + expect(account).to be_captain_auto_resolve_evaluated + end + + it 'correctly gets and sets captain_auto_resolve_mode' do + account.captain_auto_resolve_mode = 'legacy' + + expect(account.captain_auto_resolve_mode).to eq('legacy') + expect(account.settings['captain_auto_resolve_mode']).to eq('legacy') + expect(account).to be_captain_auto_resolve_legacy + end + + it 'allows clearing captain_auto_resolve_mode to fall back to feature defaults' do + allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false) + account.captain_auto_resolve_mode = nil + + expect(account).to be_valid + expect(account.captain_auto_resolve_mode).to eq('legacy') + expect(account.settings['captain_auto_resolve_mode']).to be_nil + end + + it 'falls back to disabled mode from legacy settings key' do + account.settings = { 'captain_disable_auto_resolve' => true } + + expect(account.captain_auto_resolve_mode).to eq('disabled') + expect(account).to be_captain_auto_resolve_disabled + end + it 'handles nil values correctly' do account.auto_resolve_after = nil account.auto_resolve_message = nil From 73a90f284119330511ed87f38e63c12b42d85985 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 16 Mar 2026 11:04:27 +0530 Subject: [PATCH 04/16] feat: update bunny video support in HC (#13815) Bunny Video has added a new URL player.mediadelivery.net, this PR adds support for the new URL --- .../EmptyState/Portal/PortalEmptyState.vue | 1 + config/markdown_embeds.yml | 4 ++-- spec/config/markdown_embeds_spec.rb | 6 +++++- spec/lib/custom_markdown_renderer_spec.rb | 16 ++++++++++++++-- 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue index b7a986254..192e1d5ee 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/EmptyState/Portal/PortalEmptyState.vue @@ -26,6 +26,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {