Compare commits

...
Author SHA1 Message Date
Sojan JoseandGitHub 95aab43e3b Merge branch 'develop' into codex/agent-bot-assignment-status 2026-07-15 21:03:01 -07:00
Sojan Jose 5f397db19b Revert "refactor: route assignment handoff through service"
This reverts commit ce178cd3fe.
2026-07-06 21:58:00 -07:00
Sojan Jose ce178cd3fe refactor: route assignment handoff through service 2026-07-06 21:56:12 -07:00
Sojan Jose 1f35360ecb refactor: share bot handoff state transition 2026-07-06 20:24:21 -07:00
Sojan Jose 4a0933aaf6 refactor: reuse status notifications for bot handoff 2026-07-06 16:48:23 -07:00
Sojan Jose f5ba24a9c5 fix: clear bot handoff dispatch flag 2026-07-02 23:16:37 -07:00
Sojan Jose 4c25024f74 fix: emit bot handoff on assignment takeover 2026-07-02 23:10:28 -07:00
Sojan Jose 98592b6e62 refactor: simplify bot takeover callback order 2026-07-02 22:40:06 -07:00
Sojan Jose 0848c40c0f refactor: clarify bot takeover assignment callback 2026-07-02 22:18:23 -07:00
Sojan Jose 7ec4468af2 fix: open bot handled conversations on human assignment 2026-07-02 21:20:31 -07:00
Sojan Jose 14d7df5e50 fix: open bot handled conversations on team takeover 2026-07-02 21:15:18 -07:00
Sojan Jose 26691aa9ed test: cover agent bot assignment status transitions 2026-07-02 21:07:51 -07:00
Sojan Jose 210ab45c8e Merge branch 'develop' of github.com:chatwoot/chatwoot into HEAD 2026-07-02 21:06:06 -07:00
Sojan Jose f208af126d refactor: simplify agent bot takeover status check 2026-07-02 20:28:23 -07:00
Sojan Jose 1c5ebcea0c Revert "fix: preserve closed status on bot assignment"
This reverts commit 50b373c151.
2026-06-26 16:16:43 -07:00
Sojan Jose 50b373c151 fix: preserve closed status on bot assignment 2026-06-26 16:11:08 -07:00
Sojan Jose ffa9e45d70 fix: preserve status on human reassignment 2026-06-26 14:41:43 -07:00
Sojan Jose 81b4d7abfc feat: update status on agent bot assignment 2026-06-26 14:35:28 -07:00
5 changed files with 89 additions and 12 deletions
+19 -5
View File
@@ -74,7 +74,6 @@ class Conversation < ApplicationRecord
validates :inbox_id, presence: true
validates :contact_id, presence: true
before_validation :validate_additional_attributes
before_validation :reset_agent_bot_when_assignee_present
validates :additional_attributes, jsonb_attributes_length: true
validates :custom_attributes, jsonb_attributes_length: true
validates :uuid, uniqueness: true
@@ -125,6 +124,7 @@ class Conversation < ApplicationRecord
has_many :attachments, through: :messages
has_many :reporting_events, dependent: :destroy_async
before_save :handle_agent_bot_takeover_by_assignee
before_save :ensure_snooze_until_reset
before_create :determine_conversation_status
before_create :ensure_waiting_since
@@ -172,8 +172,8 @@ class Conversation < ApplicationRecord
end
def bot_handoff!
update(waiting_since: Time.current) if waiting_since.blank?
open!
mark_bot_handoff
save!
dispatcher_dispatch(CONVERSATION_BOT_HANDOFF)
end
@@ -280,12 +280,18 @@ class Conversation < ApplicationRecord
self.additional_attributes = {} unless additional_attributes.is_a?(Hash)
end
def reset_agent_bot_when_assignee_present
return if assignee_id.blank?
def handle_agent_bot_takeover_by_assignee
return if assignee_id.blank? || assignee_agent_bot_id.blank?
mark_bot_handoff if pending?
self.assignee_agent_bot_id = nil
end
def mark_bot_handoff
self.waiting_since ||= Time.current
self.status = :open
end
def determine_conversation_status
self.status = :resolved and return if contact.blocked?
@@ -355,6 +361,7 @@ class Conversation < ApplicationRecord
CONVERSATION_OPENED => -> { saved_change_to_status? && open? },
CONVERSATION_RESOLVED => -> { saved_change_to_status? && resolved? },
CONVERSATION_STATUS_CHANGED => -> { saved_change_to_status? },
CONVERSATION_BOT_HANDOFF => -> { agent_bot_takeover_by_assignee? },
CONVERSATION_READ => -> { saved_change_to_contact_last_seen_at? },
CONVERSATION_CONTACT_CHANGED => -> { saved_change_to_contact_id? }
}.each do |event, condition|
@@ -362,6 +369,13 @@ class Conversation < ApplicationRecord
end
end
def agent_bot_takeover_by_assignee?
assignee_id.present? &&
saved_change_to_status?(from: 'pending', to: 'open') &&
saved_change_to_assignee_agent_bot_id? &&
assignee_agent_bot_id.blank?
end
def dispatcher_dispatch(event_name, changed_attributes = nil)
Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, conversation: self, notifiable_assignee_change: notifiable_assignee_change?,
changed_attributes: changed_attributes,
@@ -15,7 +15,7 @@ class Conversations::AssignmentService
def assign_agent
conversation.assignee = assignee
conversation.assignee_agent_bot = nil
conversation.assignee_agent_bot = nil if assignee.blank?
conversation.save!
assignee
end
@@ -25,6 +25,7 @@ class Conversations::AssignmentService
conversation.assignee = nil
conversation.assignee_agent_bot = agent_bot
conversation.status = :pending
conversation.save!
agent_bot
end
@@ -44,6 +44,7 @@ RSpec.describe 'Conversation Assignment API', type: :request do
end
it 'assigns a user to the conversation' do
conversation.update!(assignee_agent_bot: agent_bot, status: :pending)
params = { assignee_id: agent.id }
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
@@ -52,10 +53,13 @@ RSpec.describe 'Conversation Assignment API', type: :request do
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.assignee).to eq(agent)
conversation.reload
expect(conversation.assignee).to eq(agent)
expect(conversation.status).to eq('open')
end
it 'assigns an agent bot to the conversation' do
conversation.update!(status: :open)
params = { assignee_id: agent_bot.id, assignee_type: 'AgentBot' }
expect(Conversations::AssignmentService).to receive(:new)
@@ -72,12 +76,14 @@ RSpec.describe 'Conversation Assignment API', type: :request do
conversation.reload
expect(conversation.assignee_agent_bot).to eq(agent_bot)
expect(conversation.assignee).to be_nil
expect(conversation.status).to eq('pending')
end
it 'assigns a team to the conversation' do
team_member = create(:user, account: account, role: :agent, auto_offline: false)
create(:inbox_member, inbox: conversation.inbox, user: team_member)
create(:team_member, team: team, user: team_member)
conversation.update!(assignee_agent_bot: agent_bot, status: :pending)
params = { team_id: team.id }
post api_v1_account_conversation_assignments_url(account_id: account.id, conversation_id: conversation.display_id),
@@ -88,7 +94,10 @@ RSpec.describe 'Conversation Assignment API', type: :request do
expect(response).to have_http_status(:success)
expect(conversation.reload.team).to eq(team)
# assignee will be from team
expect(conversation.reload.assignee).to eq(team_member)
conversation.reload
expect(conversation.assignee).to eq(team_member)
expect(conversation.assignee_agent_bot).to be_nil
expect(conversation.status).to eq('open')
end
end
+27
View File
@@ -57,6 +57,33 @@ describe ActionService do
action_service.assign_agent([agent.id])
expect(conversation.reload.assignee).to eq(agent)
end
it 'opens bot-owned pending conversations when assigning the agent' do
inbox_member
conversation.update!(assignee: nil, assignee_agent_bot: create(:agent_bot, account: account), status: :pending)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
action_service.assign_agent([agent.id])
conversation.reload
expect(conversation.assignee).to eq(agent)
expect(conversation.assignee_agent_bot).to be_nil
expect(conversation.status).to eq('open')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Events::Types::CONVERSATION_BOT_HANDOFF, kind_of(Time), hash_including(conversation: conversation))
end
it 'dispatches bot handoff once when the same conversation is saved again' do
inbox_member
conversation.update!(assignee: nil, assignee_agent_bot: create(:agent_bot, account: account), status: :pending)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
action_service.assign_agent([agent.id])
conversation.update!(priority: :urgent)
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(Events::Types::CONVERSATION_BOT_HANDOFF, kind_of(Time), hash_including(conversation: conversation)).once
end
end
context 'when agent is unconfirmed' do
@@ -19,20 +19,45 @@ describe Conversations::AssignmentService do
expect(conversation.assignee_id).to be_nil
expect(conversation.assignee_agent_bot_id).to be_nil
end
it 'preserves conversation status' do
conversation.update!(status: :snoozed, snoozed_until: 1.day.from_now)
described_class.new(conversation: conversation, assignee_id: nil).perform
expect(conversation.reload.status).to eq('snoozed')
end
end
context 'when assigning a user' do
before do
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil)
conversation.update!(assignee_agent_bot: agent_bot, assignee: nil, status: :pending)
end
it 'sets the agent and clears agent bot' do
it 'sets the agent, clears agent bot and opens the conversation' do
result = described_class.new(conversation: conversation, assignee_id: agent.id).perform
conversation.reload
expect(result).to eq(agent)
expect(conversation.assignee_id).to eq(agent.id)
expect(conversation.assignee_agent_bot_id).to be_nil
expect(conversation.status).to eq('open')
end
it 'preserves status for ordinary human assignment changes' do
conversation.update!(assignee_agent_bot: nil, status: :resolved)
described_class.new(conversation: conversation, assignee_id: agent.id).perform
expect(conversation.reload.status).to eq('resolved')
end
it 'preserves status when taking over a bot-owned non-pending conversation' do
conversation.update!(assignee_agent_bot: agent_bot, status: :resolved)
described_class.new(conversation: conversation, assignee_id: agent.id).perform
expect(conversation.reload.status).to eq('resolved')
end
end
@@ -45,8 +70,8 @@ describe Conversations::AssignmentService do
)
end
it 'sets the agent bot and clears human assignee' do
conversation.update!(assignee: agent, assignee_agent_bot: nil)
it 'sets the agent bot, clears human assignee and marks the conversation pending' do
conversation.update!(assignee: agent, assignee_agent_bot: nil, status: :open)
result = service.perform
@@ -54,6 +79,7 @@ describe Conversations::AssignmentService do
expect(result).to eq(agent_bot)
expect(conversation.assignee_agent_bot_id).to eq(agent_bot.id)
expect(conversation.assignee_id).to be_nil
expect(conversation.status).to eq('pending')
end
end
end