feat: Migrate ruby llm captain (#12981)

Co-authored-by: aakashb95 <aakash@chatwoot.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Aakash Bakhle
2025-12-04 18:26:10 +05:30
committed by GitHub
parent 0a17976913
commit eed2eaceb0
41 changed files with 474 additions and 734 deletions

View File

@@ -7,7 +7,6 @@ RSpec.describe Captain::Copilot::ChatService do
let(:assistant) { create(:captain_assistant, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:mock_openai_client) { instance_double(OpenAI::Client) }
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) do
create(
@@ -20,13 +19,29 @@ RSpec.describe Captain::Copilot::ChatService do
{ user_id: user.id, copilot_thread_id: copilot_thread.id, conversation_id: conversation.display_id }
end
# RubyLLM mocks
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:mock_response) do
instance_double(RubyLLM::Message, content: '{ "content": "Hey", "reasoning": "Test reasoning", "reply_suggestion": false }')
end
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
create(:installation_config, name: 'CAPTAIN_OPEN_AI_ENDPOINT', value: 'https://api.openai.com/')
allow(OpenAI::Client).to receive(:new).and_return(mock_openai_client)
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
InstallationConfig.find_or_create_by(name: 'CAPTAIN_OPEN_AI_API_KEY') do |c|
c.value = 'test-key'
end
allow(RubyLLM).to receive(:chat).and_return(mock_chat)
allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
allow(mock_chat).to receive(:with_params).and_return(mock_chat)
allow(mock_chat).to receive(:with_tool).and_return(mock_chat)
allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
allow(mock_chat).to receive(:add_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_new_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_end_message).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_call).and_return(mock_chat)
allow(mock_chat).to receive(:on_tool_result).and_return(mock_chat)
allow(mock_chat).to receive(:messages).and_return([])
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
describe '#initialize' do
@@ -48,48 +63,6 @@ RSpec.describe Captain::Copilot::ChatService do
expect(messages.second[:role]).to eq('system')
expect(messages.second[:content]).to include(account.id.to_s)
end
it 'initializes OpenAI client with configured endpoint' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://api.openai.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
context 'when CAPTAIN_OPEN_AI_ENDPOINT is not configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.destroy
end
it 'uses default OpenAI endpoint' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://api.openai.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
end
context 'when custom endpoint is configured' do
before do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT').update!(value: 'https://custom.azure.com/')
end
it 'uses custom endpoint for OpenAI client' do
expect(OpenAI::Client).to receive(:new).with(
access_token: 'test-key',
uri_base: 'https://custom.azure.com/',
log_errors: Rails.env.development?
)
described_class.new(assistant, config)
end
end
end
describe '#generate_response' do
@@ -112,82 +85,19 @@ RSpec.describe Captain::Copilot::ChatService do
end
it 'returns the response from request_chat_completion' do
expect(service.generate_response('Hello')).to eq({ 'content' => 'Hey' })
result = service.generate_response('Hello')
expect(result).to eq({ 'content' => 'Hey', 'reasoning' => 'Test reasoning', 'reply_suggestion' => false })
end
context 'when response contains tool calls' do
before do
allow(mock_openai_client).to receive(:chat).and_return(
{
choices: [{ message: { 'tool_calls' => tool_calls } }]
}.with_indifferent_access,
{
choices: [{ message: { content: '{ "content": "Tool response processed" }' } }]
}.with_indifferent_access
)
end
context 'when tool call is valid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_conversation',
'arguments' => "{ \"conversation_id\": #{conversation.display_id} }"
}
}]
end
it 'processes tool calls and appends them to messages' do
result = service.generate_response("Find conversation #{conversation.id}")
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{ role: 'assistant', tool_calls: tool_calls }
)
expect(service.messages).to include(
{
role: 'tool', tool_call_id: 'call_123', content: conversation.to_llm_text
}
)
expect(result).to eq({ 'content' => 'Tool response processed' })
end
end
context 'when tool call is invalid' do
let(:tool_calls) do
[{
'id' => 'call_123',
'function' => {
'name' => 'get_settings',
'arguments' => '{}'
}
}]
end
it 'handles invalid tool calls' do
result = service.generate_response('Find settings')
expect(result).to eq({ 'content' => 'Tool response processed' })
expect(service.messages).to include(
{
role: 'assistant', tool_calls: tool_calls
}
)
expect(service.messages).to include(
{
role: 'tool',
tool_call_id: 'call_123',
content: 'Tool not available'
}
)
end
end
it 'increments response usage for the account' do
expect do
service.generate_response('Hello')
end.to(change { account.reload.custom_attributes['captain_responses_usage'].to_i }.by(1))
end
end
describe '#setup_user' do
describe 'user setup behavior' do
it 'sets user when user_id is present in config' do
service = described_class.new(assistant, { user_id: user.id })
expect(service.user).to eq(user)
@@ -199,7 +109,7 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#setup_message_history' do
describe 'message history behavior' do
context 'when copilot_thread_id is present' do
it 'finds the copilot thread and sets previous history from it' do
service = described_class.new(assistant, { copilot_thread_id: copilot_thread.id })
@@ -227,7 +137,7 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#build_messages' do
describe 'message building behavior' do
it 'includes system message and account context' do
service = described_class.new(assistant, {})
messages = service.messages
@@ -257,13 +167,9 @@ RSpec.describe Captain::Copilot::ChatService do
end
end
describe '#persist_message' do
describe 'message persistence behavior' do
context 'when copilot_thread is present' do
it 'creates a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
it 'creates a copilot message with the response' do
expect do
described_class.new(assistant, { copilot_thread_id: copilot_thread.id }).generate_response('Hello')
end.to change(CopilotMessage, :count).by(1)
@@ -276,10 +182,6 @@ RSpec.describe Captain::Copilot::ChatService do
context 'when copilot_thread is not present' do
it 'does not create a copilot message' do
allow(mock_openai_client).to receive(:chat).and_return({
choices: [{ message: { content: '{ "content": "Hey" }' } }]
}.with_indifferent_access)
expect do
described_class.new(assistant, {}).generate_response('Hello')
end.not_to(change(CopilotMessage, :count))

