Files
chatwoot/spec/enterprise/services/captain/assistant/agent_runner_service_spec.rb
T
7522457740 feat: v2 - generations get trace level attributes (#14878)
# Pull Request Template

~~Note: merge only after https://github.com/chatwoot/ai-agents/pull/74
has been merged~~

## Description

Before:
<img width="436" height="617" alt="image"
src="https://github.com/user-attachments/assets/fd5be8dc-abab-4e01-b251-366648b998ea"
/>


After:
<img width="446" height="577" alt="image"
src="https://github.com/user-attachments/assets/8d96d2f2-c126-4cca-b3a2-f2a62b204adf"
/>


<img width="441" height="558" alt="image"
src="https://github.com/user-attachments/assets/89da4157-48a5-4fdc-9c67-9bf987e31638"
/>



## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sony Mathew <sony@chatwoot.com>
2026-06-29 13:50:17 +05:30

645 lines
24 KiB
Ruby

# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Captain::Assistant::AgentRunnerService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:contact) { create(:contact, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:scenario) { create(:captain_scenario, assistant: assistant, enabled: true) }
let(:mock_runner) { instance_double(Agents::AgentRunner) }
let(:mock_agent) { instance_double(Agents::Agent) }
let(:mock_scenario_agent) { instance_double(Agents::Agent) }
let(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil) }
let(:message_history) do
[
{ role: 'user', content: 'Hello there' },
{ role: 'assistant', content: 'Hi! How can I help you?', agent_name: 'Assistant' },
{ role: 'user', content: 'I need help with my account' }
]
end
before do
allow(assistant).to receive(:agent).and_return(mock_agent)
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
allow(scenario).to receive(:agent).and_return(mock_scenario_agent)
allow(Agents::Runner).to receive(:with_agents).and_return(mock_runner)
allow(mock_runner).to receive(:run).and_return(mock_result)
allow(mock_runner).to receive(:on_tool_complete).and_return(mock_runner)
allow(mock_runner).to receive(:on_run_complete).and_return(mock_runner)
allow(mock_agent).to receive(:register_handoffs)
allow(mock_scenario_agent).to receive(:register_handoffs)
end
describe '#initialize' do
it 'sets instance variables correctly' do
service = described_class.new(assistant: assistant, conversation: conversation)
expect(service.instance_variable_get(:@assistant)).to eq(assistant)
expect(service.instance_variable_get(:@conversation)).to eq(conversation)
expect(service.instance_variable_get(:@callbacks)).to eq({})
end
it 'accepts callbacks parameter' do
callbacks = { on_agent_thinking: proc { |x| x } }
service = described_class.new(assistant: assistant, callbacks: callbacks)
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
end
end
describe '#generate_response' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds agents and wires them together' do
expect(assistant).to receive(:agent).and_return(mock_agent)
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([scenario])
expect(assistant).to receive(:scenarios).and_return(scenarios_relation)
expect(scenario).to receive(:agent).and_return(mock_scenario_agent)
expect(mock_agent).to receive(:register_handoffs).with(mock_scenario_agent)
expect(mock_scenario_agent).to receive(:register_handoffs).with(mock_agent)
service.generate_response(message_history: message_history)
end
it 'creates runner with agents' do
expect(Agents::Runner).to receive(:with_agents).with(mock_agent, mock_scenario_agent)
service.generate_response(message_history: message_history)
end
it 'runs agent with extracted user message and context' do
expected_context = hash_including(
session_id: "#{account.id}_#{conversation.display_id}",
conversation_history: [
{ role: :user, content: 'Hello there', agent_name: nil },
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
],
state: hash_including(
account_id: account.id,
assistant_id: assistant.id,
conversation: hash_including(id: conversation.id),
contact: hash_including(id: contact.id)
)
)
expect(mock_runner).to receive(:run).with(
'I need help with my account',
context: expected_context,
max_turns: 100
)
service.generate_response(message_history: message_history)
end
context 'when the latest user message is multimodal' do
let(:multimodal_message_history) do
[
{ role: 'assistant', content: 'Please share a screenshot' },
{
role: 'user',
content: [
{ type: 'text', text: 'What does this error mean?' },
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
]
}
]
end
it 'passes image attachments to the runner input' do
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
expect(input).to be_a(RubyLLM::Content)
expect(input.text).to eq('What does this error mean?')
expect(input.attachments.first.source.to_s).to eq('https://example.com/error.png')
expect(context[:conversation_history]).to eq([{ role: :assistant, content: 'Please share a screenshot', agent_name: nil }])
expect(max_turns).to eq(100)
end
service.generate_response(message_history: multimodal_message_history)
end
it 'preserves multimodal content in earlier history messages' do
history_with_prior_image = [
{
role: 'user',
content: [
{ type: 'text', text: 'Here is my error screenshot' },
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
]
},
{ role: 'assistant', content: 'I see the error. Try restarting.' },
{ role: 'user', content: 'It still does not work' }
]
expect(mock_runner).to receive(:run) do |input, context:, max_turns:|
expect(input).to eq('It still does not work')
# The earlier user message with the image should preserve the multimodal array
first_history_msg = context[:conversation_history].first
expect(first_history_msg[:content]).to be_a(Array)
expect(first_history_msg[:content]).to include(
{ type: 'text', text: 'Here is my error screenshot' },
{ type: 'image_url', image_url: { url: 'https://example.com/error.png' } }
)
expect(max_turns).to eq(100)
end
service.generate_response(message_history: history_with_prior_image)
end
it 'stores multimodal trace payloads in runner context' do
expect(mock_runner).to receive(:run) do |_input, context:, max_turns:|
expect(context[:captain_v2_trace_input]).to include('image_url')
expect(context[:captain_v2_trace_current_input]).to include('image_url')
expect(max_turns).to eq(100)
end
service.generate_response(message_history: multimodal_message_history)
end
end
it 'processes and formats agent result' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
end
context 'when handoff tool was called during agent execution' do
let(:runner_context) { { captain_v2_handoff_tool_called: true } }
let(:mock_result) do
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context)
end
it 'includes handoff_tool_called flag in response' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Let me connect you',
'agent_name' => nil,
'handoff_tool_called' => true
})
end
end
context 'when no scenarios are enabled' do
before do
scenarios_relation = instance_double(Captain::Scenario)
allow(scenarios_relation).to receive(:enabled).and_return([])
allow(assistant).to receive(:scenarios).and_return(scenarios_relation)
end
it 'only uses assistant agent' do
expect(Agents::Runner).to receive(:with_agents).with(mock_agent)
expect(mock_agent).not_to receive(:register_handoffs)
service.generate_response(message_history: message_history)
end
end
context 'when agent result is a string' do
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'Simple string response',
'reasoning' => 'Processed by agent',
'agent_name' => nil,
'handoff_tool_called' => false
})
end
end
context 'when an error occurs' do
let(:error) { StandardError.new('Test error') }
before do
allow(mock_runner).to receive(:run).and_raise(error)
allow(ChatwootExceptionTracker).to receive(:new).and_return(
instance_double(ChatwootExceptionTracker, capture_exception: true)
)
end
it 'captures exception and returns error response' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: conversation.account)
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'conversation_handoff',
'reasoning' => 'Error occurred: Test error',
'handoff_tool_called' => false
})
end
it 'logs error details' do
expect(Rails.logger).to receive(:error).with('[Captain V2] AgentRunnerService error: Test error')
expect(Rails.logger).to receive(:error).with(kind_of(String))
service.generate_response(message_history: message_history)
end
context 'when conversation is nil' do
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
it 'handles missing conversation gracefully' do
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: nil)
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'conversation_handoff',
'reasoning' => 'Error occurred: Test error',
'handoff_tool_called' => false
})
end
end
context 'when HandoffTool fired before the runner errored' do
# The stubbed runner never invokes the on_tool_complete callback, so we call
# track_handoff_usage directly to simulate the flag being set before the raise.
before do
allow(mock_runner).to receive(:run) do
service.send(:track_handoff_usage,
Captain::Tools::HandoffTool.new(assistant).name,
Captain::Tools::HandoffTool.new(assistant).name,
Struct.new(:context).new({}))
raise error
end
end
it 'surfaces handoff_tool_called in error_response so the job routes to the V2 path' do
result = service.generate_response(message_history: message_history)
expect(result).to eq({
'response' => 'conversation_handoff',
'reasoning' => 'Error occurred: Test error',
'handoff_tool_called' => true
})
end
end
end
end
describe '#build_context' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds context with conversation history and state' do
context = service.send(:build_context, message_history)
expect(context).to include(
conversation_history: array_including(
{ role: :user, content: 'Hello there', agent_name: nil },
{ role: :assistant, content: 'Hi! How can I help you?', agent_name: 'Assistant' }
),
state: hash_including(
account_id: account.id,
assistant_id: assistant.id
)
)
end
context 'with multimodal content' do
let(:multimodal_content) do
[
{ type: 'text', text: 'Can you help with this image?' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
]
end
let(:multimodal_message_history) do
[{ role: 'user', content: multimodal_content }]
end
it 'preserves multimodal arrays in conversation history for image context retention' do
context = service.send(:build_context, multimodal_message_history)
expect(context[:conversation_history].first[:content]).to eq(multimodal_content)
end
end
end
describe '#extract_last_user_message' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'extracts the last user message' do
result = service.send(:extract_last_user_message, message_history)
expect(result).to eq('I need help with my account')
end
it 'returns multimodal content with image attachments for the runner input' do
multimodal_message_history = [
{
role: 'user',
content: [
{ type: 'text', text: 'Can you check this screenshot?' },
{ type: 'image_url', image_url: { url: 'https://example.com/image.jpg' } }
]
}
]
result = service.send(:extract_last_user_message, multimodal_message_history)
expect(result).to be_a(RubyLLM::Content)
expect(result.text).to eq('Can you check this screenshot?')
expect(result.attachments.first.source.to_s).to eq('https://example.com/image.jpg')
end
end
describe '#extract_text_from_content' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'extracts text from string content' do
result = service.send(:extract_text_from_content, 'Simple text')
expect(result).to eq('Simple text')
end
it 'extracts response from hash content' do
content = { 'response' => 'Hash response' }
result = service.send(:extract_text_from_content, content)
expect(result).to eq('Hash response')
end
it 'extracts text from multimodal array content' do
content = [
{ type: 'text', text: 'First part' },
{ type: 'image_url', image_url: { url: 'image.jpg' } },
{ type: 'text', text: 'Second part' }
]
result = service.send(:extract_text_from_content, content)
expect(result).to eq('First part Second part')
end
end
describe '#dynamic_trace_attributes' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'adds serialized trace input attributes when present in context' do
context = {
state: {
account_id: account.id,
assistant_id: assistant.id,
conversation: { id: conversation.id, display_id: conversation.display_id }
},
captain_v2_trace_input: '[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.com/image.jpg"}}]}]'
}
context_wrapper = Struct.new(:context).new(context)
attributes = service.send(:dynamic_trace_attributes, context_wrapper)
expect(attributes['langfuse.trace.input']).to include('image_url')
expect(attributes['langfuse.observation.input']).to include('image_url')
expect(attributes['langfuse.user.id']).to eq(account.id.to_s)
end
end
describe 'InstrumentationAttributeProvider' do
subject(:provider) { Captain::Assistant::InstrumentationAttributeProvider.new(service) }
let(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'delegates root trace attributes to the service' do
context = {
state: {
account_id: account.id,
assistant_id: assistant.id,
conversation: { id: conversation.id, display_id: conversation.display_id }
}
}
context_wrapper = Struct.new(:context).new(context)
attributes = provider.call(context_wrapper)
expect(attributes).to include(
'langfuse.user.id' => account.id.to_s,
'langfuse.trace.metadata.assistant_id' => assistant.id.to_s
)
end
it 'marks final response generations for observation-level evaluators' do
message = instance_double(RubyLLM::Message, tool_calls: {})
attributes = provider.generation_attributes(nil, nil, message)
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
end
it 'marks tool call generations separately from final responses' do
tool_call = instance_double(RubyLLM::ToolCall)
message = instance_double(RubyLLM::Message, tool_calls: { 'call_1' => tool_call })
attributes = provider.generation_attributes(nil, nil, message)
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
end
end
describe '#build_state' do
subject(:service) { described_class.new(assistant: assistant, conversation: conversation) }
it 'builds state with assistant and account information' do
state = service.send(:build_state)
expect(state).to include(
account_id: account.id,
assistant_id: assistant.id,
assistant_config: assistant.config
)
end
it 'includes conversation attributes when conversation is present' do
state = service.send(:build_state)
expect(state[:conversation]).to include(
id: conversation.id,
inbox_id: inbox.id,
contact_id: contact.id,
status: conversation.status
)
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 'always includes contact attributes in state for tool access' do
state = service.send(:build_state)
expect(state[:contact]).to include(
id: contact.id,
name: contact.name,
email: contact.email
)
end
it 'does not include campaign when conversation has no campaign' do
state = service.send(:build_state)
expect(state).not_to have_key(:campaign)
end
context 'when conversation has a campaign' do
let(:campaign) { create(:campaign, account: account, title: 'Summer Sale', message: 'Check out our deals!', description: 'Seasonal promo') }
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, campaign: campaign) }
it 'includes campaign attributes in state' do
state = service.send(:build_state)
expect(state[:campaign]).to include(
id: campaign.id,
title: 'Summer Sale',
message: 'Check out our deals!',
description: 'Seasonal promo'
)
end
it 'only includes attributes defined in CAMPAIGN_STATE_ATTRIBUTES' do
state = service.send(:build_state)
expect(state[:campaign].keys).to match_array(described_class::CAMPAIGN_STATE_ATTRIBUTES)
end
end
context 'when conversation is nil' do
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
it 'builds state without conversation and contact' do
state = service.send(:build_state)
expect(state).to include(
account_id: account.id,
assistant_id: assistant.id,
assistant_config: assistant.config
)
expect(state).not_to have_key(:conversation)
expect(state).not_to have_key(:contact)
expect(state).not_to have_key(:campaign)
end
end
end
describe '#add_usage_metadata_callback' do
it 'sets credit_used=false when handoff tool is used' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
tool_complete_callback = nil
run_complete_callback = nil
span_class = Class.new do
def set_attribute(*); end
end
root_span = instance_double(span_class)
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
allow(runner).to receive(:on_tool_complete) do |&block|
tool_complete_callback = block
runner
end
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
run_complete_callback.call('assistant', nil, context_wrapper)
end
it 'registers handoff tracking callback when OTEL is disabled' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
tool_complete_callback = nil
allow(ChatwootApp).to receive(:otel_enabled?).and_return(false)
allow(runner).to receive(:on_tool_complete) do |&block|
tool_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
context_wrapper = Struct.new(:context).new({})
expect(tool_complete_callback).not_to be_nil
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
expect(context_wrapper.context[:captain_v2_handoff_tool_called]).to be true
end
it 'does not register OTEL run callback when OTEL is disabled' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
allow(ChatwootApp).to receive(:otel_enabled?).and_return(false)
allow(runner).to receive(:on_tool_complete).and_return(runner)
expect(runner).not_to receive(:on_run_complete)
service.send(:add_usage_metadata_callback, runner)
end
it 'sets credit_used=true when handoff tool is not used' do
service = described_class.new(assistant: assistant, conversation: conversation)
runner = instance_double(Agents::AgentRunner)
run_complete_callback = nil
span_class = Class.new do
def set_attribute(*); end
end
root_span = instance_double(span_class)
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
allow(runner).to receive(:on_tool_complete).and_return(runner)
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'true')
run_complete_callback.call('assistant', nil, context_wrapper)
end
end
describe 'constants' do
it 'defines conversation state attributes' do
expect(described_class::CONVERSATION_STATE_ATTRIBUTES).to include(
:id, :display_id, :inbox_id, :contact_id, :status, :priority
)
end
it 'defines contact state attributes' do
expect(described_class::CONTACT_STATE_ATTRIBUTES).to include(
:id, :name, :email, :phone_number, :identifier, :contact_type
)
end
it 'defines campaign state attributes' do
expect(described_class::CAMPAIGN_STATE_ATTRIBUTES).to include(
:id, :title, :message, :campaign_type, :description
)
end
end
end