Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Creates (the view directory drops the `_agent` suffix, the agent file keeps it):
- `app/views/agents/agent_name/action1.md.erb`
- `app/views/agents/agent_name/action2.md.erb`
- `test/agents/agent_name_agent_test.rb` and
`test/docs/previews/agent_name_agent_preview.rb`
`test/agents/previews/agent_name_agent_preview.rb`

### Adding a Tool to an Agent

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Discover and call generated agent previews** (`activeagent`). TestUnit
writes previews under `test/agents/previews`, with current URLs and query
parameters passed to the agent. Discovery checks for newly added files,
and the default image interceptor accepts generation objects.

- **Restore Rails browser previews** (`activeagent`). `/rails/agents` lists
previews and renders their prompt requests without executing a provider.
Namespaced actions and Markdown downloads work, unknown actions return
404, and all endpoints enforce Rails' local-request restriction. Preview
directories participate in development reloading, including configured
paths and the legacy `test/docs/previews` directory. The dashboard gem is
not needed; the routes remain disabled outside development by default.

- **Generate runnable agent tests** (`activeagent`). TestUnit agent tests
inherit from `ActiveSupport::TestCase` instead of the nonexistent
`ActiveAgent::TestCase`. Plain and namespaced agents, including agents
Expand Down
36 changes: 35 additions & 1 deletion docs/framework/rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ app/agents/support_agent.rb
app/views/agents/support/instructions.md
app/views/agents/support/respond.md.erb
test/agents/support_agent_test.rb
test/docs/previews/support_agent_preview.rb
test/agents/previews/support_agent_preview.rb
```

Multiple actions:
Expand Down Expand Up @@ -212,3 +212,37 @@ end

- **[Configuration](/framework/configuration)** - Environment-specific settings and YAML configuration
- **[Instrumentation](/framework/instrumentation)** - Rails.logger integration and event monitoring

## Browser previews

In development, open `/rails/agents` to list your agent previews. Choose an
action to inspect the request as escaped Markdown, or download it as a
`.md` file. A preview builds the request without calling the provider. It
uses the agent's normal provider configuration.

The generator writes `ActiveAgent::Preview` subclasses under
`test/agents/previews`. Existing previews under `test/docs/previews` are
also discovered. Add another directory before the application initializes:

```ruby
# config/application.rb
config.active_agent.preview_paths << Rails.root.join("lib/agent_previews").to_s
```

Generated preview methods pass their query parameters through `with(params)`
to the agent. An action that needs parameters can be opened with, for
example, `/rails/agents/support_agent/respond?message=Hello`.
Namespaced previews use `/rails/agents/admin/user_agent/create`.
Preview files reload with the application in development.

Set `config.active_agent.show_previews = false` to omit the routes.
Previews default to disabled outside development. When enabled, the
controller uses the same local-request restriction as Rails' own diagnostic
pages. Setting `config.consider_all_requests_local = true` bypasses that
restriction, so remote requests can access previews when
`config.active_agent.show_previews = true`. Preview content can include
agent instructions and interpolated parameters; keep
`config.consider_all_requests_local = false` in any remotely accessible
environment where previews are enabled.

The `actionagent` dashboard gem is not required.
4 changes: 2 additions & 2 deletions lib/active_agent/concerns/preview.rb
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def initialize(params = {})
class << self
# Returns all agent preview classes.
def all
load_previews if descendants.empty?
load_previews
descendants.sort_by { |agent| agent.name.titleize }
end

Expand Down Expand Up @@ -111,7 +111,7 @@ def preview_name

def load_previews
preview_paths.each do |preview_path|
Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require file }
Dir["#{preview_path}/**/*_preview.rb"].sort.each { |file| require_dependency file }
end
end

Expand Down
1 change: 1 addition & 0 deletions lib/active_agent/inline_preview_interceptor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def initialize(context) # :nodoc:
end

def transform! # :nodoc:
return context unless context.respond_to?(:html_part)
return context if html_part.blank?

html_part.body = html_part.decoded.gsub(PATTERN) do |match|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ActiveAgent Previews</title>
<style>
body { color-scheme: light dark; font: 16px/1.5 system-ui, sans-serif; max-width: 1000px; padding: 1rem; margin: auto; }
pre { white-space: pre-wrap; overflow-wrap: anywhere; border: 1px solid; border-radius: .25rem; padding: 1rem; }
a { text-underline-offset: .2em; }
</style>
</head>
<body>
<nav><%= link_to "All agent previews", rails_agent_previews_path %></nav>
<%= yield %>
</body>
</html>
14 changes: 14 additions & 0 deletions lib/active_agent/preview/templates/rails/agents/index.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<h1>Agent Previews</h1>
<% if @previews.empty? %>
<p>No previews found. Generate an agent or add an ActiveAgent::Preview subclass under test/agents/previews.</p>
<% end %>
<% @previews.each do |preview| %>
<section>
<h2><%= preview.preview_name %></h2>
<ul>
<% preview.prompts.each do |prompt| %>
<li><%= link_to prompt, rails_agent_preview_path("#{preview.preview_name}/#{prompt}") %></li>
<% end %>
</ul>
</section>
<% end %>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<h1><%= @preview.preview_name %> / <%= @prompt %></h1>
<p><%= link_to "Download Markdown", "#{download_rails_agent_preview_path("#{@preview.preview_name}/#{@prompt}")}?#{request.query_parameters.to_query}" %></p>
<pre><%= @content %></pre>
16 changes: 11 additions & 5 deletions lib/active_agent/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
require "active_agent/railtie/schema_generator_extension"
require "abstract_controller/railties/routes_helpers"

Rails.autoload :AgentsController, "rails/agents_controller"

module ActiveAgent
class Railtie < Rails::Railtie # :nodoc:
config.active_agent = ActiveSupport::OrderedOptions.new
Expand Down Expand Up @@ -101,8 +103,12 @@ class Railtie < Rails::Railtie # :nodoc:
end

initializer "active_agent.set_autoload_paths", before: :set_autoload_paths do |app|
# options = app.config.active_agent
# app.config.paths["test/agents/previews"].concat(options.preview_paths)
options = app.config.active_agent
options.preview_paths |= [ "#{app.root}/test/agents/previews", "#{app.root}/test/docs/previews" ]
# This runs before set_configs assigns show_previews its development default.
if options.show_previews.nil? ? Rails.env.development? : options.show_previews
app.config.autoload_paths.concat(options.preview_paths)
end
end

initializer "active_agent.compile_config_methods" do
Expand Down Expand Up @@ -132,9 +138,9 @@ class Railtie < Rails::Railtie # :nodoc:

if options.show_previews
app.routes.prepend do
get "/rails/agents" => "rails/agents#index", :internal => true
get "/rails/agents/download/*path" => "rails/agents#download", :internal => true
get "/rails/agents/*path" => "rails/agents#preview", :internal => true
get "/rails/agents" => "rails/agents#index", :internal => true, :as => :rails_agent_previews
get "/rails/agents/download/*path" => "rails/agents#download", :internal => true, :as => :download_rails_agent_preview, :format => false
get "/rails/agents/*path" => "rails/agents#preview", :internal => true, :as => :rails_agent_preview, :format => false
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/generators/test_unit/agent_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ def create_test_files
end

def create_preview_files
template "preview.rb", File.join("test/docs/previews", class_path, "#{file_name}_agent_preview.rb")
template "preview.rb", File.join("test/agents/previews", class_path, "#{file_name}_agent_preview.rb")
end

private
Expand Down
6 changes: 3 additions & 3 deletions lib/generators/test_unit/templates/preview.rb.tt
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<% module_namespacing do -%>
# Preview all agent views/prompts templates at http://localhost:3000/active_agent/agents/<%= file_path %>_agent
# Preview all agent views/prompts templates at http://localhost:3000/rails/agents/<%= file_path %>_agent
class <%= class_name %>AgentPreview < ActiveAgent::Preview
<% actions.each_with_index do |action, index| -%>
<% if index != 0 -%>

<% end -%>
# Preview this email at http://localhost:3000/active_agent/agents/<%= file_path %>_agent/<%= action %>
# Preview this prompt at http://localhost:3000/rails/agents/<%= file_path %>_agent/<%= action %>
def <%= action %>
<%= class_name %>Agent.<%= action %>
<%= class_name %>Agent.with(params).<%= action %>
end
<% end -%>
end
Expand Down
43 changes: 43 additions & 0 deletions lib/rails/agents_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

require "rails/application_controller"

class Rails::AgentsController < Rails::ApplicationController # :nodoc:
prepend_view_path File.expand_path("../active_agent/preview/templates", __dir__)
layout "active_agent/preview"
before_action :require_local!

def index
@previews = ActiveAgent::Preview.all
end

def preview
if found = ActiveAgent::Preview.find(params[:path])
@previews = [ found ]
render :index
elsif load_preview
render :preview
end
end

def download
return unless load_preview

send_data @content, type: "text/markdown", disposition: "attachment",
filename: "#{@preview.preview_name.tr('/', '-')}-#{@prompt}.md"
end

private

def load_preview
name, _, @prompt = params[:path].to_s.rpartition("/")
@preview = ActiveAgent::Preview.find(name)
unless @preview&.prompt_exists?(@prompt)
head :not_found
return false
end

@content = @preview.call(@prompt, request.query_parameters).prompt_preview
true
end
end
9 changes: 6 additions & 3 deletions test/generators/test_unit/agent_generator_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase
test "generates preview files" do
run_generator [ "user", "create", "update" ]

assert_file "test/docs/previews/user_agent_preview.rb" do |content|
assert_file "test/agents/previews/user_agent_preview.rb" do |content|
assert_match(/class UserAgentPreview < ActiveAgent::Preview/, content)
assert_match(/def create/, content)
assert_match(/def update/, content)
assert_includes content, "/rails/agents/user_agent/create"
assert_not_includes content, "Preview this email"
assert_includes content, "UserAgent.with(params).create"
end
end

Expand All @@ -33,7 +36,7 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase
assert_match(/class Admin::UserAgentTest < ActiveSupport::TestCase/, content)
end

assert_file "test/docs/previews/admin/user_agent_preview.rb" do |content|
assert_file "test/agents/previews/admin/user_agent_preview.rb" do |content|
assert_match(/class Admin::UserAgentPreview < ActiveAgent::Preview/, content)
end
end
Expand All @@ -59,7 +62,7 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase
assert_no_match(/^ test "/, content) # No actual test methods (only commented ones)
end

assert_file "test/docs/previews/user_agent_preview.rb" do |content|
assert_file "test/agents/previews/user_agent_preview.rb" do |content|
assert_match(/class UserAgentPreview < ActiveAgent::Preview/, content)
assert_no_match(/def [a-z]/, content) # No action methods
end
Expand Down
119 changes: 119 additions & 0 deletions test/preview_integration_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
require "test_helper"
require "generators/active_agent/agent/agent_generator"
require "generators/test_unit/agent_generator"
require "generators/erb/agent_generator"
require "open3"
require "tmpdir"

class RailsAgentPreviewIntegrationTest < ActiveSupport::TestCase
test "generated previews can be listed rendered downloaded and reloaded" do
assert_preview_host(enabled: true)
end

test "disabled previews have no routes" do
assert_preview_host(enabled: false)
end

test "production does not expose previews by default" do
assert_preview_host(enabled: nil, environment: "production")
end

private

def assert_preview_host(enabled:, environment: "development")
Dir.mktmpdir("agent-preview-host") do |root|
FileUtils.mkdir_p("#{root}/app/agents")
FileUtils.mkdir_p("#{root}/config")
File.write("#{root}/app/agents/application_agent.rb", <<~RUBY)
class ApplicationAgent < ActiveAgent::Base
generate_with :mock
end
RUBY
%w[preview_sample admin/preview_sample].each do |name|
ActiveAgent::Generators::AgentGenerator.start(
[ name, "greet", "--test-framework=test_unit", "--quiet" ], destination_root: root
)
end
FileUtils.mkdir_p("#{root}/test/docs/previews")
File.write("#{root}/test/docs/previews/legacy_preview.rb", "class LegacyPreview < ActiveAgent::Preview; end")
FileUtils.mkdir_p("#{root}/custom_previews")
File.write("#{root}/custom_previews/custom_preview.rb", "class CustomPreview < ActiveAgent::Preview; end")
File.write("#{root}/config/routes.rb", "Rails.application.routes.draw {}")
File.write("#{root}/check.rb", host_script(enabled:, environment:))
output, status = Open3.capture2e(RbConfig.ruby, "check.rb", chdir: root)
assert status.success?, output
assert_includes output, "preview host verified"
end
end

def host_script(enabled:, environment:)
<<~RUBY
ENV["RAILS_ENV"] = #{environment.inspect}
require "rails"
require "active_model/railtie"
require "active_job/railtie"
require "action_controller/railtie"
require "active_agent"
require "webmock"
WebMock.disable_net_connect!
class PreviewHost < Rails::Application
config.root = __dir__
config.eager_load = false
config.cache_classes = false
config.secret_key_base = "synthetic-test-secret"
config.logger = Logger.new(File::NULL)
config.hosts.clear
config.consider_all_requests_local = false
config.action_dispatch.show_exceptions = :none
config.active_agent.show_previews = #{enabled.inspect}
config.active_agent.preview_paths << File.join(__dir__, "custom_previews")
end
PreviewHost.initialize!
require "active_agent/providers/mock_provider"
ActiveAgent::Providers::MockProvider.prepend(Module.new do
def api_prompt_execute(*)
raise "previews must not execute a provider request"
end
end)
require "action_dispatch/testing/integration"
session = ActionDispatch::Integration::Session.new(Rails.application)
session.remote_addr = "127.0.0.1"
if #{!!enabled}
session.get "/rails/agents"
raise "index failed: \#{session.response.status}" unless session.response.status == 200
%w[preview_sample_agent admin/preview_sample_agent legacy custom].each do |name|
raise "missing \#{name}" unless session.response.body.include?(name)
end
session.get "/rails/agents/admin/preview_sample_agent"
raise "namespaced list failed" unless session.response.status == 200
session.get "/rails/agents/admin/preview_sample_agent/greet", params: { message: "<script>synthetic</script>", host: "example.invalid", script_name: "//example.invalid" }
raise "preview failed: \#{session.response.status}" unless session.response.status == 200
raise "unsafe download link" unless session.response.body.include?('href="/rails/agents/download/admin/preview_sample_agent/greet?')
raise "unescaped content" unless session.response.body.include?("&lt;script&gt;synthetic&lt;/script&gt;")
session.get "/rails/agents/download/preview_sample_agent/greet", params: { message: "synthetic download" }
raise "download failed" unless session.response.status == 200 && session.response.body.include?("synthetic download")
raise "wrong download type" unless session.response.media_type == "text/markdown"
raise "not an attachment" unless session.response.headers["Content-Disposition"].include?("attachment")
%w[missing/greet preview_sample_agent/missing preview_sample_agent/object_id].each do |path|
session.get "/rails/agents/\#{path}"
raise "unknown path accepted" unless session.response.status == 404
end
file = File.join(__dir__, "test/agents/previews/preview_sample_agent_preview.rb")
File.write(file, File.read(file).sub("def greet", "def changed"))
Rails.application.reloader.reload!
session.get "/rails/agents/preview_sample_agent"
raise "reload failed" unless session.response.status == 200 && session.response.body.include?("changed")
session.get "/rails/agents/preview_sample_agent/changed"
raise "reloaded prompt failed" unless session.response.status == 200
session.remote_addr = "203.0.113.7"
[ "/rails/agents", "/rails/agents/preview_sample_agent/changed", "/rails/agents/download/preview_sample_agent/changed" ].each do |path|
session.get path
raise "remote preview accepted" unless session.response.status == 403
end
else
raise "preview route exists" if Rails.application.routes.routes.any? { |route| route.defaults[:controller] == "rails/agents" }
end
puts "preview host verified"
RUBY
end
end
Loading
Loading