mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
Merge branch 'feat/captain-product-flows' into feat/custom-tools-frontend
This commit is contained in:
@@ -223,6 +223,3 @@
|
||||
- name: quoted_email_reply
|
||||
display_name: Quoted Email Reply
|
||||
enabled: false
|
||||
- name: captain_custom_tools
|
||||
display_name: Captain Custom Tools
|
||||
enabled: false
|
||||
|
||||
@@ -53,10 +53,8 @@ class Captain::Assistant < ApplicationRecord
|
||||
def available_agent_tools
|
||||
tools = self.class.built_in_agent_tools.dup
|
||||
|
||||
if account.feature_enabled?(:captain_custom_tools)
|
||||
custom_tools = Captain::CustomTool.where(account_id: account_id, enabled: true).map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
end
|
||||
custom_tools = account.captain_custom_tools.enabled.map(&:to_tool_metadata)
|
||||
tools.concat(custom_tools)
|
||||
|
||||
tools
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
|
||||
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
|
||||
|
||||
has_many :copilot_threads, dependent: :destroy_async
|
||||
has_many :voice_channels, dependent: :destroy_async, class_name: '::Channel::Voice'
|
||||
|
||||
@@ -7,14 +7,6 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
let(:tool) { described_class.new(assistant, custom_tool) }
|
||||
let(:tool_context) { Struct.new(:state).new({}) }
|
||||
|
||||
describe '#description' do
|
||||
it 'returns the custom tool description' do
|
||||
custom_tool.update!(description: 'Fetches order status from external API')
|
||||
|
||||
expect(tool.description).to eq('Fetches order status from external API')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
it 'returns true when custom tool is enabled' do
|
||||
custom_tool.update!(enabled: true)
|
||||
@@ -34,10 +26,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'GET',
|
||||
endpoint_url: 'https://api.example.com/orders/123',
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/orders/123')
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "success"}')
|
||||
end
|
||||
|
||||
@@ -45,7 +37,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"status": "success"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://api.example.com/orders/123')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,11 +45,11 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://api.example.com/orders',
|
||||
endpoint_url: 'https://example.com/orders',
|
||||
request_template: '{"order_id": "{{ order_id }}"}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:post, 'https://api.example.com/orders')
|
||||
stub_request(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}', headers: { 'Content-Type' => 'application/json' })
|
||||
.to_return(status: 200, body: '{"created": true}')
|
||||
end
|
||||
@@ -66,7 +58,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context, order_id: '123')
|
||||
|
||||
expect(result).to eq('{"created": true}')
|
||||
expect(WebMock).to have_requested(:post, 'https://api.example.com/orders')
|
||||
expect(WebMock).to have_requested(:post, 'https://example.com/orders')
|
||||
.with(body: '{"order_id": "123"}')
|
||||
end
|
||||
end
|
||||
@@ -74,10 +66,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
context 'with template variables in URL' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://api.example.com/orders/{{ order_id }}',
|
||||
endpoint_url: 'https://example.com/orders/{{ order_id }}',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/orders/456')
|
||||
stub_request(:get, 'https://example.com/orders/456')
|
||||
.to_return(status: 200, body: '{"order_id": "456"}')
|
||||
end
|
||||
|
||||
@@ -85,7 +77,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context, order_id: '456')
|
||||
|
||||
expect(result).to eq('{"order_id": "456"}')
|
||||
expect(WebMock).to have_requested(:get, 'https://api.example.com/orders/456')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/orders/456')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -94,10 +86,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
custom_tool.update!(
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'secret_bearer_token' },
|
||||
endpoint_url: 'https://api.example.com/data',
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
@@ -106,7 +98,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://api.example.com/data')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'Authorization' => 'Bearer secret_bearer_token' })
|
||||
end
|
||||
end
|
||||
@@ -116,10 +108,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
custom_tool.update!(
|
||||
auth_type: 'basic',
|
||||
auth_config: { 'username' => 'user123', 'password' => 'pass456' },
|
||||
endpoint_url: 'https://api.example.com/data',
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
@@ -128,7 +120,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://api.example.com/data')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(basic_auth: %w[user123 pass456])
|
||||
end
|
||||
end
|
||||
@@ -138,10 +130,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
custom_tool.update!(
|
||||
auth_type: 'api_key',
|
||||
auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' },
|
||||
endpoint_url: 'https://api.example.com/data',
|
||||
endpoint_url: 'https://example.com/data',
|
||||
response_template: nil
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
.to_return(status: 200, body: '{"authenticated": true}')
|
||||
end
|
||||
@@ -150,7 +142,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
expect(result).to eq('{"authenticated": true}')
|
||||
expect(WebMock).to have_requested(:get, 'https://api.example.com/data')
|
||||
expect(WebMock).to have_requested(:get, 'https://example.com/data')
|
||||
.with(headers: { 'X-API-Key' => 'api_key_123' })
|
||||
end
|
||||
end
|
||||
@@ -158,10 +150,10 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
context 'with response template' do
|
||||
before do
|
||||
custom_tool.update!(
|
||||
endpoint_url: 'https://api.example.com/orders/123',
|
||||
endpoint_url: 'https://example.com/orders/123',
|
||||
response_template: 'Order status: {{ response.status }}, ID: {{ response.order_id }}'
|
||||
)
|
||||
stub_request(:get, 'https://api.example.com/orders/123')
|
||||
stub_request(:get, 'https://example.com/orders/123')
|
||||
.to_return(status: 200, body: '{"status": "shipped", "order_id": "123"}')
|
||||
end
|
||||
|
||||
@@ -174,8 +166,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
|
||||
context 'when handling errors' do
|
||||
it 'returns generic error message on network failure' do
|
||||
custom_tool.update!(endpoint_url: 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://api.example.com/data').to_raise(SocketError.new('Failed to connect'))
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
@@ -183,8 +175,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
end
|
||||
|
||||
it 'returns generic error message on timeout' do
|
||||
custom_tool.update!(endpoint_url: 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://api.example.com/data').to_timeout
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_timeout
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
@@ -192,8 +184,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 404' do
|
||||
custom_tool.update!(endpoint_url: 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://api.example.com/data').to_return(status: 404, body: 'Not found')
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 404, body: 'Not found')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
@@ -201,8 +193,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
end
|
||||
|
||||
it 'returns generic error message on HTTP 500' do
|
||||
custom_tool.update!(endpoint_url: 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://api.example.com/data').to_return(status: 500, body: 'Server error')
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_return(status: 500, body: 'Server error')
|
||||
|
||||
result = tool.perform(tool_context)
|
||||
|
||||
@@ -210,8 +202,8 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
end
|
||||
|
||||
it 'logs error details' do
|
||||
custom_tool.update!(endpoint_url: 'https://api.example.com/data')
|
||||
stub_request(:get, 'https://api.example.com/data').to_raise(StandardError.new('Test error'))
|
||||
custom_tool.update!(endpoint_url: 'https://example.com/data')
|
||||
stub_request(:get, 'https://example.com/data').to_raise(StandardError.new('Test error'))
|
||||
|
||||
expect(Rails.logger).to receive(:error).with(/HttpTool execution error.*Test error/)
|
||||
|
||||
@@ -223,14 +215,14 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
|
||||
it 'correctly integrates URL rendering, body rendering, auth, and response formatting' do
|
||||
custom_tool.update!(
|
||||
http_method: 'POST',
|
||||
endpoint_url: 'https://api.example.com/users/{{ user_id }}/orders',
|
||||
endpoint_url: 'https://example.com/users/{{ user_id }}/orders',
|
||||
request_template: '{"product": "{{ product }}", "quantity": {{ quantity }}}',
|
||||
auth_type: 'bearer',
|
||||
auth_config: { 'token' => 'integration_token' },
|
||||
response_template: 'Created order #{{ response.order_number }} for {{ response.product }}'
|
||||
)
|
||||
|
||||
stub_request(:post, 'https://api.example.com/users/42/orders')
|
||||
stub_request(:post, 'https://example.com/users/42/orders')
|
||||
.with(
|
||||
body: '{"product": "Widget", "quantity": 5}',
|
||||
headers: {
|
||||
|
||||
@@ -42,58 +42,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
|
||||
end
|
||||
end
|
||||
|
||||
describe '.available_agent_tools' do
|
||||
before do
|
||||
# Mock the YAML file loading
|
||||
allow(YAML).to receive(:load_file).and_return([
|
||||
{
|
||||
'id' => 'add_contact_note',
|
||||
'title' => 'Add Contact Note',
|
||||
'description' => 'Add a note to a contact',
|
||||
'icon' => 'note-add'
|
||||
},
|
||||
{
|
||||
'id' => 'invalid_tool',
|
||||
'title' => 'Invalid Tool',
|
||||
'description' => 'This tool does not exist',
|
||||
'icon' => 'invalid'
|
||||
}
|
||||
])
|
||||
|
||||
# Mock class resolution - only add_contact_note exists
|
||||
allow(test_class).to receive(:resolve_tool_class) do |tool_id|
|
||||
case tool_id
|
||||
when 'add_contact_note'
|
||||
Captain::Tools::AddContactNoteTool
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns only resolvable tools' do
|
||||
tools = test_class.available_agent_tools
|
||||
|
||||
expect(tools.length).to eq(1)
|
||||
expect(tools.first).to eq({
|
||||
id: 'add_contact_note',
|
||||
title: 'Add Contact Note',
|
||||
description: 'Add a note to a contact',
|
||||
icon: 'note-add'
|
||||
})
|
||||
end
|
||||
|
||||
it 'logs warnings for unresolvable tools' do
|
||||
expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
|
||||
|
||||
test_class.available_agent_tools
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
expect(YAML).to receive(:load_file).once.and_return([])
|
||||
|
||||
2.times { test_class.available_agent_tools }
|
||||
end
|
||||
end
|
||||
|
||||
describe '.resolve_tool_class' do
|
||||
it 'resolves valid tool classes' do
|
||||
# Mock the constantize to return a class
|
||||
@@ -116,28 +64,6 @@ RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
|
||||
end
|
||||
end
|
||||
|
||||
describe '.available_tool_ids' do
|
||||
before do
|
||||
allow(test_class).to receive(:available_agent_tools).and_return([
|
||||
{ id: 'add_contact_note', title: 'Add Contact Note', description: '...',
|
||||
icon: 'note' },
|
||||
{ id: 'update_priority', title: 'Update Priority', description: '...',
|
||||
icon: 'priority' }
|
||||
])
|
||||
end
|
||||
|
||||
it 'returns array of tool IDs' do
|
||||
ids = test_class.available_tool_ids
|
||||
expect(ids).to eq(%w[add_contact_note update_priority])
|
||||
end
|
||||
|
||||
it 'memoizes the result' do
|
||||
expect(test_class).to receive(:available_agent_tools).once.and_return([])
|
||||
|
||||
2.times { test_class.available_tool_ids }
|
||||
end
|
||||
end
|
||||
|
||||
describe '#extract_tool_ids_from_text' do
|
||||
it 'extracts tool IDs from text' do
|
||||
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
|
||||
|
||||
Reference in New Issue
Block a user