View File

@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
article_id: {
type: 'number',
description: 'The ID of the article to retrieve'
}
},
required: %w[article_id]
}
)
it 'defines article_id parameter' do
expect(service.parameters.keys).to contain_exactly(:article_id)
end
end
@@ -79,13 +68,13 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
describe '#execute' do
context 'when article_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
expect(service.execute(article_id: nil)).to eq('Article not found')
end
end
context 'when article is not found' do
it 'returns not found message' do
expect(service.execute({ 'article_id' => 999 })).to eq('Article not found')
expect(service.execute(article_id: 999)).to eq('Article not found')
end
end
@@ -94,7 +83,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
let(:article) { create(:article, account: account, portal: portal, author: user, title: 'Test Article', content: 'Content') }
it 'returns the article in llm text format' do
result = service.execute({ 'article_id' => article.id })
result = service.execute(article_id: article.id)
expect(result).to eq(article.to_llm_text)
end
@@ -104,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::GetArticleService do
let(:other_article) { create(:article, account: other_account, portal: other_portal, author: user, title: 'Other Article') }
it 'returns not found message' do
expect(service.execute({ 'article_id' => other_article.id })).to eq('Article not found')
expect(service.execute(article_id: other_article.id)).to eq('Article not found')
end
end
end

View File

@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
contact_id: {
type: 'number',
description: 'The ID of the contact to retrieve'
}
},
required: %w[contact_id]
}
)
it 'defines contact_id parameter' do
expect(service.parameters.keys).to contain_exactly(:contact_id)
end
end
@@ -78,14 +67,14 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
describe '#execute' do
context 'when contact_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
it 'returns not found message' do
expect(service.execute(contact_id: nil)).to eq('Contact not found')
end
end
context 'when contact is not found' do
it 'returns not found message' do
expect(service.execute({ 'contact_id' => 999 })).to eq('Contact not found')
expect(service.execute(contact_id: 999)).to eq('Contact not found')
end
end
@@ -93,7 +82,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
let(:contact) { create(:contact, account: account) }
it 'returns the contact in llm text format' do
result = service.execute({ 'contact_id' => contact.id })
result = service.execute(contact_id: contact.id)
expect(result).to eq(contact.to_llm_text)
end
@@ -102,7 +91,7 @@ RSpec.describe Captain::Tools::Copilot::GetContactService do
let(:other_contact) { create(:contact, account: other_account) }
it 'returns not found message' do
expect(service.execute({ 'contact_id' => other_contact.id })).to eq('Contact not found')
expect(service.execute(contact_id: other_contact.id)).to eq('Contact not found')
end
end
end

