durable_workflow 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.claude/todo/01.amend.md +133 -0
- data/.claude/todo/02.amend.md +444 -0
- data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
- data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
- data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
- data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
- data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
- data/.claude/todo/phase-1-core/todo.md +574 -0
- data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
- data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
- data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
- data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
- data/.claude/todo/phase-3-extensions/todo.md +262 -0
- data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
- data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
- data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
- data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
- data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
- data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
- data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
- data/.claude/todo/phase-5-validation/.DS_Store +0 -0
- data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
- data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
- data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
- data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
- data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
- data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
- data/.env.example +3 -0
- data/.rubocop.yml +64 -0
- data/0.3.amend.md +89 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/Gemfile +22 -0
- data/Gemfile.lock +192 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +16 -0
- data/durable_workflow.gemspec +43 -0
- data/examples/approval_request.rb +106 -0
- data/examples/calculator.rb +154 -0
- data/examples/file_search_demo.rb +77 -0
- data/examples/hello_workflow.rb +57 -0
- data/examples/item_processor.rb +96 -0
- data/examples/order_fulfillment/Gemfile +6 -0
- data/examples/order_fulfillment/README.md +84 -0
- data/examples/order_fulfillment/run.rb +85 -0
- data/examples/order_fulfillment/services.rb +146 -0
- data/examples/order_fulfillment/workflow.yml +188 -0
- data/examples/parallel_fetch.rb +102 -0
- data/examples/service_integration.rb +137 -0
- data/examples/support_agent/Gemfile +6 -0
- data/examples/support_agent/README.md +91 -0
- data/examples/support_agent/config/claude_desktop.json +12 -0
- data/examples/support_agent/mcp_server.rb +49 -0
- data/examples/support_agent/run.rb +67 -0
- data/examples/support_agent/services.rb +113 -0
- data/examples/support_agent/workflow.yml +286 -0
- data/lib/durable_workflow/core/condition.rb +45 -0
- data/lib/durable_workflow/core/engine.rb +145 -0
- data/lib/durable_workflow/core/executors/approval.rb +51 -0
- data/lib/durable_workflow/core/executors/assign.rb +18 -0
- data/lib/durable_workflow/core/executors/base.rb +90 -0
- data/lib/durable_workflow/core/executors/call.rb +76 -0
- data/lib/durable_workflow/core/executors/end.rb +19 -0
- data/lib/durable_workflow/core/executors/halt.rb +24 -0
- data/lib/durable_workflow/core/executors/loop.rb +118 -0
- data/lib/durable_workflow/core/executors/parallel.rb +77 -0
- data/lib/durable_workflow/core/executors/registry.rb +34 -0
- data/lib/durable_workflow/core/executors/router.rb +26 -0
- data/lib/durable_workflow/core/executors/start.rb +61 -0
- data/lib/durable_workflow/core/executors/transform.rb +71 -0
- data/lib/durable_workflow/core/executors/workflow.rb +32 -0
- data/lib/durable_workflow/core/parser.rb +189 -0
- data/lib/durable_workflow/core/resolver.rb +61 -0
- data/lib/durable_workflow/core/schema_validator.rb +47 -0
- data/lib/durable_workflow/core/types/base.rb +41 -0
- data/lib/durable_workflow/core/types/condition.rb +25 -0
- data/lib/durable_workflow/core/types/configs.rb +103 -0
- data/lib/durable_workflow/core/types/entry.rb +26 -0
- data/lib/durable_workflow/core/types/results.rb +41 -0
- data/lib/durable_workflow/core/types/state.rb +95 -0
- data/lib/durable_workflow/core/types/step_def.rb +15 -0
- data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
- data/lib/durable_workflow/core/types.rb +29 -0
- data/lib/durable_workflow/core/validator.rb +318 -0
- data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
- data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
- data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
- data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
- data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
- data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
- data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
- data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
- data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
- data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
- data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
- data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
- data/lib/durable_workflow/extensions/ai/types.rb +213 -0
- data/lib/durable_workflow/extensions/ai.rb +6 -0
- data/lib/durable_workflow/extensions/base.rb +77 -0
- data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
- data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
- data/lib/durable_workflow/runners/async.rb +100 -0
- data/lib/durable_workflow/runners/stream.rb +126 -0
- data/lib/durable_workflow/runners/sync.rb +40 -0
- data/lib/durable_workflow/storage/active_record.rb +148 -0
- data/lib/durable_workflow/storage/redis.rb +133 -0
- data/lib/durable_workflow/storage/sequel.rb +144 -0
- data/lib/durable_workflow/storage/store.rb +43 -0
- data/lib/durable_workflow/utils.rb +25 -0
- data/lib/durable_workflow/version.rb +5 -0
- data/lib/durable_workflow.rb +70 -0
- data/sig/durable_workflow.rbs +4 -0
- metadata +275 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# 01-TEST-GAPS: Missing Test Coverage
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Identify and fill gaps in existing test coverage. Most tests already exist - this focuses on what's missing.
|
|
6
|
+
|
|
7
|
+
## Current State
|
|
8
|
+
|
|
9
|
+
- 423 tests passing
|
|
10
|
+
- Core types, executors, engine, parser well covered
|
|
11
|
+
- Storage adapters have basic coverage
|
|
12
|
+
|
|
13
|
+
## Missing Tests
|
|
14
|
+
|
|
15
|
+
### 1. MCP Components (NEW from Phase 4)
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
test/unit/extensions/ai/mcp/
|
|
19
|
+
client_test.rb
|
|
20
|
+
adapter_test.rb
|
|
21
|
+
server_test.rb
|
|
22
|
+
rack_app_test.rb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
#### `test/unit/extensions/ai/mcp/client_test.rb`
|
|
26
|
+
|
|
27
|
+
```ruby
|
|
28
|
+
# frozen_string_literal: true
|
|
29
|
+
|
|
30
|
+
require "test_helper"
|
|
31
|
+
require "durable_workflow/extensions/ai"
|
|
32
|
+
|
|
33
|
+
class MCPClientTest < Minitest::Test
|
|
34
|
+
AI = DurableWorkflow::Extensions::AI
|
|
35
|
+
|
|
36
|
+
def setup
|
|
37
|
+
AI::MCP::Client.reset!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def teardown
|
|
41
|
+
AI::MCP::Client.reset!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def test_for_caches_connections_by_url
|
|
45
|
+
config = AI::MCPServerConfig.new(url: "https://example.com/mcp")
|
|
46
|
+
|
|
47
|
+
# Stub the actual MCP client creation
|
|
48
|
+
mock_client = Object.new
|
|
49
|
+
::MCP::Client.stub :new, mock_client do
|
|
50
|
+
::MCP::Transports::HTTP.stub :new, Object.new do
|
|
51
|
+
client1 = AI::MCP::Client.for(config)
|
|
52
|
+
client2 = AI::MCP::Client.for(config)
|
|
53
|
+
|
|
54
|
+
assert_same client1, client2
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def test_for_creates_http_transport_by_default
|
|
60
|
+
config = AI::MCPServerConfig.new(url: "https://example.com/mcp")
|
|
61
|
+
transport_created = nil
|
|
62
|
+
|
|
63
|
+
::MCP::Transports::HTTP.stub :new, ->(url:, headers:) {
|
|
64
|
+
transport_created = { url: url, headers: headers }
|
|
65
|
+
Object.new
|
|
66
|
+
} do
|
|
67
|
+
::MCP::Client.stub :new, Object.new do
|
|
68
|
+
AI::MCP::Client.for(config)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
assert_equal "https://example.com/mcp", transport_created[:url]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def test_for_creates_stdio_transport_when_specified
|
|
76
|
+
config = AI::MCPServerConfig.new(
|
|
77
|
+
transport: :stdio,
|
|
78
|
+
command: ["python", "server.py"]
|
|
79
|
+
)
|
|
80
|
+
transport_created = nil
|
|
81
|
+
|
|
82
|
+
::MCP::Transports::Stdio.stub :new, ->(command:) {
|
|
83
|
+
transport_created = { command: command }
|
|
84
|
+
Object.new
|
|
85
|
+
} do
|
|
86
|
+
::MCP::Client.stub :new, Object.new do
|
|
87
|
+
AI::MCP::Client.for(config)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
assert_equal ["python", "server.py"], transport_created[:command]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_interpolate_env_replaces_variables
|
|
95
|
+
ENV["TEST_SECRET"] = "my_secret_value"
|
|
96
|
+
|
|
97
|
+
config = AI::MCPServerConfig.new(
|
|
98
|
+
url: "https://example.com",
|
|
99
|
+
headers: { "Authorization" => "Bearer ${TEST_SECRET}" }
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Access private method for testing
|
|
103
|
+
result = AI::MCP::Client.send(:interpolate_env, config.headers)
|
|
104
|
+
|
|
105
|
+
assert_equal "Bearer my_secret_value", result["Authorization"]
|
|
106
|
+
ensure
|
|
107
|
+
ENV.delete("TEST_SECRET")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_call_tool_raises_for_unknown_tool
|
|
111
|
+
config = AI::MCPServerConfig.new(url: "https://example.com")
|
|
112
|
+
|
|
113
|
+
mock_client = Minitest::Mock.new
|
|
114
|
+
mock_client.expect :tools, []
|
|
115
|
+
|
|
116
|
+
AI::MCP::Client.stub :for, mock_client do
|
|
117
|
+
error = assert_raises(DurableWorkflow::ExecutionError) do
|
|
118
|
+
AI::MCP::Client.call_tool(config, "unknown_tool", {})
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
assert_match(/MCP tool not found/, error.message)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_reset_clears_connection_cache
|
|
126
|
+
config = AI::MCPServerConfig.new(url: "https://example.com")
|
|
127
|
+
|
|
128
|
+
::MCP::Client.stub :new, Object.new do
|
|
129
|
+
::MCP::Transports::HTTP.stub :new, Object.new do
|
|
130
|
+
AI::MCP::Client.for(config)
|
|
131
|
+
AI::MCP::Client.reset!
|
|
132
|
+
|
|
133
|
+
# After reset, should create new client
|
|
134
|
+
client = AI::MCP::Client.for(config)
|
|
135
|
+
refute_nil client
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
#### `test/unit/extensions/ai/mcp/adapter_test.rb`
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
# frozen_string_literal: true
|
|
146
|
+
|
|
147
|
+
require "test_helper"
|
|
148
|
+
require "durable_workflow/extensions/ai"
|
|
149
|
+
|
|
150
|
+
class MCPAdapterTest < Minitest::Test
|
|
151
|
+
AI = DurableWorkflow::Extensions::AI
|
|
152
|
+
|
|
153
|
+
def test_to_mcp_tool_creates_mcp_tool
|
|
154
|
+
# Create a simple RubyLLM::Tool subclass
|
|
155
|
+
tool_class = Class.new(RubyLLM::Tool) do
|
|
156
|
+
description "Test tool description"
|
|
157
|
+
param :input, type: :string, desc: "Input parameter"
|
|
158
|
+
|
|
159
|
+
def execute(input:)
|
|
160
|
+
"Result: #{input}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
tool_instance = tool_class.new
|
|
165
|
+
mcp_tool = nil
|
|
166
|
+
|
|
167
|
+
::MCP::Tool.stub :define, ->(name:, description:, input_schema:, &block) {
|
|
168
|
+
mcp_tool = { name: name, description: description, schema: input_schema, block: block }
|
|
169
|
+
Object.new
|
|
170
|
+
} do
|
|
171
|
+
AI::MCP::Adapter.to_mcp_tool(tool_instance)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
assert_equal "Test tool description", mcp_tool[:description]
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def test_execute_tool_returns_response
|
|
178
|
+
tool_class = Class.new(RubyLLM::Tool) do
|
|
179
|
+
description "Echo tool"
|
|
180
|
+
|
|
181
|
+
def execute(**args)
|
|
182
|
+
"Echoed: #{args[:message]}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
tool = tool_class.new
|
|
187
|
+
response = nil
|
|
188
|
+
|
|
189
|
+
::MCP::Tool::Response.stub :new, ->(content, **opts) {
|
|
190
|
+
response = { content: content, opts: opts }
|
|
191
|
+
Object.new
|
|
192
|
+
} do
|
|
193
|
+
AI::MCP::Adapter.execute_tool(tool, { message: "hello" }, {})
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
assert_equal [{ type: "text", text: "Echoed: hello" }], response[:content]
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def test_execute_tool_handles_errors
|
|
200
|
+
tool_class = Class.new(RubyLLM::Tool) do
|
|
201
|
+
description "Failing tool"
|
|
202
|
+
|
|
203
|
+
def execute(**)
|
|
204
|
+
raise "Tool error"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
tool = tool_class.new
|
|
209
|
+
response = nil
|
|
210
|
+
|
|
211
|
+
::MCP::Tool::Response.stub :new, ->(content, **opts) {
|
|
212
|
+
response = { content: content, opts: opts }
|
|
213
|
+
Object.new
|
|
214
|
+
} do
|
|
215
|
+
AI::MCP::Adapter.execute_tool(tool, {}, {})
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
assert response[:opts][:is_error]
|
|
219
|
+
assert_match(/Error:/, response[:content].first[:text])
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def test_format_result_handles_hash
|
|
223
|
+
result = AI::MCP::Adapter.send(:format_result, { key: "value" })
|
|
224
|
+
|
|
225
|
+
assert_includes result, "key"
|
|
226
|
+
assert_includes result, "value"
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def test_format_result_handles_string
|
|
230
|
+
result = AI::MCP::Adapter.send(:format_result, "plain string")
|
|
231
|
+
|
|
232
|
+
assert_equal "plain string", result
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def test_format_result_handles_array
|
|
236
|
+
result = AI::MCP::Adapter.send(:format_result, [1, 2, 3])
|
|
237
|
+
|
|
238
|
+
assert_includes result, "1"
|
|
239
|
+
assert_includes result, "2"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
#### `test/unit/extensions/ai/mcp/server_test.rb`
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
# frozen_string_literal: true
|
|
248
|
+
|
|
249
|
+
require "test_helper"
|
|
250
|
+
require "durable_workflow/extensions/ai"
|
|
251
|
+
|
|
252
|
+
class MCPServerTest < Minitest::Test
|
|
253
|
+
include DurableWorkflow::TestHelpers
|
|
254
|
+
AI = DurableWorkflow::Extensions::AI
|
|
255
|
+
|
|
256
|
+
def setup
|
|
257
|
+
AI::ToolRegistry.reset!
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def teardown
|
|
261
|
+
AI::ToolRegistry.reset!
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def test_build_creates_mcp_server
|
|
265
|
+
workflow = create_workflow_with_tools
|
|
266
|
+
|
|
267
|
+
server = nil
|
|
268
|
+
::MCP::Server.stub :new, ->(name:, version:, tools:, server_context:) {
|
|
269
|
+
server = { name: name, version: version, tools: tools }
|
|
270
|
+
Object.new
|
|
271
|
+
} do
|
|
272
|
+
AI::MCP::Server.build(workflow)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
assert_match(/durable_workflow/, server[:name])
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def test_build_with_custom_name
|
|
279
|
+
workflow = create_workflow_with_tools
|
|
280
|
+
|
|
281
|
+
server = nil
|
|
282
|
+
::MCP::Server.stub :new, ->(name:, **) {
|
|
283
|
+
server = { name: name }
|
|
284
|
+
Object.new
|
|
285
|
+
} do
|
|
286
|
+
AI::MCP::Server.build(workflow, name: "custom_server")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
assert_equal "custom_server", server[:name]
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def test_expose_workflow_adds_workflow_tool
|
|
293
|
+
workflow = create_workflow_with_tools
|
|
294
|
+
|
|
295
|
+
tools_count = 0
|
|
296
|
+
::MCP::Server.stub :new, ->(tools:, **) {
|
|
297
|
+
tools_count = tools.size
|
|
298
|
+
Object.new
|
|
299
|
+
} do
|
|
300
|
+
::MCP::Tool.stub :define, Object.new do
|
|
301
|
+
AI::MCP::Server.build(workflow, expose_workflow: true)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Should have workflow tools + exposed workflow tool
|
|
306
|
+
assert tools_count >= 1
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def create_workflow_with_tools
|
|
312
|
+
DurableWorkflow::Core::WorkflowDef.new(
|
|
313
|
+
id: "test_wf",
|
|
314
|
+
name: "Test Workflow",
|
|
315
|
+
version: "1.0",
|
|
316
|
+
steps: [
|
|
317
|
+
DurableWorkflow::Core::StepDef.new(
|
|
318
|
+
id: "start",
|
|
319
|
+
type: "start",
|
|
320
|
+
config: DurableWorkflow::Core::StartConfig.new
|
|
321
|
+
)
|
|
322
|
+
],
|
|
323
|
+
extensions: {
|
|
324
|
+
ai: {
|
|
325
|
+
tools: {}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### 2. Configuration Tests
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# test/unit/extensions/ai/configuration_test.rb
|
|
337
|
+
|
|
338
|
+
class ConfigurationTest < Minitest::Test
|
|
339
|
+
AI = DurableWorkflow::Extensions::AI
|
|
340
|
+
|
|
341
|
+
def test_default_model_is_gpt_4o_mini
|
|
342
|
+
config = AI::Configuration.new
|
|
343
|
+
assert_equal "gpt-4o-mini", config.default_model
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def test_api_keys_empty_by_default
|
|
347
|
+
config = AI::Configuration.new
|
|
348
|
+
assert_equal({}, config.api_keys)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def test_configure_yields_configuration
|
|
352
|
+
AI.configure do |c|
|
|
353
|
+
c.default_model = "claude-3-sonnet"
|
|
354
|
+
c.api_keys[:anthropic] = "test-key"
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
assert_equal "claude-3-sonnet", AI.configuration.default_model
|
|
358
|
+
assert_equal "test-key", AI.configuration.api_keys[:anthropic]
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def test_chat_uses_default_model
|
|
362
|
+
model_used = nil
|
|
363
|
+
|
|
364
|
+
RubyLLM.stub :chat, ->(model:) {
|
|
365
|
+
model_used = model
|
|
366
|
+
Object.new
|
|
367
|
+
} do
|
|
368
|
+
AI.chat
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
assert_equal AI.configuration.default_model, model_used
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def test_chat_accepts_model_override
|
|
375
|
+
model_used = nil
|
|
376
|
+
|
|
377
|
+
RubyLLM.stub :chat, ->(model:) {
|
|
378
|
+
model_used = model
|
|
379
|
+
Object.new
|
|
380
|
+
} do
|
|
381
|
+
AI.chat(model: "gpt-4")
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
assert_equal "gpt-4", model_used
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
### 3. ToolRegistry Tests
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
# test/unit/extensions/ai/tool_registry_test.rb
|
|
393
|
+
|
|
394
|
+
class ToolRegistryTest < Minitest::Test
|
|
395
|
+
AI = DurableWorkflow::Extensions::AI
|
|
396
|
+
|
|
397
|
+
def setup
|
|
398
|
+
AI::ToolRegistry.reset!
|
|
399
|
+
# Clean up generated tools
|
|
400
|
+
AI::GeneratedTools.constants.each do |c|
|
|
401
|
+
AI::GeneratedTools.send(:remove_const, c)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def teardown
|
|
406
|
+
AI::ToolRegistry.reset!
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def test_register_stores_tool_class
|
|
410
|
+
tool_class = Class.new(RubyLLM::Tool) do
|
|
411
|
+
description "Test"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
AI::ToolRegistry.register(tool_class)
|
|
415
|
+
|
|
416
|
+
refute_empty AI::ToolRegistry.all
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def test_register_from_def_creates_ruby_llm_tool
|
|
420
|
+
tool_def = AI::ToolDef.new(
|
|
421
|
+
id: "test_tool",
|
|
422
|
+
description: "A test tool",
|
|
423
|
+
parameters: [],
|
|
424
|
+
service: "TestService",
|
|
425
|
+
method_name: "call"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
AI::ToolRegistry.register_from_def(tool_def)
|
|
429
|
+
|
|
430
|
+
tool_class = AI::ToolRegistry["test_tool"]
|
|
431
|
+
refute_nil tool_class
|
|
432
|
+
assert tool_class < RubyLLM::Tool
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def test_bracket_accessor_retrieves_tool
|
|
436
|
+
tool_def = AI::ToolDef.new(
|
|
437
|
+
id: "lookup",
|
|
438
|
+
description: "Lookup",
|
|
439
|
+
parameters: [],
|
|
440
|
+
service: "LookupService",
|
|
441
|
+
method_name: "find"
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
AI::ToolRegistry.register_from_def(tool_def)
|
|
445
|
+
|
|
446
|
+
assert_equal AI::ToolRegistry["lookup"], AI::ToolRegistry.registry["lookup"]
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
def test_all_returns_all_tool_classes
|
|
450
|
+
tool_def1 = AI::ToolDef.new(
|
|
451
|
+
id: "tool_a",
|
|
452
|
+
description: "Tool A",
|
|
453
|
+
parameters: [],
|
|
454
|
+
service: "ServiceA",
|
|
455
|
+
method_name: "call"
|
|
456
|
+
)
|
|
457
|
+
tool_def2 = AI::ToolDef.new(
|
|
458
|
+
id: "tool_b",
|
|
459
|
+
description: "Tool B",
|
|
460
|
+
parameters: [],
|
|
461
|
+
service: "ServiceB",
|
|
462
|
+
method_name: "call"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
AI::ToolRegistry.register_from_def(tool_def1)
|
|
466
|
+
AI::ToolRegistry.register_from_def(tool_def2)
|
|
467
|
+
|
|
468
|
+
assert_equal 2, AI::ToolRegistry.all.size
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def test_reset_clears_registry
|
|
472
|
+
tool_def = AI::ToolDef.new(
|
|
473
|
+
id: "temp",
|
|
474
|
+
description: "Temp",
|
|
475
|
+
parameters: [],
|
|
476
|
+
service: "TempService",
|
|
477
|
+
method_name: "call"
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
AI::ToolRegistry.register_from_def(tool_def)
|
|
481
|
+
AI::ToolRegistry.reset!
|
|
482
|
+
|
|
483
|
+
assert_empty AI::ToolRegistry.all
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### 4. ToolDef#to_ruby_llm_tool Tests
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
# test/unit/extensions/ai/types/tool_def_test.rb
|
|
492
|
+
|
|
493
|
+
class ToolDefTest < Minitest::Test
|
|
494
|
+
AI = DurableWorkflow::Extensions::AI
|
|
495
|
+
|
|
496
|
+
def setup
|
|
497
|
+
# Clean up generated tools
|
|
498
|
+
AI::GeneratedTools.constants.each do |c|
|
|
499
|
+
AI::GeneratedTools.send(:remove_const, c)
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
def test_to_ruby_llm_tool_creates_subclass
|
|
504
|
+
tool_def = AI::ToolDef.new(
|
|
505
|
+
id: "my_tool",
|
|
506
|
+
description: "My tool description",
|
|
507
|
+
parameters: [],
|
|
508
|
+
service: "MyService",
|
|
509
|
+
method_name: "call"
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
513
|
+
|
|
514
|
+
assert tool_class < RubyLLM::Tool
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def test_generated_tool_has_description
|
|
518
|
+
tool_def = AI::ToolDef.new(
|
|
519
|
+
id: "described_tool",
|
|
520
|
+
description: "This is the description",
|
|
521
|
+
parameters: [],
|
|
522
|
+
service: "MyService",
|
|
523
|
+
method_name: "call"
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
527
|
+
|
|
528
|
+
assert_equal "This is the description", tool_class.new.description
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
def test_generated_tool_has_parameters
|
|
532
|
+
tool_def = AI::ToolDef.new(
|
|
533
|
+
id: "param_tool",
|
|
534
|
+
description: "Tool with params",
|
|
535
|
+
parameters: [
|
|
536
|
+
AI::ToolParam.new(name: "query", type: "string", required: true, description: "Search query"),
|
|
537
|
+
AI::ToolParam.new(name: "limit", type: "integer", required: false, description: "Max results")
|
|
538
|
+
],
|
|
539
|
+
service: "SearchService",
|
|
540
|
+
method_name: "search"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
544
|
+
|
|
545
|
+
# Verify the tool was created with proper params
|
|
546
|
+
refute_nil tool_class
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def test_generated_tool_stores_tool_def_reference
|
|
550
|
+
tool_def = AI::ToolDef.new(
|
|
551
|
+
id: "ref_tool",
|
|
552
|
+
description: "Reference tool",
|
|
553
|
+
parameters: [],
|
|
554
|
+
service: "RefService",
|
|
555
|
+
method_name: "call"
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
559
|
+
|
|
560
|
+
assert_equal tool_def, tool_class.tool_def
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def test_generated_tool_execute_calls_service
|
|
564
|
+
# Define a test service
|
|
565
|
+
Object.const_set(:ExecuteTestService, Module.new do
|
|
566
|
+
def self.do_thing(input:)
|
|
567
|
+
"Result: #{input}"
|
|
568
|
+
end
|
|
569
|
+
end)
|
|
570
|
+
|
|
571
|
+
tool_def = AI::ToolDef.new(
|
|
572
|
+
id: "execute_tool",
|
|
573
|
+
description: "Execute test",
|
|
574
|
+
parameters: [
|
|
575
|
+
AI::ToolParam.new(name: "input", type: "string", required: true)
|
|
576
|
+
],
|
|
577
|
+
service: "ExecuteTestService",
|
|
578
|
+
method_name: "do_thing"
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
582
|
+
tool_instance = tool_class.new
|
|
583
|
+
|
|
584
|
+
result = tool_instance.execute(input: "test")
|
|
585
|
+
|
|
586
|
+
assert_equal "Result: test", result
|
|
587
|
+
ensure
|
|
588
|
+
Object.send(:remove_const, :ExecuteTestService) if defined?(ExecuteTestService)
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
def test_generated_tool_class_is_named
|
|
592
|
+
tool_def = AI::ToolDef.new(
|
|
593
|
+
id: "named_tool",
|
|
594
|
+
description: "Named tool",
|
|
595
|
+
parameters: [],
|
|
596
|
+
service: "NamedService",
|
|
597
|
+
method_name: "call"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
tool_class = tool_def.to_ruby_llm_tool
|
|
601
|
+
|
|
602
|
+
# Should be defined under GeneratedTools module
|
|
603
|
+
assert AI::GeneratedTools.const_defined?(:NamedTool)
|
|
604
|
+
assert_equal AI::GeneratedTools::NamedTool, tool_class
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
## Acceptance Criteria
|
|
610
|
+
|
|
611
|
+
1. All MCP components have tests
|
|
612
|
+
2. Configuration tests verify API key handling
|
|
613
|
+
3. ToolRegistry tests cover registration and retrieval
|
|
614
|
+
4. ToolDef#to_ruby_llm_tool tests verify class generation
|
|
615
|
+
5. All new tests pass with existing 423 tests
|