From bdbbaa38de626f4830965284e1ce15a7c475429a Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:33:04 +0530 Subject: [PATCH] feat(captain): add inactivity timer backend (2/5) (#15303) Captain V2 assistants can now persist a configurable inactivity timer and choose whether inactivity resolution sends the saved closing message or resolves silently. This PR contains only the API, persistence, runtime behavior, and backend specs. ## Closes [AI-163](https://linear.app/chatwoot/issue/AI-163) ## Depends on Stack 2 of 5. Based on the assistant-policy foundation in #15299. The frontend follows in #15308. ## What changed - Added per-assistant inactivity duration and resolution-message settings with safe defaults. - Restricted the Part 2 settings API to Captain V2 while keeping the Part 1 policy mode available without V2. - Updated inactivity handling to use the assistant timer and skip the public resolution message when disabled. - Serialized the effective timer and message settings for the frontend. - Added model, request, and job coverage, including the explicit Captain V2 boundary. ## How to test 1. Enable Captain V2 and update `auto_resolve_after` and `send_inactivity_resolution_message` through the assistant API. 2. Run the inactivity job and confirm it uses the assistant timer. 3. Disable the resolution message and confirm the conversation resolves silently. 4. Disable Captain V2 and confirm timer/message updates are ignored while `auto_resolve_mode` remains updateable. --------- Co-authored-by: Sony Mathew Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Co-authored-by: iamsivin --- .../Settings/SettingsToggleSection.vue | 1 + .../settings/AssistantSystemSettingsForm.vue | 212 ++++++++++++++++-- .../components-next/select/Select.vue | 5 + .../i18n/locale/en/integrations.json | 16 +- .../captain/assistants/settings/System.vue | 12 +- .../accounts/captain/assistants_controller.rb | 3 + ...ox_pending_conversations_resolution_job.rb | 13 +- enterprise/app/models/captain/assistant.rb | 39 +++- .../models/captain/_assistant.json.jbuilder | 6 +- .../captain/assistants_controller_spec.rb | 55 +++++ ...nding_conversations_resolution_job_spec.rb | 19 ++ .../models/captain/assistant_spec.rb | 41 ++++ 12 files changed, 394 insertions(+), 28 deletions(-) diff --git a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue index 332b91066..c2ada05e3 100644 --- a/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue +++ b/app/javascript/dashboard/components-next/Settings/SettingsToggleSection.vue @@ -42,6 +42,7 @@ const modelValue = defineModel({ type: Boolean, default: false }); {{ description }} +
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, + label: t( + 'CAPTAIN.ASSISTANTS.FORM.INACTIVITY_RESOLUTION.DURATION_HOURS_SHORT', + { count: hours } + ), + })) +); + +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) }, resolutionMessage: { minLength: minLength(1) }, instructions: { minLength: minLength(1) }, + inactivityThresholdMinutes: { + required, + minValue: minValue(MIN_INACTIVITY_MINUTES), + maxValue: maxValue(MAX_INACTIVITY_MINUTES), + }, }; const v$ = useVuelidate(validationRules, state); @@ -49,6 +125,7 @@ const formErrors = computed(() => ({ handoffMessage: getErrorMessage('handoffMessage'), resolutionMessage: getErrorMessage('resolutionMessage'), instructions: getErrorMessage('instructions'), + inactivityThresholdMinutes: getErrorMessage('inactivityThresholdMinutes'), })); const updateStateFromAssistant = assistant => { @@ -56,16 +133,24 @@ const updateStateFromAssistant = assistant => { state.handoffMessage = config.handoff_message; state.resolutionMessage = config.resolution_message; state.instructions = config.instructions; + state.inactivityThresholdMinutes = config.auto_resolve_after ?? 60; + state.sendInactivityResolutionMessage = + config.send_inactivity_resolution_message ?? true; }; const handleSystemMessagesUpdate = async () => { - const validations = [ - v$.value.handoffMessage.$validate(), - v$.value.resolutionMessage.$validate(), - ]; + const validations = [v$.value.handoffMessage.$validate()]; - if (!isCaptainV2Enabled.value) { - validations.push(v$.value.instructions.$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 result = await Promise.all(validations).then(results => @@ -77,11 +162,17 @@ const handleSystemMessagesUpdate = async () => { config: { ...props.assistant.config, handoff_message: state.handoffMessage, - resolution_message: state.resolutionMessage, }, }; - if (!isCaptainV2Enabled.value) { + if (isCaptainV2Enabled.value) { + Object.assign(payload.config, { + auto_resolve_after: state.inactivityThresholdMinutes, + send_inactivity_resolution_message: state.sendInactivityResolutionMessage, + resolution_message: state.resolutionMessage, + }); + } else { + payload.config.resolution_message = state.resolutionMessage; payload.config.instructions = state.instructions; } @@ -99,16 +190,103 @@ watch(