View File

@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
conversation_id: {
type: 'number',
description: 'The ID of the conversation to retrieve'
}
},
required: %w[conversation_id]
}
)
it 'defines conversation_id parameter' do
expect(service.parameters.keys).to contain_exactly(:conversation_id)
end
end
@@ -107,15 +96,9 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
end
describe '#execute' do
context 'when conversation_id is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
end
end
context 'when conversation is not found' do
it 'returns not found message' do
expect(service.execute({ 'conversation_id' => 999 })).to eq('Conversation not found')
expect(service.execute(conversation_id: 999)).to eq('Conversation not found')
end
end
@@ -124,7 +107,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
it 'returns the conversation in llm text format' do
result = service.execute({ 'conversation_id' => conversation.display_id })
result = service.execute(conversation_id: conversation.display_id)
expect(result).to eq(conversation.to_llm_text)
end
@@ -143,7 +126,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
content: 'Private note content',
private: true)
result = service.execute({ 'conversation_id' => conversation.display_id })
result = service.execute(conversation_id: conversation.display_id)
# Verify that the result includes both regular and private messages
expect(result).to include('Regular message')
@@ -157,7 +140,7 @@ RSpec.describe Captain::Tools::Copilot::GetConversationService do
let(:other_conversation) { create(:conversation, account: other_account, inbox: other_inbox) }
it 'returns not found message' do
expect(service.execute({ 'conversation_id' => other_conversation.display_id })).to eq('Conversation not found')
expect(service.execute(conversation_id: other_conversation.display_id)).to eq('Conversation not found')
end
end
end

View File

@@ -18,32 +18,6 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
query: {
type: 'string',
description: 'Search articles by title or content (partial match)'
},
category_id: {
type: 'number',
description: 'Filter articles by category ID'
},
status: {
type: 'string',
enum: %w[draft published archived],
description: 'Filter articles by status'
}
},
required: ['query']
}
)
end
end
describe '#active?' do
context 'when user is an admin' do
let(:user) { create(:user, :administrator, account: account) }
@@ -95,15 +69,9 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
end
describe '#execute' do
context 'when query is blank' do
it 'returns error message' do
expect(service.execute({})).to eq('Missing required parameters')
end
end
context 'when no articles are found' do
it 'returns no articles found message' do
expect(service.execute({ 'query' => 'test' })).to eq('No articles found')
expect(service.execute(query: 'test', category_id: nil, status: nil)).to eq('No articles found')
end
end
@@ -113,7 +81,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article2) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 2', content: 'Content 2') }
it 'returns formatted articles with count' do
result = service.execute({ 'query' => 'Test' })
result = service.execute(query: 'Test', category_id: nil, status: nil)
expect(result).to include('Total number of articles: 2')
expect(result).to include(article1.to_llm_text)
expect(result).to include(article2.to_llm_text)
@@ -124,7 +92,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article3) { create(:article, account: account, portal: portal, author: user, category: category, title: 'Test Article 3') }
it 'returns only articles from the specified category' do
result = service.execute({ 'query' => 'Test', 'category_id' => category.id })
result = service.execute(query: 'Test', category_id: category.id, status: nil)
expect(result).to include('Total number of articles: 1')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article1.to_llm_text)
@@ -137,7 +105,7 @@ RSpec.describe Captain::Tools::Copilot::SearchArticlesService do
let!(:article4) { create(:article, account: account, portal: portal, author: user, title: 'Test Article 4', status: 'draft') }
it 'returns only articles with the specified status' do
result = service.execute({ 'query' => 'Test', 'status' => 'published' })
result = service.execute(query: 'Test', category_id: nil, status: 'published')
expect(result).to include(article3.to_llm_text)
expect(result).not_to include(article4.to_llm_text)
end

