Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,481 @@
require 'rails_helper'
RSpec.describe Captain::CustomTool, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:endpoint_url) }
it { is_expected.to define_enum_for(:http_method).with_values('GET' => 'GET', 'POST' => 'POST').backed_by_column_of_type(:string) }
it {
expect(subject).to define_enum_for(:auth_type).with_values('none' => 'none', 'bearer' => 'bearer', 'basic' => 'basic',
'api_key' => 'api_key').backed_by_column_of_type(:string).with_prefix(:auth)
}
describe 'slug uniqueness' do
let(:account) { create(:account) }
it 'validates uniqueness of slug scoped to account' do
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
duplicate = build(:captain_custom_tool, account: account, slug: 'custom_test_tool')
expect(duplicate).not_to be_valid
expect(duplicate.errors[:slug]).to include('has already been taken')
end
it 'allows same slug across different accounts' do
account2 = create(:account)
create(:captain_custom_tool, account: account, slug: 'custom_test_tool')
different_account_tool = build(:captain_custom_tool, account: account2, slug: 'custom_test_tool')
expect(different_account_tool).to be_valid
end
end
describe 'param_schema validation' do
let(:account) { create(:account) }
it 'is valid with proper param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'required' => true }
])
expect(tool).to be_valid
end
it 'is valid with empty param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [])
expect(tool).to be_valid
end
it 'is invalid when param_schema is missing name' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing type' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'description' => 'Order ID' }
])
expect(tool).not_to be_valid
end
it 'is invalid when param_schema is missing description' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string' }
])
expect(tool).not_to be_valid
end
it 'is invalid with additional properties in param_schema' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID', 'extra_field' => 'value' }
])
expect(tool).not_to be_valid
end
it 'is valid when required field is omitted (defaults to optional param)' do
tool = build(:captain_custom_tool, account: account, param_schema: [
{ 'name' => 'order_id', 'type' => 'string', 'description' => 'Order ID' }
])
expect(tool).to be_valid
end
end
end
describe 'scopes' do
let(:account) { create(:account) }
describe '.enabled' do
it 'returns only enabled custom tools' do
enabled_tool = create(:captain_custom_tool, account: account, enabled: true)
disabled_tool = create(:captain_custom_tool, account: account, enabled: false)
enabled_ids = described_class.enabled.pluck(:id)
expect(enabled_ids).to include(enabled_tool.id)
expect(enabled_ids).not_to include(disabled_tool.id)
end
end
end
describe 'slug generation' do
let(:account) { create(:account) }
it 'generates slug from title on creation' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status')
expect(tool.slug).to eq('custom_fetch_order_status')
end
it 'adds custom_ prefix to generated slug' do
tool = create(:captain_custom_tool, account: account, title: 'My Tool')
expect(tool.slug).to start_with('custom_')
end
it 'does not override manually set slug' do
tool = create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_manual_slug')
expect(tool.slug).to eq('custom_manual_slug')
end
it 'handles slug collisions by appending random suffix' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
tool2 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool2.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
end
it 'handles multiple slug collisions' do
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool')
create(:captain_custom_tool, account: account, title: 'Test Tool', slug: 'custom_test_tool_abc123')
tool3 = create(:captain_custom_tool, account: account, title: 'Test Tool')
expect(tool3.slug).to match(/^custom_test_tool_[a-z0-9]{6}$/)
expect(tool3.slug).not_to eq('custom_test_tool')
expect(tool3.slug).not_to eq('custom_test_tool_abc123')
end
it 'does not generate slug when title is blank' do
tool = build(:captain_custom_tool, account: account, title: nil)
expect(tool).not_to be_valid
expect(tool.errors[:title]).to include("can't be blank")
end
it 'parameterizes title correctly' do
tool = create(:captain_custom_tool, account: account, title: 'Fetch Order Status & Details!')
expect(tool.slug).to eq('custom_fetch_order_status_details')
end
end
describe 'factory' do
it 'creates a valid custom tool with default attributes' do
tool = create(:captain_custom_tool)
expect(tool).to be_valid
expect(tool.title).to be_present
expect(tool.slug).to be_present
expect(tool.endpoint_url).to be_present
expect(tool.http_method).to eq('GET')
expect(tool.auth_type).to eq('none')
expect(tool.enabled).to be true
end
it 'creates valid tool with POST trait' do
tool = create(:captain_custom_tool, :with_post)
expect(tool.http_method).to eq('POST')
expect(tool.request_template).to be_present
end
it 'creates valid tool with bearer auth trait' do
tool = create(:captain_custom_tool, :with_bearer_auth)
expect(tool.auth_type).to eq('bearer')
expect(tool.auth_config['token']).to eq('test_bearer_token_123')
end
it 'creates valid tool with basic auth trait' do
tool = create(:captain_custom_tool, :with_basic_auth)
expect(tool.auth_type).to eq('basic')
expect(tool.auth_config['username']).to eq('test_user')
expect(tool.auth_config['password']).to eq('test_pass')
end
it 'creates valid tool with api key trait' do
tool = create(:captain_custom_tool, :with_api_key)
expect(tool.auth_type).to eq('api_key')
expect(tool.auth_config['key']).to eq('test_api_key')
expect(tool.auth_config['location']).to eq('header')
end
end
describe 'Toolable concern' do
let(:account) { create(:account) }
describe '#build_request_url' do
it 'returns static URL when no template variables present' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders')
expect(tool.build_request_url({})).to eq('https://api.example.com/orders')
end
it 'renders URL template with params' do
tool = create(:captain_custom_tool, account: account, endpoint_url: 'https://api.example.com/orders/{{ order_id }}')
expect(tool.build_request_url({ order_id: '12345' })).to eq('https://api.example.com/orders/12345')
end
it 'handles multiple template variables' do
tool = create(:captain_custom_tool, account: account,
endpoint_url: 'https://api.example.com/{{ resource }}/{{ id }}?details={{ show_details }}')
result = tool.build_request_url({ resource: 'orders', id: '123', show_details: 'true' })
expect(result).to eq('https://api.example.com/orders/123?details=true')
end
end
describe '#build_request_body' do
it 'returns nil when request_template is blank' do
tool = create(:captain_custom_tool, account: account, request_template: nil)
expect(tool.build_request_body({})).to be_nil
end
it 'renders request body template with params' do
tool = create(:captain_custom_tool, account: account,
request_template: '{ "order_id": "{{ order_id }}", "source": "chatwoot" }')
result = tool.build_request_body({ order_id: '12345' })
expect(result).to eq('{ "order_id": "12345", "source": "chatwoot" }')
end
end
describe '#build_auth_headers' do
it 'returns empty hash for none auth type' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_auth_headers).to eq({})
end
it 'returns bearer token header' do
tool = create(:captain_custom_tool, :with_bearer_auth, account: account)
expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' })
end
it 'returns API key header when location is header' do
tool = create(:captain_custom_tool, :with_api_key, account: account)
expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' })
end
it 'returns empty hash for API key when location is not header' do
tool = create(:captain_custom_tool, account: account, auth_type: 'api_key',
auth_config: { key: 'test_key', location: 'query', name: 'api_key' })
expect(tool.build_auth_headers).to eq({})
end
it 'returns empty hash for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_auth_headers).to eq({})
end
end
describe '#build_basic_auth_credentials' do
it 'returns nil for non-basic auth types' do
tool = create(:captain_custom_tool, account: account, auth_type: 'none')
expect(tool.build_basic_auth_credentials).to be_nil
end
it 'returns username and password array for basic auth' do
tool = create(:captain_custom_tool, :with_basic_auth, account: account)
expect(tool.build_basic_auth_credentials).to eq(%w[test_user test_pass])
end
end
describe '#format_response' do
it 'returns raw response when no response_template' do
tool = create(:captain_custom_tool, account: account, response_template: nil)
expect(tool.format_response('raw response')).to eq('raw response')
end
it 'renders response template with JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order status: {{ response.status }}')
raw_response = '{"status": "shipped", "tracking": "123ABC"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order status: shipped')
end
it 'handles response template with multiple fields' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Order {{ response.id }} is {{ response.status }}. Tracking: {{ response.tracking }}')
raw_response = '{"id": "12345", "status": "delivered", "tracking": "ABC123"}'
result = tool.format_response(raw_response)
expect(result).to eq('Order 12345 is delivered. Tracking: ABC123')
end
it 'handles non-JSON response' do
tool = create(:captain_custom_tool, account: account,
response_template: 'Response: {{ response }}')
raw_response = 'plain text response'
result = tool.format_response(raw_response)
expect(result).to eq('Response: plain text response')
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,
slug: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool')
metadata = tool.to_tool_metadata
expect(metadata).to eq({
id: 'custom_test-tool',
title: 'Test Tool',
description: 'A test tool',
custom: true
})
end
end
describe '#tool' do
let(:assistant) { create(:captain_assistant, account: account) }
it 'returns HttpTool instance' do
tool = create(:captain_custom_tool, account: account)
tool_instance = tool.tool(assistant)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'sets description on the tool class' do
tool = create(:captain_custom_tool, account: account, description: 'Fetches order data')
tool_instance = tool.tool(assistant)
expect(tool_instance.description).to eq('Fetches order data')
end
it 'sets parameters on the tool class' do
tool = create(:captain_custom_tool, :with_params, account: account)
tool_instance = tool.tool(assistant)
params = tool_instance.parameters
expect(params.keys).to contain_exactly(:order_id, :include_details)
expect(params[:order_id].name).to eq(:order_id)
expect(params[:order_id].type).to eq('string')
expect(params[:order_id].description).to eq('The order ID')
expect(params[:order_id].required).to be true
expect(params[:include_details].name).to eq(:include_details)
expect(params[:include_details].required).to be false
end
it 'works with empty param_schema' do
tool = create(:captain_custom_tool, account: account, param_schema: [])
tool_instance = tool.tool(assistant)
expect(tool_instance.parameters).to be_empty
end
end
end
end

