feat(captain): add advanced inactivity policy backend (4/5) (#15306)

Captain can now use each assistant's saved setting when a customer stops
replying. Captain can review the conversation and resolve or hand it
off, resolve it after the selected time without review, or leave it
pending until the customer replies.

The job checks the conversation again while holding a database lock
before it changes the status. A new customer reply or another worker
cannot cause an outdated resolve or handoff.

## Closes

[AI-163](https://linear.app/chatwoot/issue/AI-163)

## What changed

- Added assistant modes for review, always resolve, and wait for the
customer.
- Kept the account setting as the fallback for assistants that do not
have a saved mode.
- Skipped scheduling when resolution is disabled on the assistant or
through the older account setting.
- Rechecked the conversation status and activity time before each
resolve or handoff.
- Recorded events only after a status change succeeds.
- Kept out of office messages out of campaign conversations.

## How to test

1. Set an assistant to review conversations. Run the inactivity job with
complete and incomplete decisions. Confirm the first conversation is
resolved and the second is handed off.
2. Set the assistant to always resolve. Confirm an eligible pending
conversation is resolved after the selected time.
3. Set the assistant to wait for the customer. Confirm the scheduler
does not enqueue the inactivity job and the conversation remains
pending.
4. Add a customer reply while the review is running. Confirm the job
does not resolve or hand off the updated conversation.
5. Run two workers for the same conversation. Confirm only one status
change and one event are recorded.

---------

Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-08-12 18:33:48 +05:30
committed by GitHub
parent 121b743f88
commit a47eb375ad
13 changed files with 657 additions and 186 deletions

View File

@@ -27,8 +27,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
<div
class="flex flex-col items-start outline outline-1 -outline-offset-1 outline-n-weak rounded-xl [interpolate-size:allow-keywords]"
>
<div class="flex flex-col gap-1 items-start w-full px-4 py-3">
<div class="flex items-center gap-3 w-full justify-between">
<div class="flex flex-col gap-1 items-start w-full py-3">
<div class="flex items-center gap-3 w-full justify-between px-4">
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
@@ -39,7 +39,7 @@ const modelValue = defineModel({ type: Boolean, default: false });
</template>
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
<span v-if="description" class="text-body-main text-n-slate-11 px-4">
{{ description }}
</span>
<slot />

View File

@@ -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'
);
});
});

View File

