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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/.claude/todo/01.amend.md +133 -0
  3. data/.claude/todo/02.amend.md +444 -0
  4. data/.claude/todo/phase-1-core/01-GEMSPEC.md +193 -0
  5. data/.claude/todo/phase-1-core/02-TYPES.md +462 -0
  6. data/.claude/todo/phase-1-core/03-EXECUTION.md +551 -0
  7. data/.claude/todo/phase-1-core/04-STEPS.md +603 -0
  8. data/.claude/todo/phase-1-core/05-PARSER.md +719 -0
  9. data/.claude/todo/phase-1-core/todo.md +574 -0
  10. data/.claude/todo/phase-2-runtime/01-STORAGE.md +641 -0
  11. data/.claude/todo/phase-2-runtime/02-RUNNERS.md +511 -0
  12. data/.claude/todo/phase-3-extensions/01-EXTENSION-SYSTEM.md +298 -0
  13. data/.claude/todo/phase-3-extensions/02-AI-PLUGIN.md +936 -0
  14. data/.claude/todo/phase-3-extensions/todo.md +262 -0
  15. data/.claude/todo/phase-4-ai-rework/01-DEPENDENCIES.md +107 -0
  16. data/.claude/todo/phase-4-ai-rework/02-CONFIGURATION.md +123 -0
  17. data/.claude/todo/phase-4-ai-rework/03-TOOL-REGISTRY.md +237 -0
  18. data/.claude/todo/phase-4-ai-rework/04-MCP-SERVER.md +432 -0
  19. data/.claude/todo/phase-4-ai-rework/05-MCP-CLIENT.md +333 -0
  20. data/.claude/todo/phase-4-ai-rework/06-EXECUTORS.md +397 -0
  21. data/.claude/todo/phase-4-ai-rework/todo.md +265 -0
  22. data/.claude/todo/phase-5-validation/.DS_Store +0 -0
  23. data/.claude/todo/phase-5-validation/01-TEST-GAPS.md +615 -0
  24. data/.claude/todo/phase-5-validation/01-TESTS.md +2378 -0
  25. data/.claude/todo/phase-5-validation/02-EXAMPLES-SIMPLE.md +744 -0
  26. data/.claude/todo/phase-5-validation/02-EXAMPLES.md +1857 -0
  27. data/.claude/todo/phase-5-validation/03-EXAMPLE-SUPPORT-AGENT.md +95 -0
  28. data/.claude/todo/phase-5-validation/04-EXAMPLE-ORDER-FULFILLMENT.md +94 -0
  29. data/.claude/todo/phase-5-validation/05-EXAMPLE-DATA-PIPELINE.md +145 -0
  30. data/.env.example +3 -0
  31. data/.rubocop.yml +64 -0
  32. data/0.3.amend.md +89 -0
  33. data/CHANGELOG.md +5 -0
  34. data/CODE_OF_CONDUCT.md +84 -0
  35. data/Gemfile +22 -0
  36. data/Gemfile.lock +192 -0
  37. data/LICENSE.txt +21 -0
  38. data/README.md +39 -0
  39. data/Rakefile +16 -0
  40. data/durable_workflow.gemspec +43 -0
  41. data/examples/approval_request.rb +106 -0
  42. data/examples/calculator.rb +154 -0
  43. data/examples/file_search_demo.rb +77 -0
  44. data/examples/hello_workflow.rb +57 -0
  45. data/examples/item_processor.rb +96 -0
  46. data/examples/order_fulfillment/Gemfile +6 -0
  47. data/examples/order_fulfillment/README.md +84 -0
  48. data/examples/order_fulfillment/run.rb +85 -0
  49. data/examples/order_fulfillment/services.rb +146 -0
  50. data/examples/order_fulfillment/workflow.yml +188 -0
  51. data/examples/parallel_fetch.rb +102 -0
  52. data/examples/service_integration.rb +137 -0
  53. data/examples/support_agent/Gemfile +6 -0
  54. data/examples/support_agent/README.md +91 -0
  55. data/examples/support_agent/config/claude_desktop.json +12 -0
  56. data/examples/support_agent/mcp_server.rb +49 -0
  57. data/examples/support_agent/run.rb +67 -0
  58. data/examples/support_agent/services.rb +113 -0
  59. data/examples/support_agent/workflow.yml +286 -0
  60. data/lib/durable_workflow/core/condition.rb +45 -0
  61. data/lib/durable_workflow/core/engine.rb +145 -0
  62. data/lib/durable_workflow/core/executors/approval.rb +51 -0
  63. data/lib/durable_workflow/core/executors/assign.rb +18 -0
  64. data/lib/durable_workflow/core/executors/base.rb +90 -0
  65. data/lib/durable_workflow/core/executors/call.rb +76 -0
  66. data/lib/durable_workflow/core/executors/end.rb +19 -0
  67. data/lib/durable_workflow/core/executors/halt.rb +24 -0
  68. data/lib/durable_workflow/core/executors/loop.rb +118 -0
  69. data/lib/durable_workflow/core/executors/parallel.rb +77 -0
  70. data/lib/durable_workflow/core/executors/registry.rb +34 -0
  71. data/lib/durable_workflow/core/executors/router.rb +26 -0
  72. data/lib/durable_workflow/core/executors/start.rb +61 -0
  73. data/lib/durable_workflow/core/executors/transform.rb +71 -0
  74. data/lib/durable_workflow/core/executors/workflow.rb +32 -0
  75. data/lib/durable_workflow/core/parser.rb +189 -0
  76. data/lib/durable_workflow/core/resolver.rb +61 -0
  77. data/lib/durable_workflow/core/schema_validator.rb +47 -0
  78. data/lib/durable_workflow/core/types/base.rb +41 -0
  79. data/lib/durable_workflow/core/types/condition.rb +25 -0
  80. data/lib/durable_workflow/core/types/configs.rb +103 -0
  81. data/lib/durable_workflow/core/types/entry.rb +26 -0
  82. data/lib/durable_workflow/core/types/results.rb +41 -0
  83. data/lib/durable_workflow/core/types/state.rb +95 -0
  84. data/lib/durable_workflow/core/types/step_def.rb +15 -0
  85. data/lib/durable_workflow/core/types/workflow_def.rb +43 -0
  86. data/lib/durable_workflow/core/types.rb +29 -0
  87. data/lib/durable_workflow/core/validator.rb +318 -0
  88. data/lib/durable_workflow/extensions/ai/ai.rb +149 -0
  89. data/lib/durable_workflow/extensions/ai/configuration.rb +41 -0
  90. data/lib/durable_workflow/extensions/ai/executors/agent.rb +150 -0
  91. data/lib/durable_workflow/extensions/ai/executors/file_search.rb +52 -0
  92. data/lib/durable_workflow/extensions/ai/executors/guardrail.rb +152 -0
  93. data/lib/durable_workflow/extensions/ai/executors/handoff.rb +33 -0
  94. data/lib/durable_workflow/extensions/ai/executors/mcp.rb +47 -0
  95. data/lib/durable_workflow/extensions/ai/mcp/adapter.rb +73 -0
  96. data/lib/durable_workflow/extensions/ai/mcp/client.rb +77 -0
  97. data/lib/durable_workflow/extensions/ai/mcp/rack_app.rb +66 -0
  98. data/lib/durable_workflow/extensions/ai/mcp/server.rb +122 -0
  99. data/lib/durable_workflow/extensions/ai/tool_registry.rb +63 -0
  100. data/lib/durable_workflow/extensions/ai/types.rb +213 -0
  101. data/lib/durable_workflow/extensions/ai.rb +6 -0
  102. data/lib/durable_workflow/extensions/base.rb +77 -0
  103. data/lib/durable_workflow/runners/adapters/inline.rb +42 -0
  104. data/lib/durable_workflow/runners/adapters/sidekiq.rb +69 -0
  105. data/lib/durable_workflow/runners/async.rb +100 -0
  106. data/lib/durable_workflow/runners/stream.rb +126 -0
  107. data/lib/durable_workflow/runners/sync.rb +40 -0
  108. data/lib/durable_workflow/storage/active_record.rb +148 -0
  109. data/lib/durable_workflow/storage/redis.rb +133 -0
  110. data/lib/durable_workflow/storage/sequel.rb +144 -0
  111. data/lib/durable_workflow/storage/store.rb +43 -0
  112. data/lib/durable_workflow/utils.rb +25 -0
  113. data/lib/durable_workflow/version.rb +5 -0
  114. data/lib/durable_workflow.rb +70 -0
  115. data/sig/durable_workflow.rbs +4 -0
  116. 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