From af143fe6466322563e86942cc1658e31c84d5c42 Mon Sep 17 00:00:00 2001 From: Andy Wang Date: Sat, 24 Oct 2026 12:28:28 +0800 Subject: [PATCH 1/3] Make generated agent previews discoverable and callable Write previews to the directory the Railtie searches and use current preview URLs. Forward preview parameters to the generated agent call. Look for new preview files on each discovery, and leave generation objects intact in the mail-specific image interceptor. Cover the original failures and retain inline-image handling for mail objects. --- CHANGELOG.md | 7 +++ lib/active_agent/concerns/preview.rb | 4 +- .../inline_preview_interceptor.rb | 1 + lib/generators/test_unit/agent_generator.rb | 2 +- .../test_unit/templates/preview.rb.tt | 6 +-- .../test_unit/agent_generator_test.rb | 9 ++-- test/preview_test.rb | 52 +++++++++++++++++++ 7 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 test/preview_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ccb8b28f..b3030bac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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. + ### Changed - **One badge for every status and message role in the dashboard** diff --git a/lib/active_agent/concerns/preview.rb b/lib/active_agent/concerns/preview.rb index cedb6c801..a3bddc230 100644 --- a/lib/active_agent/concerns/preview.rb +++ b/lib/active_agent/concerns/preview.rb @@ -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 @@ -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 diff --git a/lib/active_agent/inline_preview_interceptor.rb b/lib/active_agent/inline_preview_interceptor.rb index dad912bd7..2f3539d5b 100644 --- a/lib/active_agent/inline_preview_interceptor.rb +++ b/lib/active_agent/inline_preview_interceptor.rb @@ -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| diff --git a/lib/generators/test_unit/agent_generator.rb b/lib/generators/test_unit/agent_generator.rb index 2fbde3915..f33526df3 100644 --- a/lib/generators/test_unit/agent_generator.rb +++ b/lib/generators/test_unit/agent_generator.rb @@ -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 diff --git a/lib/generators/test_unit/templates/preview.rb.tt b/lib/generators/test_unit/templates/preview.rb.tt index c46df4b8e..2e08c8bf4 100644 --- a/lib/generators/test_unit/templates/preview.rb.tt +++ b/lib/generators/test_unit/templates/preview.rb.tt @@ -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 diff --git a/test/generators/test_unit/agent_generator_test.rb b/test/generators/test_unit/agent_generator_test.rb index cd8ba55df..8c4f2045a 100644 --- a/test/generators/test_unit/agent_generator_test.rb +++ b/test/generators/test_unit/agent_generator_test.rb @@ -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 @@ -33,7 +36,7 @@ class TestUnit::Generators::AgentGeneratorTest < Rails::Generators::TestCase assert_match(/class Admin::UserAgentTest < ActiveAgent::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 @@ -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 diff --git a/test/preview_test.rb b/test/preview_test.rb new file mode 100644 index 000000000..b7d991268 --- /dev/null +++ b/test/preview_test.rb @@ -0,0 +1,52 @@ +require "test_helper" +require "tmpdir" + +class AgentPreviewTest < ActiveSupport::TestCase + class SyntheticAgent < ActiveAgent::Base + generate_with :mock + + def greet + prompt(message: "Synthetic preview") + end + end + + class SyntheticPreview < ActiveAgent::Preview + def greet + SyntheticAgent.greet + end + end + + test "the default preview interceptor accepts a generation" do + generation = SyntheticPreview.call(:greet) + assert_kind_of ActiveAgent::Generation, generation + assert_includes generation.prompt_preview, "Synthetic preview" + end + + test "mail-shaped previews still have inline images rewritten" do + part = Struct.new(:body) do + def decoded = body + end.new('') + attachment = Struct.new(:body) do + def attachment? = true + def cid = "synthetic" + def mime_type = "image/png" + end.new(Struct.new(:raw_source).new("synthetic-image")) + context = Struct.new(:html_part, :all_parts).new(part, [ attachment ]) + + ActiveAgent::InlinePreviewInterceptor.previewing_prompt(context) + assert_includes part.body, "data:image/png;base64,c3ludGhldGljLWltYWdl" + end + + test "previews added after the first discovery are discovered" do + original = ActiveAgent::Base.preview_paths + Dir.mktmpdir do |directory| + ActiveAgent::Base.preview_paths = [ directory ] + ActiveAgent::Preview.all + File.write(File.join(directory, "late_preview.rb"), "class LatePreview < ActiveAgent::Preview; end") + assert_includes ActiveAgent::Preview.all.map(&:name), "LatePreview" + end + ensure + ActiveAgent::Base.preview_paths = original + Object.send(:remove_const, :LatePreview) if Object.const_defined?(:LatePreview) + end +end From 9f898a3811c33f589347e749e5cc756a918052f2 Mon Sep 17 00:00:00 2001 From: Andy Wang Date: Sat, 24 Oct 2026 12:30:30 +0800 Subject: [PATCH 2/3] Restore Rails browser preview and download endpoints The Railtie registers preview routes but ships no controller for them. Render escaped Markdown requests, list namespaced previews and download only declared preview actions. Apply Rails diagnostic-page access rules. Register default, legacy and configured preview directories with the Rails reloader. Exercise generated previews in fresh framework-only hosts, including reloads, disabled routes and refused remote requests. The tests fail if a preview executes the mock provider. --- AGENTS.md | 2 +- CHANGELOG.md | 8 ++ docs/framework/rails.md | 29 ++++- .../layouts/active_agent/preview.html.erb | 17 +++ .../templates/rails/agents/index.html.erb | 14 +++ .../templates/rails/agents/preview.html.erb | 3 + lib/active_agent/railtie.rb | 15 ++- lib/rails/agents_controller.rb | 43 +++++++ test/preview_integration_test.rb | 119 ++++++++++++++++++ 9 files changed, 243 insertions(+), 7 deletions(-) create mode 100644 lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb create mode 100644 lib/active_agent/preview/templates/rails/agents/index.html.erb create mode 100644 lib/active_agent/preview/templates/rails/agents/preview.html.erb create mode 100644 lib/rails/agents_controller.rb create mode 100644 test/preview_integration_test.rb diff --git a/AGENTS.md b/AGENTS.md index 7ee842836..8673fac57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b3030bac0..eed968592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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. + ### Changed - **One badge for every status and message role in the dashboard** diff --git a/docs/framework/rails.md b/docs/framework/rails.md index c73d55ae7..5a633c1db 100644 --- a/docs/framework/rails.md +++ b/docs/framework/rails.md @@ -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: @@ -212,3 +212,30 @@ 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. The `actionagent` dashboard gem is not required. diff --git a/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb b/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb new file mode 100644 index 000000000..bfdaa22ce --- /dev/null +++ b/lib/active_agent/preview/templates/layouts/active_agent/preview.html.erb @@ -0,0 +1,17 @@ + + + + + + ActiveAgent Previews + + + + + <%= yield %> + + diff --git a/lib/active_agent/preview/templates/rails/agents/index.html.erb b/lib/active_agent/preview/templates/rails/agents/index.html.erb new file mode 100644 index 000000000..c32d42e0e --- /dev/null +++ b/lib/active_agent/preview/templates/rails/agents/index.html.erb @@ -0,0 +1,14 @@ +

