From 7c60ad9e28950c71985194781d91db752f468ffe Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 26 Feb 2026 16:16:33 +0530 Subject: [PATCH] feat: include contact verified status with each tool call (#13663) Co-authored-by: aakashb95 --- enterprise/app/models/concerns/toolable.rb | 6 ++ .../captain/assistant/agent_runner_service.rb | 21 ++++--- .../lib/captain/prompts/assistant.liquid | 2 +- .../lib/captain/prompts/scenario.liquid | 2 +- .../lib/captain/tools/http_tool_spec.rb | 58 ++++++++++++++++++- .../models/captain/custom_tool_spec.rb | 37 ++++++++++++ .../assistant/agent_runner_service_spec.rb | 9 +++ 7 files changed, 125 insertions(+), 10 deletions(-) diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index 51ec1be3e..f40ac4a65 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -71,6 +71,7 @@ module Concerns::Toolable add_base_headers(headers, state) add_conversation_headers(headers, state[:conversation]) if state[:conversation] add_contact_headers(headers, state[:contact]) if state[:contact] + add_contact_inbox_headers(headers, state[:contact_inbox]) end end @@ -91,6 +92,11 @@ module Concerns::Toolable headers['X-Chatwoot-Contact-Phone'] = contact[:phone_number].to_s if contact[:phone_number].present? end + def add_contact_inbox_headers(headers, contact_inbox) + headers['X-Chatwoot-Contact-Inbox-Id'] = contact_inbox[:id].to_s if contact_inbox&.[](:id) + headers['X-Chatwoot-Contact-Inbox-Verified'] = (contact_inbox&.[](:hmac_verified) || false).to_s + end + def format_response(raw_response_body) return raw_response_body if response_template.blank? diff --git a/enterprise/app/services/captain/assistant/agent_runner_service.rb b/enterprise/app/services/captain/assistant/agent_runner_service.rb index 8238ca4d8..bdf35e98e 100644 --- a/enterprise/app/services/captain/assistant/agent_runner_service.rb +++ b/enterprise/app/services/captain/assistant/agent_runner_service.rb @@ -16,6 +16,8 @@ class Captain::Assistant::AgentRunnerService custom_attributes additional_attributes ].freeze + CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze + CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze def initialize(assistant:, conversation: nil, callbacks: {}) @@ -127,16 +129,21 @@ class Captain::Assistant::AgentRunnerService assistant_config: @assistant.config } - if @conversation - state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) - state[:channel_type] = @conversation.inbox&.channel_type - state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact - state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign - end - + build_conversation_state(state) if @conversation state end + def build_conversation_state(state) + state[:conversation] = @conversation.attributes.symbolize_keys.slice(*CONVERSATION_STATE_ATTRIBUTES) + state[:channel_type] = @conversation.inbox&.channel_type + state[:contact] = @conversation.contact.attributes.symbolize_keys.slice(*CONTACT_STATE_ATTRIBUTES) if @conversation.contact + state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign + return unless @conversation.contact_inbox + + state[:contact_inbox] = + @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES) + end + def build_and_wire_agents assistant_agent = @assistant.agent scenario_agents = @assistant.scenarios.enabled.map(&:agent) diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 1ba3ba7a7..aa94ae1d4 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc {% endif -%} {% if campaign.id -%} -{% render 'campaign' %} +{% render 'campaign', campaign: campaign %} {% endif -%} {% endif -%} diff --git a/enterprise/lib/captain/prompts/scenario.liquid b/enterprise/lib/captain/prompts/scenario.liquid index 10eeb6fd7..6d0f11821 100644 --- a/enterprise/lib/captain/prompts/scenario.liquid +++ b/enterprise/lib/captain/prompts/scenario.liquid @@ -22,7 +22,7 @@ Here's the metadata we have about the current conversation and the contact assoc {% endif -%} {% if campaign.id -%} -{% render 'campaign' %} +{% render 'campaign', campaign: campaign %} {% endif -%} {% endif -%} diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index 967a10574..e05308a7d 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -249,6 +249,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do id: conversation.id, display_id: conversation.display_id }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + }, contact: { id: contact.id, email: contact.email, @@ -272,6 +276,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do '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-Inbox-Id' => conversation.contact_inbox.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Id' => contact.id.to_s, 'X-Chatwoot-Contact-Email' => contact.email }) @@ -282,6 +288,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') .with(headers: { 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Email' => contact.email }) end @@ -296,6 +303,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do 'Content-Type' => 'application/json', 'X-Chatwoot-Account-Id' => account.id.to_s, 'X-Chatwoot-Tool-Slug' => custom_tool.slug, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Email' => contact.email } ) @@ -316,6 +324,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do .with(headers: { 'Authorization' => 'Bearer test_token', 'X-Chatwoot-Account-Id' => account.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s, 'X-Chatwoot-Contact-Id' => contact.id.to_s }) .to_return(status: 200, body: '{"success": true}') @@ -336,13 +345,18 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do conversation: { id: conversation.id, display_id: conversation.display_id + }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified } }) 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 + 'X-Chatwoot-Conversation-Id' => conversation.id.to_s, + 'X-Chatwoot-Contact-Inbox-Verified' => conversation.contact_inbox.hmac_verified.to_s }) .to_return(status: 200, body: '{"success": true}') @@ -351,6 +365,32 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') end + it 'defaults contact inbox verified header to false when contact inbox is missing' do + tool_context_without_contact_inbox = 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 + } + }) + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Contact-Inbox-Verified' => 'false' + }) + .to_return(status: 200, body: '{"success": true}') + + tool.perform(tool_context_without_contact_inbox) + + expect(WebMock).to have_requested(:get, 'https://example.com/api/data') + .with(headers: { 'X-Chatwoot-Contact-Inbox-Verified' => 'false' }) + end + it 'includes contact phone when present' do contact.update!(phone_number: '+1234567890') tool_context_with_state.state[:contact][:phone_number] = '+1234567890' @@ -366,6 +406,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/api/data') .with(headers: { 'X-Chatwoot-Contact-Phone' => '+1234567890' }) end + + it 'includes unverified contact inbox status explicitly as false' do + conversation.contact_inbox.update!(hmac_verified: false) + tool_context_with_state.state[:contact_inbox][:hmac_verified] = false + + stub_request(:get, 'https://example.com/api/data') + .with(headers: { + 'X-Chatwoot-Contact-Inbox-Verified' => 'false' + }) + .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-Inbox-Verified' => 'false' }) + 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 0ead8fb1f..60b66778f 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -341,6 +341,10 @@ RSpec.describe Captain::CustomTool, type: :model do id: conversation.id, display_id: conversation.display_id }, + contact_inbox: { + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + }, contact: { id: contact.id, email: contact.email, @@ -376,6 +380,13 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers['X-Chatwoot-Contact-Email']).to eq(contact.email) end + it 'includes contact inbox verification metadata when present' do + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Id']).to eq(conversation.contact_inbox.id.to_s) + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq(conversation.contact_inbox.hmac_verified.to_s) + end + it 'handles missing conversation gracefully' do state[:conversation] = nil @@ -396,11 +407,21 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers['X-Chatwoot-Account-Id']).to eq(account.id.to_s) end + it 'handles missing contact inbox gracefully' do + state[:contact_inbox] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Id']).to be_nil + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + 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') + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') end it 'omits contact email header when email is blank' do @@ -418,6 +439,22 @@ RSpec.describe Captain::CustomTool, type: :model do expect(headers).not_to have_key('X-Chatwoot-Contact-Phone') end + + it 'includes contact inbox verified header when false' do + state[:contact_inbox][:hmac_verified] = false + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + end + + it 'defaults contact inbox verified header to false when value is nil' do + state[:contact_inbox][:hmac_verified] = nil + + headers = tool.build_metadata_headers(state) + + expect(headers['X-Chatwoot-Contact-Inbox-Verified']).to eq('false') + end end describe '#to_tool_metadata' do diff --git a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb index 0d22b8266..2ac3c6589 100644 --- a/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb +++ b/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb @@ -384,6 +384,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do expect(state[:channel_type]).to eq(inbox.channel_type) end + it 'includes contact inbox attributes when conversation is present' do + state = service.send(:build_state) + + expect(state[:contact_inbox]).to include( + id: conversation.contact_inbox.id, + hmac_verified: conversation.contact_inbox.hmac_verified + ) + end + it 'includes contact attributes when contact is present' do state = service.send(:build_state)