feat: scenario tools [CW-4597] (#11908)

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
Shivam Mishra
2025-07-21 16:42:12 +05:30
committed by GitHub
parent bb9e3a5495
commit b71a0da10d
21 changed files with 1139 additions and 6 deletions

View File

@@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddContactNoteTool, 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({ contact: { id: contact.id } }) }
describe '#description' do
it 'returns the correct description' do
expect(tool.description).to eq('Add a note to a contact profile')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:note)
expect(tool.parameters[:note].name).to eq(:note)
expect(tool.parameters[:note].type).to eq('string')
expect(tool.parameters[:note].description).to eq('The note content to add to the contact')
end
end
describe '#perform' do
context 'when contact exists' do
context 'with valid note content' do
it 'creates a contact note and returns success message' do
note_content = 'This is a contact note'
expect do
result = tool.perform(tool_context, note: note_content)
expect(result).to eq("Note added successfully to contact #{contact.name} (ID: #{contact.id})")
end.to change(Note, :count).by(1)
created_note = Note.last
expect(created_note.content).to eq(note_content)
expect(created_note.account).to eq(account)
expect(created_note.contact).to eq(contact)
expect(created_note.user).to eq(assistant.account.users.first)
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'add_contact_note',
{ contact_id: contact.id, note_length: 19 }
)
tool.perform(tool_context, note: 'This is a test note')
end
end
context 'with blank note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: '')
expect(result).to eq('Note content is required')
end
it 'does not create a note' do
expect do
tool.perform(tool_context, note: '')
end.not_to change(Note, :count)
end
end
context 'with nil note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: nil)
expect(result).to eq('Note content is required')
end
end
end
context 'when contact does not exist' do
let(:tool_context) { Struct.new(:state).new({ contact: { id: 999_999 } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
it 'does not create a note' do
expect do
tool.perform(tool_context, note: 'Some note')
end.not_to change(Note, :count)
end
end
context 'when contact state is missing' do
let(:tool_context) { Struct.new(:state).new({}) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
end
context 'when contact id is nil' do
let(:tool_context) { Struct.new(:state).new({ contact: { id: nil } }) }
it 'returns error message' do
result = tool.perform(tool_context, note: 'Some note')
expect(result).to eq('Contact not found')
end
end
end
describe '#active?' do
it 'returns true for public tools' do
expect(tool.active?).to be true
end
end
end

View File

@@ -0,0 +1,125 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddLabelToConversationTool, 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(:label) { create(:label, account: account, title: 'urgent') }
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('Add a label to a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:label_name)
expect(tool.parameters[:label_name].name).to eq(:label_name)
expect(tool.parameters[:label_name].type).to eq('string')
expect(tool.parameters[:label_name].description).to eq('The name of the label to add')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid label that exists' do
before { label }
it 'adds label to conversation and returns success message' do
result = tool.perform(tool_context, label_name: 'urgent')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
expect(conversation.reload.label_list).to include('urgent')
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'added_label',
{ conversation_id: conversation.id, label: 'urgent' }
)
tool.perform(tool_context, label_name: 'urgent')
end
it 'handles case insensitive label names' do
result = tool.perform(tool_context, label_name: 'URGENT')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
end
it 'strips whitespace from label names' do
result = tool.perform(tool_context, label_name: ' urgent ')
expect(result).to eq("Label 'urgent' added to conversation ##{conversation.display_id}")
end
end
context 'with label that does not exist' do
it 'returns error message' do
result = tool.perform(tool_context, label_name: 'nonexistent')
expect(result).to eq('Label not found')
end
it 'does not add any labels to conversation' do
expect do
tool.perform(tool_context, label_name: 'nonexistent')
end.not_to(change { conversation.reload.labels.count })
end
end
context 'with blank label name' do
it 'returns error message for empty string' do
result = tool.perform(tool_context, label_name: '')
expect(result).to eq('Label name is required')
end
it 'returns error message for nil' do
result = tool.perform(tool_context, label_name: nil)
expect(result).to eq('Label name is required')
end
it 'returns error message for whitespace only' do
result = tool.perform(tool_context, label_name: ' ')
expect(result).to eq('Label name is required')
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, label_name: 'urgent')
expect(result).to eq('Conversation not found')
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, label_name: 'urgent')
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, label_name: 'urgent')
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

View File

@@ -0,0 +1,124 @@
require 'rails_helper'
RSpec.describe Captain::Tools::AddPrivateNoteTool, 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('Add a private note to a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:note)
expect(tool.parameters[:note].name).to eq(:note)
expect(tool.parameters[:note].type).to eq('string')
expect(tool.parameters[:note].description).to eq('The private note content')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid note content' do
it 'creates a private note and returns success message' do
note_content = 'This is a private note'
expect do
result = tool.perform(tool_context, note: note_content)
expect(result).to eq('Private note added successfully')
end.to change(Message, :count).by(1)
end
it 'creates a private note with correct attributes' do
note_content = 'This is a private note'
tool.perform(tool_context, note: note_content)
created_message = Message.last
expect(created_message.content).to eq(note_content)
expect(created_message.message_type).to eq('outgoing')
expect(created_message.private).to be true
expect(created_message.account).to eq(account)
expect(created_message.inbox).to eq(inbox)
expect(created_message.conversation).to eq(conversation)
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'add_private_note',
{ conversation_id: conversation.id, note_length: 19 }
)
tool.perform(tool_context, note: 'This is a test note')
end
end
context 'with blank note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: '')
expect(result).to eq('Note content is required')
end
it 'does not create a message' do
expect do
tool.perform(tool_context, note: '')
end.not_to change(Message, :count)
end
end
context 'with nil note content' do
it 'returns error message' do
result = tool.perform(tool_context, note: nil)
expect(result).to eq('Note content is required')
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, note: 'Some note')
expect(result).to eq('Conversation not found')
end
it 'does not create a message' do
expect do
tool.perform(tool_context, note: 'Some note')
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, note: 'Some note')
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, note: 'Some note')
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