Agent Previews

+<% if @previews.empty? %> +

No previews found. Generate an agent or add an ActiveAgent::Preview subclass under test/agents/previews.

+<% end %> +<% @previews.each do |preview| %> +
+

<%= preview.preview_name %>

+
    + <% preview.prompts.each do |prompt| %> +
  • <%= link_to prompt, rails_agent_preview_path("#{preview.preview_name}/#{prompt}") %>
  • + <% end %> +
+
+<% end %> diff --git a/lib/active_agent/preview/templates/rails/agents/preview.html.erb b/lib/active_agent/preview/templates/rails/agents/preview.html.erb new file mode 100644 index 000000000..c93a24e86 --- /dev/null +++ b/lib/active_agent/preview/templates/rails/agents/preview.html.erb @@ -0,0 +1,3 @@ +

<%= @preview.preview_name %> / <%= @prompt %>

+

<%= link_to "Download Markdown", "#{download_rails_agent_preview_path("#{@preview.preview_name}/#{@prompt}")}?#{request.query_parameters.to_query}" %>

+
<%= @content %>
diff --git a/lib/active_agent/railtie.rb b/lib/active_agent/railtie.rb index 5de53fe00..9c102fc37 100644 --- a/lib/active_agent/railtie.rb +++ b/lib/active_agent/railtie.rb @@ -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 @@ -101,8 +103,11 @@ 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" ] + 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 @@ -132,9 +137,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 diff --git a/lib/rails/agents_controller.rb b/lib/rails/agents_controller.rb new file mode 100644 index 000000000..92ecbc0b8 --- /dev/null +++ b/lib/rails/agents_controller.rb @@ -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 diff --git a/test/preview_integration_test.rb b/test/preview_integration_test.rb new file mode 100644 index 000000000..d1885cca2 --- /dev/null +++ b/test/preview_integration_test.rb @@ -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: "", 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?("<script>synthetic</script>") + 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 From 07b091e4464fe9a927d1af00c994a722e4b46208 Mon Sep 17 00:00:00 2001 From: Andy Wang Date: Sun, 25 Oct 2026 21:26:54 +0800 Subject: [PATCH 3/3] Clarify preview access and initialization behavior --- docs/framework/rails.md | 9 ++++++++- lib/active_agent/railtie.rb | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/framework/rails.md b/docs/framework/rails.md index 5a633c1db..50548e3f9 100644 --- a/docs/framework/rails.md +++ b/docs/framework/rails.md @@ -238,4 +238,11 @@ 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. The `actionagent` dashboard gem is not required. +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. diff --git a/lib/active_agent/railtie.rb b/lib/active_agent/railtie.rb index 9c102fc37..fef752e63 100644 --- a/lib/active_agent/railtie.rb +++ b/lib/active_agent/railtie.rb @@ -105,6 +105,7 @@ class Railtie < Rails::Railtie # :nodoc: initializer "active_agent.set_autoload_paths", before: :set_autoload_paths do |app| 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