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:
@@ -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 />
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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="
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user