From 5fd3d5e036c6b4fe3e3beb164c59b6dc9bd830b3 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:39:14 +0530 Subject: [PATCH 1/2] feat: allow zero conversation limit capacity policy (#13964) ## Description Two improvements to Agent Capacity Policy: **1. Support exclusion via zero conversation limit** Allow `conversation_limit` to be `0` on inbox capacity limits. Agents with a zero limit are excluded from auto-assignment for that inbox while remaining members for manual assignment. **2. Fix exclusion rules duration input** - Default changed from `10` to `null` so time-based exclusion isn't applied unless explicitly set. - Minimum lowered from 10 to 1 minute. - `DurationInput` updated to handle `null` values correctly. ## Type of change Please delete options that are not relevant. - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? - Added model and capacity service specs for zero-limit exclusion behavior. - Tested manually via UI flows ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Muhsin Keloth --- .../components/ExclusionRules.vue | 6 +-- .../components/InboxCapacityLimits.vue | 3 +- .../components-next/input/DurationInput.vue | 6 +++ .../pages/AgentCapacityEditPage.vue | 2 +- .../components/AgentCapacityPolicyForm.vue | 6 +-- enterprise/app/models/inbox_capacity_limit.rb | 2 +- .../models/inbox_capacity_limit_spec.rb | 14 +++++- .../auto_assignment/capacity_service_spec.rb | 49 +++++++++++++++++++ 8 files changed, 78 insertions(+), 10 deletions(-) diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue index 3ac7dfd5f..2aafec45b 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/ExclusionRules.vue @@ -20,11 +20,11 @@ const excludedLabels = defineModel('excludedLabels', { const excludeOlderThanMinutes = defineModel('excludeOlderThanMinutes', { type: Number, - default: 10, + default: null, }); -// Duration limits: 10 minutes to 999 days (in minutes) -const MIN_DURATION_MINUTES = 10; +// Duration limits: 1 minute to 999 days (in minutes) +const MIN_DURATION_MINUTES = 1; const MAX_DURATION_MINUTES = 1438560; // 999 days * 24 hours * 60 minutes const { t } = useI18n(); diff --git a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue index b31248653..7b79e5280 100644 --- a/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue +++ b/app/javascript/dashboard/components-next/AssignmentPolicy/components/InboxCapacityLimits.vue @@ -27,7 +27,7 @@ const { t } = useI18n(); const BASE_KEY = 'ASSIGNMENT_POLICY.AGENT_CAPACITY_POLICY'; const DEFAULT_CONVERSATION_LIMIT = 10; -const MIN_CONVERSATION_LIMIT = 1; +const MIN_CONVERSATION_LIMIT = 0; const MAX_CONVERSATION_LIMIT = 100000; const selectedInboxIds = computed( @@ -42,6 +42,7 @@ const availableInboxes = computed(() => const isLimitValid = limit => { return ( + Number.isInteger(limit.conversationLimit) && limit.conversationLimit >= MIN_CONVERSATION_LIMIT && limit.conversationLimit <= MAX_CONVERSATION_LIMIT ); diff --git a/app/javascript/dashboard/components-next/input/DurationInput.vue b/app/javascript/dashboard/components-next/input/DurationInput.vue index 7a9fbc12d..b0597648a 100644 --- a/app/javascript/dashboard/components-next/input/DurationInput.vue +++ b/app/javascript/dashboard/components-next/input/DurationInput.vue @@ -32,6 +32,7 @@ const convertToMinutes = newValue => { const transformedValue = computed({ get() { + if (duration.value == null) return null; if (unit.value === DURATION_UNITS.MINUTES) return duration.value; if (unit.value === DURATION_UNITS.HOURS) return Math.floor(duration.value / 60); @@ -41,6 +42,10 @@ const transformedValue = computed({ return 0; }, set(newValue) { + if (newValue == null || newValue === '') { + duration.value = null; + return; + } let minuteValue = convertToMinutes(newValue); duration.value = Math.min(Math.max(minuteValue, props.min), props.max); @@ -53,6 +58,7 @@ const transformedValue = computed({ // this might create some confusion, especially when saving // this watcher fixes it by rounding the duration basically, to the nearest unit value watch(unit, () => { + if (duration.value == null) return; let adjustedValue = convertToMinutes(transformedValue.value); duration.value = Math.min(Math.max(adjustedValue, props.min), props.max); }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue index 390608733..31d0fa3e1 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentCapacityEditPage.vue @@ -81,7 +81,7 @@ const formData = computed(() => ({ ...(selectedPolicy.value?.exclusionRules?.excludedLabels || []), ], excludeOlderThanHours: - selectedPolicy.value?.exclusionRules?.excludeOlderThanHours || 10, + selectedPolicy.value?.exclusionRules?.excludeOlderThanHours ?? null, }, inboxCapacityLimits: selectedPolicy.value?.inboxCapacityLimits?.map(limit => ({ diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue index 24c4f0a38..ec82e0c16 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentCapacityPolicyForm.vue @@ -17,7 +17,7 @@ const props = defineProps({ enabled: false, exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }), @@ -84,7 +84,7 @@ const state = reactive({ description: '', exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }); @@ -120,7 +120,7 @@ const resetForm = () => { description: '', exclusionRules: { excludedLabels: [], - excludeOlderThanHours: 10, + excludeOlderThanHours: null, }, inboxCapacityLimits: [], }); diff --git a/enterprise/app/models/inbox_capacity_limit.rb b/enterprise/app/models/inbox_capacity_limit.rb index 709dd809f..7ae74d427 100644 --- a/enterprise/app/models/inbox_capacity_limit.rb +++ b/enterprise/app/models/inbox_capacity_limit.rb @@ -19,6 +19,6 @@ class InboxCapacityLimit < ApplicationRecord belongs_to :agent_capacity_policy belongs_to :inbox - validates :conversation_limit, presence: true, numericality: { greater_than: 0, only_integer: true } + validates :conversation_limit, presence: true, numericality: { greater_than_or_equal_to: 0, only_integer: true } validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id } end diff --git a/spec/enterprise/models/inbox_capacity_limit_spec.rb b/spec/enterprise/models/inbox_capacity_limit_spec.rb index 8c3f76dc4..64d9b08a0 100644 --- a/spec/enterprise/models/inbox_capacity_limit_spec.rb +++ b/spec/enterprise/models/inbox_capacity_limit_spec.rb @@ -9,10 +9,22 @@ RSpec.describe InboxCapacityLimit, type: :model do subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) } it { is_expected.to validate_presence_of(:conversation_limit) } - it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than(0).only_integer } + it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than_or_equal_to(0).only_integer } it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) } end + describe 'zero conversation limit (exclusion policy)' do + it 'allows conversation_limit of 0' do + limit = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox, conversation_limit: 0) + expect(limit).to be_valid + end + + it 'rejects negative conversation_limit' do + limit = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox, conversation_limit: -1) + expect(limit).not_to be_valid + end + end + describe 'uniqueness constraint' do it 'prevents duplicate inbox limits for the same policy' do create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) diff --git a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb index c34eaad64..f1a7cfe49 100644 --- a/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb +++ b/spec/enterprise/services/enterprise/auto_assignment/capacity_service_spec.rb @@ -86,6 +86,55 @@ RSpec.describe Enterprise::AutoAssignment::CapacityService, type: :service do end end + describe 'exclusion policy (zero conversation limit)' do + let(:excluded_agent) { create(:user, account: account, role: :agent, availability: :online) } + let(:exclusion_policy) { create(:agent_capacity_policy, account: account, name: 'Exclusion Policy') } + + before do + create(:inbox_capacity_limit, + agent_capacity_policy: exclusion_policy, + inbox: inbox, + conversation_limit: 0) + + excluded_agent.account_users.find_by(account: account) + .update!(agent_capacity_policy: exclusion_policy) + + create(:inbox_member, inbox: inbox, user: excluded_agent) + + allow(OnlineStatusTracker).to receive(:get_available_users).and_return({ + excluded_agent.id.to_s => 'online', + agent_with_capacity.id.to_s => 'online', + agent_without_capacity.id.to_s => 'online', + agent_at_capacity.id.to_s => 'online' + }) + end + + it 'always denies capacity for agents with zero limit' do + capacity_service = described_class.new + expect(capacity_service.agent_has_capacity?(excluded_agent, inbox)).to be false + end + + it 'denies capacity even when agent has no existing conversations' do + capacity_service = described_class.new + # Agent has 0 open conversations but limit is 0, so 0 < 0 is false + expect(excluded_agent.assigned_conversations.where(inbox: inbox, status: :open).count).to eq(0) + expect(capacity_service.agent_has_capacity?(excluded_agent, inbox)).to be false + end + + it 'excludes zero-limit agents from available agents list' do + capacity_service = described_class.new + online_agents = inbox.available_agents + filtered_agents = online_agents.select do |inbox_member| + capacity_service.agent_has_capacity?(inbox_member.user, inbox) + end + available_users = filtered_agents.map(&:user) + + expect(available_users).not_to include(excluded_agent) + expect(available_users).to include(agent_with_capacity) + expect(available_users).to include(agent_without_capacity) + end + end + describe 'assignment with capacity' do let(:service) { AutoAssignment::AssignmentService.new(inbox: inbox) } From f4d66566d0c80a7ae19de51abae5afead666c6cb Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 6 Apr 2026 11:14:09 +0400 Subject: [PATCH 2/2] fix(agent-bot): Include `changed_attributes` in conversation_updated webhook (#14001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `conversation_updated` webhook sent to AgentBots did not include `changed_attributes`, making it impossible for bots to distinguish between different types of conversation updates (e.g. bot assignment vs label change vs status change). This aligns the AgentBot webhook payload with the existing `WebhookListener` behavior, which already includes `changed_attributes`. ## How to reproduce 1. Assign an AgentBot to a conversation 2. Then update the conversation (e.g. add a label) 3. **Before fix:** Both events arrive with identical payload structure — bot cannot tell them apart 4. **After fix:** Each event includes `changed_attributes` showing exactly what changed ## What changed - **`AgentBotListener#conversation_updated`**: Added `changed_attributes` to the webhook payload using `extract_changed_attributes` (same pattern as `WebhookListener`) ## How to test 1. Assign an AgentBot to a conversation via API 2. Check the webhook payload — should include: ```json "changed_attributes": [ { "assignee_agent_bot_id": { "previous_value": null, "current_value": 7 } } ] ``` 3. Update the conversation (e.g. add a label) 4. Check the webhook payload — `changed_attributes` should reflect the label change, not bot assignment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- app/listeners/agent_bot_listener.rb | 3 ++- spec/listeners/agent_bot_listener_spec.rb | 23 ++++++++++++++++++----- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/app/listeners/agent_bot_listener.rb b/app/listeners/agent_bot_listener.rb index 8fdf964fb..08ee563af 100644 --- a/app/listeners/agent_bot_listener.rb +++ b/app/listeners/agent_bot_listener.rb @@ -17,9 +17,10 @@ class AgentBotListener < BaseListener def conversation_updated(event) conversation = extract_conversation_and_account(event)[0] + changed_attributes = extract_changed_attributes(event) inbox = conversation.inbox event_name = __method__.to_s - payload = conversation.webhook_data.merge(event: event_name) + payload = conversation.webhook_data.merge(event: event_name, changed_attributes: changed_attributes) agent_bots_for(inbox, conversation).each { |agent_bot| process_webhook_bot_event(agent_bot, payload) } end diff --git a/spec/listeners/agent_bot_listener_spec.rb b/spec/listeners/agent_bot_listener_spec.rb index 24af37383..a9721f9c7 100644 --- a/spec/listeners/agent_bot_listener_spec.rb +++ b/spec/listeners/agent_bot_listener_spec.rb @@ -59,9 +59,10 @@ describe AgentBotListener do describe '#conversation_updated' do let(:event_name) { 'conversation.updated' } - let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } context 'when agent bot is not configured' do + let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } + it 'does not send webhook' do expect(AgentBots::WebhookJob).not_to receive(:perform_later) listener.conversation_updated(event) @@ -69,22 +70,34 @@ describe AgentBotListener do end context 'when agent bot is configured on inbox' do - it 'sends webhook to the inbox agent bot' do + let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) } + + it 'sends webhook to the inbox agent bot with changed_attributes' do create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot) expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, - conversation.webhook_data.merge(event: 'conversation_updated')).once + conversation.webhook_data.merge(event: 'conversation_updated', + changed_attributes: nil)).once listener.conversation_updated(event) end end context 'when conversation is assigned to an agent bot' do + let!(:event) do + Events::Base.new(event_name, Time.zone.now, conversation: conversation, + changed_attributes: { 'assignee_agent_bot_id' => [nil, agent_bot.id] }) + end + before do conversation.update!(assignee_agent_bot: agent_bot, assignee: nil) end - it 'sends webhook to the assigned agent bot' do + it 'sends webhook with changed_attributes to the assigned agent bot' do + expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }] expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, - conversation.webhook_data.merge(event: 'conversation_updated')).once + conversation.webhook_data.merge( + event: 'conversation_updated', + changed_attributes: expected_changed_attributes + )).once listener.conversation_updated(event) end end