AgentBot-owned conversations are now treated as assigned across conversation lists, counts, pagination, permissions, unread membership, human auto-assignment, and advanced assignee filters. ## Closes - https://linear.app/chatwoot/issue/CW-7689/align-agent-bot-ownership-with-unassigned-counts-and-pagination ## Follow-ups - https://linear.app/chatwoot/issue/CW-7870/refresh-agentbot-ownership-state-when-deleting-an-agent-bot tracks ownership refresh during AgentBot deletion. - https://linear.app/chatwoot/issue/CW-7899/refresh-saved-filter-totals-after-live-conversation-ownership-changes tracks the existing saved-filter header count refresh gap. ## Why The backend treated every conversation without a human assignee as unassigned, even when an AgentBot owned it. The frontend already hid AgentBot-owned conversations from the Unassigned list, so counts, pagination, filters, unread membership, direct-access permissions, and auto-assignment could disagree with the visible queue. ## What changed - Treat conversations with either a human assignee or AgentBot owner as assigned. - Keep conversation counts, pagination, unread memberships, advanced filters, and automation assignee conditions aligned with the shared ownership semantics. - Keep human assignee equality and not-equality filters human-only, even when a human and AgentBot have the same numeric ID. - Exclude AgentBot-owned conversations from both legacy and V2 human auto-assignment, and from unassigned-only Enterprise access. - Preserve AgentBot ownership when Twilio or WhatsApp call flows reuse or accept an assigned conversation. - Emit ownership-change updates when only the AgentBot owner changes, so connected clients refresh queue state. ## Validation - AgentBot assignment changed ownership to the bot, moved the conversation to pending, and removed it from the open queue. - Assignee "is present" returned human- and AgentBot-owned conversations; "is not present" returned only genuinely unassigned conversations. - Automation assignee presence conditions treated AgentBot ownership as present and did not execute the absent-owner path. - Live human-assignee equality and not-equality filters excluded AgentBot-owned conversations, including numeric ID collisions. - A 32-conversation pending queue loaded across pagination with matching totals and no missing or duplicate rows. - AgentBot ownership changes and human takeover updated filtered rows immediately without a reload. - Human takeover opened the conversation and restored the public reply composer; subsequent unassignment kept the conversation open. - An unassigned-only custom-role agent saw only genuinely unassigned conversations and could not see AgentBot-owned conversations. - AgentBot-owned conversations showed the handled-by-bot banner, Take over action, and disabled public reply composer. - New conversations in the connected inbox were assigned to the AgentBot and excluded from human auto-assignment. - Opening an AgentBot-owned conversation did not let the legacy assignment callback or its locked recheck replace the bot. - Moving an AgentBot-owned conversation to an auto-assigning team preserved the bot and did not create a second human owner. - Twilio conference pickup, Twilio outbound reuse, WhatsApp outbound reuse, and inbound WhatsApp acceptance preserved existing AgentBot owners. - Focused ownership, filters, pagination, permissions, unread-count, auto-assignment, frontend, and lint checks passed locally. - GitHub Actions, Docker builds, CircleCI, security checks, and the final Codex review are green on the final head.
96 lines
4.2 KiB
Ruby
96 lines
4.2 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Conversations::UnreadCounts::Builder do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:label) { create(:label, account: account, title: 'urgent', show_on_sidebar: true) }
|
|
let(:assignee) { create(:user, account: account, role: :agent) }
|
|
let(:team) { create(:team, account: account, allow_auto_assign: false) }
|
|
let(:store) { Conversations::UnreadCounts::Store }
|
|
|
|
after do
|
|
store.clear_account!(account.id)
|
|
end
|
|
|
|
describe '#build_base!' do
|
|
it 'stores unread open conversations by inbox and label inbox' do
|
|
unread_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
|
create_read_conversation
|
|
create_resolved_unread_conversation
|
|
|
|
described_class.new(account).build_base!
|
|
|
|
expect(store.base_ready?(account.id)).to be(true)
|
|
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
|
|
expect(redis_set_members(store.label_inbox_key(account.id, label.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
|
|
expect(redis_set_members(store.team_inbox_key(account.id, team.id, inbox.id))).to contain_exactly(unread_conversation.id.to_s)
|
|
end
|
|
|
|
it 'clears assignment-aware cache data before rebuilding base data' do
|
|
assigned_conversation = create_unread_conversation(
|
|
account: account,
|
|
inbox: inbox,
|
|
labels: [label.title],
|
|
assignee: assignee,
|
|
team: team
|
|
)
|
|
|
|
described_class.new(account).build_assignment!
|
|
described_class.new(account).build_base!
|
|
|
|
expect(store.assignment_ready?(account.id)).to be(false)
|
|
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to be_empty
|
|
expect(redis_set_members(store.inbox_key(account.id, inbox.id))).to contain_exactly(assigned_conversation.id.to_s)
|
|
end
|
|
end
|
|
|
|
describe '#build_assignment!' do
|
|
it 'stores unread open conversations by unassigned and assignee dimensions' do
|
|
assigned_conversation = create_unread_conversation(
|
|
account: account,
|
|
inbox: inbox,
|
|
labels: [label.title],
|
|
assignee: assignee,
|
|
team: team
|
|
)
|
|
unassigned_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
|
agent_bot_conversation = create_unread_conversation(account: account, inbox: inbox, labels: [label.title], team: team)
|
|
agent_bot_conversation.update!(assignee_agent_bot: create(:agent_bot, account: account))
|
|
|
|
described_class.new(account).build_assignment!
|
|
|
|
expect(store.assignment_ready?(account.id)).to be(true)
|
|
expect(redis_set_members(store.inbox_assignee_key(account.id, inbox.id, assignee.id))).to contain_exactly(assigned_conversation.id.to_s)
|
|
expect(redis_set_members(store.label_inbox_assignee_key(account.id, label.id, inbox.id, assignee.id))).to contain_exactly(
|
|
assigned_conversation.id.to_s
|
|
)
|
|
expect(redis_set_members(store.team_inbox_assignee_key(account.id, team.id, inbox.id, assignee.id))).to contain_exactly(
|
|
assigned_conversation.id.to_s
|
|
)
|
|
expect(redis_set_members(store.inbox_unassigned_key(account.id, inbox.id))).to contain_exactly(unassigned_conversation.id.to_s)
|
|
expect(redis_set_members(store.label_inbox_unassigned_key(account.id, label.id, inbox.id))).to contain_exactly(
|
|
unassigned_conversation.id.to_s
|
|
)
|
|
expect(redis_set_members(store.team_inbox_unassigned_key(account.id, team.id, inbox.id))).to contain_exactly(
|
|
unassigned_conversation.id.to_s
|
|
)
|
|
end
|
|
end
|
|
|
|
def create_read_conversation
|
|
conversation = create(:conversation, account: account, inbox: inbox, agent_last_seen_at: 1.minute.from_now)
|
|
create(:message, account: account, inbox: inbox, conversation: conversation, message_type: :incoming)
|
|
conversation
|
|
end
|
|
|
|
def create_resolved_unread_conversation
|
|
conversation = create_unread_conversation(account: account, inbox: inbox)
|
|
conversation.update!(status: :resolved)
|
|
conversation
|
|
end
|
|
|
|
def redis_set_members(key)
|
|
Redis::Alfred.pipelined { |pipeline| pipeline.smembers(key) }.first
|
|
end
|
|
end
|