diff --git a/app/jobs/delete_object_job.rb b/app/jobs/delete_object_job.rb index 756d0feb1..4e9030012 100644 --- a/app/jobs/delete_object_job.rb +++ b/app/jobs/delete_object_job.rb @@ -2,10 +2,6 @@ class DeleteObjectJob < ApplicationJob queue_as :low BATCH_SIZE = 5_000 - HEAVY_ASSOCIATIONS = { - Account => %i[conversations contacts inboxes reporting_events], - Inbox => %i[conversations contact_inboxes reporting_events] - }.freeze def perform(object, user = nil, ip = nil) # Pre-purge heavy associations for large objects to avoid @@ -19,11 +15,18 @@ class DeleteObjectJob < ApplicationJob private + def heavy_associations + { + Account => %i[conversations contacts inboxes reporting_events], + Inbox => %i[conversations contact_inboxes reporting_events] + }.freeze + end + def purge_heavy_associations(object) - klass = HEAVY_ASSOCIATIONS.keys.find { |k| object.is_a?(k) } + klass = heavy_associations.keys.find { |k| object.is_a?(k) } return unless klass - HEAVY_ASSOCIATIONS[klass].each do |assoc| + heavy_associations[klass].each do |assoc| next unless object.respond_to?(assoc) batch_destroy(object.public_send(assoc)) diff --git a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb index ec879b042..b3b0f52d4 100644 --- a/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/sla_policies_controller.rb @@ -17,7 +17,7 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc end def destroy - @sla_policy.destroy! + ::DeleteObjectJob.perform_later(@sla_policy, Current.user, request.ip) if @sla_policy.present? head :ok end diff --git a/enterprise/app/jobs/enterprise/delete_object_job.rb b/enterprise/app/jobs/enterprise/delete_object_job.rb index 147fa0d43..375015bc7 100644 --- a/enterprise/app/jobs/enterprise/delete_object_job.rb +++ b/enterprise/app/jobs/enterprise/delete_object_job.rb @@ -1,10 +1,18 @@ module Enterprise::DeleteObjectJob + private + + def heavy_associations + super.merge( + SlaPolicy => %i[applied_slas] + ).freeze + end + def process_post_deletion_tasks(object, user, ip) create_audit_entry(object, user, ip) end def create_audit_entry(object, user, ip) - return unless %w[Inbox Conversation].include?(object.class.to_s) && user.present? + return unless %w[Inbox Conversation SlaPolicy].include?(object.class.to_s) && user.present? Enterprise::AuditLog.create( auditable: object, diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index ad047e8f8..51ec1be3e 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -66,6 +66,31 @@ module Concerns::Toolable [auth_config['username'], auth_config['password']] end + def build_metadata_headers(state) + {}.tap do |headers| + add_base_headers(headers, state) + add_conversation_headers(headers, state[:conversation]) if state[:conversation] + add_contact_headers(headers, state[:contact]) if state[:contact] + end + end + + def add_base_headers(headers, state) + headers['X-Chatwoot-Account-Id'] = state[:account_id].to_s if state[:account_id] + headers['X-Chatwoot-Assistant-Id'] = state[:assistant_id].to_s if state[:assistant_id] + headers['X-Chatwoot-Tool-Slug'] = slug if slug.present? + end + + def add_conversation_headers(headers, conversation) + headers['X-Chatwoot-Conversation-Id'] = conversation[:id].to_s if conversation[:id] + headers['X-Chatwoot-Conversation-Display-Id'] = conversation[:display_id].to_s if conversation[:display_id] + end + + def add_contact_headers(headers, contact) + headers['X-Chatwoot-Contact-Id'] = contact[:id].to_s if contact[:id] + headers['X-Chatwoot-Contact-Email'] = contact[:email].to_s if contact[:email].present? + headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present? + end + def format_response(raw_response_body) return raw_response_body if response_template.blank? diff --git a/enterprise/app/models/sla_policy.rb b/enterprise/app/models/sla_policy.rb index f53f00ed5..f61d5a1f3 100644 --- a/enterprise/app/models/sla_policy.rb +++ b/enterprise/app/models/sla_policy.rb @@ -22,7 +22,7 @@ class SlaPolicy < ApplicationRecord validates :name, presence: true has_many :conversations, dependent: :nullify - has_many :applied_slas, dependent: :destroy + has_many :applied_slas, dependent: :destroy_async def push_event_data { diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb index b634de04e..b4593d27f 100644 --- a/enterprise/lib/captain/tools/http_tool.rb +++ b/enterprise/lib/captain/tools/http_tool.rb @@ -11,11 +11,11 @@ class Captain::Tools::HttpTool < Agents::Tool @custom_tool.enabled? end - def perform(_tool_context, **params) + def perform(tool_context, **params) url = @custom_tool.build_request_url(params) body = @custom_tool.build_request_body(params) - response = execute_http_request(url, body) + response = execute_http_request(url, body, tool_context) @custom_tool.format_response(response.body) rescue StandardError => e Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") @@ -39,7 +39,7 @@ class Captain::Tools::HttpTool < Agents::Tool # 1MB of text ≈ 250K tokens, which exceeds most LLM context windows MAX_RESPONSE_SIZE = 1.megabyte - def execute_http_request(url, body) + def execute_http_request(url, body, tool_context) uri = URI.parse(url) # Check if resolved IP is private @@ -53,6 +53,7 @@ class Captain::Tools::HttpTool < Agents::Tool request = build_http_request(uri, body) apply_authentication(request) + apply_metadata_headers(request, tool_context) response = http.request(request) @@ -102,4 +103,10 @@ class Captain::Tools::HttpTool < Agents::Tool credentials = @custom_tool.build_basic_auth_credentials request.basic_auth(*credentials) if credentials end + + def apply_metadata_headers(request, tool_context) + state = tool_context&.state || {} + metadata_headers = @custom_tool.build_metadata_headers(state) + metadata_headers.each { |key, value| request[key] = value } + end end diff --git a/lib/tasks/apply_sla.rake b/lib/tasks/apply_sla.rake new file mode 100644 index 000000000..70adf8cf3 --- /dev/null +++ b/lib/tasks/apply_sla.rake @@ -0,0 +1,100 @@ +# Apply SLA Policy to Conversations +# +# This task applies an SLA policy to existing conversations that don't have one assigned. +# It processes conversations in batches and only affects conversations with sla_policy_id = nil. +# +# Usage Examples: +# # Using arguments (may need escaping in some shells) +# bundle exec rake "sla:apply_to_conversations[19,1,500]" +# +# # Using environment variables (recommended) +# SLA_POLICY_ID=19 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations +# +# Parameters: +# SLA_POLICY_ID: ID of the SLA policy to apply (required) +# ACCOUNT_ID: ID of the account (required) +# BATCH_SIZE: Number of conversations to process (default: 1000) +# +# Notes: +# - Only runs in development environment +# - Processes conversations in order of newest first (id DESC) +# - Safe to run multiple times - skips conversations that already have SLA policies +# - Creates AppliedSla records automatically via Rails callbacks +# - SlaEvent records are created later by background jobs when violations occur +# +# rubocop:disable Metrics/BlockLength +namespace :sla do + desc 'Apply SLA policy to existing conversations' + task :apply_to_conversations, [:sla_policy_id, :account_id, :batch_size] => :environment do |_t, args| + unless Rails.env.development? + puts 'This task can only be run in the development environment.' + puts "Current environment: #{Rails.env}" + exit(1) + end + + sla_policy_id = args[:sla_policy_id] || ENV.fetch('SLA_POLICY_ID', nil) + account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil) + batch_size = (args[:batch_size] || ENV['BATCH_SIZE'] || 1000).to_i + + if sla_policy_id.blank? + puts 'Error: SLA_POLICY_ID is required' + puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]' + puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations' + exit(1) + end + + if account_id.blank? + puts 'Error: ACCOUNT_ID is required' + puts 'Usage: bundle exec rake sla:apply_to_conversations[sla_policy_id,account_id,batch_size]' + puts 'Or: SLA_POLICY_ID=1 ACCOUNT_ID=1 BATCH_SIZE=500 bundle exec rake sla:apply_to_conversations' + exit(1) + end + + account = Account.find_by(id: account_id) + unless account + puts "Error: Account with ID #{account_id} not found" + exit(1) + end + + sla_policy = account.sla_policies.find_by(id: sla_policy_id) + unless sla_policy + puts "Error: SLA Policy with ID #{sla_policy_id} not found for Account #{account_id}" + exit(1) + end + + conversations = account.conversations.where(sla_policy_id: nil).order(id: :desc).limit(batch_size) + total_count = conversations.count + + if total_count.zero? + puts 'No conversations found without SLA policy' + exit(0) + end + + puts "Applying SLA Policy '#{sla_policy.name}' (ID: #{sla_policy_id}) to #{total_count} conversations in Account #{account_id}" + puts "Processing in batches of #{batch_size}" + puts "Started at: #{Time.current}" + + start_time = Time.current + processed_count = 0 + error_count = 0 + + conversations.find_in_batches(batch_size: batch_size) do |batch| + batch.each do |conversation| + conversation.update!(sla_policy_id: sla_policy_id) + processed_count += 1 + puts "Processed #{processed_count}/#{total_count} conversations" if (processed_count % 100).zero? + rescue StandardError => e + error_count += 1 + puts "Error applying SLA to conversation #{conversation.id}: #{e.message}" + end + end + + elapsed_time = Time.current - start_time + puts "\nCompleted!" + puts "Successfully processed: #{processed_count} conversations" + puts "Errors encountered: #{error_count}" if error_count.positive? + puts "Total time: #{elapsed_time.round(2)}s" + puts "Average time per conversation: #{(elapsed_time / processed_count).round(3)}s" if processed_count.positive? + end +end +# rubocop:enable Metrics/BlockLength diff --git a/lib/tasks/bulk_conversations.rake b/lib/tasks/bulk_conversations.rake new file mode 100644 index 000000000..e17851f31 --- /dev/null +++ b/lib/tasks/bulk_conversations.rake @@ -0,0 +1,176 @@ +# Generate Bulk Conversations +# +# This task creates bulk conversations with fake contacts and movie dialogue messages +# for testing purposes. Each conversation gets random messages between contacts and agents. +# +# Usage Examples: +# # Using arguments (may need escaping in some shells) +# bundle exec rake "conversations:generate_bulk[100,1,1]" +# +# # Using environment variables (recommended) +# COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk +# +# # Generate 50 conversations +# COUNT=50 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk +# +# Parameters: +# COUNT: Number of conversations to create (default: 10) +# ACCOUNT_ID: ID of the account (required) +# INBOX_ID: ID of the inbox that belongs to the account (required) +# +# What it creates: +# - Unique contacts with fake names, emails, phone numbers +# - Conversations with random status (open/resolved/pending) +# - 3-10 messages per conversation with movie quotes +# - Alternating incoming/outgoing message flow +# +# Notes: +# - Only runs in development environment +# - Creates realistic test data for conversation testing +# - Progress shown every 10 conversations +# - All contacts get unique email addresses to avoid conflicts +# +# rubocop:disable Metrics/BlockLength +namespace :conversations do + desc 'Generate bulk conversations with contacts and movie dialogue messages' + task :generate_bulk, [:count, :account_id, :inbox_id] => :environment do |_t, args| + unless Rails.env.development? + puts 'This task can only be run in the development environment.' + puts "Current environment: #{Rails.env}" + exit(1) + end + + count = (args[:count] || ENV['COUNT'] || 10).to_i + account_id = args[:account_id] || ENV.fetch('ACCOUNT_ID', nil) + inbox_id = args[:inbox_id] || ENV.fetch('INBOX_ID', nil) + + if account_id.blank? + puts 'Error: ACCOUNT_ID is required' + puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]' + puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk' + exit(1) + end + + if inbox_id.blank? + puts 'Error: INBOX_ID is required' + puts 'Usage: bundle exec rake conversations:generate_bulk[count,account_id,inbox_id]' + puts 'Or: COUNT=100 ACCOUNT_ID=1 INBOX_ID=1 bundle exec rake conversations:generate_bulk' + exit(1) + end + + account = Account.find_by(id: account_id) + inbox = Inbox.find_by(id: inbox_id) + + unless account + puts "Error: Account with ID #{account_id} not found" + exit(1) + end + + unless inbox + puts "Error: Inbox with ID #{inbox_id} not found" + exit(1) + end + + unless inbox.account_id == account.id + puts "Error: Inbox #{inbox_id} does not belong to Account #{account_id}" + exit(1) + end + + puts "Generating #{count} conversations for Account ##{account.id} in Inbox ##{inbox.id}..." + puts "Started at: #{Time.current}" + + start_time = Time.current + created_count = 0 + + count.times do |i| + contact = create_contact(account) + contact_inbox = create_contact_inbox(contact, inbox) + conversation = create_conversation(contact_inbox) + add_messages(conversation) + + created_count += 1 + puts "Created conversation #{i + 1}/#{count} (ID: #{conversation.id})" if ((i + 1) % 10).zero? + rescue StandardError => e + puts "Error creating conversation #{i + 1}: #{e.message}" + puts e.backtrace.first(5).join("\n") + end + + elapsed_time = Time.current - start_time + puts "\nCompleted!" + puts "Successfully created: #{created_count} conversations" + puts "Total time: #{elapsed_time.round(2)}s" + puts "Average time per conversation: #{(elapsed_time / created_count).round(3)}s" if created_count.positive? + end + + def create_contact(account) + Contact.create!( + account: account, + name: Faker::Name.name, + email: "#{SecureRandom.uuid}@example.com", + phone_number: generate_e164_phone_number, + additional_attributes: { + source: 'bulk_generator', + company: Faker::Company.name, + city: Faker::Address.city + } + ) + end + + def generate_e164_phone_number + country_code = [1, 44, 61, 91, 81].sample + subscriber_number = rand(1_000_000..9_999_999_999).to_s + subscriber_number = subscriber_number[0...(15 - country_code.to_s.length)] + "+#{country_code}#{subscriber_number}" + end + + def create_contact_inbox(contact, inbox) + ContactInboxBuilder.new( + contact: contact, + inbox: inbox + ).perform + end + + def create_conversation(contact_inbox) + ConversationBuilder.new( + params: ActionController::Parameters.new( + status: %w[open resolved pending].sample, + additional_attributes: {}, + custom_attributes: {} + ), + contact_inbox: contact_inbox + ).perform + end + + def add_messages(conversation) + num_messages = rand(3..10) + message_type = %w[incoming outgoing].sample + + num_messages.times do + message_type = message_type == 'incoming' ? 'outgoing' : 'incoming' + create_message(conversation, message_type) + end + end + + def create_message(conversation, message_type) + sender = if message_type == 'incoming' + conversation.contact + else + conversation.account.users.sample || conversation.account.administrators.first + end + + conversation.messages.create!( + account: conversation.account, + inbox: conversation.inbox, + sender: sender, + message_type: message_type, + content: generate_movie_dialogue, + content_type: :text, + private: false + ) + end + + def generate_movie_dialogue + Faker::Movie.quote + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb index b1619ef85..e1a4fa538 100644 --- a/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb +++ b/spec/enterprise/controllers/api/v1/accounts/sla_policies_controller_spec.rb @@ -161,12 +161,13 @@ RSpec.describe 'Enterprise SLA API', type: :request do let(:sla_policy) { create(:sla_policy, account: account) } context 'when it is an authenticated user' do - it 'deletes the sla_policy' do + it 'queues the sla_policy for deletion' do + expect(DeleteObjectJob).to receive(:perform_later).with(sla_policy, administrator, kind_of(String)) + delete "/api/v1/accounts/#{account.id}/sla_policies/#{sla_policy.id}", headers: administrator.create_new_auth_token expect(response).to have_http_status(:success) - expect(SlaPolicy.count).to eq(1) end end diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index d48af2752..967a10574 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -237,5 +237,135 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(result).to eq('Created order #ORD-789 for Widget') end end + + context 'with metadata headers' do + let(:conversation) { create(:conversation, account: account) } + let(:contact) { conversation.contact } + let(:tool_context_with_state) do + Struct.new(:state).new({ + account_id: account.id, + assistant_id: assistant.id, + conversation: { + id: conversation.id, + display_id: conversation.display_id + }, + contact: { + id: contact.id, + email: contact.email, + phone_number: contact.phone_number + } + }) + end + + before do + custom_tool.update!( + endpoint_url: 'https://example.com/api/data', + response_template: nil + ) + end + + it 'includes metadata headers in GET request' do + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Assistant-Id' => assistant.id.to_s, + 'X-Chatwoot-Tool-Slug' => custom_tool.slug, + 'X-Chatwoot-Conversation-Id' => conversation.id.to_s, + 'X-Chatwoot-Conversation-Display-Id' => conversation.display_id.to_s, + 'X-Chatwoot-Contact-Id' => contact.id.to_s, + 'X-Chatwoot-Contact-Email' => contact.email + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_with_state) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Email' => contact.email + }) + end + + it 'includes metadata headers in POST request' do + custom_tool.update!(http_method: 'POST', request_template: '{"data": "test"}') + + stub_request(:post, 'https://example.com/api/data') + .with( + body: '{"data": "test"}', + headers: { + 'Content-Type' => 'application/json', + 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Tool-Slug' => custom_tool.slug, + 'X-Chatwoot-Contact-Email' => contact.email + } + ) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_with_state) + + expect(WebMock).to have_requested(:post, 'https://example.com/api/data') + end + + it 'includes metadata headers along with authentication headers' do + custom_tool.update!( + auth_type: 'bearer', + auth_config: { 'token' => 'test_token' } + ) + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'Authorization' => 'Bearer test_token', + 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Id' => contact.id.to_s + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_with_state) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { + 'Authorization' => 'Bearer test_token', + 'X-Chatwoot-Contact-Id' => contact.id.to_s + }) + end + + it 'handles missing contact in tool context' do + tool_context_no_contact = Struct.new(:state).new({ + account_id: account.id, + assistant_id: assistant.id, + conversation: { + id: conversation.id, + display_id: conversation.display_id + } + }) + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Conversation-Id' => conversation.id.to_s + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_no_contact) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + end + + it 'includes contact phone when present' do + contact.update!(phone_number: '+1234567890') + tool_context_with_state.state[:contact][:phone_number] = '+1234567890' + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Contact-Phone' => '+1234567890' + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_with_state) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' }) + end + end end end diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index 5f6c7b19a..f936eeaa5 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -327,6 +327,98 @@ RSpec.describe Captain::CustomTool, type: :model do end end + describe '#build_metadata_headers' do + let(:tool) { create(:captain_custom_tool, account: account, slug: 'custom_test_tool') } + let(:conversation) { create(:conversation, account: account) } + let(:contact) { conversation.contact } + + let(:state) do + { + account_id: account.id, + assistant_id: 123, + conversation: { + id: conversation.id, + display_id: conversation.display_id + }, + contact: { + id: contact.id, + email: contact.email, + phone_number: contact.phone_number + } + } + end + + it 'includes account and assistant metadata' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s) + expect(headers['X-Chatwoot-Assistant-Id']).to eq('123') + end + + it 'includes tool slug' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool') + end + + it 'includes conversation metadata when present' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Conversation-Id']).to eq(conversation.id.to_s) + expect(headers['X-Chatwoot-Conversation-Display-Id']).to eq(conversation.display_id.to_s) + end + + it 'includes contact metadata when present' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Id']).to eq(contact.id.to_s) + expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email) + end + + it 'handles missing conversation gracefully' do + state[:conversation] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Conversation-Id']).to be_nil + expect(headers['X-Chatwoot-Conversation-Display-Id']).to be_nil + expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s) + end + + it 'handles missing contact gracefully' do + state[:contact] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Id']).to be_nil + expect(headers['X-Chatwoot-Contact-Email']).to be_nil + expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s) + end + + it 'handles empty state' do + headers = tool.build_metadata_headers({}) + + expect(headers).to be_a(Hash) + expect(headers['X-Chatwoot-Tool-Slug']).to eq('custom_test_tool') + end + + it 'omits contact email header when email is blank' do + state[:contact][:email] = '' + + headers = tool.build_metadata_headers(state) + + expect(headers).not_to have_key('X-Chatwoot-Contact-Email') + end + + it 'omits contact phone header when phone number is blank' do + state[:contact][:phone_number] = '' + + headers = tool.build_metadata_headers(state) + + expect(headers).not_to have_key('X-Chatwoot-Contact-Phone') + end + end + describe '#to_tool_metadata' do it 'returns tool metadata hash with custom flag' do tool = create(:captain_custom_tool, account: account,