discourse-ai/spec/lib/completions/tool_definition_spec.rb
Sam 2c6459429f
DEV: use a proper object for tool definition (#1337)
* DEV: use a proper object for tool definition

This moves away from using a loose hash to define tools, which
is error prone.

Instead given a proper object we will also be able to coerce the
return values to match tool definition correctly

* fix xml tools

* fix anthropic tools

* fix specs... a few more to go

* specs are passing

* FIX: coerce values for XML tool calls

* Update spec/lib/completions/tool_definition_spec.rb

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-05-15 17:32:39 +10:00

339 lines
10 KiB
Ruby

# frozen_string_literal: true
RSpec.describe DiscourseAi::Completions::ToolDefinition do
# Test case 1: Basic tool definition creation
describe "#initialize" do
it "creates a tool with name, description and parameters" do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "query",
description: "The search query",
type: :string,
required: true,
)
tool =
described_class.new(
name: "search_engine",
description: "Searches the web for information",
parameters: [param],
)
expect(tool.name).to eq("search_engine")
expect(tool.description).to eq("Searches the web for information")
expect(tool.parameters.size).to eq(1)
expect(tool.parameters.first.name).to eq("query")
end
end
# Test case 2: Creating tool from hash
describe ".from_hash" do
it "creates a tool from a hash representation" do
hash = {
name: "calculator",
description: "Performs math operations",
parameters: [
{
name: "expression",
description: "Math expression to evaluate",
type: "string",
required: true,
},
],
}
tool = described_class.from_hash(hash)
expect(tool.name).to eq("calculator")
expect(tool.description).to eq("Performs math operations")
expect(tool.parameters.size).to eq(1)
expect(tool.parameters.first.name).to eq("expression")
expect(tool.parameters.first.type).to eq(:string)
end
it "rejects a hash with extra keys" do
hash = {
name: "calculator",
description: "Performs math operations",
parameters: [],
extra_key: "should not be here",
}
expect { described_class.from_hash(hash) }.to raise_error(ArgumentError, /Unexpected keys/)
end
end
# Test case 3: Parameter with enum validation
describe DiscourseAi::Completions::ToolDefinition::ParameterDefinition do
context "with enum values" do
it "accepts valid enum values matching the type" do
param =
described_class.new(
name: "operation",
description: "Math operation to perform",
type: :string,
enum: %w[add subtract multiply divide],
)
expect(param.enum).to eq(%w[add subtract multiply divide])
end
it "rejects enum values that don't match the specified type" do
expect {
described_class.new(
name: "operation",
description: "Math operation to perform",
type: :integer,
enum: %w[add subtract], # String values for integer type
)
}.to raise_error(ArgumentError, /enum values must be integers/)
end
end
context "with item_type specification" do
it "only allows item_type for array type parameters" do
expect {
described_class.new(
name: "colors",
description: "List of colors",
type: :array,
item_type: :string,
)
}.not_to raise_error
expect {
described_class.new(
name: "color",
description: "A single color",
type: :string,
item_type: :string,
)
}.to raise_error(ArgumentError, /item_type can only be specified for array type/)
end
end
end
# Test case 4: Coercing string parameters
describe "#coerce_parameters" do
context "with string parameters" do
let(:tool) do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "name",
description: "User's name",
type: :string,
)
described_class.new(
name: "greeting",
description: "Generates a greeting",
parameters: [param],
)
end
it "converts numbers to strings" do
result = tool.coerce_parameters(name: 123)
expect(result[:name]).to eq("123")
end
it "converts booleans to strings" do
result = tool.coerce_parameters(name: true)
expect(result[:name]).to eq("true")
end
end
# Test case 5: Coercing number parameters
context "with number parameters" do
let(:tool) do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "price",
description: "Item price",
type: :number,
)
described_class.new(name: "store", description: "Store operations", parameters: [param])
end
it "converts string numbers to floats" do
result = tool.coerce_parameters(price: "42.99")
expect(result[:price]).to eq(42.99)
end
it "converts integers to floats" do
result = tool.coerce_parameters(price: 42)
expect(result[:price]).to eq(42.0)
end
it "returns nil for invalid number strings" do
result = tool.coerce_parameters(price: "not a number")
expect(result[:price]).to be_nil
end
end
# Test case 6: Coercing array parameters with item types
context "with array parameters and item types" do
let(:tool) do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "numbers",
description: "List of numeric values",
type: :array,
item_type: :integer,
)
described_class.new(
name: "stats",
description: "Statistical operations",
parameters: [param],
)
end
it "converts string elements to integers" do
result = tool.coerce_parameters(numbers: %w[1 2 3])
expect(result[:numbers]).to eq([1, 2, 3])
end
it "parses JSON strings into arrays and converts elements" do
result = tool.coerce_parameters(numbers: "[1, 2, 3]")
expect(result[:numbers]).to eq([1, 2, 3])
end
it "handles mixed type arrays appropriately" do
result = tool.coerce_parameters(numbers: [1, "two", 3.5])
expect(result[:numbers]).to eq([1, nil, 3])
end
end
# Test case 7: Required parameters
context "with required and optional parameters" do
let(:tool) do
param1 =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "required_param",
description: "This is required",
type: :string,
required: true,
)
param2 =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "optional_param",
description: "This is optional",
type: :string,
)
described_class.new(
name: "test_tool",
description: "Test tool",
parameters: [param1, param2],
)
end
it "includes missing required parameters as nil" do
result = tool.coerce_parameters(optional_param: "value")
expect(result[:required_param]).to be_nil
expect(result[:optional_param]).to eq("value")
end
it "skips missing optional parameters" do
result = tool.coerce_parameters({})
expect(result[:required_param]).to be_nil
expect(result.key?("optional_param")).to be false
end
end
# Test case 8: Boolean parameter coercion
context "with boolean parameters" do
let(:tool) do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "flag",
description: "Boolean flag",
type: :boolean,
)
described_class.new(name: "feature", description: "Feature toggle", parameters: [param])
end
it "preserves true/false values" do
result = tool.coerce_parameters(flag: true)
expect(result[:flag]).to be true
end
it "converts 'true'/'false' strings to booleans" do
result = tool.coerce_parameters({ flag: true })
expect(result[:flag]).to be true
result = tool.coerce_parameters({ flag: "False" })
expect(result[:flag]).to be false
end
it "returns nil for invalid boolean strings" do
result = tool.coerce_parameters({ "flag" => "not a boolean" })
expect(result["flag"]).to be_nil
end
end
end
# Test case 9: Duplicate parameter validation
describe "duplicate parameter validation" do
it "rejects tool definitions with duplicate parameter names" do
param1 =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "query",
description: "Search query",
type: :string,
)
param2 =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "query", # Same name as param1
description: "Another parameter",
type: :string,
)
expect {
described_class.new(
name: "search",
description: "Search tool",
parameters: [param1, param2],
)
}.to raise_error(ArgumentError, /Duplicate parameter names/)
end
end
# Test case 10: Serialization to hash
describe "#to_h" do
it "serializes the tool to a hash with all properties" do
param =
DiscourseAi::Completions::ToolDefinition::ParameterDefinition.new(
name: "colors",
description: "List of colors",
type: :array,
item_type: :string,
required: true,
)
tool =
described_class.new(
name: "palette",
description: "Color palette generator",
parameters: [param],
)
hash = tool.to_h
expect(hash[:name]).to eq("palette")
expect(hash[:description]).to eq("Color palette generator")
expect(hash[:parameters].size).to eq(1)
param_hash = hash[:parameters].first
expect(param_hash[:name]).to eq("colors")
expect(param_hash[:type]).to eq(:array)
expect(param_hash[:item_type]).to eq(:string)
expect(param_hash[:required]).to eq(true)
end
end
end