rspec-agents 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 (104) hide show
  1. checksums.yaml +7 -0
  2. data/bin/rspec-agents +24 -0
  3. data/lib/async_workers/channel_config.rb +34 -0
  4. data/lib/async_workers/doc/process_manager_design.md +512 -0
  5. data/lib/async_workers/errors.rb +21 -0
  6. data/lib/async_workers/managed_process.rb +284 -0
  7. data/lib/async_workers/output_stream.rb +86 -0
  8. data/lib/async_workers/rpc_channel.rb +159 -0
  9. data/lib/async_workers/transport/base.rb +57 -0
  10. data/lib/async_workers/transport/stdio_transport.rb +91 -0
  11. data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
  12. data/lib/async_workers/worker_group.rb +175 -0
  13. data/lib/async_workers.rb +17 -0
  14. data/lib/rspec/agents/agent_response.rb +61 -0
  15. data/lib/rspec/agents/agents/base.rb +123 -0
  16. data/lib/rspec/agents/cli.rb +342 -0
  17. data/lib/rspec/agents/conversation.rb +308 -0
  18. data/lib/rspec/agents/criterion.rb +237 -0
  19. data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
  20. data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
  21. data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
  22. data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
  23. data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
  24. data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
  25. data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
  26. data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
  27. data/lib/rspec/agents/doc/scenario_guide.md +289 -0
  28. data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
  29. data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
  30. data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
  31. data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
  32. data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
  33. data/lib/rspec/agents/dsl/test_context.rb +223 -0
  34. data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
  35. data/lib/rspec/agents/dsl.rb +398 -0
  36. data/lib/rspec/agents/evaluation_result.rb +44 -0
  37. data/lib/rspec/agents/event_bus.rb +78 -0
  38. data/lib/rspec/agents/events.rb +141 -0
  39. data/lib/rspec/agents/isolated_event_bus.rb +86 -0
  40. data/lib/rspec/agents/judge.rb +244 -0
  41. data/lib/rspec/agents/llm/anthropic.rb +143 -0
  42. data/lib/rspec/agents/llm/base.rb +64 -0
  43. data/lib/rspec/agents/llm/mock.rb +181 -0
  44. data/lib/rspec/agents/llm/response.rb +52 -0
  45. data/lib/rspec/agents/matchers.rb +554 -0
  46. data/lib/rspec/agents/message.rb +81 -0
  47. data/lib/rspec/agents/metadata.rb +120 -0
  48. data/lib/rspec/agents/observers/base.rb +70 -0
  49. data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
  50. data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
  51. data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
  52. data/lib/rspec/agents/parallel/controller.rb +284 -0
  53. data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
  54. data/lib/rspec/agents/parallel/partitioner.rb +31 -0
  55. data/lib/rspec/agents/parallel/run_result.rb +22 -0
  56. data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
  57. data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
  58. data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
  59. data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
  60. data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
  61. data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
  62. data/lib/rspec/agents/prompt_builders/base.rb +113 -0
  63. data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
  64. data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
  65. data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
  66. data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
  67. data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
  68. data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
  69. data/lib/rspec/agents/runners/headless_runner.rb +272 -0
  70. data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
  71. data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
  72. data/lib/rspec/agents/runners/user_simulator.rb +261 -0
  73. data/lib/rspec/agents/scenario.rb +133 -0
  74. data/lib/rspec/agents/scenario_loader.rb +145 -0
  75. data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
  76. data/lib/rspec/agents/serialization/extension.rb +199 -0
  77. data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
  78. data/lib/rspec/agents/serialization/presenters.rb +281 -0
  79. data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
  80. data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
  81. data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
  82. data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
  83. data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
  84. data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
  85. data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
  86. data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
  87. data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
  88. data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
  89. data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
  90. data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
  91. data/lib/rspec/agents/serialization.rb +374 -0
  92. data/lib/rspec/agents/simulator_config.rb +336 -0
  93. data/lib/rspec/agents/spec_executor.rb +494 -0
  94. data/lib/rspec/agents/stable_example_id.rb +147 -0
  95. data/lib/rspec/agents/templates/user_simulation.erb +9 -0
  96. data/lib/rspec/agents/tool_call.rb +53 -0
  97. data/lib/rspec/agents/topic.rb +307 -0
  98. data/lib/rspec/agents/topic_graph.rb +236 -0
  99. data/lib/rspec/agents/triggers.rb +122 -0
  100. data/lib/rspec/agents/turn.rb +63 -0
  101. data/lib/rspec/agents/turn_executor.rb +91 -0
  102. data/lib/rspec/agents/version.rb +7 -0
  103. data/lib/rspec/agents.rb +145 -0
  104. metadata +242 -0
