Extract and pass image attachments from the latest user message to the runner, excluding the last user message from the context for processing. Fixes #13588 # Pull Request Template ## Description Adds image support to captain v2 ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## 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. specs and local testing <img width="754" height="1008" alt="image" src="https://github.com/user-attachments/assets/914cbc2c-9d30-42d0-87d4-9e5430845c87" /> langfuse also shows media correctly with the instrumentation code: <img width="1800" height="1260" alt="image" src="https://github.com/user-attachments/assets/ce0f5fa6-b1a5-42ec-a213-9a82b1751037" /> ## 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: Shivam Mishra <scm.mymail@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
482 lines
18 KiB
Ruby
482 lines
18 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::Runner) }
|
|
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_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 })
|
|
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
|
|
})
|
|
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'
|
|
})
|
|
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'
|
|
})
|
|
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 '#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 attributes when contact is present' do
|
|
state = service.send(:build_state)
|
|
|
|
expect(state[:contact]).to include(
|
|
id: contact.id,
|
|
name: contact.name,
|
|
email: contact.email
|
|
)
|
|
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)
|
|
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 '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
|
|
end
|
|
end
|