require 'rails_helper' RSpec.describe Captain::Tools::HandoffTool, type: :model do let(:account) { create(:account) } let(:assistant) { create(:captain_assistant, account: account) } let(:tool) { described_class.new(assistant) } let(:user) { create(:user, account: account) } let(:inbox) { create(:inbox, account: account) } let(:contact) { create(:contact, account: account) } let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) } let(:tool_context) { Struct.new(:state).new({ conversation: { id: conversation.id } }) } describe '#description' do it 'returns the correct description' do expect(tool.description).to eq('Hand off the conversation to a human agent when unable to assist further') end end describe '#parameters' do it 'returns the correct parameters' do expect(tool.parameters).to have_key(:reason) expect(tool.parameters[:reason].name).to eq(:reason) expect(tool.parameters[:reason].type).to eq('string') expect(tool.parameters[:reason].description).to eq('The reason why handoff is needed (optional)') expect(tool.parameters[:reason].required).to be false end end describe '#perform' do context 'when conversation exists' do context 'with reason provided' do it 'creates a private note with reason and hands off conversation' do reason = 'Customer needs specialized support' expect do result = tool.perform(tool_context, reason: reason) expect(result).to eq("Conversation handed off to human support team (Reason: #{reason})") end.to change(Message, :count).by(1) end it 'creates message with correct attributes' do reason = 'Customer needs specialized support' tool.perform(tool_context, reason: reason) created_message = Message.last expect(created_message.content).to eq(reason) expect(created_message.message_type).to eq('outgoing') expect(created_message.private).to be true expect(created_message.sender).to eq(assistant) expect(created_message.account).to eq(account) expect(created_message.inbox).to eq(inbox) expect(created_message.conversation).to eq(conversation) end it 'triggers bot handoff on conversation' do # The tool finds the conversation by ID, so we need to mock the found conversation found_conversation = Conversation.find(conversation.id) scoped_conversations = Conversation.where(account_id: assistant.account_id) allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) expect(found_conversation).to receive(:bot_handoff!) tool.perform(tool_context, reason: 'Test reason') end it 'logs tool usage with reason' do reason = 'Customer needs help' expect(tool).to receive(:log_tool_usage).with( 'tool_handoff', { conversation_id: conversation.id, reason: reason } ) tool.perform(tool_context, reason: reason) end end context 'without reason provided' do it 'creates a private note with nil content and hands off conversation' do expect do result = tool.perform(tool_context) expect(result).to eq('Conversation handed off to human support team') end.to change(Message, :count).by(1) created_message = Message.last expect(created_message.content).to be_nil end it 'logs tool usage with default reason' do expect(tool).to receive(:log_tool_usage).with( 'tool_handoff', { conversation_id: conversation.id, reason: 'Agent requested handoff' } ) tool.perform(tool_context) end end context 'when handoff fails' do before do # Mock the conversation lookup and handoff failure found_conversation = Conversation.find(conversation.id) scoped_conversations = Conversation.where(account_id: assistant.account_id) allow(Conversation).to receive(:where).with(account_id: assistant.account_id).and_return(scoped_conversations) allow(scoped_conversations).to receive(:find_by).with(id: conversation.id).and_return(found_conversation) allow(found_conversation).to receive(:bot_handoff!).and_raise(StandardError, 'Handoff error') exception_tracker = instance_double(ChatwootExceptionTracker) allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker) allow(exception_tracker).to receive(:capture_exception) end it 'returns error message' do result = tool.perform(tool_context, reason: 'Test') expect(result).to eq('Failed to handoff conversation') end it 'captures exception' do exception_tracker = instance_double(ChatwootExceptionTracker) expect(ChatwootExceptionTracker).to receive(:new).with(instance_of(StandardError)).and_return(exception_tracker) expect(exception_tracker).to receive(:capture_exception) tool.perform(tool_context, reason: 'Test') end end end context 'when conversation does not exist' do let(:tool_context) { Struct.new(:state).new({ conversation: { id: 999_999 } }) } it 'returns error message' do result = tool.perform(tool_context, reason: 'Test') expect(result).to eq('Conversation not found') end it 'does not create a message' do expect do tool.perform(tool_context, reason: 'Test') end.not_to change(Message, :count) end end context 'when conversation state is missing' do let(:tool_context) { Struct.new(:state).new({}) } it 'returns error message' do result = tool.perform(tool_context, reason: 'Test') expect(result).to eq('Conversation not found') end end context 'when conversation id is nil' do let(:tool_context) { Struct.new(:state).new({ conversation: { id: nil } }) } it 'returns error message' do result = tool.perform(tool_context, reason: 'Test') expect(result).to eq('Conversation not found') end end end describe '#active?' do it 'returns true for public tools' do expect(tool.active?).to be true end end end