@@ -1,16 +1,18 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { computed, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { maxValue, minLength, minValue, required } from '@vuelidate/validators';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import Switch from 'dashboard/components-next/switch/Switch.vue';
import DurationSelect from './DurationSelect.vue';
const props = defineProps({
assistant: {
@@ -28,81 +30,58 @@ const isCaptainV2Enabled = computed(() =>
isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN_V2)
);
const MIN_INACTIVITY_MINUTES = 5;
const MAX_INACTIVITY_MINUTES = 24 * 60;
const initialState = {
handoffMessage: '',
resolutionMessage: '',
instructions: '',
autoResolveMode: 'evaluated',
inactivityThresholdMinutes: 60,
sendInactivityResolutionMessage: true,
};
const state = reactive({ ...initialState });
const MINUTES_PER_HOUR = 60;
const INACTIVITY_STEP_MINUTES = 5;
const MIN_INACTIVITY_MINUTES = 5;
const MAX_INACTIVITY_MINUTES = 24 * MINUTES_PER_HOUR;
const MAX_INACTIVITY_HOURS = MAX_INACTIVITY_MINUTES / MINUTES_PER_HOUR;
const hoursPart = totalMinutes => Math.floor(totalMinutes / MINUTES_PER_HOUR);
const minutesPart = totalMinutes => totalMinutes % MINUTES_PER_HOUR;
const setInactivityThreshold = (hours, minutes) => {
state.inactivityThresholdMinutes = Math.min(
Math.max(hours * MINUTES_PER_HOUR + minutes, MIN_INACTIVITY_MINUTES),
MAX_INACTIVITY_MINUTES
);
};
const inactivityThresholdHours = computed({
get: () => hoursPart(state.inactivityThresholdMinutes),
set: hours =>
setInactivityThreshold(
Number(hours),
minutesPart(state.inactivityThresholdMinutes)
),
});
const inactivityThresholdRemainingMinutes = computed({
get: () => minutesPart(state.inactivityThresholdMinutes),
set: minutes =>
setInactivityThreshold(
hoursPart(state.inactivityThresholdMinutes),
Number(minutes)
),
});
const inactivityHourOptions = computed(() =>
Array.from({ length: MAX_INACTIVITY_HOURS + 1 }, (_, hours) => ({
value: hours,
const autoResolveOptions = computed(() => [
{
value: 'evaluated',
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_SHORT',
{ count: hours }
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.EVALUATED.LABEL'
),
}))
description: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.EVALUATED.DESCRIPTION'
),
},
{
value: 'legacy',
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.LEGACY.LABEL'
),
description: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.LEGACY.DESCRIPTION'
),
},
{
value: 'disabled',
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.DISABLED.LABEL'
),
description: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODES.DISABLED.DESCRIPTION'
),
},
]);
const shouldShowInactivityDuration = computed(
() => state.autoResolveMode !== 'disabled'
);
const initialActionTimingLabel = computed(() =>
state.autoResolveMode === 'evaluated'
? t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.REVIEW_AFTER')
: t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLVE_AFTER')
);
const inactivityMinuteOptions = computed(() => {
const hours = inactivityThresholdHours.value;
return Array.from(
{ length: MINUTES_PER_HOUR / INACTIVITY_STEP_MINUTES },
(_, index) => {
const minutes = index * INACTIVITY_STEP_MINUTES;
return {
value: minutes,
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_MINUTES_SHORT',
{ count: minutes }
),
disabled:
(hours === 0 && minutes === 0) ||
(hours === MAX_INACTIVITY_HOURS && minutes !== 0),
};
}
);
});
const validationRules = {
handoffMessage: { minLength: minLength(1) },
@@ -133,30 +112,30 @@ const updateStateFromAssistant = assistant => {
state.handoffMessage = config.handoff_message;
state.resolutionMessage = config.resolution_message;
state.instructions = config.instructions;
state.autoResolveMode = config.auto_resolve_mode ?? 'evaluated';
state.inactivityThresholdMinutes = config.auto_resolve_after ?? 60;
state.sendInactivityResolutionMessage =
config.send_inactivity_resolution_message ?? true;
};
const handleSystemMessagesUpdate = async () => {
const validations = [v$.value.handoffMessage.$validate()];
if (isCaptainV2Enabled.value) {
validations.push(v$.value.inactivityThresholdMinutes.$validate());
if (state.sendInactivityResolutionMessage) {
validations.push(v$.value.resolutionMessage.$validate());
}
} else {
validations.push(
v$.value.resolutionMessage.$validate(),
v$.value.instructions.$validate()
);
const fieldsToValidate = () => {
if (!isCaptainV2Enabled.value) {
return ['handoffMessage', 'resolutionMessage', 'instructions'];
}
const result = await Promise.all(validations).then(results =>
results.every(Boolean)
);
if (!result) return;
const fields = ['handoffMessage'];
if (shouldShowInactivityDuration.value) {
fields.push('inactivityThresholdMinutes');
if (state.sendInactivityResolutionMessage) fields.push('resolutionMessage');
}
return fields;
};
const handleSystemMessagesUpdate = async () => {
const isValid = await Promise.all(
fieldsToValidate().map(field => v$.value[field].$validate())
).then(results => results.every(Boolean));
if (!isValid) return;
const payload = {
config: {
@@ -167,6 +146,7 @@ const handleSystemMessagesUpdate = async () => {
if (isCaptainV2Enabled.value) {
Object.assign(payload.config, {
auto_resolve_mode: state.autoResolveMode,
auto_resolve_after: state.inactivityThresholdMinutes,
send_inactivity_resolution_message: state.sendInactivityResolutionMessage,
resolution_message: state.resolutionMessage,
@@ -194,68 +174,128 @@ watch(
v-if="isCaptainV2Enabled"
hide-toggle
:header="t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.TITLE')"
:description="
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DESCRIPTION')
"
>
<div class="flex w-full flex-col gap-4 py-2">
<div class="flex w-full flex-col gap-4 pt-3">
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
class="flex flex-col gap-3 px-4"
role="radiogroup"
:aria-label="
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.MODE_LABEL')
"
>
<span class="text-body-main text-n-slate-12">
{{
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_LABEL')
}}
</span>
<div class="flex shrink-0 gap-2">
<Select
v-model="inactivityThresholdHours"
:options="inactivityHourOptions"
<RadioCard
v-for="option in autoResolveOptions"
:id="`auto-resolve-${option.value}`"
:key="option.value"
:label="option.label"
:description="option.description"
name="auto-resolve-mode"
:is-active="state.autoResolveMode === option.value"
@select="state.autoResolveMode = option.value"
/>
</div>
<div
v-if="shouldShowInactivityDuration"
class="flex flex-col gap-3 border-t border-n-weak pt-4 px-4"
>
<div
class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"
>
<span class="text-body-main font-medium text-n-slate-12">
{{ initialActionTimingLabel }}
</span>
<DurationSelect
v-model="state.inactivityThresholdMinutes"
:error="formErrors.inactivityThresholdMinutes"
:aria-label="
:hours-aria-label="
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_ARIA_LABEL'
)
"
class="[&>select]:min-w-24"
/>
<Select
v-model="inactivityThresholdRemainingMinutes"
:options="inactivityMinuteOptions"
:error="formErrors.inactivityThresholdMinutes"
:aria-label="
:minutes-aria-label="
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_MINUTES_ARIA_LABEL'
)
"
class="[&>select]:min-w-28"
/>
</div>
<p
v-if="formErrors.inactivityThresholdMinutes"
class="mb-0 text-xs text-n-ruby-9"
>
{{ formErrors.inactivityThresholdMinutes }}
</p>
</div>
<Banner
v-if="state.autoResolveMode === 'legacy'"
color="amber"
class="mx-4"
>
<div class="flex items-start gap-2">
<span class="i-lucide-triangle-alert mt-0.5 size-4 shrink-0" />
{{
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.ALWAYS_WARNING')
}}
</div>
</Banner>
<Banner
v-if="state.autoResolveMode === 'disabled'"
color="blue"
class="mx-4"
>
<div class="flex items-start gap-2">
<span class="i-lucide-info mt-0.5 size-4 shrink-0" />
{{
t('CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.PENDING_INFO')
}}
</div>
</Banner>
<div
v-if="shouldShowInactivityDuration"
class="flex flex-col gap-2 border-t border-n-weak pt-4 pb-1 px-4"
>
<div class="flex items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<span class="text-body-main font-medium text-n-slate-12">
{{
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
)
}}
</span>
<span class="text-body-main text-n-slate-11">
{{
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.DESCRIPTION'
)
}}
</span>
</div>
<Switch
v-model="state.sendInactivityResolutionMessage"
:aria-label="
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
)
"
/>
</div>
</div>
<p
v-if="formErrors.inactivityThresholdMinutes"
class="mb-0 text-xs text-n-ruby-9"
>
{{ formErrors.inactivityThresholdMinutes }}
</p>
<div class="flex items-center justify-between gap-3">
<span class="text-body-main text-n-slate-12">
{{
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
)
}}
</span>
<Switch
v-model="state.sendInactivityResolutionMessage"
:aria-label="
t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.RESOLUTION_MESSAGE.TITLE'
)
"
/>
</div>
</div>
<template v-if="state.sendInactivityResolutionMessage" #editor>
<template
v-if="
shouldShowInactivityDuration && state.sendInactivityResolutionMessage
"
#editor
>
<Editor
v-model="state.resolutionMessage"
:placeholder="

View File

@@ -0,0 +1,58 @@
import { shallowMount } from '@vue/test-utils';
import { describe, expect, it, vi } from 'vitest';
import Select from 'dashboard/components-next/select/Select.vue';
import DurationSelect from './DurationSelect.vue';
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key, { count }) => `${key}:${count}` }),
}));
const mountComponent = modelValue =>
shallowMount(DurationSelect, {
props: {
modelValue,
hoursAriaLabel: 'Hours',
minutesAriaLabel: 'Minutes',
},
});
describe('DurationSelect', () => {
it('shows the hour and minute parts of the duration', () => {
const selects = mountComponent(75).findAllComponents(Select);
expect(selects[0].props()).toMatchObject({
modelValue: 1,
ariaLabel: 'Hours',
});
expect(selects[1].props()).toMatchObject({
modelValue: 15,
ariaLabel: 'Minutes',
});
});
it('combines changed hours and minutes into one value', async () => {
const wrapper = mountComponent(75);
const selects = wrapper.findAllComponents(Select);
selects[0].vm.$emit('update:modelValue', 2);
expect(wrapper.emitted('update:modelValue').at(-1)[0]).toBe(135);
await wrapper.setProps({ modelValue: 135 });
selects[1].vm.$emit('update:modelValue', 10);
expect(wrapper.emitted('update:modelValue').at(-1)[0]).toBe(130);
});
it('keeps the duration within five minutes and one day', () => {
const minimum = mountComponent(5);
const zeroMinutes = minimum
.findAllComponents(Select)[1]
.props('options')
.find(option => option.value === 0);
expect(zeroMinutes.disabled).toBe(true);
const maximum = mountComponent(1430);
maximum.findAllComponents(Select)[0].vm.$emit('update:modelValue', 24);
expect(maximum.emitted('update:modelValue').at(-1)[0]).toBe(1440);
});
});

