Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
feat: separate Captain V2 handoff messages by business hours
  • Loading branch information
aakashb95 committed Aug 21, 2026
commit 544ba032d4ca8abe53fe8aff557bb867944b855a
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { nextTick } from 'vue';
import { flushPromises, shallowMount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import AssistantSystemSettingsForm from './AssistantSystemSettingsForm.vue';
Expand All @@ -11,14 +12,20 @@ vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));

const featureState = vi.hoisted(() => ({ captainV2: true }));

vi.mock('dashboard/composables/useAccount', () => ({
useAccount: () => ({ isCloudFeatureEnabled: () => true }),
useAccount: () => ({
isCloudFeatureEnabled: () => featureState.captainV2,
}),
}));

const assistant = {
config: {
product_name: 'Chatwoot',
handoff_message: 'I will connect you with the team.',
handoff_message_outside_business_hours:
'The team will reply when they are back.',
resolution_message: 'I will close this conversation for now.',
auto_resolve_mode: 'evaluated',
auto_resolve_after: 75,
Expand All @@ -38,6 +45,10 @@ const submitForm = async wrapper => {
};

describe('AssistantSystemSettingsForm', () => {
beforeEach(() => {
featureState.captainV2 = true;
});

it('shows the evaluated policy controls from the saved config', () => {
const wrapper = mountComponent();
const modeCards = wrapper.findAllComponents(RadioCard);
Expand Down Expand Up @@ -106,4 +117,67 @@ describe('AssistantSystemSettingsForm', () => {
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.ALWAYS_WARNING'
);
});

it('shows and saves separate handoff messages', async () => {
const wrapper = mountComponent();
const editors = wrapper.findAllComponents(Editor);
const businessHoursEditor = editors.find(
editor =>
editor.props('label') ===
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.BUSINESS_HOURS.LABEL'
);
const outsideBusinessHoursEditor = editors.find(
editor =>
editor.props('label') ===
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.OUTSIDE_BUSINESS_HOURS.LABEL'
);

expect(businessHoursEditor.props('modelValue')).toBe(
'I will connect you with the team.'
);
expect(outsideBusinessHoursEditor.props('modelValue')).toBe(
'The team will reply when they are back.'
);

outsideBusinessHoursEditor.vm.$emit(
'update:modelValue',
'We are offline and will reply later.'
);
await nextTick();
await submitForm(wrapper);

expect(wrapper.emitted('submit')[0][0].config).toEqual({
...assistant.config,
handoff_message_outside_business_hours:
'We are offline and will reply later.',
});
});

it('keeps the single handoff message for Captain V1', async () => {
featureState.captainV2 = false;
const legacyAssistant = {
config: {
handoff_message: 'I will connect you with the team.',
resolution_message: 'I will close this conversation for now.',
instructions: 'Answer from the knowledge base.',
},
};
const wrapper = shallowMount(AssistantSystemSettingsForm, {
props: { assistant: legacyAssistant },
global: { stubs: { Banner: false, SettingsToggleSection: false } },
});

expect(wrapper.text()).toContain(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL'
);
expect(wrapper.text()).not.toContain(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.OUTSIDE_BUSINESS_HOURS.LABEL'
);

await submitForm(wrapper);

expect(wrapper.emitted('submit')[0][0]).toEqual({
config: legacyAssistant.config,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const MAX_INACTIVITY_MINUTES = 24 * 60;

const initialState = {
handoffMessage: '',
handoffMessageOutsideBusinessHours: '',
resolutionMessage: '',
instructions: '',
autoResolveMode: 'evaluated',
Expand Down Expand Up @@ -85,6 +86,7 @@ const initialActionTimingLabel = computed(() =>

const validationRules = {
handoffMessage: { minLength: minLength(1) },
handoffMessageOutsideBusinessHours: { minLength: minLength(1) },
resolutionMessage: { minLength: minLength(1) },
instructions: { minLength: minLength(1) },
inactivityThresholdMinutes: {
Expand All @@ -102,6 +104,9 @@ const getErrorMessage = field => {

const formErrors = computed(() => ({
handoffMessage: getErrorMessage('handoffMessage'),
handoffMessageOutsideBusinessHours: getErrorMessage(
'handoffMessageOutsideBusinessHours'
),
resolutionMessage: getErrorMessage('resolutionMessage'),
instructions: getErrorMessage('instructions'),
inactivityThresholdMinutes: getErrorMessage('inactivityThresholdMinutes'),
Expand All @@ -110,6 +115,8 @@ const formErrors = computed(() => ({
const updateStateFromAssistant = assistant => {
const { config = {} } = assistant;
state.handoffMessage = config.handoff_message;
state.handoffMessageOutsideBusinessHours =
config.handoff_message_outside_business_hours;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.autoResolveMode = config.auto_resolve_mode ?? 'evaluated';
Expand All @@ -123,7 +130,7 @@ const fieldsToValidate = () => {
return ['handoffMessage', 'resolutionMessage', 'instructions'];
}

const fields = ['handoffMessage'];
const fields = ['handoffMessage', 'handoffMessageOutsideBusinessHours'];
if (shouldShowInactivityDuration.value) {
fields.push('inactivityThresholdMinutes');
if (state.sendInactivityResolutionMessage) fields.push('resolutionMessage');
Expand All @@ -146,6 +153,8 @@ const handleSystemMessagesUpdate = async () => {

if (isCaptainV2Enabled.value) {
Object.assign(payload.config, {
handoff_message_outside_business_hours:
state.handoffMessageOutsideBusinessHours,
auto_resolve_mode: state.autoResolveMode,
auto_resolve_after: state.inactivityThresholdMinutes,
send_inactivity_resolution_message: state.sendInactivityResolutionMessage,
Expand Down Expand Up @@ -309,6 +318,62 @@ watch(
</SettingsToggleSection>

<SettingsToggleSection
v-if="isCaptainV2Enabled"
hide-toggle
:header="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.TITLE')"
:description="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.DESCRIPTION')"
>
<div
class="flex w-full flex-col gap-6 border-t border-n-weak px-4 pb-4 pt-4"
>
<Editor
v-model="state.handoffMessage"
:label="
t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.BUSINESS_HOURS.LABEL')
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.BUSINESS_HOURS.PLACEHOLDER'
)
"
:message="
formErrors.handoffMessage ||
t(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.BUSINESS_HOURS.DESCRIPTION'
)
"
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
class="z-0"
/>

<Editor
v-model="state.handoffMessageOutsideBusinessHours"
:label="
t(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.OUTSIDE_BUSINESS_HOURS.LABEL'
)
"
:placeholder="
t(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.OUTSIDE_BUSINESS_HOURS.PLACEHOLDER'
)
"
:message="
formErrors.handoffMessageOutsideBusinessHours ||
t(
'CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.OUTSIDE_BUSINESS_HOURS.DESCRIPTION'
)
"
:message-type="
formErrors.handoffMessageOutsideBusinessHours ? 'error' : 'info'
"
class="z-0"
/>
</div>
</SettingsToggleSection>

<SettingsToggleSection
v-else
hide-toggle
:header="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.LABEL')"
>
Expand Down
16 changes: 14 additions & 2 deletions app/javascript/dashboard/i18n/locale/en/integrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -592,7 +592,19 @@
},
"HANDOFF_MESSAGE": {
"LABEL": "Handoff message",
"PLACEHOLDER": "Enter handoff message"
"PLACEHOLDER": "Enter handoff message",
"TITLE": "Handoff messages",
"DESCRIPTION": "Choose what Captain says when it hands a conversation to your team.",
"BUSINESS_HOURS": {
"LABEL": "During business hours",
"DESCRIPTION": "Captain sends this message when the inbox is open or has no business hours set.",
"PLACEHOLDER": "Enter the handoff message"
},
"OUTSIDE_BUSINESS_HOURS": {
"LABEL": "Outside business hours",
"DESCRIPTION": "Captain sends this message when the inbox is closed. If left blank, Captain uses the inbox out-of-office message, then the business-hours handoff message.",
"PLACEHOLDER": "Enter the handoff message"
}
},
"RESOLUTION_MESSAGE": {
"LABEL": "Resolution Message",
Expand Down Expand Up @@ -698,7 +710,7 @@
"SYSTEM_SETTINGS": {
"TITLE": "System settings",
"DESCRIPTION": "Customize what the assistant says when ending a conversation or transferring to a human.",
"DESCRIPTION_V2": "Manage what Captain does when customers stop replying and set the handoff message."
"DESCRIPTION_V2": "Manage what Captain does when customers stop replying and set handoff messages."
},
"AUDIENCE": {
"TITLE": "Audience",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def assistant_params
:response_window
]
if Current.account.feature_enabled?('captain_integration_v2')
assistant_config_attributes += [:auto_resolve_after, :send_inactivity_resolution_message]
assistant_config_attributes += [:auto_resolve_after, :send_inactivity_resolution_message, :handoff_message_outside_business_hours]
end

permitted = params.require(:assistant).permit(:name, :description,
Expand Down
18 changes: 9 additions & 9 deletions enterprise/app/jobs/captain/conversation/response_builder_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -145,18 +145,25 @@ def process_v1_handoff
create_handoff_message
@conversation.bot_handoff!
report_v1_handoff_not_executed if conversation_pending?
send_out_of_office_message_if_applicable
send_out_of_office_message_if_applicable unless captain_v2_enabled?
end
end

def process_v2_handoff
# HandoffTool already ran bot_handoff! + OOO inside the agent loop. Preserve
# HandoffTool already ran bot_handoff! inside the agent loop. Preserve
# waiting_since so this message doesn't clear the timestamp it left in place.
I18n.with_locale(@assistant.account.locale) do
create_handoff_message(preserve_waiting_since: true)
end
end

def create_handoff_message(preserve_waiting_since: false)
@handoff_message = create_outgoing_message(
@assistant.handoff_message_for(@conversation).presence || I18n.t('conversations.captain.handoff'),
preserve_waiting_since: preserve_waiting_since
)
end

def send_out_of_office_message_if_applicable
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
Expand All @@ -165,13 +172,6 @@ def send_out_of_office_message_if_applicable
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
end

def create_handoff_message(preserve_waiting_since: false)
@handoff_message = create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
preserve_waiting_since: preserve_waiting_since
)
end

# Capture runs outside the delivery transaction and never raises (the service
# swallows its own failures): a session-logging bug must never roll back the
# customer reply or trigger the top-level handle_error handoff on top of it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ def handoff_conversation(conversation, reason)
reason_category: :pending_clarification,
at: Time.current
)
send_out_of_office_message_if_applicable(conversation.reload)
send_out_of_office_message_if_applicable(conversation.reload) unless captain_v2_enabled?
rescue ActiveRecord::RecordNotFound
nil
end
Expand All @@ -152,6 +152,10 @@ def with_inference_activity_context(conversation, reason, &)
conversation.with_captain_activity_context(reason: reason, reason_type: :inference, &)
end

def captain_v2_enabled?
captain_assistant.account.feature_enabled?('captain_integration_v2')
end

def send_out_of_office_message_if_applicable(conversation)
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation) if conversation.campaign.blank?
end
Expand Down Expand Up @@ -183,7 +187,7 @@ def create_resolution_message(conversation, inbox)
end

def create_handoff_message(conversation)
handoff_message = captain_assistant.config['handoff_message']
handoff_message = captain_assistant.handoff_message_for(conversation)
return if handoff_message.blank?

conversation.messages.create!(
Expand Down
1 change: 1 addition & 0 deletions enterprise/app/models/captain/assistant.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Captain::Assistant < ApplicationRecord
include Avatarable
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
include Concerns::HandoffMessageSelectable

self.table_name = 'captain_assistants'

Expand Down
13 changes: 13 additions & 0 deletions enterprise/app/models/concerns/handoff_message_selectable.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
module Concerns::HandoffMessageSelectable
extend ActiveSupport::Concern

def handoff_message_for(conversation)
default_message = config['handoff_message']
return default_message unless account.feature_enabled?('captain_integration_v2')
return default_message if conversation.campaign.present? || !conversation.inbox.out_of_office?

config['handoff_message_outside_business_hours'].presence ||
conversation.inbox.out_of_office_message.presence ||
default_message
end
end
Loading
Loading