diff --git a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue index c2ada05e3..17833ab0d 100644 --- a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue +++ b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue @@ -27,8 +27,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
-
-
+
+
{{ header }} @@ -39,7 +39,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
- + {{ description }} diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.spec.js b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.spec.js new file mode 100644 index 000000000..f4c04652e --- /dev/null +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.spec.js @@ -0,0 +1,109 @@ +import { nextTick } from 'vue'; +import { flushPromises, shallowMount } from '@vue/test-utils'; +import { describe, expect, it, vi } from 'vitest'; +import Button from 'dashboard/components-next/button/Button.vue'; +import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; +import Switch from 'dashboard/components-next/switch/Switch.vue'; +import AssistantSystemSettingsForm from './AssistantSystemSettingsForm.vue'; +import DurationSelect from './DurationSelect.vue'; + +vi.mock('vue-i18n', () => ({ + useI18n: () => ({ t: key => key }), +})); + +vi.mock('dashboard/composables/useAccount', () => ({ + useAccount: () => ({ isCloudFeatureEnabled: () => true }), +})); + +const assistant = { + config: { + product_name: 'Chatwoot', + handoff_message: 'I will connect you with the team.', + resolution_message: 'I will close this conversation for now.', + auto_resolve_mode: 'evaluated', + auto_resolve_after: 75, + send_inactivity_resolution_message: true, + }, +}; + +const mountComponent = () => + shallowMount(AssistantSystemSettingsForm, { + props: { assistant }, + global: { stubs: { Banner: false, SettingsToggleSection: false } }, + }); + +const submitForm = async wrapper => { + wrapper.findComponent(Button).vm.$emit('click'); + await flushPromises(); +}; + +describe('AssistantSystemSettingsForm', () => { + it('shows the evaluated policy controls from the saved config', () => { + const wrapper = mountComponent(); + const modeCards = wrapper.findAllComponents(RadioCard); + + expect(modeCards).toHaveLength(3); + expect( + modeCards.every(card => card.props('name') === 'auto-resolve-mode') + ).toBe(true); + expect(modeCards[0].props('isActive')).toBe(true); + expect(wrapper.findAllComponents(DurationSelect)).toHaveLength(1); + expect(wrapper.findAllComponents(Switch)).toHaveLength(1); + expect(wrapper.text()).toContain( + 'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.REVIEW_AFTER' + ); + }); + + it('hides inactive actions and saves disabled mode without clearing settings', async () => { + const wrapper = mountComponent(); + + wrapper.findAllComponents(RadioCard)[2].vm.$emit('select'); + await nextTick(); + + expect(wrapper.findAllComponents(DurationSelect)).toHaveLength(0); + expect(wrapper.findAllComponents(Switch)).toHaveLength(0); + expect(wrapper.text()).toContain( + 'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.PENDING_INFO' + ); + + await submitForm(wrapper); + + expect(wrapper.emitted('submit')[0][0]).toEqual({ + config: { + ...assistant.config, + auto_resolve_mode: 'disabled', + }, + }); + }); + + it('saves the evaluated policy timer', async () => { + const wrapper = mountComponent(); + + const durationSelects = wrapper.findAllComponents(DurationSelect); + expect(durationSelects).toHaveLength(1); + + durationSelects[0].vm.$emit('update:modelValue', 130); + await nextTick(); + await submitForm(wrapper); + + expect(wrapper.emitted('submit')[0][0]).toEqual({ + config: { + ...assistant.config, + auto_resolve_after: 130, + }, + }); + }); + + it('shows the warning in always resolve mode', async () => { + const wrapper = mountComponent(); + + wrapper.findAllComponents(RadioCard)[1].vm.$emit('select'); + await nextTick(); + + expect(wrapper.findAllComponents(DurationSelect)).toHaveLength(1); + expect(wrapper.findAllComponents(Switch)).toHaveLength(1); + expect(wrapper.text()).toContain( + 'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.ALWAYS_WARNING' + ); + }); +}); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue index 25919737e..f04398a93 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/settings/AssistantSystemSettingsForm.vue @@ -1,16 +1,18 @@ + + diff --git a/app/javascript/dashboard/components-next/radioCard/RadioCard.vue b/app/javascript/dashboard/components-next/radioCard/RadioCard.vue index 5f6528f90..0549771e5 100644 --- a/app/javascript/dashboard/components-next/radioCard/RadioCard.vue +++ b/app/javascript/dashboard/components-next/radioCard/RadioCard.vue @@ -7,6 +7,10 @@ const props = defineProps({ type: String, required: true, }, + name: { + type: String, + default: '', + }, label: { type: String, required: true, @@ -71,7 +75,7 @@ const handleChange = () => { :id="`${id}`" :checked="isActive" :value="id" - :name="id" + :name="name || id" :disabled="disabled" type="radio" class="shadow cursor-pointer grid place-items-center border-2 border-n-strong appearance-none rounded-full w-5 h-5 checked:bg-n-brand before:content-[''] before:bg-n-brand before:border-4 before:rounded-full before:border-n-strong checked:before:w-[18px] checked:before:h-[18px] checked:border checked:border-n-brand" diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 86e187a1f..25209c4bd 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -599,13 +599,33 @@ "PLACEHOLDER": "Enter resolution message" }, "INACTIVITY_RESOLUTION": { - "TITLE": "Captain behavior on inactive conversations", + "TITLE": "When customers stop replying", + "DESCRIPTION": "Choose how Captain handles the conversation.", + "MODE_LABEL": "Action when the customer stops replying", + "REVIEW_AFTER": "Review after", + "RESOLVE_AFTER": "Resolve after", + "ALWAYS_WARNING": "Captain will resolve every conversation after the selected time without reviewing it. Some conversations that still need help may be closed.", + "PENDING_INFO": "The conversation remains pending until the customer replies.", "RESOLUTION_MESSAGE": { - "TITLE": "Resolution message" + "TITLE": "Send a message when resolving", + "DESCRIPTION": "Captain sends this closing message before it resolves the conversation." }, - "DURATION_LABEL": "Inactivity period before Captain acts", - "DURATION_HOURS_ARIA_LABEL": "Inactivity period hours", - "DURATION_MINUTES_ARIA_LABEL": "Inactivity period minutes", + "MODES": { + "DISABLED": { + "LABEL": "Wait for the customer", + "DESCRIPTION": "Captain does not resolve the conversation. Captain can still hand it off if the customer asks." + }, + "LEGACY": { + "LABEL": "Always resolve", + "DESCRIPTION": "Captain resolves every conversation after the selected time." + }, + "EVALUATED": { + "LABEL": "Let Captain review it (recommended)", + "DESCRIPTION": "Captain decides whether to resolve the conversation or hand it off." + } + }, + "DURATION_HOURS_ARIA_LABEL": "Hours before action", + "DURATION_MINUTES_ARIA_LABEL": "Minutes before action", "DURATION_HOURS_SHORT": "{count} h", "DURATION_MINUTES_SHORT": "{count} min" }, @@ -678,7 +698,7 @@ "SYSTEM_SETTINGS": { "TITLE": "System settings", "DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human.", - "DESCRIPTION_V2": "Manage inactive conversations and Captain's handoff message." + "DESCRIPTION_V2": "Manage what Captain does when customers stop replying and set the handoff message." }, "AUDIENCE": { "TITLE": "Audience", 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 145077395..c9e38caca 100644 --- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb +++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb @@ -1,15 +1,15 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze - queue_as :low def perform(inbox) - captain_assistant = inbox.captain_assistant + @captain_assistant = inbox.captain_assistant return if captain_assistant.blank? || captain_assistant.inactive_conversation_resolution_disabled? - @inactivity_cutoff_time = Time.now.utc - captain_assistant.inactivity_threshold_minutes.minutes - if evaluate_conversation_completion?(captain_assistant, inbox.account) + @inactivity_cutoff_time = Time.current - captain_assistant.inactivity_threshold_minutes.minutes + + if evaluate_conversation_completion?(inbox.account) perform_with_evaluation(inbox) else perform_time_based(inbox) @@ -20,29 +20,22 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob private - attr_reader :inactivity_cutoff_time + attr_reader :captain_assistant, :inactivity_cutoff_time - def evaluate_conversation_completion?(assistant, account) - account.feature_enabled?('captain_tasks') && assistant.evaluate_inactive_conversations_before_resolving? + def evaluate_conversation_completion?(account) + account.feature_enabled?('captain_tasks') && captain_assistant.evaluate_inactive_conversations_before_resolving? end def perform_time_based(inbox) - Current.executed_by = inbox.captain_assistant + Current.executed_by = captain_assistant resolvable_pending_conversations(inbox).each do |conversation| - create_resolution_message(conversation, inbox) - conversation.resolved! - Captain::ConversationEvents.resolved( - conversation: conversation, - assistant: inbox.captain_assistant, - source: Captain::ConversationEvents::Sources::TIME_BASED, - at: Time.current - ) + resolve_time_based_conversation(conversation, inbox) end end def perform_with_evaluation(inbox) - Current.executed_by = inbox.captain_assistant + Current.executed_by = captain_assistant resolvable_pending_conversations(inbox).each do |conversation| evaluation = evaluate_conversation(conversation, inbox) @@ -51,7 +44,7 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob if evaluation[:complete] resolve_conversation(conversation, inbox, evaluation[:reason]) else - handoff_conversation(conversation, inbox, evaluation[:reason]) + handoff_conversation(conversation, evaluation[:reason]) end end end @@ -69,58 +62,105 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob .limit(Limits::BULK_ACTIONS_LIMIT) end + def inactive_for_initial_action?(conversation) = conversation.last_activity_at < inactivity_cutoff_time + def still_resolvable_after_evaluation?(conversation) conversation.reload - conversation.pending? && conversation.last_activity_at < inactivity_cutoff_time + conversation.pending? && inactive_for_initial_action?(conversation) rescue ActiveRecord::RecordNotFound false end - def resolve_conversation(conversation, inbox, reason) - create_private_note(conversation, inbox, "Auto-resolved: #{reason}") - create_resolution_message(conversation, inbox) - conversation.with_captain_activity_context( - reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON, - reason_type: :inference - ) { conversation.resolved! } + def resolve_time_based_conversation(conversation, inbox) + resolved = false + conversation.with_lock do + conversation.reload + next unless conversation.pending? && inactive_for_initial_action?(conversation) + + create_resolution_message(conversation, inbox) + conversation.resolved! + resolved = true + end + return unless resolved + Captain::ConversationEvents.resolved( conversation: conversation, - assistant: inbox.captain_assistant, + assistant: captain_assistant, + source: Captain::ConversationEvents::Sources::TIME_BASED, + at: Time.current + ) + rescue ActiveRecord::RecordNotFound + nil + end + + def resolve_conversation(conversation, inbox, reason) + resolved = with_inference_activity_context(conversation, CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON) do + perform_locked_transition(conversation) do + conversation.resolved! + create_private_note(conversation, "Auto-resolved: #{reason}") + create_resolution_message(conversation, inbox) + end + end + record_inference_resolution(conversation) if resolved + rescue ActiveRecord::RecordNotFound + nil + end + + def record_inference_resolution(conversation) + Captain::ConversationEvents.resolved( + conversation: conversation, + assistant: captain_assistant, source: Captain::ConversationEvents::Sources::INFERENCE, at: Time.current ) end - def handoff_conversation(conversation, inbox, reason) - create_private_note(conversation, inbox, "Auto-handoff: #{reason}") - create_handoff_message(conversation, inbox) - conversation.with_captain_activity_context( - reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON, - reason_type: :inference - ) { conversation.bot_handoff! } + def handoff_conversation(conversation, reason) + handed_off = with_inference_activity_context(conversation, CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON) do + perform_locked_transition(conversation) do + conversation.bot_handoff!(dispatch_event: false) + create_private_note(conversation, "Auto-handoff: #{reason}") + create_handoff_message(conversation) + end + end + return unless handed_off + + conversation.dispatch_bot_handoff_event Captain::ConversationEvents.handed_off( conversation: conversation, - assistant: inbox.captain_assistant, + assistant: captain_assistant, source: Captain::ConversationEvents::Sources::INFERENCE, reason_category: :pending_clarification, at: Time.current ) send_out_of_office_message_if_applicable(conversation.reload) + rescue ActiveRecord::RecordNotFound + nil + end + + def perform_locked_transition(conversation) + conversation.with_lock do + conversation.reload + next false unless conversation.pending? && inactive_for_initial_action?(conversation) + + yield + true + end + end + + def with_inference_activity_context(conversation, reason, &) + conversation.with_captain_activity_context(reason: reason, reason_type: :inference, &) end def send_out_of_office_message_if_applicable(conversation) - # Campaign conversations should never receive OOO templates — the campaign itself - # serves as the initial outreach, and OOO would be confusing in that context. - return if conversation.campaign.present? - - ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) + ::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) if conversation.campaign.blank? end - def create_private_note(conversation, inbox, content) + def create_private_note(conversation, content) conversation.messages.create!( message_type: :outgoing, private: true, - sender: inbox.captain_assistant, + sender: captain_assistant, account_id: conversation.account_id, inbox_id: conversation.inbox_id, content: content @@ -128,27 +168,27 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob end def create_resolution_message(conversation, inbox) - return unless inbox.captain_assistant.send_inactivity_resolution_message? + return unless captain_assistant.send_inactivity_resolution_message? I18n.with_locale(inbox.account.locale) do - resolution_message = inbox.captain_assistant.config['resolution_message'] + resolution_message = captain_assistant.config['resolution_message'] conversation.messages.create!( message_type: :outgoing, account_id: conversation.account_id, inbox_id: conversation.inbox_id, content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'), - sender: inbox.captain_assistant + sender: captain_assistant ) end end - def create_handoff_message(conversation, inbox) - handoff_message = inbox.captain_assistant.config['handoff_message'] + def create_handoff_message(conversation) + handoff_message = captain_assistant.config['handoff_message'] return if handoff_message.blank? conversation.messages.create!( message_type: :outgoing, - sender: inbox.captain_assistant, + sender: captain_assistant, account_id: conversation.account_id, inbox_id: conversation.inbox_id, content: handoff_message, diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb index cd67255e8..5c4016b93 100644 --- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb @@ -293,6 +293,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do params: { assistant: { config: { + auto_resolve_mode: 'evaluated', auto_resolve_after: 61, send_inactivity_resolution_message: false, resolution_message: 'Saved closing message' @@ -304,6 +305,7 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do expect(response).to have_http_status(:success) expect(json_response[:config]).to include( + auto_resolve_mode: 'evaluated', auto_resolve_after: 60, send_inactivity_resolution_message: false, resolution_message: 'Saved closing message' 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 1ff90dc95..0af929689 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 @@ -147,6 +147,16 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do expect(Captain::ConversationCompletionService).not_to have_received(:new) expect(resolvable_pending_conversation.reload.status).to eq('resolved') end + + it 'uses the assistant always-resolve policy instead of the account policy' do + captain_assistant.update!(config: captain_assistant.config.merge('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 @@ -401,6 +411,51 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do end end + describe 'evaluated action transaction safety' do + let(:job) { described_class.new } + let(:conversation) { resolvable_pending_conversation.reload } + + before do + job.instance_variable_set(:@captain_assistant, captain_assistant) + job.instance_variable_set(:@inactivity_cutoff_time, 1.hour.ago) + end + + it 'rolls back resolution messages when the status transition fails' do + expect(conversation).to receive(:with_lock).and_call_original + allow(conversation).to receive(:resolved!).and_raise(StandardError, 'transition failed') + + expect do + job.send(:resolve_conversation, conversation, inbox, 'Customer question was answered') + end.to raise_error(StandardError, 'transition failed') + + expect(conversation.reload).to be_pending + expect(conversation.messages.outgoing).to be_empty + end + + it 'rolls back handoff messages when the status transition fails' do + captain_assistant.update!(config: captain_assistant.config.merge('handoff_message' => 'Connecting you to an agent.')) + expect(conversation).to receive(:with_lock).and_call_original + allow(conversation).to receive(:bot_handoff!).and_raise(StandardError, 'transition failed') + + expect do + job.send(:handoff_conversation, conversation, 'Customer needs an agent') + end.to raise_error(StandardError, 'transition failed') + + expect(conversation.reload).to be_pending + expect(conversation.messages.outgoing).to be_empty + end + + it 'dispatches the bot handoff event after leaving the lock transaction' do + open_transactions_before_handoff = ActiveRecord::Base.connection.open_transactions + expect(conversation).to receive(:bot_handoff!).with(dispatch_event: false).and_call_original + expect(conversation).to receive(:dispatch_bot_handoff_event) do + expect(ActiveRecord::Base.connection.open_transactions).to eq(open_transactions_before_handoff) + end + + job.send(:handoff_conversation, conversation, 'Customer needs an agent') + end + end + it 'does not resolve conversations when auto-resolve is disabled at execution time' do captain_assistant.update!(auto_resolve_mode: 'disabled') @@ -412,6 +467,17 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do expect(resolvable_pending_conversation.messages.outgoing).to be_empty end + it 'does not resolve conversations when the assistant policy is disabled at execution time' do + captain_assistant.update!(config: captain_assistant.config.merge('auto_resolve_mode' => 'disabled')) + + 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 + it 'falls back to disabled mode from legacy settings key' do captain_assistant.update!(config: captain_assistant.config.except('auto_resolve_mode')) inbox.account.update!(settings: inbox.account.settings.merge('captain_disable_auto_resolve' => true)) 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 1eacd7bbf..52bd1bc23 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,23 @@ RSpec.describe Account::ConversationsResolutionSchedulerJob, type: :job do 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) + assistant.update!(config: assistant.config.except('auto_resolve_mode')) + account.update!(settings: account.settings.merge('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 + 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) diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb index 9cdfc822c..525f2954e 100644 --- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb +++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb @@ -9,7 +9,7 @@ RSpec.describe Captain::ConversationCompletionService do let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) } before do - create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key') + InstallationConfig.find_or_initialize_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: 'test-key') allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context) allow(mock_chat).to receive(:with_instructions) allow(mock_chat).to receive(:with_schema).and_return(mock_chat) diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb index b9adb43ae..3e0ea2d9d 100644 --- a/spec/enterprise/models/captain/assistant_spec.rb +++ b/spec/enterprise/models/captain/assistant_spec.rb @@ -47,6 +47,29 @@ RSpec.describe Captain::Assistant, type: :model do end end + describe '#auto_resolve_mode' do + let(:account) { create(:account, captain_auto_resolve_mode: 'legacy') } + + it 'uses the assistant setting when configured' do + assistant = create(:captain_assistant, account: account, config: { 'auto_resolve_mode' => 'disabled' }) + + expect(assistant.auto_resolve_mode).to eq('disabled') + end + + it 'falls back to the account setting for assistants that have not been migrated' do + assistant = create(:captain_assistant, account: account) + + expect(assistant.auto_resolve_mode).to eq('legacy') + end + + it 'rejects unsupported modes' do + assistant = build(:captain_assistant, account: account, config: { 'auto_resolve_mode' => 'unsupported' }) + + expect(assistant).not_to be_valid + expect(assistant.errors[:auto_resolve_mode]).to be_present + end + end + describe '#responds_to_audience?' do it 'returns true when no audience is configured' do expect(assistant.responds_to_audience?(contact, conversation)).to be(true)