# frozen_string_literal: true RSpec.describe Dispatch::Adapter::Claude::RequestBuilder::Tools do # Convenience: build a ToolDefinition from keyword args def tool(name:, description: "A tool", parameters: nil) Dispatch::Adapter::ToolDefinition.new( name: name, description: description, parameters: parameters || { "type" => "object", "properties" => {}, "required" => [] } ) end # Build and return the wire hash for a single tool def built(tool_obj, is_oauth: false, disable_strict: false) described_class.build([tool_obj], is_oauth: is_oauth, disable_strict: disable_strict).first end # ── Basic structure ────────────────────────────────────────────────────────── describe ".build" do it "returns an empty array for nil tools" do expect(described_class.build(nil)).to eq([]) end it "returns an empty array for empty tools" do expect(described_class.build([])).to eq([]) end it "produces :name, :description, :input_schema keys for each tool" do wire = built(tool(name: "grep")) expect(wire).to have_key(:name) expect(wire).to have_key(:description) expect(wire).to have_key(:input_schema) end end # ── additionalProperties injection ────────────────────────────────────────── describe "additionalProperties injection" do it "sets additionalProperties: false on object nodes" do t = tool(name: "x", parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } } }) schema = built(t)[:input_schema] expect(schema["additionalProperties"]).to eq(false) end it "preserves an explicitly-set additionalProperties value" do t = tool(name: "x", parameters: { "type" => "object", "properties" => {}, "additionalProperties" => true }) schema = built(t)[:input_schema] expect(schema["additionalProperties"]).to eq(true) end it "sets additionalProperties: false on nested object nodes" do t = tool(name: "x", parameters: { "type" => "object", "properties" => { "inner" => { "type" => "object", "properties" => { "x" => { "type" => "integer" } } } } }) schema = built(t)[:input_schema] expect(schema["properties"]["inner"]["additionalProperties"]).to eq(false) end end # ── Unsupported field removal ──────────────────────────────────────────────── describe "unsupported field removal" do it "removes patternProperties from object nodes" do t = tool(name: "x", parameters: { "type" => "object", "properties" => {}, "patternProperties" => { ".*" => { "type" => "string" } } }) schema = built(t)[:input_schema] expect(schema).not_to have_key("patternProperties") end it "removes maxItems from object nodes" do t = tool(name: "x", parameters: { "type" => "object", "properties" => {}, "maxItems" => 5 }) schema = built(t)[:input_schema] expect(schema).not_to have_key("maxItems") end it "removes minItems from object nodes" do t = tool(name: "x", parameters: { "type" => "object", "properties" => {}, "minItems" => 1 }) schema = built(t)[:input_schema] expect(schema).not_to have_key("minItems") end it "removes minItems from array nodes when value is not 0 or 1" do t = tool(name: "x", parameters: { "type" => "array", "items" => { "type" => "string" }, "minItems" => 3 }) schema = built(t)[:input_schema] expect(schema).not_to have_key("minItems") end it "keeps minItems on array nodes when value is 0" do t = tool(name: "x", parameters: { "type" => "array", "items" => { "type" => "string" }, "minItems" => 0 }) schema = built(t)[:input_schema] expect(schema["minItems"]).to eq(0) end it "keeps minItems on array nodes when value is 1" do t = tool(name: "x", parameters: { "type" => "array", "items" => { "type" => "string" }, "minItems" => 1 }) schema = built(t)[:input_schema] expect(schema["minItems"]).to eq(1) end it "does not strip maxItems from array nodes" do t = tool(name: "x", parameters: { "type" => "array", "items" => { "type" => "string" }, "maxItems" => 10 }) schema = built(t)[:input_schema] # maxItems only removed from object nodes, not arrays — check object version # In this test, type is "array" so maxItems should remain expect(schema["maxItems"]).to eq(10) end end # ── proxy_ prefix ──────────────────────────────────────────────────────────── describe "OAuth proxy_ prefix" do it "prefixes non-builtin tool names with proxy_ when is_oauth: true" do wire = built(tool(name: "grep"), is_oauth: true) expect(wire[:name]).to eq("proxy_grep") end it "does not prefix in non-OAuth mode" do wire = built(tool(name: "grep"), is_oauth: false) expect(wire[:name]).to eq("grep") end it "does not prefix builtin tools even in OAuth mode" do %w[web_search code_execution text_editor computer].each do |builtin| wire = built(tool(name: builtin), is_oauth: true) expect(wire[:name]).to eq(builtin), "expected #{builtin} to be unprefixed" end end it "does not double-prefix already-prefixed names" do wire = built(tool(name: "proxy_grep"), is_oauth: true) expect(wire[:name]).to eq("proxy_grep") end end # ── Strict mode — allowlist ─────────────────────────────────────────────────── describe "strict mode allowlist" do let(:simple_strict_schema) do { "type" => "object", "properties" => { "command" => { "type" => "string" } }, "required" => ["command"] } end it "sets strict: true for bash when schema fits" do wire = built(tool(name: "bash", parameters: simple_strict_schema)) expect(wire[:strict]).to eq(true) end it "sets strict: true for python when schema fits" do wire = built(tool(name: "python", parameters: simple_strict_schema)) expect(wire[:strict]).to eq(true) end it "sets strict: true for edit when schema fits" do wire = built(tool(name: "edit", parameters: simple_strict_schema)) expect(wire[:strict]).to eq(true) end it "sets strict: true for find when schema fits" do wire = built(tool(name: "find", parameters: simple_strict_schema)) expect(wire[:strict]).to eq(true) end it "does NOT set strict for non-allowlist tools" do wire = built(tool(name: "grep", parameters: simple_strict_schema)) expect(wire[:strict]).to be_nil end it "does NOT set strict for custom tools not in allowlist" do wire = built(tool(name: "my_custom_tool", parameters: simple_strict_schema)) expect(wire[:strict]).to be_nil end end # ── disable_strict ─────────────────────────────────────────────────────────── describe "disable_strict: true" do it "suppresses strict: true for allowlist tools" do t = tool(name: "bash", parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] }) wire = built(t, disable_strict: true) expect(wire[:strict]).to be_nil end end describe "ENV[CLAUDE_NO_STRICT]" do around do |example| original = ENV.fetch("CLAUDE_NO_STRICT", nil) ENV["CLAUDE_NO_STRICT"] = "1" example.run ensure ENV["CLAUDE_NO_STRICT"] = original end it "suppresses strict: true for allowlist tools" do t = tool(name: "bash", parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] }) wire = built(t) expect(wire[:strict]).to be_nil end end # ── Strict mode normalisation ───────────────────────────────────────────────── describe "strict mode normalisation of optional params" do it "moves optional properties into required when strict: true is applied" do params = { "type" => "object", "properties" => { "cmd" => { "type" => "string" }, "timeout" => { "type" => "integer" } }, "required" => ["cmd"] } wire = built(tool(name: "bash", parameters: params)) expect(wire[:strict]).to eq(true) # After normalisation, all props are required expect(wire[:input_schema]["required"]).to contain_exactly("cmd", "timeout") end it "wraps previously-optional properties as nullable in the schema" do params = { "type" => "object", "properties" => { "cmd" => { "type" => "string" }, "timeout" => { "type" => "integer" } }, "required" => ["cmd"] } wire = built(tool(name: "bash", parameters: params)) timeout_schema = wire[:input_schema]["properties"]["timeout"] # Should be wrapped in anyOf [..., {type: "null"}] expect(timeout_schema).to have_key("anyOf") null_branch = timeout_schema["anyOf"].find { |s| s["type"] == "null" } expect(null_branch).not_to be_nil end end # ── Budget limits ──────────────────────────────────────────────────────────── describe "strict mode budget limits" do it "does not set strict: true when optional param count exceeds MAX_STRICT_OPTIONAL_PARAMS" do props = (1..25).to_h do |i| ["opt_param_#{i}", { "type" => "string" }] end params = { "type" => "object", "properties" => props, "required" => [] # all optional } wire = built(tool(name: "bash", parameters: params)) # 25 optional params > MAX_STRICT_OPTIONAL_PARAMS (24) expect(wire[:strict]).to be_nil end it "sets strict: true when optional param count is exactly at the limit" do props = (1..24).to_h do |i| ["opt_param_#{i}", { "type" => "string" }] end params = { "type" => "object", "properties" => props, "required" => [] # all optional — 24 is at the limit } wire = built(tool(name: "bash", parameters: params)) expect(wire[:strict]).to eq(true) end end # ── Hash tool input ─────────────────────────────────────────────────────────── describe "plain Hash tool definitions" do it "accepts tools as plain Hashes with symbol keys" do tool_hash = { name: "bash", description: "Run a bash command", parameters: { "type" => "object", "properties" => { "cmd" => { "type" => "string" } }, "required" => ["cmd"] } } result = described_class.build([tool_hash]).first expect(result[:name]).to eq("bash") expect(result[:input_schema]).to be_a(Hash) end it "accepts tools as plain Hashes with string keys" do tool_hash = { "name" => "bash", "description" => "Run a bash command", "parameters" => { "type" => "object", "properties" => {}, "required" => [] } } result = described_class.build([tool_hash]).first expect(result[:name]).to eq("bash") end end # ── Multiple tools ─────────────────────────────────────────────────────────── describe "multiple tools" do it "converts each tool independently" do tools = [ tool(name: "bash"), tool(name: "grep"), tool(name: "python") ] wires = described_class.build(tools) expect(wires.map { |w| w[:name] }).to eq(%w[bash grep python]) end it "only marks allowlist tools as strict" do tools = [ tool(name: "bash"), tool(name: "grep"), tool(name: "find") ] wires = described_class.build(tools) names_with_strict = wires.select { |w| w[:strict] }.map { |w| w[:name] } expect(names_with_strict).to contain_exactly("bash", "find") expect(wires.find { |w| w[:name] == "grep" }[:strict]).to be_nil end end # ── Deep-clone safety ──────────────────────────────────────────────────────── describe "deep-clone safety" do it "does not mutate the original parameters hash" do original_params = { "type" => "object", "properties" => { "cmd" => { "type" => "string" } } } t = tool(name: "grep", parameters: original_params) described_class.build([t]) # Original should not have additionalProperties injected expect(original_params).not_to have_key("additionalProperties") end end end