View File

@@ -0,0 +1,117 @@
require 'rails_helper'
RSpec.describe Captain::Tools::UpdatePriorityTool, 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('Update the priority of a conversation')
end
end
describe '#parameters' do
it 'returns the correct parameters' do
expect(tool.parameters).to have_key(:priority)
expect(tool.parameters[:priority].name).to eq(:priority)
expect(tool.parameters[:priority].type).to eq('string')
expect(tool.parameters[:priority].description).to eq('The priority level: low, medium, high, urgent, or nil to remove priority')
end
end
describe '#perform' do
context 'when conversation exists' do
context 'with valid priority levels' do
%w[low medium high urgent].each do |priority|
it "updates conversation priority to #{priority}" do
result = tool.perform(tool_context, priority: priority)
expect(result).to eq("Priority updated to '#{priority}' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to eq(priority)
end
end
it 'removes priority when set to nil' do
conversation.update!(priority: 'high')
result = tool.perform(tool_context, priority: 'nil')
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to be_nil
end
it 'removes priority when set to empty string' do
conversation.update!(priority: 'high')
result = tool.perform(tool_context, priority: '')
expect(result).to eq("Priority updated to 'none' for conversation ##{conversation.display_id}")
expect(conversation.reload.priority).to be_nil
end
it 'logs tool usage' do
expect(tool).to receive(:log_tool_usage).with(
'update_priority',
{ conversation_id: conversation.id, priority: 'high' }
)
tool.perform(tool_context, priority: 'high')
end
end
context 'with invalid priority levels' do
it 'returns error message for invalid priority' do
result = tool.perform(tool_context, priority: 'invalid')
expect(result).to eq('Invalid priority. Valid options: low, medium, high, urgent, nil')
end
it 'does not update conversation priority' do
original_priority = conversation.priority
tool.perform(tool_context, priority: 'invalid')
expect(conversation.reload.priority).to eq(original_priority)
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, priority: 'high')
expect(result).to eq('Conversation not found')
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, priority: 'high')
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, priority: 'high')
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

