summaryrefslogtreecommitdiffhomepage
path: root/spec/dispatch
diff options
context:
space:
mode:
Diffstat (limited to 'spec/dispatch')
-rw-r--r--spec/dispatch/tools/definition_spec.rb231
-rw-r--r--spec/dispatch/tools/interface_spec.rb4
-rw-r--r--spec/dispatch/tools/registry_spec.rb227
-rw-r--r--spec/dispatch/tools/result_spec.rb107
4 files changed, 565 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