From 4e6dddb257e841135d639cdc3b7e271ff56e16a6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Oct 2025 20:16:17 +0000 Subject: [PATCH 01/10] feat: Add tests for Agent, Aggregator, Exit, and Gate Co-authored-by: santiago.d.diaz --- spec/mars/agent_spec.rb | 93 +++++++++++++++++++++ spec/mars/aggregator_spec.rb | 75 +++++++++++++++++ spec/mars/exit_spec.rb | 71 ++++++++++++++++ spec/mars/gate_spec.rb | 158 +++++++++++++++++++++++++++++++++++ 4 files changed, 397 insertions(+) create mode 100644 spec/mars/agent_spec.rb create mode 100644 spec/mars/aggregator_spec.rb create mode 100644 spec/mars/exit_spec.rb create mode 100644 spec/mars/gate_spec.rb diff --git a/spec/mars/agent_spec.rb b/spec/mars/agent_spec.rb new file mode 100644 index 0000000..1cf1ae5 --- /dev/null +++ b/spec/mars/agent_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +RSpec.describe Mars::Agent do + describe "#initialize" do + it "initializes with a name" do + agent = described_class.new(name: "TestAgent") + expect(agent.name).to eq("TestAgent") + end + + it "accepts options parameter" do + options = { model: "gpt-4", temperature: 0.7 } + agent = described_class.new(name: "TestAgent", options: options) + expect(agent.name).to eq("TestAgent") + end + + it "accepts tools parameter as an array" do + tools = [double("tool1"), double("tool2")] + agent = described_class.new(name: "TestAgent", tools: tools) + expect(agent.name).to eq("TestAgent") + end + + it "accepts a single tool and converts it to an array" do + tool = double("tool") + agent = described_class.new(name: "TestAgent", tools: tool) + expect(agent.name).to eq("TestAgent") + end + + it "accepts schema parameter" do + schema = { type: "object", properties: {} } + agent = described_class.new(name: "TestAgent", schema: schema) + expect(agent.name).to eq("TestAgent") + end + + it "accepts all parameters together" do + agent = described_class.new( + name: "CompleteAgent", + options: { model: "gpt-4" }, + tools: [double("tool")], + schema: { type: "object" } + ) + expect(agent.name).to eq("CompleteAgent") + end + end + + describe "#name" do + it "returns the agent name" do + agent = described_class.new(name: "MyAgent") + expect(agent.name).to eq("MyAgent") + end + end + + describe "#run" do + let(:mock_chat) { instance_double(RubyLLM::Chat) } + let(:agent) { described_class.new(name: "TestAgent") } + + before do + allow(RubyLLM::Chat).to receive(:new).and_return(mock_chat) + allow(mock_chat).to receive(:with_tools).and_return(mock_chat) + allow(mock_chat).to receive(:with_schema).and_return(mock_chat) + end + + it "delegates to chat.ask with the input" do + expect(mock_chat).to receive(:ask).with("test input").and_return("response") + result = agent.run("test input") + expect(result).to eq("response") + end + + it "creates chat with provided options" do + options = { model: "gpt-4", temperature: 0.5 } + agent = described_class.new(name: "TestAgent", options: options) + + expect(RubyLLM::Chat).to receive(:new).with(**options).and_return(mock_chat) + allow(mock_chat).to receive(:ask).with("input").and_return("output") + + agent.run("input") + end + + it "reuses the same chat instance across multiple runs" do + allow(mock_chat).to receive(:ask).and_return("response") + + expect(RubyLLM::Chat).to receive(:new).once.and_return(mock_chat) + + agent.run("first input") + agent.run("second input") + end + end + + describe "inheritance" do + it "inherits from Mars::Runnable" do + expect(described_class.ancestors).to include(Mars::Runnable) + end + end +end diff --git a/spec/mars/aggregator_spec.rb b/spec/mars/aggregator_spec.rb new file mode 100644 index 0000000..1bcefef --- /dev/null +++ b/spec/mars/aggregator_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +RSpec.describe Mars::Aggregator do + describe "#initialize" do + it "initializes with a default name" do + aggregator = described_class.new + expect(aggregator.name).to eq("Aggregator") + end + + it "initializes with a custom name" do + aggregator = described_class.new("CustomAggregator") + expect(aggregator.name).to eq("CustomAggregator") + end + end + + describe "#name" do + it "returns the aggregator name" do + aggregator = described_class.new("MyAggregator") + expect(aggregator.name).to eq("MyAggregator") + end + end + + describe "#run" do + let(:aggregator) { described_class.new } + + context "when called without a block" do + it "joins inputs with newlines" do + inputs = ["first", "second", "third"] + result = aggregator.run(inputs) + expect(result).to eq("first\nsecond\nthird") + end + + it "handles empty array" do + result = aggregator.run([]) + expect(result).to eq("") + end + + it "handles single input" do + result = aggregator.run(["single"]) + expect(result).to eq("single") + end + + it "handles numeric inputs" do + inputs = [1, 2, 3] + result = aggregator.run(inputs) + expect(result).to eq("1\n2\n3") + end + end + + context "when called with a block" do + it "executes the block and returns its value" do + result = aggregator.run(["ignored"]) { "block result" } + expect(result).to eq("block result") + end + + it "ignores the inputs when block is given" do + inputs = ["first", "second"] + result = aggregator.run(inputs) { "custom aggregation" } + expect(result).to eq("custom aggregation") + end + + it "can perform custom aggregation logic" do + inputs = [1, 2, 3, 4, 5] + result = aggregator.run(inputs) { inputs.sum } + expect(result).to eq(15) + end + end + end + + describe "inheritance" do + it "inherits from Mars::Runnable" do + expect(described_class.ancestors).to include(Mars::Runnable) + end + end +end diff --git a/spec/mars/exit_spec.rb b/spec/mars/exit_spec.rb new file mode 100644 index 0000000..ac90b6d --- /dev/null +++ b/spec/mars/exit_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +RSpec.describe Mars::Exit do + describe "#initialize" do + it "initializes with a default name" do + exit_node = described_class.new + expect(exit_node.name).to eq("Exit") + end + + it "initializes with a custom name" do + exit_node = described_class.new(name: "CustomExit") + expect(exit_node.name).to eq("CustomExit") + end + end + + describe "#name" do + it "returns the exit name" do + exit_node = described_class.new(name: "MyExit") + expect(exit_node.name).to eq("MyExit") + end + end + + describe "#run" do + let(:exit_node) { described_class.new } + + it "returns the input unchanged" do + input = "test input" + result = exit_node.run(input) + expect(result).to eq(input) + end + + it "works with string inputs" do + result = exit_node.run("hello") + expect(result).to eq("hello") + end + + it "works with numeric inputs" do + result = exit_node.run(42) + expect(result).to eq(42) + end + + it "works with array inputs" do + input = [1, 2, 3] + result = exit_node.run(input) + expect(result).to eq(input) + end + + it "works with hash inputs" do + input = { key: "value" } + result = exit_node.run(input) + expect(result).to eq(input) + end + + it "works with nil input" do + result = exit_node.run(nil) + expect(result).to be_nil + end + + it "returns the exact same object (not a copy)" do + input = "test" + result = exit_node.run(input) + expect(result).to be(input) + end + end + + describe "inheritance" do + it "inherits from Mars::Runnable" do + expect(described_class.ancestors).to include(Mars::Runnable) + end + end +end diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb new file mode 100644 index 0000000..6691ae1 --- /dev/null +++ b/spec/mars/gate_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +RSpec.describe Mars::Gate do + describe "#initialize" do + let(:condition) { ->(input) { input > 5 } } + let(:branches) { { true => Mars::Exit.new, false => Mars::Exit.new } } + + it "initializes with required parameters" do + gate = described_class.new(name: "TestGate", condition: condition, branches: branches) + expect(gate.name).to eq("TestGate") + end + + it "requires a name parameter" do + expect { + described_class.new(condition: condition, branches: branches) + }.to raise_error(ArgumentError) + end + + it "requires a condition parameter" do + expect { + described_class.new(name: "TestGate", branches: branches) + }.to raise_error(ArgumentError) + end + + it "requires a branches parameter" do + expect { + described_class.new(name: "TestGate", condition: condition) + }.to raise_error(ArgumentError) + end + end + + describe "#name" do + let(:condition) { ->(input) { input > 5 } } + let(:branches) { { true => Mars::Exit.new } } + + it "returns the gate name" do + gate = described_class.new(name: "MyGate", condition: condition, branches: branches) + expect(gate.name).to eq("MyGate") + end + end + + describe "#run" do + context "with simple boolean condition" do + let(:condition) { ->(input) { input > 5 } } + let(:true_branch) { instance_double(Mars::Runnable) } + let(:false_branch) { instance_double(Mars::Runnable) } + let(:branches) { { true => true_branch, false => false_branch } } + let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } + + it "executes true branch when condition is true" do + expect(true_branch).to receive(:run).with(10).and_return("true result") + result = gate.run(10) + expect(result).to eq("true result") + end + + it "executes false branch when condition is false" do + expect(false_branch).to receive(:run).with(3).and_return("false result") + result = gate.run(3) + expect(result).to eq("false result") + end + end + + context "with string-based condition" do + let(:condition) { ->(input) { input.length > 5 ? "long" : "short" } } + let(:long_branch) { instance_double(Mars::Runnable) } + let(:short_branch) { instance_double(Mars::Runnable) } + let(:branches) { { "long" => long_branch, "short" => short_branch } } + let(:gate) { described_class.new(name: "LengthGate", condition: condition, branches: branches) } + + it "routes to correct branch based on string result" do + expect(long_branch).to receive(:run).with("longstring").and_return("long result") + result = gate.run("longstring") + expect(result).to eq("long result") + end + + it "routes to short branch for short strings" do + expect(short_branch).to receive(:run).with("hi").and_return("short result") + result = gate.run("hi") + expect(result).to eq("short result") + end + end + + context "with default exit behavior" do + let(:condition) { ->(input) { input > 5 ? "high" : "low" } } + let(:high_branch) { instance_double(Mars::Runnable) } + let(:branches) { { "high" => high_branch } } + let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } + + it "uses default Exit node for undefined branches" do + expect(high_branch).to receive(:run).with(10).and_return("high result") + result = gate.run(10) + expect(result).to eq("high result") + end + + it "returns input unchanged when branch is not defined" do + # For input 3, condition returns "low" which is not in branches + # Should use default Exit node which returns input unchanged + result = gate.run(3) + expect(result).to eq(3) + end + end + + context "with complex condition logic" do + let(:condition) do + lambda do |input| + case input + when 0..10 then "low" + when 11..50 then "medium" + else "high" + end + end + end + + let(:low_branch) { instance_double(Mars::Runnable) } + let(:medium_branch) { instance_double(Mars::Runnable) } + let(:high_branch) { instance_double(Mars::Runnable) } + let(:branches) { { "low" => low_branch, "medium" => medium_branch, "high" => high_branch } } + let(:gate) { described_class.new(name: "RangeGate", condition: condition, branches: branches) } + + it "routes to low branch" do + expect(low_branch).to receive(:run).with(5).and_return("low result") + result = gate.run(5) + expect(result).to eq("low result") + end + + it "routes to medium branch" do + expect(medium_branch).to receive(:run).with(25).and_return("medium result") + result = gate.run(25) + expect(result).to eq("medium result") + end + + it "routes to high branch" do + expect(high_branch).to receive(:run).with(100).and_return("high result") + result = gate.run(100) + expect(result).to eq("high result") + end + end + + context "with nested runnable execution" do + let(:condition) { ->(input) { input[:type] } } + let(:exit_node) { Mars::Exit.new(name: "TestExit") } + let(:branches) { { "passthrough" => exit_node } } + let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } + + it "passes input through Exit node" do + input = { type: "passthrough", data: "test" } + result = gate.run(input) + expect(result).to eq(input) + end + end + end + + describe "inheritance" do + it "inherits from Mars::Runnable" do + expect(described_class.ancestors).to include(Mars::Runnable) + end + end +end From bec384cf71810a65e24785f11b48d16e84ba734c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Oct 2025 20:22:44 +0000 Subject: [PATCH 02/10] Refactor: Improve Mars::Agent spec and chat delegation Co-authored-by: santiago.d.diaz --- spec/mars/agent_spec.rb | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/spec/mars/agent_spec.rb b/spec/mars/agent_spec.rb index 1cf1ae5..c16a4b1 100644 --- a/spec/mars/agent_spec.rb +++ b/spec/mars/agent_spec.rb @@ -50,38 +50,33 @@ end describe "#run" do - let(:mock_chat) { instance_double(RubyLLM::Chat) } let(:agent) { described_class.new(name: "TestAgent") } - - before do - allow(RubyLLM::Chat).to receive(:new).and_return(mock_chat) - allow(mock_chat).to receive(:with_tools).and_return(mock_chat) - allow(mock_chat).to receive(:with_schema).and_return(mock_chat) - end + let(:mock_chat) { double("chat") } it "delegates to chat.ask with the input" do + allow(agent).to receive(:chat).and_return(mock_chat) expect(mock_chat).to receive(:ask).with("test input").and_return("response") + result = agent.run("test input") expect(result).to eq("response") end - it "creates chat with provided options" do - options = { model: "gpt-4", temperature: 0.5 } - agent = described_class.new(name: "TestAgent", options: options) - - expect(RubyLLM::Chat).to receive(:new).with(**options).and_return(mock_chat) - allow(mock_chat).to receive(:ask).with("input").and_return("output") + it "calls chat method to get the chat instance" do + allow(agent).to receive(:chat).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return("response") + expect(agent).to receive(:chat).at_least(:once) agent.run("input") end - it "reuses the same chat instance across multiple runs" do - allow(mock_chat).to receive(:ask).and_return("response") + it "passes different inputs to chat.ask" do + allow(agent).to receive(:chat).and_return(mock_chat) - expect(RubyLLM::Chat).to receive(:new).once.and_return(mock_chat) + expect(mock_chat).to receive(:ask).with("first input").and_return("first response") + expect(mock_chat).to receive(:ask).with("second input").and_return("second response") - agent.run("first input") - agent.run("second input") + expect(agent.run("first input")).to eq("first response") + expect(agent.run("second input")).to eq("second response") end end From 9606891245d2f184d425f31bce4eb445d90c134d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 23 Oct 2025 20:28:08 +0000 Subject: [PATCH 03/10] Refactor: Improve test clarity and remove redundant code Co-authored-by: santiago.d.diaz --- spec/mars/agent_spec.rb | 8 ++++---- spec/mars/gate_spec.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/mars/agent_spec.rb b/spec/mars/agent_spec.rb index c16a4b1..e17f726 100644 --- a/spec/mars/agent_spec.rb +++ b/spec/mars/agent_spec.rb @@ -56,7 +56,7 @@ it "delegates to chat.ask with the input" do allow(agent).to receive(:chat).and_return(mock_chat) expect(mock_chat).to receive(:ask).with("test input").and_return("response") - + result = agent.run("test input") expect(result).to eq("response") end @@ -64,17 +64,17 @@ it "calls chat method to get the chat instance" do allow(agent).to receive(:chat).and_return(mock_chat) allow(mock_chat).to receive(:ask).and_return("response") - + expect(agent).to receive(:chat).at_least(:once) agent.run("input") end it "passes different inputs to chat.ask" do allow(agent).to receive(:chat).and_return(mock_chat) - + expect(mock_chat).to receive(:ask).with("first input").and_return("first response") expect(mock_chat).to receive(:ask).with("second input").and_return("second response") - + expect(agent.run("first input")).to eq("first response") expect(agent.run("second input")).to eq("second response") end diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index 6691ae1..80c6b86 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -110,7 +110,7 @@ end end end - + let(:low_branch) { instance_double(Mars::Runnable) } let(:medium_branch) { instance_double(Mars::Runnable) } let(:high_branch) { instance_double(Mars::Runnable) } From 0f2df696ff1f99a8a579f171baf2ab44d94b2ec8 Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 23 Oct 2025 17:53:22 -0300 Subject: [PATCH 04/10] fix linters and add environment.json for cursor background agents --- .cursor/environment.json | 9 +++++ .gitignore | 1 + .rubocop.yml | 9 +++++ spec/mars/agent_spec.rb | 32 ++++++++-------- spec/mars/aggregator_spec.rb | 4 +- spec/mars/gate_spec.rb | 72 ++++++++++++++++++++++++------------ 6 files changed, 85 insertions(+), 42 deletions(-) create mode 100644 .cursor/environment.json diff --git a/.cursor/environment.json b/.cursor/environment.json new file mode 100644 index 0000000..6ca604b --- /dev/null +++ b/.cursor/environment.json @@ -0,0 +1,9 @@ +{ + "agentCanUpdateSnapshot": true, + "terminals": [ + { + "name": "Show env", + "command": "env" + } + ] +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5c249f1..6418a1a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ /spec/reports/ /tmp/ +.idea/ # rspec failure tracking .rspec_status diff --git a/.rubocop.yml b/.rubocop.yml index 5c614cd..322119b 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -17,3 +17,12 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes + +RSpec/MultipleExpectations: + Max: 2 + +RSpec/ExampleLength: + Max: 10 + +RSpec/VerifiedDoubleReference: + Enabled: false diff --git a/spec/mars/agent_spec.rb b/spec/mars/agent_spec.rb index e17f726..2396c97 100644 --- a/spec/mars/agent_spec.rb +++ b/spec/mars/agent_spec.rb @@ -14,13 +14,15 @@ end it "accepts tools parameter as an array" do - tools = [double("tool1"), double("tool2")] + tool1 = instance_double("Tool1") + tool2 = instance_double("Tool2") + tools = [tool1, tool2] agent = described_class.new(name: "TestAgent", tools: tools) expect(agent.name).to eq("TestAgent") end it "accepts a single tool and converts it to an array" do - tool = double("tool") + tool = instance_double("Tool") agent = described_class.new(name: "TestAgent", tools: tool) expect(agent.name).to eq("TestAgent") end @@ -32,10 +34,11 @@ end it "accepts all parameters together" do + tool = instance_double("Tool") agent = described_class.new( name: "CompleteAgent", options: { model: "gpt-4" }, - tools: [double("tool")], + tools: [tool], schema: { type: "object" } ) expect(agent.name).to eq("CompleteAgent") @@ -51,32 +54,27 @@ describe "#run" do let(:agent) { described_class.new(name: "TestAgent") } - let(:mock_chat) { double("chat") } + let(:mock_chat) { instance_double("Chat") } it "delegates to chat.ask with the input" do allow(agent).to receive(:chat).and_return(mock_chat) - expect(mock_chat).to receive(:ask).with("test input").and_return("response") + allow(mock_chat).to receive(:ask).with("test input").and_return("response") result = agent.run("test input") - expect(result).to eq("response") - end - it "calls chat method to get the chat instance" do - allow(agent).to receive(:chat).and_return(mock_chat) - allow(mock_chat).to receive(:ask).and_return("response") - - expect(agent).to receive(:chat).at_least(:once) - agent.run("input") + expect(result).to eq("response") + expect(mock_chat).to have_received(:ask).with("test input") end it "passes different inputs to chat.ask" do allow(agent).to receive(:chat).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return("response") - expect(mock_chat).to receive(:ask).with("first input").and_return("first response") - expect(mock_chat).to receive(:ask).with("second input").and_return("second response") + agent.run("first input") + agent.run("second input") - expect(agent.run("first input")).to eq("first response") - expect(agent.run("second input")).to eq("second response") + expect(mock_chat).to have_received(:ask).with("first input") + expect(mock_chat).to have_received(:ask).with("second input") end end diff --git a/spec/mars/aggregator_spec.rb b/spec/mars/aggregator_spec.rb index 1bcefef..c433db0 100644 --- a/spec/mars/aggregator_spec.rb +++ b/spec/mars/aggregator_spec.rb @@ -25,7 +25,7 @@ context "when called without a block" do it "joins inputs with newlines" do - inputs = ["first", "second", "third"] + inputs = %w[first second third] result = aggregator.run(inputs) expect(result).to eq("first\nsecond\nthird") end @@ -54,7 +54,7 @@ end it "ignores the inputs when block is given" do - inputs = ["first", "second"] + inputs = %w[first second] result = aggregator.run(inputs) { "custom aggregation" } expect(result).to eq("custom aggregation") end diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index 80c6b86..ac94575 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -11,21 +11,21 @@ end it "requires a name parameter" do - expect { + expect do described_class.new(condition: condition, branches: branches) - }.to raise_error(ArgumentError) + end.to raise_error(ArgumentError) end it "requires a condition parameter" do - expect { + expect do described_class.new(name: "TestGate", branches: branches) - }.to raise_error(ArgumentError) + end.to raise_error(ArgumentError) end it "requires a branches parameter" do - expect { + expect do described_class.new(name: "TestGate", condition: condition) - }.to raise_error(ArgumentError) + end.to raise_error(ArgumentError) end end @@ -42,54 +42,69 @@ describe "#run" do context "with simple boolean condition" do let(:condition) { ->(input) { input > 5 } } - let(:true_branch) { instance_double(Mars::Runnable) } - let(:false_branch) { instance_double(Mars::Runnable) } + let(:true_branch) { instance_spy(Mars::Runnable) } + let(:false_branch) { instance_spy(Mars::Runnable) } let(:branches) { { true => true_branch, false => false_branch } } let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } it "executes true branch when condition is true" do - expect(true_branch).to receive(:run).with(10).and_return("true result") + allow(true_branch).to receive(:run).with(10).and_return("true result") + result = gate.run(10) + expect(result).to eq("true result") + expect(true_branch).to have_received(:run).with(10) end it "executes false branch when condition is false" do - expect(false_branch).to receive(:run).with(3).and_return("false result") + allow(false_branch).to receive(:run).with(3).and_return("false result") + result = gate.run(3) + expect(result).to eq("false result") + expect(false_branch).to have_received(:run).with(3) end end context "with string-based condition" do let(:condition) { ->(input) { input.length > 5 ? "long" : "short" } } - let(:long_branch) { instance_double(Mars::Runnable) } - let(:short_branch) { instance_double(Mars::Runnable) } + let(:long_branch) { instance_spy(Mars::Runnable) } + let(:short_branch) { instance_spy(Mars::Runnable) } let(:branches) { { "long" => long_branch, "short" => short_branch } } let(:gate) { described_class.new(name: "LengthGate", condition: condition, branches: branches) } it "routes to correct branch based on string result" do - expect(long_branch).to receive(:run).with("longstring").and_return("long result") + allow(long_branch).to receive(:run).with("longstring").and_return("long result") + result = gate.run("longstring") + expect(result).to eq("long result") + expect(long_branch).to have_received(:run).with("longstring") end it "routes to short branch for short strings" do - expect(short_branch).to receive(:run).with("hi").and_return("short result") + allow(short_branch).to receive(:run).with("hi").and_return("short result") + result = gate.run("hi") + expect(result).to eq("short result") + expect(short_branch).to have_received(:run).with("hi") end end context "with default exit behavior" do let(:condition) { ->(input) { input > 5 ? "high" : "low" } } - let(:high_branch) { instance_double(Mars::Runnable) } + let(:high_branch) { instance_spy(Mars::Runnable) } let(:branches) { { "high" => high_branch } } let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } it "uses default Exit node for undefined branches" do - expect(high_branch).to receive(:run).with(10).and_return("high result") + allow(high_branch).to receive(:run).with(10).and_return("high result") + result = gate.run(10) + expect(result).to eq("high result") + expect(high_branch).to have_received(:run).with(10) end it "returns input unchanged when branch is not defined" do @@ -111,28 +126,39 @@ end end - let(:low_branch) { instance_double(Mars::Runnable) } - let(:medium_branch) { instance_double(Mars::Runnable) } - let(:high_branch) { instance_double(Mars::Runnable) } + let(:low_branch) { instance_spy(Mars::Runnable) } + let(:medium_branch) { instance_spy(Mars::Runnable) } + let(:high_branch) { instance_spy(Mars::Runnable) } let(:branches) { { "low" => low_branch, "medium" => medium_branch, "high" => high_branch } } - let(:gate) { described_class.new(name: "RangeGate", condition: condition, branches: branches) } it "routes to low branch" do - expect(low_branch).to receive(:run).with(5).and_return("low result") + gate = described_class.new(name: "RangeGate", condition: condition, branches: branches) + allow(low_branch).to receive(:run).with(5).and_return("low result") + result = gate.run(5) + expect(result).to eq("low result") + expect(low_branch).to have_received(:run).with(5) end it "routes to medium branch" do - expect(medium_branch).to receive(:run).with(25).and_return("medium result") + gate = described_class.new(name: "RangeGate", condition: condition, branches: branches) + allow(medium_branch).to receive(:run).with(25).and_return("medium result") + result = gate.run(25) + expect(result).to eq("medium result") + expect(medium_branch).to have_received(:run).with(25) end it "routes to high branch" do - expect(high_branch).to receive(:run).with(100).and_return("high result") + gate = described_class.new(name: "RangeGate", condition: condition, branches: branches) + allow(high_branch).to receive(:run).with(100).and_return("high result") + result = gate.run(100) + expect(result).to eq("high result") + expect(high_branch).to have_received(:run).with(100) end end From bfd94bfba6d7defb8c76fb5d920e27a08f0ff1bd Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 23 Oct 2025 17:58:00 -0300 Subject: [PATCH 05/10] update rubocop yml --- .rubocop.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 322119b..a2447c6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -19,10 +19,10 @@ Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes RSpec/MultipleExpectations: - Max: 2 + Enabled: false RSpec/ExampleLength: - Max: 10 + Enabled: false RSpec/VerifiedDoubleReference: Enabled: false From d46aaf0ce3f141f58b8f0b3959aa32f2e2326345 Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 30 Oct 2025 16:59:36 -0300 Subject: [PATCH 06/10] update gitignore --- .cursor/environment.json | 9 --------- .gitignore | 1 - 2 files changed, 10 deletions(-) delete mode 100644 .cursor/environment.json diff --git a/.cursor/environment.json b/.cursor/environment.json deleted file mode 100644 index 6ca604b..0000000 --- a/.cursor/environment.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "agentCanUpdateSnapshot": true, - "terminals": [ - { - "name": "Show env", - "command": "env" - } - ] -} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6418a1a..5c249f1 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ /spec/reports/ /tmp/ -.idea/ # rspec failure tracking .rspec_status From 34126cba65533045e8e20b486d0f17bb4916a31a Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 30 Oct 2025 17:23:43 -0300 Subject: [PATCH 07/10] update agent_spec --- .rubocop.yml | 4 +- spec/mars/agent_spec.rb | 91 ++++++++++------------------------------- 2 files changed, 24 insertions(+), 71 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a2447c6..af86687 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -18,10 +18,10 @@ Style/StringLiterals: Style/StringLiteralsInInterpolation: EnforcedStyle: double_quotes -RSpec/MultipleExpectations: +RSpec/ExampleLength: Enabled: false -RSpec/ExampleLength: +RSpec/MultipleExpectations: Enabled: false RSpec/VerifiedDoubleReference: diff --git a/spec/mars/agent_spec.rb b/spec/mars/agent_spec.rb index 2396c97..89ce677 100644 --- a/spec/mars/agent_spec.rb +++ b/spec/mars/agent_spec.rb @@ -1,86 +1,39 @@ # frozen_string_literal: true RSpec.describe Mars::Agent do - describe "#initialize" do - it "initializes with a name" do - agent = described_class.new(name: "TestAgent") - expect(agent.name).to eq("TestAgent") + describe "#run" do + let(:agent) { described_class.new(name: "TestAgent", options: { model: "test-model" }) } + let(:mock_chat_instance) do + instance_double("RubyLLM::Chat").tap do |mock| + allow(mock).to receive_messages(with_tools: mock, with_schema: mock, ask: nil) + end end + let(:mock_chat_class) { class_double("RubyLLM::Chat", new: mock_chat_instance) } - it "accepts options parameter" do - options = { model: "gpt-4", temperature: 0.7 } - agent = described_class.new(name: "TestAgent", options: options) - expect(agent.name).to eq("TestAgent") + before do + stub_const("RubyLLM::Chat", mock_chat_class) end - it "accepts tools parameter as an array" do - tool1 = instance_double("Tool1") - tool2 = instance_double("Tool2") - tools = [tool1, tool2] - agent = described_class.new(name: "TestAgent", tools: tools) - expect(agent.name).to eq("TestAgent") - end + it "initializes RubyLLM::Chat with provided options" do + agent.run("test input") - it "accepts a single tool and converts it to an array" do - tool = instance_double("Tool") - agent = described_class.new(name: "TestAgent", tools: tool) - expect(agent.name).to eq("TestAgent") + expect(mock_chat_class).to have_received(:new).with(model: "test-model") end - it "accepts schema parameter" do - schema = { type: "object", properties: {} } - agent = described_class.new(name: "TestAgent", schema: schema) - expect(agent.name).to eq("TestAgent") - end + it "configures chat with tools if provided" do + tools = [proc { "tool" }] + agent_with_tools = described_class.new(name: "TestAgent", tools: tools) + agent_with_tools.run("test input") - it "accepts all parameters together" do - tool = instance_double("Tool") - agent = described_class.new( - name: "CompleteAgent", - options: { model: "gpt-4" }, - tools: [tool], - schema: { type: "object" } - ) - expect(agent.name).to eq("CompleteAgent") + expect(mock_chat_instance).to have_received(:with_tools).with(tools) end - end - - describe "#name" do - it "returns the agent name" do - agent = described_class.new(name: "MyAgent") - expect(agent.name).to eq("MyAgent") - end - end - - describe "#run" do - let(:agent) { described_class.new(name: "TestAgent") } - let(:mock_chat) { instance_double("Chat") } - it "delegates to chat.ask with the input" do - allow(agent).to receive(:chat).and_return(mock_chat) - allow(mock_chat).to receive(:ask).with("test input").and_return("response") - - result = agent.run("test input") - - expect(result).to eq("response") - expect(mock_chat).to have_received(:ask).with("test input") - end - - it "passes different inputs to chat.ask" do - allow(agent).to receive(:chat).and_return(mock_chat) - allow(mock_chat).to receive(:ask).and_return("response") - - agent.run("first input") - agent.run("second input") - - expect(mock_chat).to have_received(:ask).with("first input") - expect(mock_chat).to have_received(:ask).with("second input") - end - end + it "configures chat with schema if provided" do + schema = { type: "object" } + agent_with_schema = described_class.new(name: "TestAgent", schema: schema) - describe "inheritance" do - it "inherits from Mars::Runnable" do - expect(described_class.ancestors).to include(Mars::Runnable) + agent_with_schema.run("test input") + expect(mock_chat_instance).to have_received(:with_schema).with(schema) end end end From 8ceb7cd64525fc4bc6ae6c575ca05e26721a77f6 Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 30 Oct 2025 17:29:19 -0300 Subject: [PATCH 08/10] update specs to reduce examples --- spec/mars/aggregator_spec.rb | 25 -------------------- spec/mars/exit_spec.rb | 37 ------------------------------ spec/mars/gate_spec.rb | 44 ------------------------------------ 3 files changed, 106 deletions(-) diff --git a/spec/mars/aggregator_spec.rb b/spec/mars/aggregator_spec.rb index c433db0..1c2b6c1 100644 --- a/spec/mars/aggregator_spec.rb +++ b/spec/mars/aggregator_spec.rb @@ -1,25 +1,6 @@ # frozen_string_literal: true RSpec.describe Mars::Aggregator do - describe "#initialize" do - it "initializes with a default name" do - aggregator = described_class.new - expect(aggregator.name).to eq("Aggregator") - end - - it "initializes with a custom name" do - aggregator = described_class.new("CustomAggregator") - expect(aggregator.name).to eq("CustomAggregator") - end - end - - describe "#name" do - it "returns the aggregator name" do - aggregator = described_class.new("MyAggregator") - expect(aggregator.name).to eq("MyAggregator") - end - end - describe "#run" do let(:aggregator) { described_class.new } @@ -66,10 +47,4 @@ end end end - - describe "inheritance" do - it "inherits from Mars::Runnable" do - expect(described_class.ancestors).to include(Mars::Runnable) - end - end end diff --git a/spec/mars/exit_spec.rb b/spec/mars/exit_spec.rb index ac90b6d..ec8306e 100644 --- a/spec/mars/exit_spec.rb +++ b/spec/mars/exit_spec.rb @@ -1,34 +1,9 @@ # frozen_string_literal: true RSpec.describe Mars::Exit do - describe "#initialize" do - it "initializes with a default name" do - exit_node = described_class.new - expect(exit_node.name).to eq("Exit") - end - - it "initializes with a custom name" do - exit_node = described_class.new(name: "CustomExit") - expect(exit_node.name).to eq("CustomExit") - end - end - - describe "#name" do - it "returns the exit name" do - exit_node = described_class.new(name: "MyExit") - expect(exit_node.name).to eq("MyExit") - end - end - describe "#run" do let(:exit_node) { described_class.new } - it "returns the input unchanged" do - input = "test input" - result = exit_node.run(input) - expect(result).to eq(input) - end - it "works with string inputs" do result = exit_node.run("hello") expect(result).to eq("hello") @@ -55,17 +30,5 @@ result = exit_node.run(nil) expect(result).to be_nil end - - it "returns the exact same object (not a copy)" do - input = "test" - result = exit_node.run(input) - expect(result).to be(input) - end - end - - describe "inheritance" do - it "inherits from Mars::Runnable" do - expect(described_class.ancestors).to include(Mars::Runnable) - end end end diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index ac94575..0c525e2 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -1,44 +1,6 @@ # frozen_string_literal: true RSpec.describe Mars::Gate do - describe "#initialize" do - let(:condition) { ->(input) { input > 5 } } - let(:branches) { { true => Mars::Exit.new, false => Mars::Exit.new } } - - it "initializes with required parameters" do - gate = described_class.new(name: "TestGate", condition: condition, branches: branches) - expect(gate.name).to eq("TestGate") - end - - it "requires a name parameter" do - expect do - described_class.new(condition: condition, branches: branches) - end.to raise_error(ArgumentError) - end - - it "requires a condition parameter" do - expect do - described_class.new(name: "TestGate", branches: branches) - end.to raise_error(ArgumentError) - end - - it "requires a branches parameter" do - expect do - described_class.new(name: "TestGate", condition: condition) - end.to raise_error(ArgumentError) - end - end - - describe "#name" do - let(:condition) { ->(input) { input > 5 } } - let(:branches) { { true => Mars::Exit.new } } - - it "returns the gate name" do - gate = described_class.new(name: "MyGate", condition: condition, branches: branches) - expect(gate.name).to eq("MyGate") - end - end - describe "#run" do context "with simple boolean condition" do let(:condition) { ->(input) { input > 5 } } @@ -175,10 +137,4 @@ end end end - - describe "inheritance" do - it "inherits from Mars::Runnable" do - expect(described_class.ancestors).to include(Mars::Runnable) - end - end end From ba81f0c93a1e4be27ee90a156df1cc8f39bab3ec Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 30 Oct 2025 17:39:54 -0300 Subject: [PATCH 09/10] remove test file --- spec/mars/exit_spec.rb | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 spec/mars/exit_spec.rb diff --git a/spec/mars/exit_spec.rb b/spec/mars/exit_spec.rb deleted file mode 100644 index ec8306e..0000000 --- a/spec/mars/exit_spec.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Mars::Exit do - describe "#run" do - let(:exit_node) { described_class.new } - - it "works with string inputs" do - result = exit_node.run("hello") - expect(result).to eq("hello") - end - - it "works with numeric inputs" do - result = exit_node.run(42) - expect(result).to eq(42) - end - - it "works with array inputs" do - input = [1, 2, 3] - result = exit_node.run(input) - expect(result).to eq(input) - end - - it "works with hash inputs" do - input = { key: "value" } - result = exit_node.run(input) - expect(result).to eq(input) - end - - it "works with nil input" do - result = exit_node.run(nil) - expect(result).to be_nil - end - end -end From 25f4166865a4768a0133ce23afa6cc4b37ccea5e Mon Sep 17 00:00:00 2001 From: Santiago Diaz Date: Thu, 30 Oct 2025 17:46:37 -0300 Subject: [PATCH 10/10] fix spec --- spec/mars/gate_spec.rb | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/spec/mars/gate_spec.rb b/spec/mars/gate_spec.rb index 0c525e2..aac613e 100644 --- a/spec/mars/gate_spec.rb +++ b/spec/mars/gate_spec.rb @@ -54,13 +54,13 @@ end end - context "with default exit behavior" do + context "with missing branch" do let(:condition) { ->(input) { input > 5 ? "high" : "low" } } let(:high_branch) { instance_spy(Mars::Runnable) } let(:branches) { { "high" => high_branch } } let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } - it "uses default Exit node for undefined branches" do + it "executes defined branch when condition matches" do allow(high_branch).to receive(:run).with(10).and_return("high result") result = gate.run(10) @@ -69,11 +69,9 @@ expect(high_branch).to have_received(:run).with(10) end - it "returns input unchanged when branch is not defined" do + it "raises an error when branch is not defined" do # For input 3, condition returns "low" which is not in branches - # Should use default Exit node which returns input unchanged - result = gate.run(3) - expect(result).to eq(3) + expect { gate.run(3) }.to raise_error(NoMethodError) end end @@ -123,18 +121,5 @@ expect(high_branch).to have_received(:run).with(100) end end - - context "with nested runnable execution" do - let(:condition) { ->(input) { input[:type] } } - let(:exit_node) { Mars::Exit.new(name: "TestExit") } - let(:branches) { { "passthrough" => exit_node } } - let(:gate) { described_class.new(name: "TestGate", condition: condition, branches: branches) } - - it "passes input through Exit node" do - input = { type: "passthrough", data: "test" } - result = gate.run(input) - expect(result).to eq(input) - end - end end end