From 4e6924a0053755d2bd4f6b03ba35ef1cb82ff0e8 Mon Sep 17 00:00:00 2001 From: Sarbada Date: Sun, 16 Nov 2025 05:43:25 +0545 Subject: [PATCH 1/2] feat(active_prompt): add and finalize mountable Rails engine (#243) - Add ActivePrompt engine (engine.rb, version.rb, module) - Isolate namespace via \`isolate_namespace\` - Wire asset pipeline (JS/CSS + manifest) and precompile hooks - Add engine routes with GET /health (no optional format) - Add minimal controller/helper stubs - Mount engine in dummy app and load engine before routes - Define \`ActiveAgent::TestCase\` to satisfy Zeitwerk - Add Minitest coverage: engine load/isolation, routes, assets - Stabilize route tests (assert named route and exact path) - Test hygiene: clean tmp/generators and remove lingering constants to avoid generator collisions --- app/assets/config/active_prompt_manifest.js | 2 + .../javascripts/active_prompt/application.js | 8 ++ .../stylesheets/active_prompt/application.css | 6 + .../active_prompt/application_controller.rb | 7 + .../active_prompt/application_helper.rb | 6 + config/routes.rb | 5 + .../json-and-auto-parses-JSON.md | 12 +- .../plain-content-type.md | 8 +- lib/active_agent/test_case.rb | 131 ++---------------- lib/active_prompt.rb | 7 + lib/active_prompt/engine.rb | 36 +++++ lib/active_prompt/version.rb | 5 + test/active_prompt/asset_pipeline_test.rb | 22 +++ test/active_prompt/engine_test.rb | 17 +++ test/active_prompt/load_test.rb | 10 ++ test/active_prompt/routes_test.rb | 19 +++ test/dummy/config/application.rb | 4 + test/dummy/config/routes.rb | 1 + test/test_helper.rb | 35 +++++ 19 files changed, 209 insertions(+), 132 deletions(-) create mode 100644 app/assets/config/active_prompt_manifest.js create mode 100644 app/assets/javascripts/active_prompt/application.js create mode 100644 app/assets/stylesheets/active_prompt/application.css create mode 100644 app/controllers/active_prompt/application_controller.rb create mode 100644 app/helpers/active_prompt/application_helper.rb create mode 100644 config/routes.rb create mode 100644 lib/active_prompt.rb create mode 100644 lib/active_prompt/engine.rb create mode 100644 lib/active_prompt/version.rb create mode 100644 test/active_prompt/asset_pipeline_test.rb create mode 100644 test/active_prompt/engine_test.rb create mode 100644 test/active_prompt/load_test.rb create mode 100644 test/active_prompt/routes_test.rb diff --git a/app/assets/config/active_prompt_manifest.js b/app/assets/config/active_prompt_manifest.js new file mode 100644 index 00000000..25434838 --- /dev/null +++ b/app/assets/config/active_prompt_manifest.js @@ -0,0 +1,2 @@ +//= link_directory ../javascripts/active_prompt .js +//= link_directory ../stylesheets/active_prompt .css diff --git a/app/assets/javascripts/active_prompt/application.js b/app/assets/javascripts/active_prompt/application.js new file mode 100644 index 00000000..eaa45c39 --- /dev/null +++ b/app/assets/javascripts/active_prompt/application.js @@ -0,0 +1,8 @@ +// This file is compiled into the host app's asset pipeline as active_prompt/application.js +// Add engine-specific JS here. +//= require_self + +(function () { + // Namespace guard + window.ActivePrompt = window.ActivePrompt || {}; +})(); diff --git a/app/assets/stylesheets/active_prompt/application.css b/app/assets/stylesheets/active_prompt/application.css new file mode 100644 index 00000000..8f41e56f --- /dev/null +++ b/app/assets/stylesheets/active_prompt/application.css @@ -0,0 +1,6 @@ +/* + *= require_self + */ + +/* Add engine-specific styles here */ +.active-prompt--hidden { display: none; } diff --git a/app/controllers/active_prompt/application_controller.rb b/app/controllers/active_prompt/application_controller.rb new file mode 100644 index 00000000..ab1aeff6 --- /dev/null +++ b/app/controllers/active_prompt/application_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivePrompt + class ApplicationController < ActionController::Base + protect_from_forgery with: :exception + end +end diff --git a/app/helpers/active_prompt/application_helper.rb b/app/helpers/active_prompt/application_helper.rb new file mode 100644 index 00000000..8ea40ac1 --- /dev/null +++ b/app/helpers/active_prompt/application_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ActivePrompt + module ApplicationHelper + end +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000..27ab3143 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ActivePrompt::Engine.routes.draw do + get "health", to: proc { [200, { "Content-Type" => "text/plain" }, ["ok"]] }, as: :health +end diff --git a/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md b/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md index b8cf1eff..fc01d544 100644 --- a/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md @@ -1,21 +1,21 @@ -[activeagent/test/integration/structured_output_json_parsing_test.rb:69](vscode://file//Users/justinbowen/Documents/GitHub/claude-could/activeagent/test/integration/structured_output_json_parsing_test.rb:69) +[activeagent/test/integration/structured_output_json_parsing_test.rb:69](vscode://file//Users/sarbadajaiswal/development/Justin/activeagents/activeagent/test/integration/structured_output_json_parsing_test.rb:69) ```ruby # Response object -#"John Doe", "age"=>30, "email"=>"john@example.com"}, + @content={"name" => "John Doe", "age" => 30, "email" => "john@example.com"}, @role=:assistant> - @prompt=# + @prompt=# @content_type="application/json" @raw_response={...}> # Message content -response.message.content # => {"name"=>"John Doe", "age"=>30, "email"=>"john@example.com"} +response.message.content # => {"name" => "John Doe", "age" => 30, "email" => "john@example.com"} ``` \ No newline at end of file diff --git a/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md b/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md index 6015e052..b57c5c3b 100644 --- a/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md @@ -1,18 +1,18 @@ -[activeagent/test/integration/structured_output_json_parsing_test.rb:151](vscode://file//Users/justinbowen/Documents/GitHub/claude-could/activeagent/test/integration/structured_output_json_parsing_test.rb:151) +[activeagent/test/integration/structured_output_json_parsing_test.rb:151](vscode://file//Users/sarbadajaiswal/development/Justin/activeagents/activeagent/test/integration/structured_output_json_parsing_test.rb:151) ```ruby # Response object -# - @prompt=# + @prompt=# @content_type="text/plain" @raw_response={...}> diff --git a/lib/active_agent/test_case.rb b/lib/active_agent/test_case.rb index 121f188c..18868b5b 100644 --- a/lib/active_agent/test_case.rb +++ b/lib/active_agent/test_case.rb @@ -1,125 +1,12 @@ -# # frozen_string_literal: true +# frozen_string_literal: true -# require_relative "test_helper" -# require "active_support/test_case" -# require "rails-dom-testing" +require "active_support/test_case" -# module ActiveAgent -# class NonInferrableAgentError < ::StandardError -# def initialize(name) -# super("Unable to determine the agent to test from #{name}. " \ -# "You'll need to specify it using tests YourAgent in your " \ -# "test case definition") -# end -# end +module ActiveAgent + class TestCase < ActiveSupport::TestCase + # minimal base to satisfy Zeitwerk + end +end -# class TestCase < ActiveSupport::TestCase -# module ClearTestDeliveries -# extend ActiveSupport::Concern - -# included do -# setup :clear_test_generations -# teardown :clear_test_generations -# end - -# private - -# def clear_test_generations -# if ActiveAgent::Base.generation_method == :test -# ActiveAgent::Base.generations.clear -# end -# end -# end - -# module Behavior -# extend ActiveSupport::Concern - -# include ActiveSupport::Testing::ConstantLookup -# include TestHelper -# include Rails::Dom::Testing::Assertions::SelectorAssertions -# include Rails::Dom::Testing::Assertions::DomAssertions - -# included do -# class_attribute :_agent_class -# setup :initialize_test_generations -# setup :set_expected_prompt -# teardown :restore_test_generations -# ActiveSupport.run_load_hooks(:active_agent_test_case, self) -# end - -# module ClassMethods -# def tests(agent) -# case agent -# when String, Symbol -# self._agent_class = agent.to_s.camelize.constantize -# when Module -# self._agent_class = agent -# else -# raise NonInferrableAgentError.new(agent) -# end -# end - -# def agent_class -# if agent = _agent_class -# agent -# else -# tests determine_default_agent(name) -# end -# end - -# def determine_default_agent(name) -# agent = determine_constant_from_test_name(name) do |constant| -# Class === constant && constant < ActiveAgent::Base -# end -# raise NonInferrableAgentError.new(name) if agent.nil? -# agent -# end -# end - -# # Reads the fixture file for the given agent. -# # -# # This is useful when testing agents by being able to write the body of -# # an promt inside a fixture. See the testing guide for a concrete example: -# # https://guides.rubyonrails.org/testing.html#revenge-of-the-fixtures -# def read_fixture(action) -# IO.readlines(File.join(Rails.root, "test", "fixtures", self.class.agent_class.name.underscore, action)) -# end - -# private - -# def initialize_test_generations -# set_generation_method :test -# @old_perform_generations = ActiveAgent::Base.perform_generations -# ActiveAgent::Base.perform_generations = true -# ActiveAgent::Base.generations.clear -# end - -# def restore_test_generations -# restore_generation_method -# ActiveAgent::Base.perform_generations = @old_perform_generations -# end - -# def set_generation_method(method) -# @old_generation_method = ActiveAgent::Base.generation_method -# ActiveAgent::Base.generation_method = method -# end - -# def restore_generation_method -# ActiveAgent::Base.generations.clear -# ActiveAgent::Base.generation_method = @old_generation_method -# end - -# def set_expected_prompt -# @expected = ActiveAgent::ActionPrompt::Prompt.new -# @expected.content_type ["text", "plain", {"charset" => charset}] -# @expected.mime_version = "1.0" -# end - -# def charset -# "UTF-8" -# end -# end - -# include Behavior -# end -# end +# Back-compat for any existing tests +ActiveAgentTestCase = ActiveAgent::TestCase unless defined?(ActiveAgentTestCase) diff --git a/lib/active_prompt.rb b/lib/active_prompt.rb new file mode 100644 index 00000000..77166e20 --- /dev/null +++ b/lib/active_prompt.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "active_prompt/version" +require "active_prompt/engine" if defined?(Rails) + +module ActivePrompt +end diff --git a/lib/active_prompt/engine.rb b/lib/active_prompt/engine.rb new file mode 100644 index 00000000..51369f14 --- /dev/null +++ b/lib/active_prompt/engine.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails/engine" + +module ActivePrompt + class Engine < ::Rails::Engine + isolate_namespace ActivePrompt + + # Ensures the engine's app/ is eager loaded in production and autoloaded in dev/test + config.autoload_paths << root.join("lib").to_s + + # Keep generated files tidy (no assets/helpers/tests by default from generators) + config.generators do |g| + g.assets false + g.helper false + g.test_framework :rspec, fixture: false if defined?(RSpec) + end + + # Sprockets / asset pipeline configuration + initializer "active_prompt.assets.precompile" do |app| + # When the engine is used within a host Rails app, ensure our assets are precompiled + if app.config.respond_to?(:assets) + app.config.assets.paths << root.join("app", "assets") + app.config.assets.precompile += %w[ + active_prompt/application.js + active_prompt/application.css + ] + end + end + + # Make sure the engine’s translations are available + initializer "active_prompt.i18n" do + config.i18n.load_path += Dir[root.join("config", "locales", "**", "*.yml")] + end + end +end diff --git a/lib/active_prompt/version.rb b/lib/active_prompt/version.rb new file mode 100644 index 00000000..2594fc06 --- /dev/null +++ b/lib/active_prompt/version.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ActivePrompt + VERSION = "0.1.0" +end diff --git a/test/active_prompt/asset_pipeline_test.rb b/test/active_prompt/asset_pipeline_test.rb new file mode 100644 index 00000000..646fde0a --- /dev/null +++ b/test/active_prompt/asset_pipeline_test.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptAssetPipelineTest < ActiveSupport::TestCase + def assets_enabled? + Rails.application.config.respond_to?(:assets) && Rails.application.config.assets + end + + test "adds engine assets path to host app (if assets enabled)" do + skip "Assets not enabled in host app" unless assets_enabled? + paths = Rails.application.config.assets.paths.map(&:to_s) + expected = ActivePrompt::Engine.root.join("app", "assets").to_s + assert_includes paths, expected + end + + test "adds engine assets to precompile list (if assets enabled)" do + skip "Assets not enabled in host app" unless assets_enabled? + precompile = Array(Rails.application.config.assets.precompile).map(&:to_s) + assert_includes precompile, "active_prompt/application.js" + assert_includes precompile, "active_prompt/application.css" + end +end diff --git a/test/active_prompt/engine_test.rb b/test/active_prompt/engine_test.rb new file mode 100644 index 00000000..bfcbc0fa --- /dev/null +++ b/test/active_prompt/engine_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptEngineTest < ActiveSupport::TestCase + test "engine constant is defined" do + assert defined?(ActivePrompt::Engine), "ActivePrompt::Engine should be defined" + end + + test "engine isolates namespace" do + assert ActivePrompt::Engine.isolated?, "Engine should isolate the ActivePrompt namespace" + end + + test "version is present and semantic" do + assert defined?(ActivePrompt::VERSION), "ActivePrompt::VERSION should be defined" + assert_match(/\A\d+\.\d+\.\d+\z/, ActivePrompt::VERSION) + end +end diff --git a/test/active_prompt/load_test.rb b/test/active_prompt/load_test.rb new file mode 100644 index 00000000..0f7a1f17 --- /dev/null +++ b/test/active_prompt/load_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptLoadTest < ActiveSupport::TestCase + test "requiring top-level file doesn't error" do + assert_nothing_raised do + require "active_prompt" + end + end +end diff --git a/test/active_prompt/routes_test.rb b/test/active_prompt/routes_test.rb new file mode 100644 index 00000000..8365693c --- /dev/null +++ b/test/active_prompt/routes_test.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptRoutesTest < ActionDispatch::IntegrationTest + test "engine mounted health endpoint responds ok" do + get "/active_prompt/health" + assert_response :success + assert_equal "ok", @response.body + end + + test "engine defines a named :health route with path /health" do + named = ActivePrompt::Engine.routes.named_routes + assert named.key?(:health), "Expected engine to define a :health named route" + + route = named[:health] + actual = route.path.spec.to_s.sub(/\(\.:format\)\z/, "") # <-- strip optional format + assert_equal "/health", actual + end +end diff --git a/test/dummy/config/application.rb b/test/dummy/config/application.rb index b1fafbad..0b853544 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -3,6 +3,8 @@ require "rails" # Pick the frameworks you want: require "active_model/railtie" +$LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) +require "active_prompt" require "active_job/railtie" require "active_record/railtie" require "active_storage/engine" @@ -34,5 +36,7 @@ class Application < Rails::Application # # config.time_zone = "Central Time (US & Canada)" # config.eager_load_paths << Rails.root.join("extras") + config.eager_load = false + config.secret_key_base = "test-secret-key-base-activeprompt" end end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 2cba1443..e3485836 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -11,4 +11,5 @@ # Defines the root path route ("/") # root "posts#index" + mount ActivePrompt::Engine => "/active_prompt", as: :active_prompt end diff --git a/test/test_helper.rb b/test/test_helper.rb index e1a86c9e..11e9a18a 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -13,6 +13,41 @@ require "vcr" require "minitest/mock" +# ------------------------------------------------------------------- +# 🔧 Test environment hygiene (prevents generator test collisions) +# - Clean any leftover generated files under tmp/generators so they +# don't get picked up by test discovery in subsequent runs. +# - Remove lingering constants that would cause class-collision checks +# to abort generator runs (e.g., UserAgentTest). +# ------------------------------------------------------------------- +begin + generated_dir = Rails.root.join("tmp", "generators") + FileUtils.rm_rf(generated_dir) +rescue => e + warn "Warning: failed to clean #{generated_dir}: #{e.message}" +end + + +# Helper to remove a constant by fully qualified name (supports namespaces) +def remove_constant(name) + names = name.to_s.split("::") + parent = Object + names[0..-2].each do |n| + return unless parent.const_defined?(n, false) + parent = parent.const_get(n) + end + last = names.last + parent.send(:remove_const, last) if parent.const_defined?(last, false) +end + +# Remove any lingering constants that the generator collision check might trip over +%w[ + UserAgentTest + Admin::UserAgentTest +].each { |const| remove_constant(const) } + +# ------------------------------------------------------------------- + # Extract full path and relative path from caller_info def extract_path_info(caller_info) if caller_info =~ /(.+):(\d+):in/ From ee54e0df73def3a73e93a63dae6bfac14b7acd5a Mon Sep 17 00:00:00 2001 From: Sarbada Date: Thu, 27 Nov 2025 21:51:23 +0545 Subject: [PATCH 2/2] =?UTF-8?q?feat(active=5Fprompt):=20add=20Solid=20Agen?= =?UTF-8?q?t=E2=80=93style=20persistence=20+=20install=20generator;=20make?= =?UTF-8?q?=20tests=20auto-migrate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ActiveRecord models: - app/models/active_prompt/prompt.rb - app/models/active_prompt/message.rb - app/models/active_prompt/action.rb - app/models/active_prompt/context.rb - Add agent DSL: - lib/active_agent/has_context.rb - enables: `has_context prompts: :prompts, messages: :messages, tools: :actions` - instance helpers: `add_prompt`, `remove_prompt` - Add engine migration (SQLite-friendly: uses `t.json`): - db/migrate/20251123000000_create_active_prompt_core.rb - Add install generator for host apps: - lib/generators/active_prompt/install/install_generator.rb - (optional) lib/active_prompt/railtie.rb + `require "active_prompt/railtie"` - Wire dummy app for verification: - test/dummy/app/models/application_agent.rb - test/dummy/db/migrate/20251123001000_create_application_agents.rb (uses `t.json`) - Tests: - test/active_prompt/models_test.rb (persistence + DSL smoke tests) - test/active_prompt/* (engine load, routes, assets) kept - test(active_prompt): add validations + message ordering coverage - Test boot: proactively migrate both dummy and engine paths: - test/test_helper.rb: construct MigrationContext with paths (no `connection.migration_context`) - still calls `ActiveRecord::Migration.maintain_test_schema!` Notes: - Accepts `rails g active_prompt:install` in host apps (or `railties:install:migrations FROM=active_prompt`). --- app/models/active_prompt/action.rb | 10 ++++ app/models/active_prompt/context.rb | 13 +++++ app/models/active_prompt/message.rb | 11 +++++ app/models/active_prompt/prompt.rb | 25 ++++++++++ ...0251127000000_create_active_prompt_core.rb | 44 +++++++++++++++++ .../json-and-auto-parses-JSON.md | 6 +-- .../plain-content-type.md | 6 +-- lib/active_agent/has_context.rb | 34 ++++++++++++++ .../install/install_generator.rb | 19 ++++++++ test/active_prompt/models_test.rb | 27 +++++++++++ test/active_prompt/ordering_test.rb | 12 +++++ test/active_prompt/validations_test.rb | 17 +++++++ test/dummy/app/models/application_agent.rb | 7 +++ ...0251127001000_create_application_agents.rb | 10 ++++ test/dummy/db/schema.rb | 39 ++++++++------- test/test_helper.rb | 47 ++++++++++++++++++- 16 files changed, 304 insertions(+), 23 deletions(-) create mode 100644 app/models/active_prompt/action.rb create mode 100644 app/models/active_prompt/context.rb create mode 100644 app/models/active_prompt/message.rb create mode 100644 app/models/active_prompt/prompt.rb create mode 100644 db/migrate/20251127000000_create_active_prompt_core.rb create mode 100644 lib/active_agent/has_context.rb create mode 100644 lib/generators/active_prompt/install/install_generator.rb create mode 100644 test/active_prompt/models_test.rb create mode 100644 test/active_prompt/ordering_test.rb create mode 100644 test/active_prompt/validations_test.rb create mode 100644 test/dummy/app/models/application_agent.rb create mode 100644 test/dummy/db/migrate/20251127001000_create_application_agents.rb diff --git a/app/models/active_prompt/action.rb b/app/models/active_prompt/action.rb new file mode 100644 index 00000000..c53c5b88 --- /dev/null +++ b/app/models/active_prompt/action.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +module ActivePrompt + class Action < ApplicationRecord + self.table_name = "active_prompt_actions" + + belongs_to :prompt, class_name: "ActivePrompt::Prompt", inverse_of: :actions + + validates :name, presence: true + end +end diff --git a/app/models/active_prompt/context.rb b/app/models/active_prompt/context.rb new file mode 100644 index 00000000..1584766f --- /dev/null +++ b/app/models/active_prompt/context.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module ActivePrompt + # Polymorphic join: attach prompts to any agent record + class Context < ApplicationRecord + self.table_name = "active_prompt_contexts" + + belongs_to :agent, polymorphic: true, inverse_of: :prompt_contexts + belongs_to :prompt, class_name: "ActivePrompt::Prompt", inverse_of: :contexts + + validates :agent, :prompt, presence: true + validates :label, length: { maximum: 255 }, allow_nil: true + end +end diff --git a/app/models/active_prompt/message.rb b/app/models/active_prompt/message.rb new file mode 100644 index 00000000..e253ef80 --- /dev/null +++ b/app/models/active_prompt/message.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module ActivePrompt + class Message < ApplicationRecord + self.table_name = "active_prompt_messages" + + belongs_to :prompt, class_name: "ActivePrompt::Prompt", inverse_of: :messages + + enum :role, { system: "system", user: "user", assistant: "assistant", tool: "tool" }, prefix: true + validates :role, :content, presence: true + end +end diff --git a/app/models/active_prompt/prompt.rb b/app/models/active_prompt/prompt.rb new file mode 100644 index 00000000..592f0da1 --- /dev/null +++ b/app/models/active_prompt/prompt.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true +module ActivePrompt + class Prompt < ApplicationRecord + self.table_name = "active_prompt_prompts" + + has_many :messages, class_name: "ActivePrompt::Message", dependent: :destroy, inverse_of: :prompt + has_many :actions, class_name: "ActivePrompt::Action", dependent: :destroy, inverse_of: :prompt + + has_many :contexts, class_name: "ActivePrompt::Context", dependent: :destroy, inverse_of: :prompt + has_many :agents, through: :contexts, source: :agent + + validates :name, presence: true + + def to_runtime + { + name: name, + description: description, + template: template, + messages: messages.order(:position).map(&:attributes), + actions: actions.map(&:attributes), + metadata: metadata || {} + } + end + end +end diff --git a/db/migrate/20251127000000_create_active_prompt_core.rb b/db/migrate/20251127000000_create_active_prompt_core.rb new file mode 100644 index 00000000..6ced3a6e --- /dev/null +++ b/db/migrate/20251127000000_create_active_prompt_core.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +class CreateActivePromptCore < ActiveRecord::Migration[7.0] + def change + create_table :active_prompt_prompts do |t| + t.string :name, null: false + t.text :description + t.json :metadata, null: false, default: {} + t.text :template + t.timestamps + end + add_index :active_prompt_prompts, :name + + create_table :active_prompt_messages do |t| + t.references :prompt, null: false, foreign_key: { to_table: :active_prompt_prompts } + t.string :role, null: false + t.text :content, null: false + t.integer :position + t.json :metadata, null: false, default: {} + t.timestamps + end + add_index :active_prompt_messages, [:prompt_id, :position] + + create_table :active_prompt_actions do |t| + t.references :prompt, null: false, foreign_key: { to_table: :active_prompt_prompts } + t.string :name, null: false + t.string :tool_name + t.json :parameters, null: false, default: {} + t.json :result, null: false, default: {} + t.string :status + t.timestamps + end + add_index :active_prompt_actions, [:prompt_id, :name] + + create_table :active_prompt_contexts do |t| + t.string :agent_type, null: false + t.bigint :agent_id, null: false + t.references :prompt, null: false, foreign_key: { to_table: :active_prompt_prompts } + t.string :label + t.json :metadata, null: false, default: {} + t.timestamps + end + add_index :active_prompt_contexts, [:agent_type, :agent_id, :prompt_id], unique: true, name: "idx_ap_contexts_agent_prompt" + end +end diff --git a/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md b/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md index fc01d544..40cd46e8 100644 --- a/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md @@ -4,15 +4,15 @@ ```ruby # Response object -# "John Doe", "age" => 30, "email" => "john@example.com"}, @role=:assistant> - @prompt=# + @prompt=# @content_type="application/json" @raw_response={...}> diff --git a/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md b/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md index b57c5c3b..71797adc 100644 --- a/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md @@ -4,15 +4,15 @@ ```ruby # Response object -# - @prompt=# + @prompt=# @content_type="text/plain" @raw_response={...}> diff --git a/lib/active_agent/has_context.rb b/lib/active_agent/has_context.rb new file mode 100644 index 00000000..3bbc04f0 --- /dev/null +++ b/lib/active_agent/has_context.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +module ActiveAgent + module HasContext + extend ActiveSupport::Concern + + class_methods do + # Example: + # has_context prompts: :prompts, messages: :messages, tools: :actions + # + # Associations added: + # has_many :prompt_contexts (ActivePrompt::Context, as: :agent) + # has_many :prompts, :messages, :actions (through prompt_contexts/prompts) + def has_context(prompts: :prompts, messages: :messages, tools: :actions) + has_many :prompt_contexts, + class_name: "ActivePrompt::Context", + as: :agent, + dependent: :destroy, + inverse_of: :agent + + has_many prompts, through: :prompt_contexts, source: :prompt + has_many messages, through: prompts, source: :messages + has_many tools, through: prompts, source: :actions + + define_method :add_prompt do |prompt, label: nil, metadata: {}| + ActivePrompt::Context.create!(agent: self, prompt:, label:, metadata:) + end + + define_method :remove_prompt do |prompt| + prompt_contexts.where(prompt:).destroy_all + end + end + end + end +end diff --git a/lib/generators/active_prompt/install/install_generator.rb b/lib/generators/active_prompt/install/install_generator.rb new file mode 100644 index 00000000..dc6e7d79 --- /dev/null +++ b/lib/generators/active_prompt/install/install_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true +require "rails/generators" +module ActivePrompt + module Generators + class InstallGenerator < Rails::Generators::Base + source_root File.expand_path("../../../../..", __dir__) # engine root + + desc "Copy ActivePrompt migrations into the host app" + + def copy_migrations + rake("railties:install:migrations FROM=active_prompt") + end + + def show_readme + say_status :info, "Run `bin/rails db:migrate` to apply ActivePrompt tables.", :blue + end + end + end +end diff --git a/test/active_prompt/models_test.rb b/test/active_prompt/models_test.rb new file mode 100644 index 00000000..510c285f --- /dev/null +++ b/test/active_prompt/models_test.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptModelsTest < ActiveSupport::TestCase + test "prompt with messages and actions persists" do + p = ActivePrompt::Prompt.create!(name: "translate", description: "Translate text") + p.messages.create!(role: :system, content: "You translate.", position: 0) + p.messages.create!(role: :user, content: "Hello", position: 1) + p.actions.create!(name: "glossary_lookup", tool_name: "glossary", parameters: { term: "Hello" }) + + assert_equal 2, p.messages.count + assert_equal 1, p.actions.count + end + + test "context attaches prompts to an agent" do + # Use test-only AR model to avoid name collision with non-AR ApplicationAgent + agent_class = ::PromptTestAgent + + agent = agent_class.create!(name: "Translator", config: {}) + prompt = ActivePrompt::Prompt.create!(name: "translate") + + agent.add_prompt(prompt, label: "default") + + assert_equal [prompt.id], agent.prompts.pluck(:id) + assert_equal 1, agent.prompt_contexts.count + end +end diff --git a/test/active_prompt/ordering_test.rb b/test/active_prompt/ordering_test.rb new file mode 100644 index 00000000..7b07c5d8 --- /dev/null +++ b/test/active_prompt/ordering_test.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptOrderingTest < ActiveSupport::TestCase + test "messages return in position order via to_runtime" do + p = ActivePrompt::Prompt.create!(name: "ordered") + p.messages.create!(role: :user, content: "B", position: 2) + p.messages.create!(role: :system, content: "A", position: 1) + order = p.to_runtime[:messages].map { |m| m["content"] } + assert_equal %w[A B], order + end +end diff --git a/test/active_prompt/validations_test.rb b/test/active_prompt/validations_test.rb new file mode 100644 index 00000000..8220b1b8 --- /dev/null +++ b/test/active_prompt/validations_test.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptValidationsTest < ActiveSupport::TestCase + test "prompt requires name" do + prompt = ActivePrompt::Prompt.new + refute prompt.valid? + assert_includes prompt.errors[:name], "can't be blank" + end + + test "message requires role and content" do + msg = ActivePrompt::Message.new + refute msg.valid? + assert_includes msg.errors[:role], "can't be blank" + assert_includes msg.errors[:content], "can't be blank" + end +end diff --git a/test/dummy/app/models/application_agent.rb b/test/dummy/app/models/application_agent.rb new file mode 100644 index 00000000..099b54cf --- /dev/null +++ b/test/dummy/app/models/application_agent.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class ApplicationAgent < ApplicationRecord + include ActiveAgent::HasContext + has_context prompts: :prompts, messages: :messages, tools: :actions + + validates :name, presence: true +end diff --git a/test/dummy/db/migrate/20251127001000_create_application_agents.rb b/test/dummy/db/migrate/20251127001000_create_application_agents.rb new file mode 100644 index 00000000..0be46c83 --- /dev/null +++ b/test/dummy/db/migrate/20251127001000_create_application_agents.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class CreateApplicationAgents < ActiveRecord::Migration[7.0] + def change + create_table :application_agents do |t| + t.string :name, null: false + t.json :config, null: false, default: {} + t.timestamps + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index d7cab59b..c60310c3 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,39 +10,46 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 3) do +ActiveRecord::Schema[8.0].define(version: 2025_11_27_001000) do + create_table "application_agents", force: :cascade do |t| + t.string "name", null: false + t.json "config", default: {}, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "posts", force: :cascade do |t| - t.string "title", null: false t.text "content" - t.integer "user_id" - t.boolean "published", default: false - t.datetime "published_at" t.datetime "created_at", null: false + t.boolean "published" + t.datetime "published_at" + t.string "title", null: false t.datetime "updated_at", null: false - t.index [ "published" ], name: "index_posts_on_published" - t.index [ "user_id" ], name: "index_posts_on_user_id" + t.integer "user_id" + t.index ["published"], name: "index_posts_on_published" + t.index ["user_id"], name: "index_posts_on_user_id" end create_table "profiles", force: :cascade do |t| - t.integer "user_id" t.text "bio" + t.datetime "created_at", null: false t.string "location" - t.string "website" t.json "social_links" - t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index [ "user_id" ], name: "index_profiles_on_user_id", unique: true + t.integer "user_id" + t.string "website" + t.index ["user_id"], name: "index_profiles_on_user_id", unique: true end create_table "users", force: :cascade do |t| - t.string "name", null: false - t.string "email", null: false + t.boolean "active" t.integer "age" - t.string "role", default: "user" - t.boolean "active", default: true t.datetime "created_at", null: false + t.string "email", null: false + t.string "name", null: false + t.string "role", default: "user" t.datetime "updated_at", null: false - t.index [ "email" ], name: "index_users_on_email", unique: true + t.index ["email"], name: "index_users_on_email", unique: true end add_foreign_key "posts", "users" diff --git a/test/test_helper.rb b/test/test_helper.rb index 11e9a18a..1b806102 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -8,7 +8,38 @@ require "jbuilder" require_relative "../test/dummy/config/environment" -ActiveRecord::Migrator.migrations_paths = [ File.expand_path("../test/dummy/db/migrate", __dir__) ] + +# Make sure BOTH dummy and engine migrations are available to the test DB +ActiveRecord::Migrator.migrations_paths = [ + File.expand_path("../test/dummy/db/migrate", __dir__), + File.expand_path("../db/migrate", __dir__) +] + +# Proactively migrate both dummy and engine paths (works across AR versions) +begin + require "active_record" + ActiveRecord::Schema.verbose = false + ActiveRecord::Base.establish_connection unless ActiveRecord::Base.connected? + + paths = ActiveRecord::Migrator.migrations_paths + + migration_context = + begin + # AR >= ~6 supports single-arg constructor + ActiveRecord::MigrationContext.new(paths) + rescue ArgumentError + # Older AR expects (paths, schema_migration) + ActiveRecord::MigrationContext.new(paths, ActiveRecord::SchemaMigration) + end + + migration_context.migrate +rescue ActiveRecord::NoDatabaseError + # If DB isn't created yet, ignore; the dummy app tasks will handle creation. +end + +# Rails still checks consistency after we migrate +ActiveRecord::Migration.maintain_test_schema! + require "rails/test_help" require "vcr" require "minitest/mock" @@ -46,6 +77,20 @@ def remove_constant(name) Admin::UserAgentTest ].each { |const| remove_constant(const) } +# ------------------------------------------------------------------- +# A tiny AR model just for tests, to avoid clashing with any non-AR ApplicationAgent +# Uses the dummy's application_agents table. +class PromptTestAgent < ActiveRecord::Base + self.table_name = "application_agents" + + begin + require "active_agent/has_context" + include ActiveAgent::HasContext + has_context prompts: :prompts, messages: :messages, tools: :actions + rescue LoadError, NameError + # If HasContext isn't present in this branch, tests that rely on it should be skipped or guarded. + end +end # ------------------------------------------------------------------- # Extract full path and relative path from caller_info