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.
- checksums.yaml +7 -0
- data/bin/rspec-agents +24 -0
- data/lib/async_workers/channel_config.rb +34 -0
- data/lib/async_workers/doc/process_manager_design.md +512 -0
- data/lib/async_workers/errors.rb +21 -0
- data/lib/async_workers/managed_process.rb +284 -0
- data/lib/async_workers/output_stream.rb +86 -0
- data/lib/async_workers/rpc_channel.rb +159 -0
- data/lib/async_workers/transport/base.rb +57 -0
- data/lib/async_workers/transport/stdio_transport.rb +91 -0
- data/lib/async_workers/transport/unix_socket_transport.rb +112 -0
- data/lib/async_workers/worker_group.rb +175 -0
- data/lib/async_workers.rb +17 -0
- data/lib/rspec/agents/agent_response.rb +61 -0
- data/lib/rspec/agents/agents/base.rb +123 -0
- data/lib/rspec/agents/cli.rb +342 -0
- data/lib/rspec/agents/conversation.rb +308 -0
- data/lib/rspec/agents/criterion.rb +237 -0
- data/lib/rspec/agents/doc/2026_01_22_observer-system-design.md +757 -0
- data/lib/rspec/agents/doc/2026_01_23_parallel_spec_runner-design.md +1060 -0
- data/lib/rspec/agents/doc/2026_01_27_event_serialization-design.md +294 -0
- data/lib/rspec/agents/doc/2026_01_27_experiment_aggregation_design.md +831 -0
- data/lib/rspec/agents/doc/2026_01_29_rspec-agents-studio-design.md +1332 -0
- data/lib/rspec/agents/doc/2026_01_29_testing-framework-design.md +1037 -0
- data/lib/rspec/agents/doc/2026_02_04-parallel-runner-ui.md +537 -0
- data/lib/rspec/agents/doc/2026_02_05_html_renderer_extensions.md +708 -0
- data/lib/rspec/agents/doc/scenario_guide.md +289 -0
- data/lib/rspec/agents/dsl/agent_proxy.rb +141 -0
- data/lib/rspec/agents/dsl/criterion_definition.rb +78 -0
- data/lib/rspec/agents/dsl/graph_builder.rb +38 -0
- data/lib/rspec/agents/dsl/runner_factory.rb +52 -0
- data/lib/rspec/agents/dsl/scenario_set_dsl.rb +166 -0
- data/lib/rspec/agents/dsl/test_context.rb +223 -0
- data/lib/rspec/agents/dsl/user_proxy.rb +71 -0
- data/lib/rspec/agents/dsl.rb +398 -0
- data/lib/rspec/agents/evaluation_result.rb +44 -0
- data/lib/rspec/agents/event_bus.rb +78 -0
- data/lib/rspec/agents/events.rb +141 -0
- data/lib/rspec/agents/isolated_event_bus.rb +86 -0
- data/lib/rspec/agents/judge.rb +244 -0
- data/lib/rspec/agents/llm/anthropic.rb +143 -0
- data/lib/rspec/agents/llm/base.rb +64 -0
- data/lib/rspec/agents/llm/mock.rb +181 -0
- data/lib/rspec/agents/llm/response.rb +52 -0
- data/lib/rspec/agents/matchers.rb +554 -0
- data/lib/rspec/agents/message.rb +81 -0
- data/lib/rspec/agents/metadata.rb +120 -0
- data/lib/rspec/agents/observers/base.rb +70 -0
- data/lib/rspec/agents/observers/parallel_terminal_observer.rb +151 -0
- data/lib/rspec/agents/observers/rpc_notify_observer.rb +43 -0
- data/lib/rspec/agents/observers/terminal_observer.rb +103 -0
- data/lib/rspec/agents/parallel/controller.rb +284 -0
- data/lib/rspec/agents/parallel/example_discovery.rb +153 -0
- data/lib/rspec/agents/parallel/partitioner.rb +31 -0
- data/lib/rspec/agents/parallel/run_result.rb +22 -0
- data/lib/rspec/agents/parallel/ui/interactive_ui.rb +605 -0
- data/lib/rspec/agents/parallel/ui/interleaved_ui.rb +139 -0
- data/lib/rspec/agents/parallel/ui/output_adapter.rb +127 -0
- data/lib/rspec/agents/parallel/ui/quiet_ui.rb +100 -0
- data/lib/rspec/agents/parallel/ui/ui_factory.rb +53 -0
- data/lib/rspec/agents/parallel/ui/ui_mode.rb +101 -0
- data/lib/rspec/agents/prompt_builders/base.rb +113 -0
- data/lib/rspec/agents/prompt_builders/criterion_evaluation.rb +136 -0
- data/lib/rspec/agents/prompt_builders/goal_achievement_evaluation.rb +142 -0
- data/lib/rspec/agents/prompt_builders/grounding_evaluation.rb +172 -0
- data/lib/rspec/agents/prompt_builders/intent_evaluation.rb +111 -0
- data/lib/rspec/agents/prompt_builders/topic_classification.rb +105 -0
- data/lib/rspec/agents/prompt_builders/user_simulation.rb +131 -0
- data/lib/rspec/agents/runners/headless_runner.rb +272 -0
- data/lib/rspec/agents/runners/parallel_terminal_runner.rb +220 -0
- data/lib/rspec/agents/runners/terminal_runner.rb +186 -0
- data/lib/rspec/agents/runners/user_simulator.rb +261 -0
- data/lib/rspec/agents/scenario.rb +133 -0
- data/lib/rspec/agents/scenario_loader.rb +145 -0
- data/lib/rspec/agents/serialization/conversation_renderer.rb +161 -0
- data/lib/rspec/agents/serialization/extension.rb +199 -0
- data/lib/rspec/agents/serialization/extensions/core_extension.rb +66 -0
- data/lib/rspec/agents/serialization/presenters.rb +281 -0
- data/lib/rspec/agents/serialization/run_data_aggregator.rb +197 -0
- data/lib/rspec/agents/serialization/run_data_builder.rb +189 -0
- data/lib/rspec/agents/serialization/templates/_alpine.min.js +5 -0
- data/lib/rspec/agents/serialization/templates/_base_components.css +196 -0
- data/lib/rspec/agents/serialization/templates/_base_components.js +46 -0
- data/lib/rspec/agents/serialization/templates/_conversation_fragment.html.haml +34 -0
- data/lib/rspec/agents/serialization/templates/_metadata_default.html.haml +17 -0
- data/lib/rspec/agents/serialization/templates/_scripts.js +89 -0
- data/lib/rspec/agents/serialization/templates/_styles.css +1211 -0
- data/lib/rspec/agents/serialization/templates/conversation_document.html.haml +29 -0
- data/lib/rspec/agents/serialization/templates/test_suite.html.haml +238 -0
- data/lib/rspec/agents/serialization/test_suite_renderer.rb +207 -0
- data/lib/rspec/agents/serialization.rb +374 -0
- data/lib/rspec/agents/simulator_config.rb +336 -0
- data/lib/rspec/agents/spec_executor.rb +494 -0
- data/lib/rspec/agents/stable_example_id.rb +147 -0
- data/lib/rspec/agents/templates/user_simulation.erb +9 -0
- data/lib/rspec/agents/tool_call.rb +53 -0
- data/lib/rspec/agents/topic.rb +307 -0
- data/lib/rspec/agents/topic_graph.rb +236 -0
- data/lib/rspec/agents/triggers.rb +122 -0
- data/lib/rspec/agents/turn.rb +63 -0
- data/lib/rspec/agents/turn_executor.rb +91 -0
- data/lib/rspec/agents/version.rb +7 -0
- data/lib/rspec/agents.rb +145 -0
- 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
|