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 <sony@chatwoot.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-08-10 17:33:04 +05:30
committed by GitHub
parent 9a9c88494e
commit bdbbaa38de
12 changed files with 394 additions and 28 deletions

View File

@@ -255,6 +255,61 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
expect(assistant.reload.config).to include('product_name' => 'Chatwoot', 'auto_resolve_mode' => 'disabled')
end
it 'keeps inactivity timer settings behind Captain V2' do
account.disable_features!('captain_integration_v2')
assistant.update!(
config: {
'auto_resolve_mode' => 'evaluated',
'auto_resolve_after' => 60,
'send_inactivity_resolution_message' => true
}
)
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: {
assistant: {
config: {
auto_resolve_mode: 'disabled',
auto_resolve_after: 90,
send_inactivity_resolution_message: false
}
}
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(assistant.reload.config).to include(
'auto_resolve_mode' => 'disabled',
'auto_resolve_after' => 60,
'send_inactivity_resolution_message' => true
)
end
it 'updates inactive conversation settings for Captain v2' do
account.enable_features!('captain_integration_v2')
patch "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}",
params: {
assistant: {
config: {
auto_resolve_after: 61,
send_inactivity_resolution_message: false,
resolution_message: 'Saved closing message'
}
}
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response[:config]).to include(
auto_resolve_after: 60,
send_inactivity_resolution_message: false,
resolution_message: 'Saved closing message'
)
end
it 'persists the nested audience condition tree' do
create(:custom_attribute_definition, account: account, attribute_model: :contact_attribute,
attribute_display_type: :text, attribute_key: 'plan_tier')

View File

@@ -78,6 +78,25 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
expect(Captain::ConversationCompletionService).not_to have_received(:new)
end
it 'uses the assistant inactivity timer' do
captain_assistant.account.enable_features!('captain_integration_v2')
captain_assistant.update!(config: captain_assistant.config.merge('auto_resolve_after' => 180))
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('pending')
end
it 'resolves silently when the resolution message is disabled' do
captain_assistant.account.enable_features!('captain_integration_v2')
captain_assistant.update!(config: captain_assistant.config.merge('send_inactivity_resolution_message' => false))
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
end
end
context 'when captain_tasks is enabled' do

View File

@@ -6,6 +6,47 @@ RSpec.describe Captain::Assistant, type: :model do
let(:contact) { create(:contact, account: account, additional_attributes: { 'country_code' => 'US' }) }
let(:conversation) { create(:conversation, account: account, contact: contact) }
describe 'inactive conversation settings' do
it 'uses safe defaults when settings are unavailable' do
assistant.account.enable_features('captain_integration_v2')
assistant.auto_resolve_after = nil
expect(assistant.inactivity_threshold_minutes).to eq(60)
assistant.auto_resolve_after = 5
assistant.send_inactivity_resolution_message = false
assistant.account.disable_features('captain_integration_v2')
expect(assistant.inactivity_threshold_minutes).to eq(60)
expect(assistant.send_inactivity_resolution_message?).to be(true)
end
it 'validates the inactivity timer range' do
assistant.auto_resolve_after = 4
expect(assistant).not_to be_valid
expect(assistant.errors[:auto_resolve_after]).to be_present
assistant.auto_resolve_after = 61.5
expect(assistant).not_to be_valid
expect(assistant.errors[:auto_resolve_after]).to be_present
end
it 'rounds the inactivity timer to the nearest five minutes' do
assistant.auto_resolve_after = 61
assistant.validate
expect(assistant.auto_resolve_after).to eq(60)
assistant.auto_resolve_after = 63
assistant.validate
expect(assistant.auto_resolve_after).to eq(65)
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)