View File

@@ -33,15 +33,119 @@ RSpec.describe Captain::Scenario, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'before_save :populate_tools' do
it 'calls populate_tools before saving' do
describe 'before_save :resolve_tool_references' do
it 'calls resolve_tool_references before saving' do
scenario = build(:captain_scenario, assistant: assistant, account: account)
expect(scenario).to receive(:populate_tools)
expect(scenario).to receive(:resolve_tool_references)
scenario.save
end
end
end
describe 'tool validation and population' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
before do
# Mock available tools
allow(described_class).to receive(:available_tool_ids).and_return(%w[
add_contact_note add_private_note update_priority
])
end
describe 'validate_instruction_tools' do
it 'is valid with valid tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note) to document')
expect(scenario).to be_valid
end
it 'is invalid with invalid tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Invalid Tool](tool://invalid_tool) to process')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool')
end
it 'is invalid with multiple invalid tools' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Invalid Tool](tool://invalid_tool) and [@Another Invalid](tool://another_invalid)')
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).to include('contains invalid tools: invalid_tool, another_invalid')
end
it 'is valid with no tool references' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Just respond politely to the customer')
expect(scenario).to be_valid
end
it 'is valid with blank instruction' do
scenario = build(:captain_scenario,
assistant: assistant,
account: account,
instruction: '')
# Will be invalid due to presence validation, not tool validation
expect(scenario).not_to be_valid
expect(scenario.errors[:instruction]).not_to include(/contains invalid tools/)
end
end
describe 'resolve_tool_references' do
it 'populates tools array with referenced tool IDs' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)')
expect(scenario.tools).to eq(%w[add_contact_note update_priority])
end
it 'sets tools to nil when no tools are referenced' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Just respond politely to the customer')
expect(scenario.tools).to be_nil
end
it 'handles duplicate tool references' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note) and [@Add Contact Note](tool://add_contact_note) again')
expect(scenario.tools).to eq(['add_contact_note'])
end
it 'updates tools when instruction changes' do
scenario = create(:captain_scenario,
assistant: assistant,
account: account,
instruction: 'Use [@Add Contact Note](tool://add_contact_note)')
expect(scenario.tools).to eq(['add_contact_note'])
scenario.update!(instruction: 'Use [@Update Priority](tool://update_priority) instead')
expect(scenario.tools).to eq(['update_priority'])
end
end
end
describe 'factory' do
it 'creates a valid scenario with associations' do
account = create(:account)

View File

@@ -0,0 +1,180 @@
require 'rails_helper'
RSpec.describe Concerns::CaptainToolsHelpers, type: :concern do
# Create a test class that includes the concern
let(:test_class) do
Class.new do
include Concerns::CaptainToolsHelpers
def self.name
'TestClass'
end
end
end
let(:test_instance) { test_class.new }
describe 'TOOL_REFERENCE_REGEX' do
it 'matches tool references in text' do
text = 'Use [@Add Contact Note](tool://add_contact_note) and [Update Priority](tool://update_priority)'
matches = text.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches.flatten).to eq(%w[add_contact_note update_priority])
end
it 'does not match invalid formats' do
invalid_formats = [
'<tool://invalid>',
'tool://invalid',
'(tool:invalid)',
'(tool://)',
'(tool://with/slash)',
'(tool://add_contact_note)',
'[@Tool](tool://)',
'[Tool](tool://with/slash)',
'[](tool://valid)'
]
invalid_formats.each do |format|
matches = format.scan(Concerns::CaptainToolsHelpers::TOOL_REFERENCE_REGEX)
expect(matches).to be_empty, "Should not match: #{format}"
end
end
end
describe '.available_agent_tools' do
before do
# Mock the YAML file loading
allow(YAML).to receive(:load_file).and_return([
{
'id' => 'add_contact_note',
'title' => 'Add Contact Note',
'description' => 'Add a note to a contact',
'icon' => 'note-add'
},
{
'id' => 'invalid_tool',
'title' => 'Invalid Tool',
'description' => 'This tool does not exist',
'icon' => 'invalid'
}
])
# Mock class resolution - only add_contact_note exists
allow(test_class).to receive(:resolve_tool_class) do |tool_id|
case tool_id
when 'add_contact_note'
Captain::Tools::AddContactNoteTool
end
end
end
it 'returns only resolvable tools' do
tools = test_class.available_agent_tools
expect(tools.length).to eq(1)
expect(tools.first).to eq({
id: 'add_contact_note',
title: 'Add Contact Note',
description: 'Add a note to a contact',
icon: 'note-add'
})
end
it 'logs warnings for unresolvable tools' do
expect(Rails.logger).to receive(:warn).with('Tool class not found for ID: invalid_tool')
test_class.available_agent_tools
end
it 'memoizes the result' do
expect(YAML).to receive(:load_file).once.and_return([])
2.times { test_class.available_agent_tools }
end
end
describe '.resolve_tool_class' do
it 'resolves valid tool classes' do
# Mock the constantize to return a class
stub_const('Captain::Tools::AddContactNoteTool', Class.new)
result = test_class.resolve_tool_class('add_contact_note')
expect(result).to eq(Captain::Tools::AddContactNoteTool)
end
it 'returns nil for invalid tool classes' do
result = test_class.resolve_tool_class('invalid_tool')
expect(result).to be_nil
end
it 'converts snake_case to PascalCase' do
stub_const('Captain::Tools::AddPrivateNoteTool', Class.new)
result = test_class.resolve_tool_class('add_private_note')
expect(result).to eq(Captain::Tools::AddPrivateNoteTool)
end
end
describe '.available_tool_ids' do
before do
allow(test_class).to receive(:available_agent_tools).and_return([
{ id: 'add_contact_note', title: 'Add Contact Note', description: '...',
icon: 'note' },
{ id: 'update_priority', title: 'Update Priority', description: '...',
icon: 'priority' }
])
end
it 'returns array of tool IDs' do
ids = test_class.available_tool_ids
expect(ids).to eq(%w[add_contact_note update_priority])
end
it 'memoizes the result' do
expect(test_class).to receive(:available_agent_tools).once.and_return([])
2.times { test_class.available_tool_ids }
end
end
describe '#extract_tool_ids_from_text' do
it 'extracts tool IDs from text' do
text = 'First [@Add Contact Note](tool://add_contact_note) then [@Update Priority](tool://update_priority)'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(%w[add_contact_note update_priority])
end
it 'returns unique tool IDs' do
text = 'Use [@Add Contact Note](tool://add_contact_note) and [@Contact Note](tool://add_contact_note) again'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(['add_contact_note'])
end
it 'returns empty array for blank text' do
expect(test_instance.extract_tool_ids_from_text('')).to eq([])
expect(test_instance.extract_tool_ids_from_text(nil)).to eq([])
expect(test_instance.extract_tool_ids_from_text(' ')).to eq([])
end
it 'returns empty array when no tools found' do
text = 'This text has no tool references'
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq([])
end
it 'handles complex text with multiple tools' do
text = <<~TEXT
Start with [@Add Contact Note](tool://add_contact_note) to document.
Then use [@Update Priority](tool://update_priority) if needed.
Finally [@Add Private Note](tool://add_private_note) for internal notes.
TEXT
result = test_instance.extract_tool_ids_from_text(text)
expect(result).to eq(%w[add_contact_note update_priority add_private_note])
end
end
end