View File

@@ -0,0 +1,92 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Select from 'dashboard/components-next/select/Select.vue';
defineProps({
error: {
type: String,
default: '',
},
hoursAriaLabel: {
type: String,
required: true,
},
minutesAriaLabel: {
type: String,
required: true,
},
});
const duration = defineModel({ type: Number, default: 60 });
const MINUTES_PER_HOUR = 60;
const STEP_MINUTES = 5;
const MIN_MINUTES = STEP_MINUTES;
const MAX_MINUTES = 24 * MINUTES_PER_HOUR;
const { t } = useI18n();
const clamp = (hours, minutes) => {
duration.value = Math.min(
Math.max(hours * MINUTES_PER_HOUR + minutes, MIN_MINUTES),
MAX_MINUTES
);
};
const hours = computed({
get: () => Math.floor(duration.value / MINUTES_PER_HOUR),
set: value => clamp(Number(value), duration.value % MINUTES_PER_HOUR),
});
const minutes = computed({
get: () => duration.value % MINUTES_PER_HOUR,
set: value => clamp(hours.value, Number(value)),
});
const hourOptions = computed(() =>
Array.from({ length: MAX_MINUTES / MINUTES_PER_HOUR + 1 }, (_, value) => ({
value,
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_SHORT',
{ count: value }
),
}))
);
const minuteOptions = computed(() =>
Array.from({ length: MINUTES_PER_HOUR / STEP_MINUTES }, (_, index) => {
const value = index * STEP_MINUTES;
return {
value,
label: t(
'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_MINUTES_SHORT',
{ count: value }
),
disabled:
(hours.value === 0 && value === 0) ||
(hours.value === MAX_MINUTES / MINUTES_PER_HOUR && value !== 0),
};
})
);
</script>
<template>
<div class="flex shrink-0 gap-2">
<Select
v-model="hours"
:options="hourOptions"
:error="error"
:aria-label="hoursAriaLabel"
class="[&>select]:min-w-24"
/>
<Select
v-model="minutes"
:options="minuteOptions"
:error="error"
:aria-label="minutesAriaLabel"
class="[&>select]:min-w-28"
/>
</div>
</template>

View File

@@ -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"

View File

@@ -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",

View File

@@ -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,

View File

@@ -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'

View File

@@ -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))

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)