View File

@@ -19,27 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
email: {
type: 'string',
description: 'Filter contacts by email'
},
phone_number: {
type: 'string',
description: 'Filter contacts by phone number'
},
name: {
type: 'string',
description: 'Filter contacts by name (partial match)'
}
},
required: []
}
)
it 'defines email, phone_number, and name parameters' do
expect(service.parameters.keys).to contain_exactly(:email, :phone_number, :name)
end
end
@@ -86,25 +67,25 @@ RSpec.describe Captain::Tools::Copilot::SearchContactsService do
end
it 'returns contacts when filtered by email' do
result = service.execute({ 'email' => 'test1@example.com' })
result = service.execute(email: 'test1@example.com')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by phone number' do
result = service.execute({ 'phone_number' => '+1234567890' })
result = service.execute(phone_number: '+1234567890')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns contacts when filtered by name' do
result = service.execute({ 'name' => 'Contact 1' })
result = service.execute(name: 'Contact 1')
expect(result).to include(contact1.to_llm_text)
expect(result).not_to include(contact2.to_llm_text)
end
it 'returns all matching contacts when no filters are provided' do
result = service.execute({})
result = service.execute
expect(result).to include(contact1.to_llm_text)
expect(result).to include(contact2.to_llm_text)
end

View File

@@ -8,7 +8,7 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
describe '#name' do
it 'returns the correct service name' do
expect(service.name).to eq('search_conversations')
expect(service.name).to eq('search_conversation')
end
end
@@ -19,10 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
end
describe '#parameters' do
it 'returns the correct parameter schema' do
params = service.parameters
expect(params[:type]).to eq('object')
expect(params[:properties]).to include(:contact_id, :status, :priority)
it 'defines the expected parameters' do
expect(service.parameters.keys).to contain_exactly(:status, :contact_id, :priority, :labels)
end
end
@@ -90,35 +88,35 @@ RSpec.describe Captain::Tools::Copilot::SearchConversationsService do
let!(:resolved_conversation) { create(:conversation, account: account, status: 'resolved', priority: 'low') }
it 'returns all conversations when no filters are applied' do
result = service.execute({})
result = service.execute
expect(result).to include('Total number of conversations: 2')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by status' do
result = service.execute({ 'status' => 'open' })
result = service.execute(status: 'open')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by contact_id' do
result = service.execute({ 'contact_id' => contact.id })
result = service.execute(contact_id: contact.id)
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'filters conversations by priority' do
result = service.execute({ 'priority' => 'high' })
result = service.execute(priority: 'high')
expect(result).to include('Total number of conversations: 1')
expect(result).to include(open_conversation.to_llm_text(include_contact_details: true))
expect(result).not_to include(resolved_conversation.to_llm_text(include_contact_details: true))
end
it 'returns appropriate message when no conversations are found' do
result = service.execute({ 'status' => 'snoozed' })
result = service.execute(status: 'snoozed')
expect(result).to eq('No conversations found')
end
end

View File