@@ -0,0 +1,131 @@
1
+ require "erb"
2
+
3
+ module RSpec
4
+ module Agents
5
+ module PromptBuilders
6
+ # Builds prompts for simulating user messages
7
+ class UserSimulation < Base
8
+ DEFAULT_TEMPLATE_PATH = File.expand_path("../templates/user_simulation.erb", __dir__)
9
+
10
+ class << self
11
+ # Build a user simulation prompt
12
+ #
13
+ # @param config [SimulatorConfig] Simulator configuration
14
+ # @param conversation [Conversation] Current conversation state
15
+ # @param current_topic [Topic, nil] Current topic (for topic-specific overrides)
16
+ # @return [String] The complete prompt
17
+ def build(config, conversation, current_topic: nil)
18
+ effective_config = current_topic ? config.for_topic(current_topic.name) : config
19
+ template_path = effective_config.template || DEFAULT_TEMPLATE_PATH
20
+
21
+ render_template(template_path, effective_config, conversation)
22
+ end
23
+
24
+ # Parse the response (typically just the raw text)
25
+ #
26
+ # @param response [LLM::Response] The LLM response
27
+ # @return [String] The user message
28
+ def parse(response)
29
+ text = response.respond_to?(:text) ? response.text : response.to_s
30
+ # Clean up any quotes or prefixes the LLM might add
31
+ text.strip
32
+ .gsub(/^["']|["']$/, "")
33
+ .gsub(/^User:\s*/i, "")
34
+ .strip
35
+ end
36
+
37
+ private
38
+
39
+ def render_template(template_path, config, conversation)
40
+ template_content = File.read(template_path)
41
+ erb = ERB.new(template_content, trim_mode: "-")
42
+
43
+ # Variables available in the template
44
+ system_section = build_system_section(config)
45
+ conversation_section = build_conversation_section(conversation)
46
+ rules_section = build_rules_section(config, conversation)
47
+ turn_count = conversation.turns.count
48
+
49
+ erb.result(binding)
50
+ end
51
+
52
+ def build_system_section(config)
53
+ parts = ["## Your Role"]
54
+ parts << "You are pretending to be a user testing an AI agent."
55
+ parts << "Approach this naturally, as a human user would."
56
+ parts << ""
57
+
58
+ role_items = config.effective_role
59
+ if role_items.any?
60
+ parts << "### Character"
61
+ parts.concat(role_items)
62
+ parts << ""
63
+ end
64
+
65
+ personality_items = config.effective_personality
66
+ if personality_items.any?
67
+ parts << "### Personality"
68
+ parts.concat(personality_items)
69
+ parts << ""
70
+ end
71
+
72
+ context_items = config.effective_context
73
+ if context_items.any?
74
+ parts << "### Context"
75
+ parts.concat(context_items)
76
+ parts << ""
77
+ end
78
+
79
+ if config.goal
80
+ parts << "### Goal"
81
+ parts << "Your goal is: #{config.goal}"
82
+ parts << ""
83
+ end
84
+
85
+ parts.join("\n")
86
+ end
87
+
88
+ def build_conversation_section(conversation)
89
+ if conversation.messages.empty?
90
+ return "## Conversation So Far\nThis is the start of the conversation. Send your opening message."
91
+ end
92
+
93
+ parts = ["## Conversation So Far"]
94
+ conversation.messages.each do |msg|
95
+ # Flip roles: in conversation, "agent" is who user is talking to
96
+ display_role = msg.role == :agent ? "Agent" : "You"
97
+ parts << "#{display_role}: #{msg.content}"
98
+ end
99
+
100
+ parts.join("\n")
101
+ end
102
+
103
+ def build_rules_section(config, conversation)
104
+ rules = config.rules || []
105
+ return "" if rules.empty?
106
+
107
+ parts = ["## Rules to Follow"]
108
+
109
+ rules.each do |rule|
110
+ case rule[:type]
111
+ when :should
112
+ parts << "- You SHOULD: #{rule[:text]}"
113
+ when :should_not
114
+ parts << "- You should NOT: #{rule[:text]}"
115
+ when :dynamic
116
+ # Evaluate dynamic rule
117
+ if rule[:block]
118
+ last_turn = conversation.turns.last
119
+ dynamic_text = rule[:block].call(last_turn&.agent_response, conversation)
120
+ parts << "- #{dynamic_text}" if dynamic_text
121
+ end
122
+ end
123
+ end
124
+
125
+ parts.join("\n")
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "stringio"
5
+ require "digest"
6
+ require "json"
7
+
8
+ module RSpec
9
+ module Agents
10
+ module Runners
11
+ # In-worker RSpec execution that emits events via EventBus.
12
+ # Used by parallel_spec_worker to run specs in subprocess with event streaming.
13
+ #
14
+ # All events (both RSpec lifecycle and conversation events) flow through
15
+ # the EventBus, which RpcNotifyObserver forwards to the controller.
16
+ #
17
+ # Event flow:
18
+ # RSpec notification → HeadlessRunner → EventBus#publish(typed event)
19
+ # Conversation turn → Conversation → EventBus#publish(typed event)
20
+ # EventBus → RpcNotifyObserver → JSON over RPC socket
21
+ #
22
+ class HeadlessRunner
23
+ # RSpec notifications we subscribe to
24
+ NOTIFICATIONS = [
25
+ :start,
26
+ :example_group_started,
27
+ :example_group_finished,
28
+ :example_started,
29
+ :example_passed,
30
+ :example_failed,
31
+ :example_pending,
32
+ :stop,
33
+ :dump_summary
34
+ ].freeze
35
+
36
+ # @param rpc_output [IO] Output stream for RPC notifications (socket)
37
+ def initialize(rpc_output:)
38
+ @rpc_output = rpc_output
39
+ @example_count = 0
40
+ @failure_count = 0
41
+ end
42
+
43
+ # Run the specified examples
44
+ #
45
+ # @param example_ids [Array<String>] RSpec example IDs (e.g., "spec/file.rb[1:1]")
46
+ # @return [Hash] Result with exit_code, example_count, failure_count
47
+ def run(example_ids)
48
+ @example_count = 0
49
+ @failure_count = 0
50
+
51
+ # Create isolated EventBus for this worker (NOT the singleton)
52
+ @event_bus = IsolatedEventBus.new
53
+
54
+ # Set up RPC forwarding - all events go through this observer
55
+ Observers::RpcNotifyObserver.new(
56
+ rpc_output: @rpc_output,
57
+ event_bus: @event_bus
58
+ )
59
+
60
+ # Reset RSpec state for clean run
61
+ RSpec.reset
62
+ RSpec.configuration.reset
63
+
64
+ # Re-register DSL after reset (reset wipes out config.include calls)
65
+ RSpec::Agents.setup_rspec!
66
+
67
+ # Enable focus filtering (fit/fdescribe/fcontext)
68
+ # This must be set after RSpec.reset since reset clears all configuration
69
+ RSpec.configuration.filter_run_when_matching :focus
70
+
71
+ # Configure output streams
72
+ null_output = StringIO.new
73
+ RSpec.configuration.output_stream = null_output
74
+ RSpec.configuration.error_stream = $stderr
75
+
76
+ # Parse options with example filter
77
+ options = RSpec::Core::ConfigurationOptions.new(example_ids)
78
+ options.configure(RSpec.configuration)
79
+
80
+ # Re-suppress output after options configure
81
+ RSpec.configuration.output_stream = null_output
82
+ RSpec.configuration.formatters.clear
83
+
84
+ # Register ourselves as a listener for RSpec lifecycle events
85
+ RSpec.configuration.reporter.register_listener(self, *NOTIFICATIONS)
86
+
87
+ # Inject event bus into test context for Conversation to find
88
+ Thread.current[:rspec_agents_event_bus] = @event_bus
89
+
90
+ # Run specs
91
+ runner = RSpec::Core::Runner.new(options)
92
+ exit_code = runner.run($stderr, null_output)
93
+
94
+ # Clean up thread-locals
95
+ Thread.current[:rspec_agents_event_bus] = nil
96
+ Thread.current[:rspec_agents_example_id] = nil
97
+
98
+ {
99
+ exit_code: exit_code,
100
+ example_count: @example_count,
101
+ failure_count: @failure_count
102
+ }
103
+ end
104
+
105
+ # --- RSpec Notification Handlers ---
106
+ # Each handler creates a typed event and publishes it through the EventBus
107
+
108
+ def start(notification)
109
+ @event_bus.publish(Events::SuiteStarted.new(
110
+ example_count: notification.count,
111
+ load_time: notification.load_time,
112
+ seed: RSpec.configuration.seed,
113
+ time: Time.now
114
+ ))
115
+ end
116
+
117
+ def example_group_started(notification)
118
+ group = notification.group
119
+ @event_bus.publish(Events::GroupStarted.new(
120
+ description: group.description,
121
+ file_path: group.metadata[:file_path],
122
+ line_number: group.metadata[:line_number],
123
+ time: Time.now
124
+ ))
125
+ end
126
+
127
+ def example_group_finished(notification)
128
+ group = notification.group
129
+ @event_bus.publish(Events::GroupFinished.new(
130
+ description: group.description,
131
+ time: Time.now
132
+ ))
133
+ end
134
+
135
+ def example_started(notification)
136
+ example = notification.example
137
+ @example_count += 1
138
+
139
+ # Generate stable example ID and store in thread-local
140
+ stable_id_obj = build_stable_example_id(example)
141
+ example_id = stable_id_obj&.hash_value || generate_example_id(example)
142
+ Thread.current[:rspec_agents_example_id] = example_id
143
+ Thread.current[:rspec_agents_stable_id] = stable_id_obj&.to_s
144
+
145
+ scenario = example.metadata[:rspec_agents_scenario]
146
+ @event_bus.publish(Events::ExampleStarted.new(
147
+ example_id: example_id,
148
+ stable_id: stable_id_obj&.to_s,
149
+ canonical_path: stable_id_obj&.canonical_path,
150
+ description: example.description,
151
+ full_description: example.full_description,
152
+ location: example.location,
153
+ scenario: scenario,
154
+ time: Time.now
155
+ ))
156
+ end
157
+
158
+ def example_passed(notification)
159
+ example_id = Thread.current[:rspec_agents_example_id]
160
+ stable_id = Thread.current[:rspec_agents_stable_id]
161
+ Thread.current[:rspec_agents_example_id] = nil
162
+ Thread.current[:rspec_agents_stable_id] = nil
163
+ example = notification.example
164
+
165
+ @event_bus.publish(Events::ExamplePassed.new(
166
+ example_id: example_id,
167
+ stable_id: stable_id,
168
+ description: example.description,
169
+ full_description: example.full_description,
170
+ duration: example.execution_result.run_time,
171
+ time: Time.now
172
+ ))
173
+ end
174
+
175
+ def example_failed(notification)
176
+ example_id = Thread.current[:rspec_agents_example_id]
177
+ stable_id = Thread.current[:rspec_agents_stable_id]
178
+ Thread.current[:rspec_agents_example_id] = nil
179
+ Thread.current[:rspec_agents_stable_id] = nil
180
+ example = notification.example
181
+ @failure_count += 1
182
+
183
+ @event_bus.publish(Events::ExampleFailed.new(
184
+ example_id: example_id,
185
+ stable_id: stable_id,
186
+ description: example.description,
187
+ full_description: example.full_description,
188
+ location: example.location,
189
+ duration: example.execution_result.run_time,
190
+ message: extract_failure_message(notification),
191
+ backtrace: extract_backtrace(notification),
192
+ time: Time.now
193
+ ))
194
+ end
195
+
196
+ def example_pending(notification)
197
+ example_id = Thread.current[:rspec_agents_example_id]
198
+ stable_id = Thread.current[:rspec_agents_stable_id]
199
+ Thread.current[:rspec_agents_example_id] = nil
200
+ Thread.current[:rspec_agents_stable_id] = nil
201
+ example = notification.example
202
+
203
+ @event_bus.publish(Events::ExamplePending.new(
204
+ example_id: example_id,
205
+ stable_id: stable_id,
206
+ description: example.description,
207
+ full_description: example.full_description,
208
+ message: example.execution_result.pending_message,
209
+ time: Time.now
210
+ ))
211
+ end
212
+
213
+ def stop(_notification)
214
+ @event_bus.publish(Events::SuiteStopped.new(
215
+ time: Time.now
216
+ ))
217
+ end
218
+
219
+ def dump_summary(notification)
220
+ @event_bus.publish(Events::SuiteSummary.new(
221
+ duration: notification.duration,
222
+ example_count: notification.example_count,
223
+ failure_count: notification.failure_count,
224
+ pending_count: notification.pending_count,
225
+ time: Time.now
226
+ ))
227
+ end
228
+
229
+ private
230
+
231
+ def generate_example_id(example)
232
+ Digest::SHA256.hexdigest(example.full_description)[0, 12]
233
+ end
234
+
235
+ def build_stable_example_id(example)
236
+ scenario = example.metadata[:rspec_agents_scenario]
237
+ StableExampleId.generate(example, scenario: scenario)
238
+ rescue => e
239
+ # Fall back gracefully if stable ID generation fails
240
+ warn "[HeadlessRunner] Failed to generate stable example ID: #{e.message}"
241
+ nil
242
+ end
243
+
244
+ def extract_failure_message(notification)
245
+ if notification.respond_to?(:exception) && notification.exception
246
+ notification.exception.message
247
+ elsif notification.example.execution_result.exception
248
+ notification.example.execution_result.exception.message
249
+ end
250
+ end
251
+
252
+ def extract_backtrace(notification)
253
+ backtrace = if notification.respond_to?(:formatted_backtrace)
254
+ notification.formatted_backtrace
255
+ elsif notification.example.execution_result.exception
256
+ notification.example.execution_result.exception.backtrace
257
+ end
258
+ filter_backtrace(backtrace)
259
+ end
260
+
261
+ def filter_backtrace(backtrace)
262
+ return nil unless backtrace
263
+
264
+ # Include only lines from the current working directory (application code)
265
+ # This matches RSpec's backtrace_inclusion_patterns approach
266
+ app_path = Dir.getwd
267
+ backtrace.select { |line| line.start_with?(app_path) }.first(10)
268
+ end
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+ require "fileutils"
5
+ require_relative "../parallel/ui/ui_factory"
6
+
7
+ module RSpec
8
+ module Agents
9
+ module Runners
10
+ # CLI-focused parallel runner with formatted terminal output.
11
+ # Thin adapter over SpecExecutor that handles UI display and output serialization.
12
+ #
13
+ # Supports three output modes:
14
+ # - Interactive: Full-screen TUI with tabs and progress bar
15
+ # - Interleaved: Simple streaming output with worker prefixes (CI-friendly)
16
+ # - Quiet: Dot progress with log files
17
+ #
18
+ # @example Basic usage
19
+ # runner = ParallelTerminalRunner.new(worker_count: 4)
20
+ # exit_code = runner.run(["spec/"])
21
+ #
22
+ # @example With explicit UI mode
23
+ # runner = ParallelTerminalRunner.new(worker_count: 4, ui_mode: :interactive)
24
+ # runner.run(["spec/"])
25
+ #
26
+ class ParallelTerminalRunner
27
+ COLORS = {
28
+ red: "\e[31m",
29
+ green: "\e[32m",
30
+ yellow: "\e[33m",
31
+ blue: "\e[34m",
32
+ cyan: "\e[36m",
33
+ dim: "\e[2m",
34
+ white: "\e[37m",
35
+ reset: "\e[0m"
36
+ }.freeze
37
+
38
+ # @param worker_count [Integer] Number of parallel workers
39
+ # @param fail_fast [Boolean] Stop on first failure
40
+ # @param output [IO] Output stream (default: $stdout)
41
+ # @param color [Boolean, nil] Force color on/off (default: auto-detect)
42
+ # @param json_path [String, nil] Path to save JSON run data
43
+ # @param html_path [String, nil] Path to save HTML report
44
+ # @param ui_mode [Symbol, nil] Output mode (:interactive, :interleaved, :quiet)
45
+ def initialize(worker_count:, fail_fast: false, output: $stdout, color: nil,
46
+ json_path: nil, html_path: nil, ui_mode: nil)
47
+ @worker_count = worker_count
48
+ @fail_fast = fail_fast
49
+ @output = output
50
+ @color = color.nil? ? output.respond_to?(:tty?) && output.tty? : color
51
+ @mutex = Mutex.new
52
+ @json_path = json_path
53
+ @html_path = html_path
54
+
55
+ @ui = Parallel::UI::UIFactory.create(
56
+ mode: ui_mode,
57
+ output: output,
58
+ color: @color
59
+ )
60
+
61
+ # Track which worker is running which example
62
+ @example_to_worker = {} # example_id => worker_index
63
+ end
64
+
65
+ # Run specs and return exit code
66
+ # @param paths [Array<String>] Spec files or directories
67
+ # @return [Integer] Exit code (0 = success, 1 = failures)
68
+ def run(paths)
69
+ executor = ParallelSpecExecutor.new(
70
+ worker_count: @worker_count,
71
+ fail_fast: @fail_fast
72
+ )
73
+
74
+ # Build example_id -> worker mapping from discovery
75
+ # This needs to be done before execution to route events to the right worker
76
+ begin
77
+ examples = Parallel::ExampleDiscovery.discover(paths)
78
+ partitions = Parallel::Partitioner.partition(examples, @worker_count)
79
+
80
+ partitions.each_with_index do |worker_examples, worker_index|
81
+ next if worker_examples.nil? || worker_examples.empty?
82
+ worker_examples.each do |ex|
83
+ @example_to_worker[ex.id] = worker_index
84
+ end
85
+ end
86
+ rescue Parallel::ExampleDiscovery::DiscoveryError => e
87
+ @output.puts colorize("Error: #{e.message}", :red)
88
+ return 1
89
+ end
90
+
91
+ # Wire up event handling
92
+ executor.on_event { |type, event| route_event_to_ui(type, event) }
93
+ executor.on_progress { |c, t, f| @ui.on_progress(completed: c, total: t, failures: f) }
94
+
95
+ # Start UI
96
+ @ui.on_run_started(worker_count: @worker_count, example_count: examples.size)
97
+ @ui.start_input_handling
98
+
99
+ begin
100
+ # Execute - Async block returns the result of the block
101
+ result = Async do |task|
102
+ executor.execute(paths, task: task)
103
+ end.wait
104
+
105
+ # Notify UI of completion
106
+ @ui.on_run_finished(results: result)
107
+
108
+ # Print summary
109
+ print_summary(result)
110
+ if @ui.failures.any?
111
+ print_failures
112
+ print_failed_examples_filter
113
+ end
114
+
115
+ # Save outputs
116
+ save_outputs(executor.run_data) if @json_path || @html_path
117
+
118
+ result&.success? ? 0 : 1
119
+ ensure
120
+ @ui.stop_input_handling
121
+ @ui.cleanup
122
+ end
123
+ end
124
+
125
+ private
126
+
127
+ def route_event_to_ui(type, event)
128
+ # Get worker index from example_id
129
+ example_id = event.respond_to?(:example_id) ? event.example_id : nil
130
+ worker = example_id ? @example_to_worker[example_id] : 0
131
+ worker ||= 0
132
+
133
+ case type
134
+ when "ExampleStarted"
135
+ @ui.on_example_started(worker: worker, event: event)
136
+ when "ExamplePassed", "ExampleFailed", "ExamplePending"
137
+ @ui.on_example_finished(worker: worker, event: event)
138
+ when "GroupStarted", "GroupFinished"
139
+ @ui.on_group_event(worker: worker, event: event)
140
+ when "UserMessage", "AgentResponse", "ToolCallCompleted", "TopicChanged"
141
+ @ui.on_conversation_event(worker: worker, event: event)
142
+ end
143
+ end
144
+
145
+ def print_summary(result)
146
+ @output.puts
147
+
148
+ return unless result
149
+
150
+ parts = ["#{result.example_count} example#{"s" unless result.example_count == 1}"]
151
+
152
+ if result.failure_count > 0
153
+ parts << colorize("#{result.failure_count} failure#{"s" unless result.failure_count == 1}", :red)
154
+ else
155
+ parts << colorize("0 failures", :green)
156
+ end
157
+
158
+ if result.error
159
+ @output.puts colorize("Error: #{result.error}", :red)
160
+ end
161
+
162
+ @output.puts parts.join(", ")
163
+ @output.puts
164
+ end
165
+
166
+ def print_failures
167
+ failures = @ui.failures
168
+ return if failures.empty?
169
+
170
+ @output.puts colorize("Failures:", :red)
171
+ @output.puts
172
+
173
+ failures.each_with_index do |event, i|
174
+ @output.puts " #{i + 1}) #{event.full_description || event.description}"
175
+ @output.puts " #{colorize(event.message, :red)}" if event.message
176
+ if event.backtrace&.any?
177
+ event.backtrace.first(3).each do |line|
178
+ @output.puts " #{colorize(line, :dim)}"
179
+ end
180
+ end
181
+ @output.puts
182
+ end
183
+ end
184
+
185
+ def print_failed_examples_filter
186
+ failures = @ui.failures
187
+ return if failures.empty?
188
+
189
+ @output.puts colorize("Failed examples:", :red)
190
+ @output.puts
191
+
192
+ failures.each do |event|
193
+ location = event.location
194
+ description = event.full_description || event.description
195
+ @output.puts colorize("bin/rspec-agents #{location}", :red) + " " + colorize("# #{description}", :dim)
196
+ end
197
+ @output.puts
198
+ end
199
+
200
+ def colorize(text, color)
201
+ return text unless @color
202
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
203
+ end
204
+
205
+ def save_outputs(run_data)
206
+ return unless run_data
207
+
208
+ if @json_path
209
+ FileUtils.mkdir_p(File.dirname(@json_path))
210
+ Serialization::JsonFile.write(@json_path, run_data)
211
+ end
212
+
213
+ if @html_path
214
+ Serialization::TestSuiteRenderer.render(run_data, output_path: @html_path)
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end