diff options
| author | Adam Malczewski <[email protected]> | 2026-03-31 21:36:46 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-03-31 21:36:46 +0900 |
| commit | 6b6e158d0614806dc970f7307fb63d392f0cb976 (patch) | |
| tree | ea19a4ccfe97b05b17a3418461daebb1d0543505 /spec | |
| parent | 7221a9d38fcd29d89794bc3041f11c5358b3155e (diff) | |
| download | dispatch-tools-interface-6b6e158d0614806dc970f7307fb63d392f0cb976.tar.gz dispatch-tools-interface-6b6e158d0614806dc970f7307fb63d392f0cb976.zip | |
imp
Diffstat (limited to 'spec')
| -rw-r--r-- | spec/dispatch/tools/definition_spec.rb | 231 | ||||
| -rw-r--r-- | spec/dispatch/tools/interface_spec.rb | 4 | ||||
| -rw-r--r-- | spec/dispatch/tools/registry_spec.rb | 227 | ||||
| -rw-r--r-- | spec/dispatch/tools/result_spec.rb | 107 | ||||
| -rw-r--r-- | spec/spec_helper.rb | 4 |
5 files changed, 569 insertions, 4 deletions
diff --git a/spec/dispatch/tools/definition_spec.rb b/spec/dispatch/tools/definition_spec.rb new file mode 100644 index 0000000..c9640e2 --- /dev/null +++ b/spec/dispatch/tools/definition_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Definition do + let(:parameters_schema) do + { + type: "object", + properties: { + path: { type: "string", description: "File path" }, + start_line: { type: "integer", description: "Start line (0-based)" }, + end_line: { type: "integer", description: "End line (0-based, -1 for EOF)" } + }, + required: ["path"] + } + end + + let(:tool) do + described_class.new( + name: "read_file", + description: "Read the contents of a file", + parameters: parameters_schema + ) do |params, _context| + Dispatch::Tools::Result.success(output: "contents of #{params[:path]}") + end + end + + describe "#initialize" do + it "creates a definition with all attributes" do + expect(tool.name).to eq("read_file") + expect(tool.description).to eq("Read the contents of a file") + expect(tool.parameters).to eq(parameters_schema) + end + + it "has read-only attributes" do + expect(tool).to respond_to(:name) + expect(tool).to respond_to(:description) + expect(tool).to respond_to(:parameters) + expect(tool).not_to respond_to(:name=) + expect(tool).not_to respond_to(:description=) + expect(tool).not_to respond_to(:parameters=) + end + end + + describe "#call" do + it "executes the block and returns a Result" do + result = tool.call({ path: "src/main.rb" }) + + expect(result).to be_a(Dispatch::Tools::Result) + expect(result.success?).to be true + expect(result.output).to eq("contents of src/main.rb") + end + + it "passes context to the block" do + context_tool = described_class.new( + name: "context_test", + description: "Test context passing", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, context| + Dispatch::Tools::Result.success(output: "worktree: #{context[:worktree_path]}") + end + + result = context_tool.call({}, context: { worktree_path: "/path/to/worktree" }) + + expect(result.output).to eq("worktree: /path/to/worktree") + end + + it "defaults context to an empty hash" do + context_tool = described_class.new( + name: "context_default", + description: "Test default context", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, context| + Dispatch::Tools::Result.success(output: "context is #{context.class}") + end + + result = context_tool.call({}) + + expect(result.output).to eq("context is Hash") + end + + it "never raises when the block raises an exception" do + failing_tool = described_class.new( + name: "failing", + description: "A tool that fails", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, _context| + raise StandardError, "something broke" + end + + result = nil + expect { result = failing_tool.call({}) }.not_to raise_error + + expect(result.failure?).to be true + expect(result.error).to include("something broke") + end + + it "catches non-StandardError exceptions from the block" do + failing_tool = described_class.new( + name: "type_error_tool", + description: "Raises TypeError", + parameters: { type: "object", properties: {}, required: [] } + ) do |_params, _context| + raise TypeError, "wrong type" + end + + result = nil + expect { result = failing_tool.call({}) }.not_to raise_error + + expect(result.failure?).to be true + expect(result.error).to include("wrong type") + end + + it "returns Result.failure when params fail validation" do + result = tool.call({}) + + expect(result.failure?).to be true + expect(result.error).to be_a(String) + expect(result.error).not_to be_empty + end + + it "symbolizes string-keyed params before passing to the block" do + received_params = nil + + spy_tool = described_class.new( + name: "spy", + description: "Records params", + parameters: { + type: "object", + properties: { + name: { type: "string" } + }, + required: [] + } + ) do |params, _context| + received_params = params + Dispatch::Tools::Result.success(output: "ok") + end + + spy_tool.call({ "name" => "Alice" }) + + expect(received_params).to have_key(:name) + expect(received_params).not_to have_key("name") + expect(received_params[:name]).to eq("Alice") + end + + it "handles deeply nested string keys by symbolizing top-level keys" do + received_params = nil + + spy_tool = described_class.new( + name: "deep_spy", + description: "Records params", + parameters: { + type: "object", + properties: { + options: { type: "object" } + }, + required: [] + } + ) do |params, _context| + received_params = params + Dispatch::Tools::Result.success(output: "ok") + end + + spy_tool.call({ "options" => { "nested" => true } }) + + expect(received_params).to have_key(:options) + end + end + + describe "#to_h" do + it "returns a hash with name, description, and parameters" do + hash = tool.to_h + + expect(hash).to eq({ + name: "read_file", + description: "Read the contents of a file", + parameters: parameters_schema + }) + end + + it "returns a plain hash, not a struct" do + expect(tool.to_h).to be_a(Hash) + end + end + + describe "#to_tool_definition" do + it "returns the same shape as to_h" do + expect(tool.to_tool_definition).to eq(tool.to_h) + end + + it "returns a plain hash" do + expect(tool.to_tool_definition).to be_a(Hash) + end + end + + describe "#validate_params" do + it "returns [true, []] for valid params" do + valid, errors = tool.validate_params({ path: "src/main.rb" }) + + expect(valid).to be true + expect(errors).to eq([]) + end + + it "returns [true, []] for valid params with optional fields" do + valid, errors = tool.validate_params({ path: "src/main.rb", start_line: 0, end_line: 10 }) + + expect(valid).to be true + expect(errors).to eq([]) + end + + it "returns [false, errors] when required params are missing" do + valid, errors = tool.validate_params({}) + + expect(valid).to be false + expect(errors).not_to be_empty + end + + it "returns [false, errors] when params have wrong types" do + valid, errors = tool.validate_params({ path: 123 }) + + expect(valid).to be false + expect(errors).not_to be_empty + end + + it "handles string-keyed params for validation" do + valid, errors = tool.validate_params({ "path" => "src/main.rb" }) + + expect(valid).to be true + expect(errors).to eq([]) + end + end +end diff --git a/spec/dispatch/tools/interface_spec.rb b/spec/dispatch/tools/interface_spec.rb index 4a4799a..4b4f64e 100644 --- a/spec/dispatch/tools/interface_spec.rb +++ b/spec/dispatch/tools/interface_spec.rb @@ -4,8 +4,4 @@ RSpec.describe Dispatch::Tools::Interface do it "has a version number" do expect(Dispatch::Tools::Interface::VERSION).not_to be nil end - - it "does something useful" do - expect(false).to eq(true) - end end diff --git a/spec/dispatch/tools/registry_spec.rb b/spec/dispatch/tools/registry_spec.rb new file mode 100644 index 0000000..fc7332c --- /dev/null +++ b/spec/dispatch/tools/registry_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Registry do + let(:read_file_tool) do + Dispatch::Tools::Definition.new( + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + ) { |params, _context| Dispatch::Tools::Result.success(output: "contents of #{params[:path]}") } + end + + let(:write_file_tool) do + Dispatch::Tools::Definition.new( + name: "write_file", + description: "Write contents to a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" } + }, + required: %w[path content] + } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "written") } + end + + let(:delete_file_tool) do + Dispatch::Tools::Definition.new( + name: "delete_file", + description: "Delete a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "deleted") } + end + + let(:registry) { described_class.new } + + describe "#register" do + it "adds a tool definition to the registry" do + registry.register(read_file_tool) + + expect(registry.has?("read_file")).to be true + end + + it "returns self for chaining" do + result = registry.register(read_file_tool) + + expect(result).to be(registry) + end + + it "supports chaining multiple registrations" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.has?("read_file")).to be true + expect(registry.has?("write_file")).to be true + end + + it "raises DuplicateToolError when registering a tool with the same name" do + registry.register(read_file_tool) + + duplicate_tool = Dispatch::Tools::Definition.new( + name: "read_file", + description: "Another read file", + parameters: { type: "object", properties: {}, required: [] } + ) { |_params, _context| Dispatch::Tools::Result.success(output: "dupe") } + + expect { registry.register(duplicate_tool) }.to raise_error(Dispatch::Tools::DuplicateToolError) + end + end + + describe "#get" do + before { registry.register(read_file_tool) } + + it "returns the tool definition by name" do + tool = registry.get("read_file") + + expect(tool).to be(read_file_tool) + end + + it "raises ToolNotFoundError for unknown tool name" do + expect { registry.get("nonexistent") }.to raise_error(Dispatch::Tools::ToolNotFoundError) + end + end + + describe "#has?" do + it "returns true when the tool is registered" do + registry.register(read_file_tool) + + expect(registry.has?("read_file")).to be true + end + + it "returns false when the tool is not registered" do + expect(registry.has?("read_file")).to be false + end + end + + describe "#tools" do + it "returns an empty array when no tools are registered" do + expect(registry.tools).to eq([]) + end + + it "returns all registered tool definitions" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.tools).to contain_exactly(read_file_tool, write_file_tool) + end + end + + describe "#tool_names" do + it "returns an empty array when no tools are registered" do + expect(registry.tool_names).to eq([]) + end + + it "returns all registered tool names as strings" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.tool_names).to contain_exactly("read_file", "write_file") + end + end + + describe "#to_a" do + it "returns an empty array when no tools are registered" do + expect(registry.to_a).to eq([]) + end + + it "returns an array of hashes with name, description, and parameters" do + registry.register(read_file_tool) + + result = registry.to_a + + expect(result).to be_an(Array) + expect(result.size).to eq(1) + expect(result.first).to eq({ + name: "read_file", + description: "Read the contents of a file", + parameters: { + type: "object", + properties: { + path: { type: "string" } + }, + required: ["path"] + } + }) + end + + it "returns plain hashes, not structs" do + registry.register(read_file_tool) + + registry.to_a.each do |entry| + expect(entry).to be_a(Hash) + end + end + + it "includes all registered tools" do + registry.register(read_file_tool).register(write_file_tool) + + names = registry.to_a.map { |h| h[:name] } + + expect(names).to contain_exactly("read_file", "write_file") + end + end + + describe "#subset" do + before do + registry.register(read_file_tool).register(write_file_tool).register(delete_file_tool) + end + + it "returns a new Registry containing only the specified tools" do + sub = registry.subset("read_file", "write_file") + + expect(sub).to be_a(described_class) + expect(sub.tool_names).to contain_exactly("read_file", "write_file") + end + + it "does not include tools not specified" do + sub = registry.subset("read_file") + + expect(sub.has?("write_file")).to be false + expect(sub.has?("delete_file")).to be false + end + + it "returns a different registry instance" do + sub = registry.subset("read_file") + + expect(sub).not_to be(registry) + end + + it "raises ToolNotFoundError when a requested name is not found" do + expect { registry.subset("read_file", "nonexistent") }.to raise_error(Dispatch::Tools::ToolNotFoundError) + end + end + + describe "#size" do + it "returns 0 for an empty registry" do + expect(registry.size).to eq(0) + end + + it "returns the number of registered tools" do + registry.register(read_file_tool).register(write_file_tool) + + expect(registry.size).to eq(2) + end + end + + describe "#empty?" do + it "returns true when no tools are registered" do + expect(registry.empty?).to be true + end + + it "returns false when tools are registered" do + registry.register(read_file_tool) + + expect(registry.empty?).to be false + end + end +end diff --git a/spec/dispatch/tools/result_spec.rb b/spec/dispatch/tools/result_spec.rb new file mode 100644 index 0000000..17c9d7f --- /dev/null +++ b/spec/dispatch/tools/result_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tools::Result do + describe ".success" do + it "creates a successful result with output" do + result = described_class.success(output: "file contents here") + + expect(result.success?).to be true + expect(result.failure?).to be false + expect(result.output).to eq("file contents here") + expect(result.error).to be_nil + end + + it "defaults metadata to an empty hash" do + result = described_class.success(output: "ok") + + expect(result.metadata).to eq({}) + end + + it "accepts custom metadata" do + result = described_class.success(output: "Gate passed.", metadata: { stop_loop: true }) + + expect(result.metadata).to eq({ stop_loop: true }) + end + end + + describe ".failure" do + it "creates a failed result with error" do + result = described_class.failure(error: "File not found: src/missing.rb") + + expect(result.success?).to be false + expect(result.failure?).to be true + expect(result.error).to eq("File not found: src/missing.rb") + expect(result.output).to be_nil + end + + it "defaults metadata to an empty hash" do + result = described_class.failure(error: "boom") + + expect(result.metadata).to eq({}) + end + + it "accepts custom metadata on failure" do + result = described_class.failure(error: "bad", metadata: { retryable: false }) + + expect(result.metadata).to eq({ retryable: false }) + end + end + + describe "#to_s" do + it "returns output on success" do + result = described_class.success(output: "hello world") + + expect(result.to_s).to eq("hello world") + end + + it "returns error on failure" do + result = described_class.failure(error: "something went wrong") + + expect(result.to_s).to eq("something went wrong") + end + + it "does not include metadata" do + result = described_class.success(output: "ok", metadata: { stop_loop: true }) + + expect(result.to_s).to eq("ok") + end + end + + describe "#to_h" do + it "returns a hash with all fields for a success result" do + result = described_class.success(output: "data", metadata: { flag: true }) + + expect(result.to_h).to eq({ + success: true, + output: "data", + error: nil, + metadata: { flag: true } + }) + end + + it "returns a hash with all fields for a failure result" do + result = described_class.failure(error: "oops") + + expect(result.to_h).to eq({ + success: false, + output: nil, + error: "oops", + metadata: {} + }) + end + end + + describe "immutability" do + it "is a frozen object" do + result = described_class.success(output: "test") + + expect(result).to be_frozen + end + + it "raises FrozenError when attempting to modify" do + result = described_class.success(output: "original", metadata: { key: "value" }) + + expect { result.instance_variable_set(:@output, "modified") }.to raise_error(FrozenError) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a5e8e34..03bea42 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true require "dispatch/tools/interface" +require "dispatch/tools/errors" +require "dispatch/tools/result" +require "dispatch/tools/definition" +require "dispatch/tools/registry" RSpec.configure do |config| # Enable flags like --only-failures and --next-failure |