View File

@@ -0,0 +1,253 @@
require 'rails_helper'
RSpec.describe Captain::Document, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'URL normalization' do
it 'removes a trailing slash before validation' do
document = create(:captain_document,
assistant: assistant,
account: account,
external_link: 'https://example.com/path/')
expect(document.external_link).to eq('https://example.com/path')
end
end
describe 'PDF support' do
let(:pdf_document) do
doc = build(:captain_document, assistant: assistant, account: account)
doc.pdf_file.attach(
io: StringIO.new('PDF content'),
filename: 'test.pdf',
content_type: 'application/pdf'
)
doc
end
describe 'validations' do
it 'allows PDF file without external link' do
pdf_document.external_link = nil
expect(pdf_document).to be_valid
end
it 'validates PDF file size' do
doc = build(:captain_document, assistant: assistant, account: account)
doc.pdf_file.attach(
io: StringIO.new('x' * 11.megabytes),
filename: 'large.pdf',
content_type: 'application/pdf'
)
doc.external_link = nil
expect(doc).not_to be_valid
expect(doc.errors[:pdf_file]).to include(I18n.t('captain.documents.pdf_size_error'))
end
end
describe '#pdf_document?' do
it 'returns true for attached PDF' do
expect(pdf_document.pdf_document?).to be true
end
it 'returns true for .pdf external links' do
doc = build(:captain_document, external_link: 'https://example.com/document.pdf')
expect(doc.pdf_document?).to be true
end
it 'returns false for non-PDF documents' do
doc = build(:captain_document, external_link: 'https://example.com')
expect(doc.pdf_document?).to be false
end
end
describe '#display_url' do
it 'returns Rails blob URL for attached PDFs' do
pdf_document.save!
# The display_url method calls rails_blob_url which returns a URL containing 'rails/active_storage'
url = pdf_document.display_url
expect(url).to be_present
end
it 'returns external_link for web documents' do
doc = create(:captain_document, external_link: 'https://example.com')
expect(doc.display_url).to eq('https://example.com')
end
end
describe '#store_openai_file_id' do
it 'stores the file ID in metadata' do
pdf_document.save!
pdf_document.store_openai_file_id('file-abc123')
expect(pdf_document.reload.openai_file_id).to eq('file-abc123')
end
end
describe 'automatic external_link generation' do
it 'generates unique external_link for PDFs' do
pdf_document.external_link = nil
pdf_document.save!
expect(pdf_document.external_link).to start_with('PDF: test_')
end
end
end
describe 'response builder job callback' do
before { clear_enqueued_jobs }
describe 'non-PDF documents' do
it 'enqueues when created with available status and content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when created available without content' do
expect do
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available with existing content' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when status transitions to available without content' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :in_progress,
content: nil
)
expect do
document.update!(status: :available)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content is populated on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: nil
)
clear_enqueued_jobs
expect do
document.update!(content: 'Fresh content from crawl')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when content changes on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: 'Updated crawl content')
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content is cleared on an available document' do
document = create(
:captain_document,
assistant: assistant,
account: account,
status: :available,
content: 'Initial content'
)
clear_enqueued_jobs
expect do
document.update!(content: nil)
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue for metadata-only updates' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.update!(metadata: { 'title' => 'Updated Again' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue while document remains in progress' do
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
expect do
document.update!(metadata: { 'title' => 'Updated' })
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
describe 'PDF documents' do
def build_pdf_document(status:, content:)
build(
:captain_document,
assistant: assistant,
account: account,
status: status,
content: content
).tap do |doc|
doc.pdf_file.attach(
io: StringIO.new('PDF content'),
filename: 'sample.pdf',
content_type: 'application/pdf'
)
end
end
it 'enqueues when created available without content' do
document = build_pdf_document(status: :available, content: nil)
expect do
document.save!
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'enqueues when status transitions to available' do
document = build_pdf_document(status: :in_progress, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(status: :available)
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
it 'does not enqueue when content updates without status change' do
document = build_pdf_document(status: :available, content: nil)
document.save!
clear_enqueued_jobs
expect do
document.update!(content: 'Extracted PDF text')
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
it 'does not enqueue when the document is destroyed' do
document = create(:captain_document, assistant: assistant, account: account, status: :available)
clear_enqueued_jobs
expect do
document.destroy!
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
end
end
end

View File

@@ -0,0 +1,344 @@
require 'rails_helper'
RSpec.describe Captain::Scenario, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:description) }
it { is_expected.to validate_presence_of(:instruction) }
it { is_expected.to validate_presence_of(:assistant_id) }
it { is_expected.to validate_presence_of(:account_id) }
end
describe 'scopes' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe '.enabled' do
it 'returns only enabled scenarios' do
enabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: true)
disabled_scenario = create(:captain_scenario, assistant: assistant, account: account, enabled: false)
expect(described_class.enabled.pluck(:id)).to include(enabled_scenario.id)
expect(described_class.enabled.pluck(:id)).not_to include(disabled_scenario.id)
end
end
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'before_save :resolve_tool_references' do
it 'calls resolve_tool_references before saving' do
scenario = build(:captain_scenario, assistant: assistant, account: account)
expect(scenario).to receive(:resolve_tool_references)
scenario.save
end
end
end
describe 'tool validation and population' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
# Mock available tools
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
end
describe 'validate_instruction_tools' do
it 'is valid with valid tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note) to document')
expect(scenario).to be_valid
end
it 'is invalid with invalid tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Invalid Tool](tool://invalid_tool) to process')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool')
end
it 'is invalid with multiple invalid tools' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Invalid Tool](tool://invalid_tool) and [@Another Invalid](tool://another_invalid)')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool, another_invalid')
end
it 'is valid with no tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Just respond politely to the customer')
expect(scenario).to be_valid
end
it 'is valid with blank instruction' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: '')
# Will be invalid due to presence validation, not tool validation
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
it 'is valid with custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).to be_valid
end
it 'is invalid with custom tool from different account' do
other_account = create(:account)
create(:captain_custom_tool, account: other_account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is invalid with disabled custom tool' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order) to get order details')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: custom_fetch-order')
end
it 'is valid with mixed static and custom tool references' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
expect(scenario).to be_valid
end
end
describe 'resolve_tool_references' do
it 'populates tools array with referenced tool IDs' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)')
expect(scenario.tools).to eq(%w[add_contact_note update_priority])
end
it 'sets tools to nil when no tools are referenced' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Just respond politely to the customer')
expect(scenario.tools).to be_nil
end
it 'handles duplicate tool references' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note) and [@Add Contact Note](tool://add_contact_note) again')
expect(scenario.tools).to eq(['add_contact_note'])
end
it 'updates tools when instruction changes' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note)')
expect(scenario.tools).to eq(['add_contact_note'])
scenario.update!(instruction: 'Use [@Update Priority](tool://update_priority) instead')
expect(scenario.tools).to eq(['update_priority'])
end
end
end
describe 'custom tool integration' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
allow(described_class).to receive(:built_in_tool_ids).and_return(%w[add_contact_note])
allow(described_class).to receive(:built_in_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note',
description: 'Add a note' }
])
end
describe '#resolved_tools' do
it 'includes custom tool metadata' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order',
title: 'Fetch Order', description: 'Gets order details')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(1)
expect(resolved.first[:id]).to eq('custom_fetch-order')
expect(resolved.first[:title]).to eq('Fetch Order')
expect(resolved.first[:description]).to eq('Gets order details')
end
it 'includes both static and custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
resolved = scenario.send(:resolved_tools)
expect(resolved.length).to eq(2)
expect(resolved.map { |t| t[:id] }).to contain_exactly('add_contact_note', 'custom_fetch-order')
end
it 'excludes disabled custom tools' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
resolved = scenario.send(:resolved_tools)
expect(resolved).to be_empty
end
end
describe '#resolve_tool_instance' do
it 'returns HttpTool instance for custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_a(Captain::Tools::HttpTool)
end
it 'returns nil for disabled custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: false)
scenario = create(:captain_scenario, assistant: assistant, account: account)
tool_metadata = { id: 'custom_fetch-order', custom: true }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).to be_nil
end
it 'returns static tool instance for non-custom tools' do
scenario = create(:captain_scenario, assistant: assistant, account: account)
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tool_metadata = { id: 'add_contact_note' }
tool_instance = scenario.send(:resolve_tool_instance, tool_metadata)
expect(tool_instance).not_to be_nil
expect(tool_instance).not_to be_a(Captain::Tools::HttpTool)
end
end
describe '#agent_tools' do
it 'returns array of tool instances including custom tools' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(1)
expect(tools.first).to be_a(Captain::Tools::HttpTool)
end
it 'excludes disabled custom tools from execution' do
custom_tool = create(:captain_custom_tool, account: account, slug: 'custom_fetch-order', enabled: true)
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Fetch Order](tool://custom_fetch-order)')
custom_tool.update!(enabled: false)
tools = scenario.send(:agent_tools)
expect(tools).to be_empty
end
it 'returns mixed static and custom tool instances' do
create(:captain_custom_tool, account: account, slug: 'custom_fetch-order')
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Note](tool://add_contact_note) and [@Fetch Order](tool://custom_fetch-order)')
allow(described_class).to receive(:resolve_tool_class).with('add_contact_note').and_return(
Class.new do
def initialize(_assistant); end
end
)
tools = scenario.send(:agent_tools)
expect(tools.length).to eq(2)
expect(tools.last).to be_a(Captain::Tools::HttpTool)
end
end
end
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)
assistant = create(:captain_assistant, account: account)
scenario = build(:captain_scenario, assistant: assistant, account: account)
expect(scenario).to be_valid
end
it 'creates a scenario with all required attributes' do
scenario = create(:captain_scenario)
expect(scenario.title).to be_present
expect(scenario.description).to be_present
expect(scenario.instruction).to be_present
expect(scenario.enabled).to be true
expect(scenario.assistant).to be_present
expect(scenario.account).to be_present
end
end
end