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)