Compare commits

...
Author SHA1 Message Date
Shivam Mishra 41670bfae9 refactor: extract agent runner service concerns
Split RunContextNormalizer and StateBuilder out of
Captain::Assistant::AgentRunnerService to keep the service within the
class length budget.
2026-05-22 21:25:28 +05:30
Shivam Mishra 1984bddb11 refactor: extract response builder job concerns
Split ResponseMetadataBuilder and MessageHistoryCollector out of
Captain::Conversation::ResponseBuilderJob to keep the job within the
class length budget.
2026-05-22 21:25:17 +05:30
Shivam Mishra bba230a1ac feat: snapshot and restore metadata from each run 2026-05-22 21:20:18 +05:30
Shivam Mishra 10f9d61d02 chore: update prompt 2026-05-22 19:36:54 +05:30
12 changed files with 494 additions and 86 deletions
@@ -0,0 +1,38 @@
module Captain::Conversation::MessageHistoryCollector
private
def collect_previous_messages
@conversation
.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.flat_map { |message| history_entries_for_message(message) }
end
def history_entries_for_message(message)
captain_run_context = message.captain_run_context if captain_v2_enabled? && message.outgoing?
return captain_run_context['messages'] if captain_run_context&.dig('messages').present?
[legacy_history_entry_for_message(message)]
end
def legacy_history_entry_for_message(message)
message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
agent_name = message.additional_attributes&.dig('captain', 'agent', 'name') || message.additional_attributes&.dig('agent_name')
message_hash[:agent_name] = agent_name if agent_name.present?
message_hash
end
def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant'
end
def prepare_multimodal_message_content(message)
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
end
end
@@ -1,5 +1,7 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::Conversation::V1ActionClassifier
include Captain::Conversation::ResponseMetadataBuilder
include Captain::Conversation::MessageHistoryCollector
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
@@ -79,32 +81,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
end
def collect_previous_messages
@conversation
.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.map do |message|
message_hash = {
content: prepare_multimodal_message_content(message),
role: determine_role(message)
}
# Include agent_name if present in additional_attributes
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
message_hash
end
end
def determine_role(message)
message.message_type == 'incoming' ? 'user' : 'assistant'
end
def prepare_multimodal_message_content(message)
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
end
def v1_handoff_requested?
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
end
@@ -153,22 +129,26 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def create_handoff_message(preserve_waiting_since: false)
create_outgoing_message(
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
agent_name: captain_v2_enabled? ? @response&.dig('agent_name') || assistant_agent_name : nil,
captain_metadata: captain_handoff_metadata,
preserve_waiting_since: preserve_waiting_since
)
end
def create_messages
validate_message_content!(@response['response'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
create_outgoing_message(@response['response'], agent_name: @response['agent_name'], captain_metadata: captain_response_metadata)
end
def validate_message_content!(content)
raise ArgumentError, 'Message content cannot be blank' if content.blank?
end
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
def create_outgoing_message(message_content, agent_name: nil, captain_metadata: nil, preserve_waiting_since: false)
additional_attrs = {}
additional_attrs[:agent_name] = agent_name if agent_name.present?
agent_name ||= captain_metadata&.dig('agent', 'name')
additional_attrs['agent_name'] = agent_name if agent_name.present?
additional_attrs['captain'] = captain_metadata if captain_metadata.present?
@conversation.messages.create!(
message_type: :outgoing,
@@ -0,0 +1,90 @@
module Captain::Conversation::ResponseMetadataBuilder
private
def captain_response_metadata
return unless captain_v2_enabled?
{
'version' => 'v2',
'agent' => captain_agent_metadata(@response['agent_name']),
'run' => captain_response_run_context
}
end
def captain_handoff_metadata
return unless captain_v2_enabled?
{
'version' => 'v2',
'agent' => captain_agent_metadata(@response&.dig('agent_name') || assistant_agent_name),
'run' => {
'messages' => [],
'handoff_tool_called' => true
}
}
end
def captain_response_run_context
run_context = (@response['run_context'] || {}).deep_stringify_keys
messages = Array(run_context['messages'])
messages = fallback_captain_run_messages if messages.empty?
{
'messages' => messages,
'handoff_tool_called' => @response['handoff_tool_called'] || false
}
end
def fallback_captain_run_messages
content = {}
content['reasoning'] = @response['reasoning'] if @response['reasoning'].present?
[
{
'role' => 'assistant',
'content' => content,
'agent_name' => @response['agent_name']
}.compact
]
end
def captain_agent_metadata(agent_name)
agent_name = agent_name.presence || assistant_agent_name
scenario = @assistant.scenarios.enabled.find { |enabled_scenario| enabled_scenario.handoff_key == agent_name }
return scenario_agent_metadata(scenario, agent_name) if scenario
return assistant_agent_metadata(agent_name) if agent_name == assistant_agent_name
unknown_agent_metadata(agent_name)
end
def scenario_agent_metadata(scenario, agent_name)
{
'name' => agent_name,
'type' => 'scenario',
'assistant_id' => @assistant.id,
'scenario_id' => scenario.id,
'handoff_key' => scenario.handoff_key
}
end
def assistant_agent_metadata(agent_name)
{
'name' => agent_name,
'type' => 'assistant',
'assistant_id' => @assistant.id
}
end
def unknown_agent_metadata(agent_name)
{
'name' => agent_name,
'type' => 'unknown',
'assistant_id' => @assistant.id
}
end
def assistant_agent_name
@assistant.name.parameterize(separator: '_')
end
end
@@ -13,8 +13,27 @@ module Enterprise::Message
data
end
def captain_run_context
captain = additional_attributes&.dig('captain')
return unless captain&.dig('version') == 'v2'
run_context = (captain['run'] || {}).deep_dup
messages = Array(run_context['messages']).map(&:deep_dup)
restore_captain_final_response(messages)
run_context.merge('messages' => messages)
end
private
def restore_captain_final_response(messages)
final_assistant_message = messages.reverse.find { |message| message['role'] == 'assistant' }
return unless final_assistant_message
content_hash = final_assistant_message['content'].is_a?(Hash) ? final_assistant_message['content'] : {}
final_assistant_message['content'] = content_hash.merge('response' => content)
end
def mark_pending_conversation_as_open_for_human_response
return unless captain_pending_conversation?
return unless human_response?
@@ -5,20 +5,9 @@ class Captain::Assistant::AgentRunnerService
include Integrations::LlmInstrumentationConstants
include Captain::Assistant::RunnerCallbacksHelper
include Captain::Assistant::TracePayloadHelper
include Captain::Assistant::RunContextNormalizer
include Captain::Assistant::StateBuilder
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
].freeze
CONTACT_STATE_ATTRIBUTES = %i[
id name email phone_number identifier contact_type
custom_attributes additional_attributes
].freeze
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
@assistant = assistant
@conversation = conversation
@@ -45,15 +34,14 @@ class Captain::Assistant::AgentRunnerService
def build_context(message_history)
conversation_history = message_history.map do |msg|
content = msg[:content]
# Preserve multimodal arrays (with image_url entries) as-is for the runner to restore with attachments.
# Only extract text from non-array formats (hashes from agent structured output, plain strings).
content = extract_text_from_content(content) unless content.is_a?(Array)
content = message_value(msg, :content)
# Preserve multimodal arrays and structured agent output as-is for replay.
content = extract_text_from_content(content) unless content.is_a?(Array) || content.is_a?(Hash)
{
role: msg[:role].to_sym,
role: message_value(msg, :role).to_sym,
content: content,
agent_name: msg[:agent_name]
agent_name: message_value(msg, :agent_name)
}
end
@@ -65,10 +53,10 @@ class Captain::Assistant::AgentRunnerService
end
def extract_last_user_message(message_history)
last_user_msg = message_history.reverse.find { |msg| msg[:role] == 'user' }
last_user_msg = message_history.reverse.find { |msg| message_value(msg, :role).to_s == 'user' }
return '' if last_user_msg.blank?
content = last_user_msg[:content]
content = message_value(last_user_msg, :content)
return extract_text_from_content(content) unless content.is_a?(Array)
text, attachments = Captain::OpenAiMessageBuilderService.extract_text_and_attachments(content)
@@ -78,12 +66,16 @@ class Captain::Assistant::AgentRunnerService
end
def message_history_without_last_user_message(message_history)
last_user_index = message_history.rindex { |msg| msg[:role] == 'user' }
last_user_index = message_history.rindex { |msg| message_value(msg, :role).to_s == 'user' }
return message_history if last_user_index.nil?
message_history.reject.with_index { |_msg, index| index == last_user_index }
end
def message_value(message, key)
message[key] || message[key.to_s]
end
def extract_text_from_content(content)
# Handle structured output from agents
return content[:response] || content['response'] || content.to_s if content.is_a?(Hash)
@@ -100,6 +92,7 @@ class Captain::Assistant::AgentRunnerService
response = output.is_a?(Hash) ? output.with_indifferent_access : { 'response' => output.to_s, 'reasoning' => 'Processed by agent' }
response['agent_name'] = result.context&.dig(:current_agent)
response['handoff_tool_called'] = result.context&.dig(:captain_v2_handoff_tool_called) || false
response['run_context'] = build_run_context(result, response)
response
end
@@ -111,30 +104,6 @@ class Captain::Assistant::AgentRunnerService
}
end
def build_state
state = {
account_id: @assistant.account_id,
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation
state
end
def build_conversation_state(state)
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
end
def slice_attrs(record, keys)
record.attributes.symbolize_keys.slice(*keys)
end
def build_and_wire_agents
assistant_agent = @assistant.agent
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
@@ -0,0 +1,34 @@
module Captain::Assistant::RunContextNormalizer
private
def build_run_context(result, response)
{
'messages' => normalized_run_messages(result.messages || []),
'handoff_tool_called' => response['handoff_tool_called']
}
end
def normalized_run_messages(messages)
normalized_messages = messages.map { |message| normalize_run_message(message) }
final_assistant_message = normalized_messages.reverse.find { |message| message['role'] == 'assistant' }
strip_final_response(final_assistant_message) if final_assistant_message
normalized_messages
end
def normalize_run_message(message)
normalized = message.deep_stringify_keys
normalized['role'] = normalized['role'].to_s if normalized['role'].present?
normalized['content'] = normalized['content'].deep_stringify_keys if normalized['content'].is_a?(Hash)
normalized
end
def strip_final_response(message)
message['content'] = normalized_final_message_content(message['content'])
end
def normalized_final_message_content(content)
return content.except('response') if content.is_a?(Hash)
{}
end
end
@@ -0,0 +1,41 @@
module Captain::Assistant::StateBuilder
CONVERSATION_STATE_ATTRIBUTES = %i[
id display_id inbox_id contact_id status priority
label_list custom_attributes additional_attributes
].freeze
CONTACT_STATE_ATTRIBUTES = %i[
id name email phone_number identifier contact_type
custom_attributes additional_attributes
].freeze
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
private
def build_state
state = {
account_id: @assistant.account_id,
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation
state
end
def build_conversation_state(state)
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
state[:channel_type] = @conversation.inbox&.channel_type
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
end
def slice_attrs(record, keys)
record.attributes.symbolize_keys.slice(*keys)
end
end
@@ -1,5 +1,5 @@
# System Context
You are part of Captain, a multi-agent AI system designed for seamless agent coordination and task execution. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
You are part of Captain, a multi-agent AI support system designed for seamless agent coordination and task execution for handling customer support requests. You can transfer conversations to specialized agents using handoff functions (e.g., `handoff_to_[agent_name]`). These transfers happen in the background - never mention or draw attention to them in your responses.
# Your Identity
You are {{name}}, a helpful and knowledgeable assistant for the product {{product_name}}. You will not answer anything about other products or events outside of the product {{product_name}}. Your role is to primarily act as an orchestrator handling multiple scenarios by using handoff tools. Your job also involves providing accurate information, assisting with tasks, and ensuring the customer gets the help they need.
@@ -37,6 +37,8 @@ Here's the metadata we have about the current conversation and the contact assoc
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
- Never suggest escalating to support, you're the support agent
- Never expose the internal workings, architecture or nomenclature of the multi-agent system, act as a human support agent
{% for guideline in response_guidelines -%}
- {{ guideline }}
{% endfor %}
@@ -1,8 +1,9 @@
# System context
You are part of a multi-agent system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
You are part of a multi-agent AI support system where you've been handed off a conversation to handle a specific task. The handoff was seamless - the user is not aware of any transfer. Continue the conversation naturally.
Some messages in the conversation history may come from other AI agents in the system. Treat them as part of the conversation flow, retain the context they carry, and hand off back to the orchestrator if the request falls outside your scope.
# Your Role
You are a specialized agent called "{{ title }}", your task is to handle the following scenario:
You are a specialized support agent called "{{ title }}", your task is to handle the following scenario:
{{ instructions }}
@@ -27,13 +28,13 @@ Here's the metadata we have about the current conversation and the contact assoc
{% endif -%}
{% if response_guidelines.size > 0 -%}
# Response Guidelines
Your responses should follow these guidelines:
- Never suggest escalating to support, you're the support agent
- Never expose the internal workings, architecture or nomenclature of the multi-agent system
{% for guideline in response_guidelines -%}
- {{ guideline }}
{% endfor %}
{% endif -%}
{% if guardrails.size > 0 -%}
# Guardrails
@@ -203,6 +203,124 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
account.reload
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
it 'stores normalized Captain run metadata on the outgoing message' do
agent_name = assistant.name.parameterize(separator: '_')
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
'response' => 'Hey, welcome to Captain V2',
'agent_name' => agent_name,
'handoff_tool_called' => false,
'run_context' => {
'messages' => [
{
'role' => 'assistant',
'content' => { 'reasoning' => 'Answered from FAQ' },
'agent_name' => agent_name
}
],
'handoff_tool_called' => false
}
})
described_class.perform_now(conversation, assistant)
attributes = conversation.messages.outgoing.last.additional_attributes
expect(attributes['agent_name']).to eq(agent_name)
expect(attributes.dig('captain', 'version')).to eq('v2')
expect(attributes.dig('captain', 'agent')).to eq({
'name' => agent_name,
'type' => 'assistant',
'assistant_id' => assistant.id
})
expect(attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Answered from FAQ' })
end
it 'stores scenario agent metadata when the scenario handles the response' do
scenario = create(:captain_scenario, assistant: assistant, account: account, title: 'Billing support')
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
'response' => 'Billing answer',
'agent_name' => scenario.handoff_key,
'handoff_tool_called' => false
})
described_class.perform_now(conversation, assistant)
expect(conversation.messages.outgoing.last.additional_attributes.dig('captain', 'agent')).to eq({
'name' => scenario.handoff_key,
'type' => 'scenario',
'assistant_id' => assistant.id,
'scenario_id' => scenario.id,
'handoff_key' => scenario.handoff_key
})
end
it 'replays persisted Captain run messages as message history' do
agent_name = assistant.name.parameterize(separator: '_')
create(
:message,
conversation: conversation,
account: account,
inbox: inbox,
sender: assistant,
message_type: :outgoing,
content: 'Prior visible answer',
additional_attributes: {
'captain' => {
'version' => 'v2',
'agent' => { 'name' => agent_name, 'type' => 'assistant', 'assistant_id' => assistant.id },
'run' => {
'messages' => [
{
'role' => 'assistant',
'content' => '',
'agent_name' => agent_name,
'tool_calls' => [
{
'id' => 'call_1',
'name' => 'captain--tools--faq_lookup',
'arguments' => { 'query' => 'billing' }
}
]
},
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
{
'role' => 'assistant',
'content' => { 'reasoning' => 'Prior reasoning' },
'agent_name' => agent_name
}
],
'handoff_tool_called' => false
}
}
}
)
expected_messages = [
{ content: 'Hello', role: 'user' },
{
'role' => 'assistant',
'content' => '',
'agent_name' => agent_name,
'tool_calls' => [
{
'id' => 'call_1',
'name' => 'captain--tools--faq_lookup',
'arguments' => { 'query' => 'billing' }
}
]
},
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
{
'role' => 'assistant',
'content' => { 'reasoning' => 'Prior reasoning', 'response' => 'Prior visible answer' },
'agent_name' => agent_name
}
]
expect(mock_agent_runner_service).to receive(:generate_response).with(message_history: expected_messages)
described_class.perform_now(conversation, assistant)
end
end
context 'when captain_v2 handoff tool fires during agent execution' do
@@ -222,6 +340,10 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
public_messages = conversation.messages.outgoing.where(private: false)
expect(public_messages.count).to eq(1)
expect(public_messages.last.content).to eq(I18n.t('conversations.captain.handoff'))
expect(public_messages.last.additional_attributes.dig('captain', 'run')).to eq({
'messages' => [],
'handoff_tool_called' => true
})
end
it 'does not call bot_handoff! again when conversation is already open' do
+41
View File
@@ -24,6 +24,47 @@ RSpec.describe Message do
expect(conversation.waiting_since).to be_nil
end
describe '#captain_run_context' do
it 'restores the final response from message content without mutating additional attributes' do
message = create(
:message,
message_type: :outgoing,
conversation: conversation,
content: 'Visible response',
additional_attributes: {
'captain' => {
'version' => 'v2',
'run' => {
'messages' => [
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
{
'role' => 'assistant',
'content' => { 'reasoning' => 'Used FAQ' },
'agent_name' => 'assistant'
}
],
'handoff_tool_called' => false
}
}
}
)
run_context = message.captain_run_context
expect(run_context['messages'].last['content']).to eq({
'reasoning' => 'Used FAQ',
'response' => 'Visible response'
})
expect(message.additional_attributes.dig('captain', 'run', 'messages').last['content']).to eq({ 'reasoning' => 'Used FAQ' })
end
it 'returns nil when the message has no Captain V2 run context' do
message = create(:message, message_type: :outgoing, conversation: conversation, additional_attributes: {})
expect(message.captain_run_context).to be_nil
end
end
describe '#mark_pending_conversation_as_open_for_human_response' do
let(:conversation) { create(:conversation, status: :pending) }
let(:captain_assistant) { create(:captain_assistant, account: conversation.account) }
@@ -13,7 +13,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
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(:mock_result) { instance_double(Agents::RunResult, output: { 'response' => 'Test response' }, context: nil, messages: []) }
let(:message_history) do
[
@@ -167,13 +167,67 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
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 })
expect(result).to eq({
'response' => 'Test response',
'agent_name' => nil,
'handoff_tool_called' => false,
'run_context' => {
'messages' => [],
'handoff_tool_called' => false
}
})
end
it 'normalizes run messages and removes duplicated final response' do
runner_messages = [
{
role: :assistant,
content: '',
agent_name: 'assistant',
tool_calls: [{ id: 'call_1', name: 'captain--tools--faq_lookup', arguments: { query: 'billing' } }]
},
{ role: :tool, content: 'FAQ result', tool_call_id: 'call_1' },
{
role: :assistant,
content: { response: 'Test response', reasoning: 'Answered from FAQ' },
agent_name: scenario.handoff_key
}
]
allow(mock_result).to receive(:messages).and_return(runner_messages)
allow(mock_result).to receive(:context).and_return({ current_agent: scenario.handoff_key })
result = service.generate_response(message_history: message_history)
expect(result['agent_name']).to eq(scenario.handoff_key)
expect(result['run_context']).to eq({
'messages' => [
{
'role' => 'assistant',
'content' => '',
'agent_name' => 'assistant',
'tool_calls' => [
{
'id' => 'call_1',
'name' => 'captain--tools--faq_lookup',
'arguments' => { 'query' => 'billing' }
}
]
},
{ 'role' => 'tool', 'content' => 'FAQ result', 'tool_call_id' => 'call_1' },
{
'role' => 'assistant',
'content' => { 'reasoning' => 'Answered from FAQ' },
'agent_name' => scenario.handoff_key
}
],
'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)
instance_double(Agents::RunResult, output: { 'response' => 'Let me connect you' }, context: runner_context, messages: [])
end
it 'includes handoff_tool_called flag in response' do
@@ -182,7 +236,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(result).to eq({
'response' => 'Let me connect you',
'agent_name' => nil,
'handoff_tool_called' => true
'handoff_tool_called' => true,
'run_context' => {
'messages' => [],
'handoff_tool_called' => true
}
})
end
end
@@ -203,7 +261,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
end
context 'when agent result is a string' do
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil) }
let(:mock_result) { instance_double(Agents::RunResult, output: 'Simple string response', context: nil, messages: []) }
it 'formats string response correctly' do
result = service.generate_response(message_history: message_history)
@@ -212,7 +270,11 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
'response' => 'Simple string response',
'reasoning' => 'Processed by agent',
'agent_name' => nil,
'handoff_tool_called' => false
'handoff_tool_called' => false,
'run_context' => {
'messages' => [],
'handoff_tool_called' => false
}
})
end
end
@@ -306,6 +368,15 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
)
end
it 'preserves structured assistant content in conversation history' do
structured_content = { 'response' => 'Visible answer', 'reasoning' => 'Used FAQ', 'custom' => { 'score' => 0.9 } }
context = service.send(:build_context, [
{ role: 'assistant', content: structured_content, agent_name: 'assistant' }
])
expect(context[:conversation_history].first[:content]).to eq(structured_content)
end
context 'with multimodal content' do
let(:multimodal_content) do
[