@@ -19,19 +19,8 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
describe '#parameters' do
it 'returns the expected parameter schema' do
expect(service.parameters).to eq(
{
type: 'object',
properties: {
term: {
type: 'string',
description: 'The search term to find Linear issues'
}
},
required: %w[term]
}
)
it 'defines term parameter' do
expect(service.parameters.keys).to contain_exactly(:term)
end
end
@@ -76,7 +65,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
describe '#execute' do
context 'when Linear integration is not enabled' do
it 'returns error message' do
expect(service.execute({ 'term' => 'test' })).to eq('Linear integration is not enabled')
expect(service.execute(term: 'test')).to eq('Linear integration is not enabled')
end
end
@@ -89,8 +78,12 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
context 'when term is blank' do
it 'returns error message' do
expect(service.execute({ 'term' => '' })).to eq('Missing required parameters')
before do
allow(linear_service).to receive(:search_issue).with('').and_return({ data: [] })
end
it 'returns no issues found message' do
expect(service.execute(term: '')).to eq('No issues found, I should try another similar search term')
end
end
@@ -100,7 +93,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns the error message' do
expect(service.execute({ 'term' => 'test' })).to eq('API Error')
expect(service.execute(term: 'test')).to eq('API Error')
end
end
@@ -110,7 +103,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns no issues found message' do
expect(service.execute({ 'term' => 'test' })).to eq('No issues found, I should try another similar search term')
expect(service.execute(term: 'test')).to eq('No issues found, I should try another similar search term')
end
end
@@ -131,7 +124,7 @@ RSpec.describe Captain::Tools::Copilot::SearchLinearIssuesService do
end
it 'returns formatted issues' do
result = service.execute({ 'term' => 'test' })
result = service.execute(term: 'test')
expect(result).to include('Total number of issues: 1')
expect(result).to include('Title: Test Issue')
expect(result).to include('ID: TEST-123')

View File

@@ -20,19 +20,8 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
describe '#parameters' do
it 'returns the required parameters schema' do
expected_schema = {
type: 'object',
properties: {
search_query: {
type: 'string',
description: 'The search query to look up in the documentation.'
}
},
required: ['search_query']
}
expect(service.parameters).to eq(expected_schema)
it 'defines query parameter' do
expect(service.parameters.keys).to contain_exactly(:query)
end
end
@@ -56,7 +45,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
it 'returns formatted responses for the search query' do
result = service.execute({ 'search_query' => question })
result = service.execute(query: question)
expect(result).to include(question)
expect(result).to include(answer)
@@ -70,7 +59,7 @@ RSpec.describe Captain::Tools::SearchDocumentationService do
end
it 'returns an empty string' do
expect(service.execute({ 'search_query' => question })).to eq('No FAQs found for the given query')
expect(service.execute(query: question)).to eq('No FAQs found for the given query')
end
end
end

View File

@@ -256,52 +256,5 @@ RSpec.describe Integrations::LlmInstrumentation do
end
end
end
describe '#instrument_tool_call' do
let(:tool_name) { 'search_documents' }
let(:arguments) { { query: 'test query' } }
context 'when OTEL provider is not configured' do
before { otel_config.update(value: '') }
it 'executes the block without tracing' do
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
expect(result).to eq('tool_result')
end
end
context 'when OTEL provider is configured' do
let(:mock_span) { instance_double(OpenTelemetry::Trace::Span) }
let(:mock_tracer) { instance_double(OpenTelemetry::Trace::Tracer) }
before do
allow(mock_span).to receive(:set_attribute)
allow(instance).to receive(:tracer).and_return(mock_tracer)
allow(mock_tracer).to receive(:in_span).and_yield(mock_span)
end
it 'executes the block and returns the result' do
result = instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
expect(result).to eq('tool_result')
end
it 'propagates instrumentation errors' do
allow(mock_tracer).to receive(:in_span).and_raise(StandardError.new('Instrumentation failed'))
expect do
instance.instrument_tool_call(tool_name, arguments) { 'tool_result' }
end.to raise_error(StandardError, 'Instrumentation failed')
end
it 'creates a span with tool name and sets observation attributes' do
tool_result = { documents: ['doc1'] }
instance.instrument_tool_call(tool_name, arguments) { tool_result }
expect(mock_tracer).to have_received(:in_span).with('tool.search_documents')
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.input', arguments.to_json)
expect(mock_span).to have_received(:set_attribute).with('langfuse.observation.output', tool_result.to_json)
end
end
end
end
end