Files
sure/test/models/assistant/function_test.rb
T
Brandon 1973c557e5 fix(ai): drop empty data-driven enums from assistant function schemas (#3016)
* fix(ai): drop empty data-driven enums from assistant function schemas

Enum values in tool schemas are built from family data (account names,
categories, merchants, tags, tickers). A family with none of these gets
enum: [], which is invalid JSON Schema. OpenAI tolerates it, but strict
OpenAI-compatible providers reject the entire request, breaking chat for
fresh families until they create a tag or merchant.

Prune empty enums in build_schema, falling back to a plain string. One
choke point covers every function and both consumers: chat tool
definitions for all providers, and the /mcp endpoint's tools/list.

* fix(ai): address review feedback on enum pruning

Stop recursion at populated enum values: enum members are literal
values, not subschemas, so a literal like enum: [{ enum: [] }] must be
preserved verbatim rather than rewritten.

Also cover PREVIEW_FUNCTION_CLASSES in the registry regression test by
enabling the preview preference on the test user, with a guard assertion
so the test fails if preview functions ever silently drop out.
2026-08-14 02:59:59 +02:00

109 lines
3.2 KiB
Ruby

require "test_helper"
class Assistant::FunctionTest < ActiveSupport::TestCase
class EmptyEnumFunction < Assistant::Function
class << self
def name
"empty_enum_function"
end
def description
"Test function with data-driven enums"
end
end
def call(params = {})
{}
end
def params_schema
build_schema(
required: [ "name" ],
properties: {
name: {
type: "string",
description: "Property-level enum built from empty user data",
enum: []
},
accounts: {
type: "array",
description: "Items-level enum built from empty user data",
items: { enum: [] },
minItems: 1,
uniqueItems: true
},
order: {
enum: [ "asc", "desc" ],
description: "Static enum that must be preserved"
}
}
)
end
end
setup do
@function = EmptyEnumFunction.new(users(:family_admin))
end
test "drops empty enums so strict providers accept the schema" do
properties = @function.to_definition[:params_schema][:properties]
refute properties[:name].key?(:enum)
assert_equal "string", properties[:name][:type]
refute properties[:accounts][:items].key?(:enum)
assert_equal "string", properties[:accounts][:items][:type]
assert_equal 1, properties[:accounts][:minItems]
end
test "preserves populated enums and surrounding schema" do
schema = @function.to_definition[:params_schema]
assert_equal [ "asc", "desc" ], schema[:properties][:order][:enum]
assert_equal [ "name" ], schema[:required]
assert_equal "object", schema[:type]
end
test "keeps populated enum values verbatim, including literal objects and arrays" do
schema = {
status: { enum: [ { enum: [] }, [ "nested" ] ] },
order: { enum: [ "asc" ] }
}
pruned = @function.send(:prune_empty_enums, schema)
assert_equal [ { enum: [] }, [ "nested" ] ], pruned[:status][:enum]
assert_equal [ "asc" ], pruned[:order][:enum]
end
test "no registered function emits an empty enum" do
user = users(:family_admin)
user.update!(preferences: (user.preferences || {}).merge("preview_features_enabled" => true))
function_classes = Assistant.function_classes(user)
assert_empty Assistant::PREVIEW_FUNCTION_CLASSES - function_classes,
"expected preview functions to be included in the assertion"
function_classes.each do |function_class|
definition = function_class.new(user).to_definition
assert_no_empty_enums definition[:params_schema], function_class.name
end
end
private
def assert_no_empty_enums(node, function_name, path = "params_schema")
case node
when Hash
node.each do |key, value|
refute key.to_sym == :enum && value == [],
"#{function_name} emits an empty enum at #{path}.#{key}, which is invalid JSON Schema"
assert_no_empty_enums(value, function_name, "#{path}.#{key}")
end
when Array
node.each_with_index do |value, index|
assert_no_empty_enums(value, function_name, "#{path}[#{index}]")
end
end
end
end