From 68ff52c432a848c4f8187c61eeff9f6a174b5294 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 9 Jul 2025 17:06:24 +0530 Subject: [PATCH] feat: add specs --- .../models/captain/scenario_spec.rb | 110 ++++++++++- .../concerns/captain_tools_helpers_spec.rb | 176 ++++++++++++++++++ 2 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 spec/enterprise/models/concerns/captain_tools_helpers_spec.rb diff --git a/spec/enterprise/models/captain/scenario_spec.rb b/spec/enterprise/models/captain/scenario_spec.rb index f944373e2..567c12436 100644 --- a/spec/enterprise/models/captain/scenario_spec.rb +++ b/spec/enterprise/models/captain/scenario_spec.rb @@ -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 (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 (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 (tool://invalid_tool) and (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 (tool://add_contact_note) then (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 (tool://add_contact_note) and (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 (tool://add_contact_note)') + + expect(scenario.tools).to eq(['add_contact_note']) + + scenario.update!(instruction: 'Use (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) diff --git a/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb new file mode 100644 index 000000000..9b31a6111 --- /dev/null +++ b/spec/enterprise/models/concerns/captain_tools_helpers_spec.rb @@ -0,0 +1,176 @@ +require 'rails_helper' + +RSpec.describe CaptainToolsHelpers, type: :concern do + # Create a test class that includes the concern + let(:test_class) do + Class.new do + include 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 (tool://add_contact_note) and (tool://update_priority)' + matches = text.scan(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://)', + '(tool://with/slash)' + ] + + invalid_formats.each do |format| + matches = format.scan(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 (tool://add_contact_note) then (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 (tool://add_contact_note) and (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 (tool://add_contact_note) to document. + Then use (tool://update_priority) if needed. + Finally (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 \ No newline at end of file