diff --git a/.gitignore b/.gitignore index 0b289be6..2b4724e7 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ docs/parts/examples/*.md Gemfile.lock gemfiles/*.lock test/dummy/config/master.key +endor/bundle \ No newline at end of file diff --git a/app/assets/config/active_prompt_manifest.js b/app/assets/config/active_prompt_manifest.js new file mode 100644 index 00000000..b58d2651 --- /dev/null +++ b/app/assets/config/active_prompt_manifest.js @@ -0,0 +1,2 @@ +//= link active_prompt/application.js +//= link active_prompt/application.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/controllers/active_prompt/health_controller.rb b/app/controllers/active_prompt/health_controller.rb new file mode 100644 index 00000000..a9ba9545 --- /dev/null +++ b/app/controllers/active_prompt/health_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ActivePrompt + class HealthController < ApplicationController + def show + render plain: "ok" + end + 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/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/application_record.rb b/app/models/active_prompt/application_record.rb new file mode 100644 index 00000000..926afac3 --- /dev/null +++ b/app/models/active_prompt/application_record.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivePrompt + class ApplicationRecord < ActiveRecord::Base + self.abstract_class = 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..98230e7d --- /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, %i[system user assistant 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..515c6a40 --- /dev/null +++ b/app/models/active_prompt/prompt.rb @@ -0,0 +1,27 @@ +# 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 + + scope :with_runtime_associations, -> { includes(:messages, :actions) } + + def to_runtime + { + name: name, + description: description, + template: template, + messages: messages.order(:position).as_json, + actions: actions.as_json, + metadata: metadata || {} + } + end + end +end diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 00000000..4022fe01 --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +ActivePrompt::Engine.routes.draw do + get "health", to: "health#show", as: :health +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..f9944883 --- /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], name: "idx_ap_messages_prompt_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], name: "idx_ap_actions_prompt_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 new file mode 100644 index 00000000..40cd46e8 --- /dev/null +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-structured-output-sets-content-type-to-application/json-and-auto-parses-JSON.md @@ -0,0 +1,21 @@ + +[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"}, + @role=:assistant> + @prompt=# + @content_type="application/json" + @raw_response={...}> + +# Message content +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 new file mode 100644 index 00000000..71797adc --- /dev/null +++ b/docs/parts/examples/structured-output-json-parsing-test.rb-test-without-structured-output-uses-text/plain-content-type.md @@ -0,0 +1,21 @@ + +[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=# + @content_type="text/plain" + @raw_response={...}> + +# Message content +response.message.content # => "The capital of France is Paris." +``` \ No newline at end of file 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/active_agent/providers/common/model.rb b/lib/active_agent/providers/common/model.rb index 0737d881..d9e96f70 100644 --- a/lib/active_agent/providers/common/model.rb +++ b/lib/active_agent/providers/common/model.rb @@ -352,10 +352,15 @@ def <=>(other) # model1 = Message.new(content: "Hello") # model2 = Message.new(content: "Hello") # model1 == model2 #=> true - def ==(other) - serialize == other&.serialize - end + def ==(other) + serialize == other&.serialize end end + + # Zeitwerk expects this file to define ActiveAgent::Providers::Common::Model + # based on its path. Provide an alias so autoloading succeeds while keeping + # the BaseModel name used throughout the codebase. + Model = BaseModel end end +end diff --git a/lib/active_agent/providers/openrouter_provider.rb b/lib/active_agent/providers/openrouter_provider.rb index 8662d753..74e91114 100644 --- a/lib/active_agent/providers/openrouter_provider.rb +++ b/lib/active_agent/providers/openrouter_provider.rb @@ -1,2 +1,9 @@ # OpenRouter, just copying OpenAI require_relative "open_router_provider" + +# Zeitwerk expects OpenrouterProvider from this file name. +module ActiveAgent + module Providers + OpenrouterProvider = OpenRouterProvider + end +end diff --git a/lib/active_agent/test_case.rb b/lib/active_agent/test_case.rb new file mode 100644 index 00000000..18868b5b --- /dev/null +++ b/lib/active_agent/test_case.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "active_support/test_case" + +module ActiveAgent + class TestCase < ActiveSupport::TestCase + # minimal base to satisfy Zeitwerk + 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/lib/generators/active_prompt/install/install_generator.rb b/lib/generators/active_prompt/install/install_generator.rb new file mode 100644 index 00000000..1159d8e6 --- /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/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/indexes_test.rb b/test/active_prompt/indexes_test.rb new file mode 100644 index 00000000..23e6b773 --- /dev/null +++ b/test/active_prompt/indexes_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +require "test_helper" + +class ActivePromptIndexesTest < ActiveSupport::TestCase + def test_messages_prompt_position_index_named + index_names = ActiveRecord::Base.connection.indexes(:active_prompt_messages).map(&:name) + assert_includes index_names, "idx_ap_messages_prompt_position" + end + + def test_actions_prompt_name_index_named + index_names = ActiveRecord::Base.connection.indexes(:active_prompt_actions).map(&:name) + assert_includes index_names, "idx_ap_actions_prompt_name" + 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/models_test.rb b/test/active_prompt/models_test.rb new file mode 100644 index 00000000..d2c13d8e --- /dev/null +++ b/test/active_prompt/models_test.rb @@ -0,0 +1,50 @@ +# 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 + + test "engine models inherit from ActivePrompt::ApplicationRecord" do + assert ActivePrompt::ApplicationRecord.abstract_class?, "ApplicationRecord should be abstract" + assert_equal ActivePrompt::ApplicationRecord, ActivePrompt::Prompt.superclass + assert_equal ActivePrompt::ApplicationRecord, ActivePrompt::Message.superclass + assert_equal ActivePrompt::ApplicationRecord, ActivePrompt::Action.superclass + assert_equal ActivePrompt::ApplicationRecord, ActivePrompt::Context.superclass + end + + test "prompt to_runtime returns eager-loadable hashes" do + prompt = ActivePrompt::Prompt.create!(name: "runtime") + prompt.messages.create!(role: :system, content: "You are helpful", position: 0) + prompt.actions.create!(name: "search", tool_name: "search", parameters: { q: "hello" }) + + runtime = ActivePrompt::Prompt.with_runtime_associations.find(prompt.id).to_runtime + + assert_equal "runtime", runtime[:name] + assert_equal 1, runtime[:messages].size + assert_equal "You are helpful", runtime[:messages].first["content"] + assert_equal 1, runtime[:actions].size + assert_equal "search", runtime[:actions].first["name"] + assert_equal({}, runtime[:metadata]) + 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/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/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/Gemfile.lock b/test/dummy/Gemfile.lock index ad51db1f..4423bd93 100644 --- a/test/dummy/Gemfile.lock +++ b/test/dummy/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../../.. specs: - activeagent (0.6.3) + activeagent (1.0.1) actionpack (>= 7.2, <= 9.0) actionview (>= 7.2, <= 9.0) activejob (>= 7.2, <= 9.0) @@ -102,6 +102,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + cgi (0.5.1) concurrent-ruby (1.3.5) connection_pool (2.5.4) crass (1.0.6) @@ -112,15 +113,6 @@ GEM drb (2.2.3) erb (5.1.1) erubi (1.13.1) - event_stream_parser (1.0.0) - faraday (2.14.0) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-multipart (1.1.1) - multipart-post (~> 2.0) - faraday-net_http (3.4.1) - net-http (>= 0.5.0) globalid (1.3.0) activesupport (>= 6.1) i18n (1.14.7) @@ -148,9 +140,6 @@ GEM mini_mime (1.1.5) minitest (5.26.0) msgpack (1.8.0) - multipart-post (2.4.1) - net-http (0.6.0) - uri net-imap (0.5.12) date net-protocol @@ -177,6 +166,10 @@ GEM racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-musl) racc (~> 1.4) + openai (0.43.0) + base64 + cgi + connection_pool pp (0.6.3) prettyprint prettyprint (0.2.0) @@ -234,10 +227,6 @@ GEM reline (0.6.2) io-console (~> 0.5) rexml (3.4.4) - ruby-openai (8.3.0) - event_stream_parser (>= 0.3.0, < 2.0.0) - faraday (>= 1) - faraday-multipart (>= 1) rubyzip (3.2.0) securerandom (0.4.1) selenium-webdriver (4.36.0) @@ -293,14 +282,14 @@ PLATFORMS DEPENDENCIES activeagent! - anthropic (~> 1.1) + anthropic (~> 1.12) bootsnap capybara debug jbuilder + openai (~> 0.34) puma (>= 5.0) rails (~> 8.0.2.1) - ruby-openai (~> 8.3) selenium-webdriver sqlite3 (>= 2.1) tzinfo-data 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/config/application.rb b/test/dummy/config/application.rb index b1fafbad..c2a66718 100644 --- a/test/dummy/config/application.rb +++ b/test/dummy/config/application.rb @@ -3,6 +3,7 @@ require "rails" # Pick the frameworks you want: require "active_model/railtie" +require_relative "../../../lib/active_prompt" require "active_job/railtie" require "active_record/railtie" require "active_storage/engine" @@ -34,5 +35,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/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..8b194b7c 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,14 @@ # # 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" @@ -19,8 +26,8 @@ t.datetime "published_at" t.datetime "created_at", 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.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| @@ -31,7 +38,7 @@ 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.index ["user_id"], name: "index_profiles_on_user_id", unique: true end create_table "users", force: :cascade do |t| @@ -42,7 +49,7 @@ t.boolean "active", default: true t.datetime "created_at", null: false 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/generators/active_prompt/install_generator_test.rb b/test/generators/active_prompt/install_generator_test.rb new file mode 100644 index 00000000..2d67a775 --- /dev/null +++ b/test/generators/active_prompt/install_generator_test.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "test_helper" +require "generators/active_prompt/install/install_generator" + +class ActivePrompt::Generators::InstallGeneratorTest < Rails::Generators::TestCase + tests ActivePrompt::Generators::InstallGenerator + destination Rails.root.join("tmp/generators") + setup :prepare_destination + + test "source_root points to engine root" do + assert_equal ActivePrompt::Engine.root.to_s, ActivePrompt::Generators::InstallGenerator.source_root + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 55047d33..27250111 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,6 +1,15 @@ # Configure Rails Environment ENV["RAILS_ENV"] = "test" +# Provide placeholder API keys so provider clients initialize without +# real credentials during cassette-driven tests. +ENV["OPENAI_API_KEY"] ||= "test-openai-key" +ENV["OPENAI_ACCESS_TOKEN"] ||= ENV["OPENAI_API_KEY"] +ENV["OPEN_AI_ACCESS_TOKEN"] ||= ENV["OPENAI_API_KEY"] +ENV["OPEN_ROUTER_ACCESS_TOKEN"] ||= "test-openrouter-key" +ENV["ANTHROPIC_API_KEY"] ||= "test-anthropic-key" +ENV["ANTHROPIC_ACCESS_TOKEN"] ||= ENV["ANTHROPIC_API_KEY"] + begin require "debug" require "pry" @@ -11,12 +20,92 @@ 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 "webmock/minitest" 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 StandardError => 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) } + +# ------------------------------------------------------------------- +# 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 def extract_path_info(caller_info) if caller_info =~ /(.+):(\d+):in/