mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
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:
1
Gemfile
1
Gemfile
@@ -177,6 +177,7 @@ gem 'reverse_markdown'
|
||||
|
||||
gem 'iso-639'
|
||||
gem 'ruby-openai'
|
||||
gem 'ai-agents', '>= 0.2.1'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
|
||||
12
Gemfile.lock
12
Gemfile.lock
@@ -126,6 +126,8 @@ GEM
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.2)
|
||||
selectize-rails (~> 0.6)
|
||||
ai-agents (0.2.1)
|
||||
ruby_llm (~> 1.3)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -717,6 +719,15 @@ GEM
|
||||
ruby2ruby (2.5.0)
|
||||
ruby_parser (~> 3.1)
|
||||
sexp_processor (~> 4.6)
|
||||
ruby_llm (1.3.1)
|
||||
base64
|
||||
event_stream_parser (~> 1)
|
||||
faraday (>= 1.10.0)
|
||||
faraday-multipart (>= 1)
|
||||
faraday-net_http (>= 1)
|
||||
faraday-retry (>= 1)
|
||||
marcel (~> 1.0)
|
||||
zeitwerk (~> 2)
|
||||
ruby_parser (3.20.0)
|
||||
sexp_processor (~> 4.16)
|
||||
sass (3.7.4)
|
||||
@@ -895,6 +906,7 @@ DEPENDENCIES
|
||||
administrate (>= 0.20.1)
|
||||
administrate-field-active_storage (>= 1.0.3)
|
||||
administrate-field-belongs_to_search (>= 0.9.0)
|
||||
ai-agents (>= 0.2.1)
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.4, >= 5.4.1)
|
||||
|
||||
26
config/agents/tools.yml
Normal file
26
config/agents/tools.yml
Normal file
@@ -0,0 +1,26 @@
|
||||
###### Captain Agent Tools Configuration #######
|
||||
# id: Tool identifier used to resolve the class (Captain::Tools::{PascalCase(id)}Tool)
|
||||
# title: Human-readable tool name for frontend display
|
||||
# description: Brief description of what the tool does
|
||||
# icon: Icon name for frontend display (frontend icon system)
|
||||
#######################################################
|
||||
|
||||
- id: add_contact_note
|
||||
title: 'Add Contact Note'
|
||||
description: 'Add a note to a contact profile'
|
||||
icon: 'note-add'
|
||||
|
||||
- id: add_private_note
|
||||
title: 'Add Private Note'
|
||||
description: 'Add a private note to a conversation (internal only)'
|
||||
icon: 'eye-off'
|
||||
|
||||
- id: update_priority
|
||||
title: 'Update Priority'
|
||||
description: 'Update conversation priority level'
|
||||
icon: 'exclamation-triangle'
|
||||
|
||||
- id: add_label_to_conversation
|
||||
title: 'Add Label to Conversation'
|
||||
description: 'Add a label to a conversation'
|
||||
icon: 'tag'
|
||||
@@ -56,6 +56,9 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
post :playground
|
||||
end
|
||||
collection do
|
||||
get :tools
|
||||
end
|
||||
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
|
||||
resources :scenarios
|
||||
end
|
||||
|
||||
@@ -32,6 +32,10 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
||||
render json: response
|
||||
end
|
||||
|
||||
def tools
|
||||
@tools = Captain::Assistant.available_agent_tools
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_assistant
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
#
|
||||
class Captain::Assistant < ApplicationRecord
|
||||
include Avatarable
|
||||
include Concerns::CaptainToolsHelpers
|
||||
|
||||
self.table_name = 'captain_assistants'
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
# index_captain_scenarios_on_enabled (enabled)
|
||||
#
|
||||
class Captain::Scenario < ApplicationRecord
|
||||
include Concerns::CaptainToolsHelpers
|
||||
|
||||
self.table_name = 'captain_scenarios'
|
||||
|
||||
belongs_to :assistant, class_name: 'Captain::Assistant'
|
||||
@@ -31,14 +33,60 @@ class Captain::Scenario < ApplicationRecord
|
||||
validates :instruction, presence: true
|
||||
validates :assistant_id, presence: true
|
||||
validates :account_id, presence: true
|
||||
validate :validate_instruction_tools
|
||||
|
||||
scope :enabled, -> { where(enabled: true) }
|
||||
|
||||
before_save :populate_tools
|
||||
before_save :resolve_tool_references
|
||||
|
||||
private
|
||||
|
||||
def populate_tools
|
||||
# TODO: Implement tools population logic
|
||||
# Validates that all tool references in the instruction are valid.
|
||||
# Parses the instruction for tool references and checks if they exist
|
||||
# in the available tools configuration.
|
||||
#
|
||||
# @return [void]
|
||||
# @api private
|
||||
# @example Valid instruction
|
||||
# scenario.instruction = "Use [Add Contact Note](tool://add_contact_note) to document"
|
||||
# scenario.valid? # => true
|
||||
#
|
||||
# @example Invalid instruction
|
||||
# scenario.instruction = "Use [Invalid Tool](tool://invalid_tool) to process"
|
||||
# scenario.valid? # => false
|
||||
# scenario.errors[:instruction] # => ["contains invalid tools: invalid_tool"]
|
||||
def validate_instruction_tools
|
||||
return if instruction.blank?
|
||||
|
||||
tool_ids = extract_tool_ids_from_text(instruction)
|
||||
return if tool_ids.empty?
|
||||
|
||||
available_tool_ids = self.class.available_tool_ids
|
||||
invalid_tools = tool_ids - available_tool_ids
|
||||
|
||||
return unless invalid_tools.any?
|
||||
|
||||
errors.add(:instruction, "contains invalid tools: #{invalid_tools.join(', ')}")
|
||||
end
|
||||
|
||||
# Resolves tool references from the instruction text into the tools field.
|
||||
# Parses the instruction for tool references and materializes them as
|
||||
# tool IDs stored in the tools JSONB field.
|
||||
#
|
||||
# @return [void]
|
||||
# @api private
|
||||
# @example
|
||||
# scenario.instruction = "First [@Add Private Note](tool://add_private_note) then [@Update Priority](tool://update_priority)"
|
||||
# scenario.save!
|
||||
# scenario.tools # => ["add_private_note", "update_priority"]
|
||||
#
|
||||
# scenario.instruction = "No tools mentioned here"
|
||||
# scenario.save!
|
||||
# scenario.tools # => nil
|
||||
def resolve_tool_references
|
||||
return if instruction.blank?
|
||||
|
||||
tool_ids = extract_tool_ids_from_text(instruction)
|
||||
self.tools = tool_ids.presence
|
||||
end
|
||||
end
|
||||
|
||||
76
enterprise/app/models/concerns/captain_tools_helpers.rb
Normal file
76
enterprise/app/models/concerns/captain_tools_helpers.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
# Provides helper methods for working with Captain agent tools including
|
||||
# tool resolution, text parsing, and metadata retrieval.
|
||||
module Concerns::CaptainToolsHelpers
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Regular expression pattern for matching tool references in text.
|
||||
# Matches patterns like [Tool name](tool://tool_id) following markdown link syntax.
|
||||
TOOL_REFERENCE_REGEX = %r{\[[^\]]+\]\(tool://([^/)]+)\)}
|
||||
|
||||
class_methods do
|
||||
# Returns all available agent tools with their metadata.
|
||||
# Only includes tools that have corresponding class files and can be resolved.
|
||||
#
|
||||
# @return [Array<Hash>] Array of tool hashes with :id, :title, :description, :icon
|
||||
def available_agent_tools
|
||||
@available_agent_tools ||= load_agent_tools
|
||||
end
|
||||
|
||||
# Resolves a tool class from a tool ID.
|
||||
# Converts snake_case tool IDs to PascalCase class names and constantizes them.
|
||||
#
|
||||
# @param tool_id [String] The snake_case tool identifier
|
||||
# @return [Class, nil] The tool class if found, nil if not resolvable
|
||||
def resolve_tool_class(tool_id)
|
||||
class_name = "Captain::Tools::#{tool_id.classify}Tool"
|
||||
class_name.safe_constantize
|
||||
end
|
||||
|
||||
# Returns an array of all available tool IDs.
|
||||
# Convenience method that extracts just the IDs from available_agent_tools.
|
||||
#
|
||||
# @return [Array<String>] Array of available tool IDs
|
||||
def available_tool_ids
|
||||
@available_tool_ids ||= available_agent_tools.map { |tool| tool[:id] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Loads agent tools from the YAML configuration file.
|
||||
# Filters out tools that cannot be resolved to actual classes.
|
||||
#
|
||||
# @return [Array<Hash>] Array of resolvable tools with metadata
|
||||
# @api private
|
||||
def load_agent_tools
|
||||
tools_config = YAML.load_file(Rails.root.join('config/agents/tools.yml'))
|
||||
|
||||
tools_config.filter_map do |tool_config|
|
||||
tool_class = resolve_tool_class(tool_config['id'])
|
||||
|
||||
if tool_class
|
||||
{
|
||||
id: tool_config['id'],
|
||||
title: tool_config['title'],
|
||||
description: tool_config['description'],
|
||||
icon: tool_config['icon']
|
||||
}
|
||||
else
|
||||
Rails.logger.warn "Tool class not found for ID: #{tool_config['id']}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Extracts tool IDs from text containing tool references.
|
||||
# Parses text for (tool://tool_id) patterns and returns unique tool IDs.
|
||||
#
|
||||
# @param text [String] Text to parse for tool references
|
||||
# @return [Array<String>] Array of unique tool IDs found in the text
|
||||
def extract_tool_ids_from_text(text)
|
||||
return [] if text.blank?
|
||||
|
||||
tool_matches = text.scan(TOOL_REFERENCE_REGEX)
|
||||
tool_matches.flatten.uniq
|
||||
end
|
||||
end
|
||||
@@ -7,6 +7,10 @@ class Captain::AssistantPolicy < ApplicationPolicy
|
||||
true
|
||||
end
|
||||
|
||||
def tools?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
json.array! @tools do |tool|
|
||||
json.id tool[:id]
|
||||
json.title tool[:title]
|
||||
json.description tool[:description]
|
||||
json.icon tool[:icon]
|
||||
end
|
||||
26
enterprise/lib/captain/tools/add_contact_note_tool.rb
Normal file
26
enterprise/lib/captain/tools/add_contact_note_tool.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Captain::Tools::AddContactNoteTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a note to a contact profile'
|
||||
param :note, type: 'string', desc: 'The note content to add to the contact'
|
||||
|
||||
def perform(tool_context, note:)
|
||||
contact = find_contact(tool_context.state)
|
||||
return 'Contact not found' unless contact
|
||||
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_contact_note', { contact_id: contact.id, note_length: note.length })
|
||||
|
||||
create_contact_note(contact, note)
|
||||
"Note added successfully to contact #{contact.name} (ID: #{contact.id})"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_contact_note(contact, note)
|
||||
contact.notes.create!(content: note)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[contact_manage]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
class Captain::Tools::AddLabelToConversationTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a label to a conversation'
|
||||
param :label_name, type: 'string', desc: 'The name of the label to add'
|
||||
|
||||
def perform(tool_context, label_name:)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
label_name = label_name&.strip&.downcase
|
||||
return 'Label name is required' if label_name.blank?
|
||||
|
||||
label = find_label(label_name)
|
||||
return 'Label not found' unless label
|
||||
|
||||
add_label_to_conversation(conversation, label_name)
|
||||
|
||||
log_tool_usage('added_label', conversation_id: conversation.id, label: label_name)
|
||||
|
||||
"Label '#{label_name}' added to conversation ##{conversation.display_id}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_label(label_name)
|
||||
account_scoped(Label).find_by(title: label_name)
|
||||
end
|
||||
|
||||
def add_label_to_conversation(conversation, label_name)
|
||||
conversation.add_labels(label_name)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to add label to conversation: #{e.message}"
|
||||
raise
|
||||
end
|
||||
end
|
||||
33
enterprise/lib/captain/tools/add_private_note_tool.rb
Normal file
33
enterprise/lib/captain/tools/add_private_note_tool.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
class Captain::Tools::AddPrivateNoteTool < Captain::Tools::BasePublicTool
|
||||
description 'Add a private note to a conversation'
|
||||
param :note, type: 'string', desc: 'The private note content'
|
||||
|
||||
def perform(tool_context, note:)
|
||||
conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless conversation
|
||||
|
||||
return 'Note content is required' if note.blank?
|
||||
|
||||
log_tool_usage('add_private_note', { conversation_id: conversation.id, note_length: note.length })
|
||||
create_private_note(conversation, note)
|
||||
|
||||
'Private note added successfully'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_private_note(conversation, note)
|
||||
conversation.messages.create!(
|
||||
account: @assistant.account,
|
||||
inbox: conversation.inbox,
|
||||
sender: @assistant,
|
||||
message_type: :outgoing,
|
||||
content: note,
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
|
||||
end
|
||||
end
|
||||
45
enterprise/lib/captain/tools/base_public_tool.rb
Normal file
45
enterprise/lib/captain/tools/base_public_tool.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
require 'agents'
|
||||
|
||||
class Captain::Tools::BasePublicTool < Agents::Tool
|
||||
def initialize(assistant)
|
||||
@assistant = assistant
|
||||
super()
|
||||
end
|
||||
|
||||
def active?
|
||||
# Public tools are always active
|
||||
true
|
||||
end
|
||||
|
||||
def permissions
|
||||
# Override in subclasses to specify required permissions
|
||||
# Returns empty array for public tools (no permissions required)
|
||||
[]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def account_scoped(model_class)
|
||||
model_class.where(account_id: @assistant.account_id)
|
||||
end
|
||||
|
||||
def find_conversation(state)
|
||||
conversation_id = state&.dig(:conversation, :id)
|
||||
return nil unless conversation_id
|
||||
|
||||
account_scoped(::Conversation).find_by(id: conversation_id)
|
||||
end
|
||||
|
||||
def find_contact(state)
|
||||
contact_id = state&.dig(:contact, :id)
|
||||
return nil unless contact_id
|
||||
|
||||
account_scoped(::Contact).find_by(id: contact_id)
|
||||
end
|
||||
|
||||
def log_tool_usage(action, details = {})
|
||||
Rails.logger.info do
|
||||
"#{self.class.name}: #{action} for assistant #{@assistant&.id} - #{details.inspect}"
|
||||
end
|
||||
end
|
||||
end
|
||||
48
enterprise/lib/captain/tools/update_priority_tool.rb
Normal file
48
enterprise/lib/captain/tools/update_priority_tool.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
class Captain::Tools::UpdatePriorityTool < Captain::Tools::BasePublicTool
|
||||
description 'Update the priority of a conversation'
|
||||
param :priority, type: 'string', desc: 'The priority level: low, medium, high, urgent, or nil to remove priority'
|
||||
|
||||
def perform(tool_context, priority:)
|
||||
@conversation = find_conversation(tool_context.state)
|
||||
return 'Conversation not found' unless @conversation
|
||||
|
||||
@normalized_priority = normalize_priority(priority)
|
||||
return "Invalid priority. Valid options: #{valid_priority_options}" unless valid_priority?(@normalized_priority)
|
||||
|
||||
log_tool_usage('update_priority', { conversation_id: @conversation.id, priority: priority })
|
||||
|
||||
execute_priority_update
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_priority_update
|
||||
update_conversation_priority(@conversation, @normalized_priority)
|
||||
priority_text = @normalized_priority || 'none'
|
||||
"Priority updated to '#{priority_text}' for conversation ##{@conversation.display_id}"
|
||||
end
|
||||
|
||||
def normalize_priority(priority)
|
||||
priority == 'nil' || priority.blank? ? nil : priority
|
||||
end
|
||||
|
||||
def valid_priority?(priority)
|
||||
valid_priorities.include?(priority)
|
||||
end
|
||||
|
||||
def valid_priorities
|
||||
@valid_priorities ||= [nil] + Conversation.priorities.keys
|
||||
end
|
||||
|
||||
def valid_priority_options
|
||||
(valid_priorities.compact + ['nil']).join(', ')
|
||||
end
|
||||
|
||||
def update_conversation_priority(conversation, priority)
|
||||
conversation.update!(priority: priority)
|
||||
end
|
||||
|
||||
def permissions
|
||||
%w[conversation_manage conversation_unassigned_manage conversation_participating_manage]
|
||||
end
|
||||
end
|
||||
116
spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
Normal file
116
spec/enterprise/lib/captain/tools/add_contact_note_tool_spec.rb
Normal 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
|
||||
@@ -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
|
||||
124
spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
Normal file
124
spec/enterprise/lib/captain/tools/add_private_note_tool_spec.rb
Normal 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
|
||||
117
spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
Normal file
117
spec/enterprise/lib/captain/tools/update_priority_tool_spec.rb
Normal 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
|
||||
@@ -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)
|
||||
|
||||
180
spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
Normal file
180
spec/enterprise/models/concerns/captain_tools_helpers_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user