From e3728fe314f0f65643a26b595c21986dd59646c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 09:18:09 +0200 Subject: [PATCH 01/13] First draft --- gemfiles/rails_7.1.gemfile.lock | 35 +++++++----- gemfiles/rails_7.2.gemfile.lock | 35 +++++++----- gemfiles/rails_8.0.gemfile.lock | 34 +++++++----- spec/ruby_llm/active_record/acts_as_spec.rb | 59 +++++++++++++++++++++ 4 files changed, 121 insertions(+), 42 deletions(-) diff --git a/gemfiles/rails_7.1.gemfile.lock b/gemfiles/rails_7.1.gemfile.lock index 6ec64bf9..0ebc4efc 100644 --- a/gemfiles/rails_7.1.gemfile.lock +++ b/gemfiles/rails_7.1.gemfile.lock @@ -98,7 +98,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -106,7 +106,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -129,10 +129,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -148,6 +148,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -185,7 +186,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -208,13 +209,13 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) mutex_m (0.3.0) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -224,6 +225,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -236,10 +239,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -248,7 +251,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.2.1) + rack (3.2.3) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -287,9 +290,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -307,7 +311,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -355,17 +359,19 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) timeout (0.4.3) traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) vcr (6.3.1) base64 webmock (3.25.1) @@ -380,6 +386,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -416,4 +423,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/gemfiles/rails_7.2.gemfile.lock b/gemfiles/rails_7.2.gemfile.lock index 5c672e34..56868ca2 100644 --- a/gemfiles/rails_7.2.gemfile.lock +++ b/gemfiles/rails_7.2.gemfile.lock @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -100,7 +100,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -123,10 +123,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -179,7 +180,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -202,12 +203,12 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -229,10 +232,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -241,7 +244,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.1.16) + rack (3.1.18) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -280,9 +283,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -300,7 +304,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -348,17 +352,19 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) timeout (0.4.3) traces (0.18.2) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) vcr (6.3.1) base64 @@ -374,6 +380,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -410,4 +417,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/gemfiles/rails_8.0.gemfile.lock b/gemfiles/rails_8.0.gemfile.lock index 86db7d4a..e8f48501 100644 --- a/gemfiles/rails_8.0.gemfile.lock +++ b/gemfiles/rails_8.0.gemfile.lock @@ -92,7 +92,7 @@ GEM rake thor (>= 0.14.0) ast (2.4.3) - async (2.32.0) + async (2.34.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -100,7 +100,7 @@ GEM traces (~> 0.18) base64 (0.3.0) benchmark (0.4.1) - bigdecimal (3.2.3) + bigdecimal (3.3.1) builder (3.3.0) childprocess (5.1.0) logger (~> 1.5) @@ -123,10 +123,10 @@ GEM docile (1.4.1) dotenv (3.1.8) drb (2.2.3) - erb (5.0.2) + erb (5.0.3) erubi (1.13.1) event_stream_parser (1.0.0) - faraday (2.13.4) + faraday (2.14.0) faraday-net_http (>= 2.0, < 3.5) json logger @@ -142,6 +142,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (~> 0.7) + ffi (1.17.2-arm64-darwin) ffi (1.17.2-x86_64-linux-gnu) fiber-annotation (0.2.0) fiber-local (1.1.0) @@ -179,7 +180,7 @@ GEM pp (>= 0.6.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - json (2.15.0) + json (2.15.1) json-schema (6.0.0) addressable (~> 2.8) bigdecimal (~> 3.1) @@ -202,12 +203,12 @@ GEM mini_magick (5.3.1) logger mini_mime (1.1.5) - minitest (5.25.5) + minitest (5.26.0) multi_json (1.17.0) multipart-post (2.4.1) net-http (0.6.0) uri - net-imap (0.5.10) + net-imap (0.5.12) date net-protocol net-pop (0.1.2) @@ -217,6 +218,8 @@ GEM net-smtp (0.5.1) net-protocol nio4r (2.7.4) + nokogiri (1.18.10-arm64-darwin) + racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) os (1.1.4) @@ -229,10 +232,10 @@ GEM ast (~> 2.4.1) racc path_expander (1.1.3) - pp (0.6.2) + pp (0.6.3) prettyprint prettyprint (0.2.0) - prism (1.5.1) + prism (1.5.2) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) @@ -241,7 +244,7 @@ GEM stringio public_suffix (6.0.2) racc (1.8.1) - rack (3.2.1) + rack (3.2.3) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -281,9 +284,10 @@ GEM zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.14.2) + rdoc (6.15.0) erb psych (>= 4.0.0) + tsort regexp_parser (2.11.3) reline (0.6.2) io-console (~> 0.5) @@ -301,7 +305,7 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-support (3.13.6) - rubocop (1.81.0) + rubocop (1.81.1) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -349,6 +353,7 @@ GEM simplecov (~> 0.19) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + sqlite3 (2.7.4-arm64-darwin) sqlite3 (2.7.4-x86_64-linux-gnu) stringio (3.1.7) thor (1.4.0) @@ -360,7 +365,7 @@ GEM unicode-display_width (3.2.0) unicode-emoji (~> 4.1) unicode-emoji (4.1.0) - uri (1.0.3) + uri (1.0.4) useragent (0.16.11) vcr (6.3.1) base64 @@ -376,6 +381,7 @@ GEM zeitwerk (2.7.3) PLATFORMS + arm64-darwin-24 x86_64-linux DEPENDENCIES @@ -412,4 +418,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.6.9 + 2.7.2 diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index bb8b3423..010f5c39 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -367,6 +367,65 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition end end + # Test for issue #425 - foreign key generation bug with namespaced models + describe 'namespaced models with explicit table_name' do + before(:all) do # rubocop:disable RSpec/BeforeAfterAll + # Reproduce issue where acts_as_chat generates wrong foreign key + # for namespaced models with explicit table_name + ActiveRecord::Migration.suppress_messages do + ActiveRecord::Migration.create_table :support_conversations, force: true do |t| + t.string :model_id + t.timestamps + end + + ActiveRecord::Migration.create_table :support_replies, force: true do |t| + t.references :conversation, null: false, foreign_key: { to_table: :support_conversations } + t.string :role + t.text :content + t.timestamps + end + end + end + + after(:all) do # rubocop:disable RSpec/BeforeAfterAll + ActiveRecord::Migration.suppress_messages do + if ActiveRecord::Base.connection.table_exists?(:support_replies) + ActiveRecord::Migration.drop_table :support_replies + end + if ActiveRecord::Base.connection.table_exists?(:support_conversations) + ActiveRecord::Migration.drop_table :support_conversations + end + end + end + + module Support # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + class Conversation < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + self.table_name = 'support_conversations' + acts_as_chat messages: :replies, message_class: 'Support::Reply' + end + + class Reply < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + self.table_name = 'support_replies' + acts_as_message chat: :conversation, chat_class: 'Support::Conversation' + end + end + + it 'generates foreign key from association name not table name' do + # Bug: acts_as_chat uses table_name.singularize -> support_conversation_id + # Fix: should use association name -> conversation_id + reflection = Support::Conversation.reflect_on_association(:replies) + expect(reflection.foreign_key).to eq('conversation_id') + end + + it 'creates messages with correct foreign key' do + conversation = Support::Conversation.create!(model: model) + + # Should use conversation_id (from association name), not support_conversation_id (from table_name) + expect { conversation.replies.create!(role: 'user', content: 'Test') }.not_to raise_error + expect(conversation.replies.count).to eq(1) + end + end + describe 'to_llm conversion' do it 'correctly converts custom messages to RubyLLM format' do bot_chat = Assistants::BotChat.create!(model: model) From 631daa193590accd1c8c487b629919856aa68cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 09:35:27 +0200 Subject: [PATCH 02/13] Remove unnecessary comments --- spec/ruby_llm/active_record/acts_as_spec.rb | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 010f5c39..0453577a 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -367,11 +367,8 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition end end - # Test for issue #425 - foreign key generation bug with namespaced models describe 'namespaced models with explicit table_name' do before(:all) do # rubocop:disable RSpec/BeforeAfterAll - # Reproduce issue where acts_as_chat generates wrong foreign key - # for namespaced models with explicit table_name ActiveRecord::Migration.suppress_messages do ActiveRecord::Migration.create_table :support_conversations, force: true do |t| t.string :model_id @@ -410,17 +407,14 @@ class Reply < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaratio end end - it 'generates foreign key from association name not table name' do - # Bug: acts_as_chat uses table_name.singularize -> support_conversation_id - # Fix: should use association name -> conversation_id + it 'uses association name for foreign key' do reflection = Support::Conversation.reflect_on_association(:replies) expect(reflection.foreign_key).to eq('conversation_id') end - it 'creates messages with correct foreign key' do + it 'creates messages successfully' do conversation = Support::Conversation.create!(model: model) - # Should use conversation_id (from association name), not support_conversation_id (from table_name) expect { conversation.replies.create!(role: 'user', content: 'Test') }.not_to raise_error expect(conversation.replies.count).to eq(1) end From c2a1af6cace6cad89075f12ad45442913ca648a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 11:51:42 +0200 Subject: [PATCH 03/13] Better match --- spec/ruby_llm/active_record/acts_as_spec.rb | 64 ++++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 0453577a..6f684269 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -367,18 +367,31 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition end end - describe 'namespaced models with explicit table_name' do + describe 'namespaced chat models with custom foreign keys' do before(:all) do # rubocop:disable RSpec/BeforeAfterAll + # Create additional tables for testing edge cases ActiveRecord::Migration.suppress_messages do - ActiveRecord::Migration.create_table :support_conversations, force: true do |t| + ActiveRecord::Migration.create_table :support_chats, force: true do |t| t.string :model_id t.timestamps end - ActiveRecord::Migration.create_table :support_replies, force: true do |t| - t.references :conversation, null: false, foreign_key: { to_table: :support_conversations } + ActiveRecord::Migration.create_table :support_messages, force: true do |t| + t.references :chat, foreign_key: { to_table: :support_chats } t.string :role t.text :content + t.string :model_id + t.integer :input_tokens + t.integer :output_tokens + t.references :tool_call, foreign_key: { to_table: :support_tool_calls } + t.timestamps + end + + ActiveRecord::Migration.create_table :support_tool_calls, force: true do |t| + t.references :message, foreign_key: { to_table: :support_messages } + t.string :tool_call_id + t.string :name + t.json :arguments t.timestamps end end @@ -386,37 +399,44 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition after(:all) do # rubocop:disable RSpec/BeforeAfterAll ActiveRecord::Migration.suppress_messages do - if ActiveRecord::Base.connection.table_exists?(:support_replies) - ActiveRecord::Migration.drop_table :support_replies - end - if ActiveRecord::Base.connection.table_exists?(:support_conversations) - ActiveRecord::Migration.drop_table :support_conversations + if ActiveRecord::Base.connection.table_exists?(:support_tool_calls) + ActiveRecord::Migration.drop_table :support_tool_calls end + ActiveRecord::Migration.drop_table :support_messages if ActiveRecord::Base.connection.table_exists?(:support_messages) + ActiveRecord::Migration.drop_table :support_chats if ActiveRecord::Base.connection.table_exists?(:support_chats) end end module Support # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration - class Conversation < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration - self.table_name = 'support_conversations' - acts_as_chat messages: :replies, message_class: 'Support::Reply' + class Chat < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + acts_as_chat message_class: 'Support::Message' end - class Reply < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration - self.table_name = 'support_replies' - acts_as_message chat: :conversation, chat_class: 'Support::Conversation' + class Message < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + acts_as_message chat_class: 'Support::Chat', tool_call_class: 'Support::ToolCall' end - end - it 'uses association name for foreign key' do - reflection = Support::Conversation.reflect_on_association(:replies) - expect(reflection.foreign_key).to eq('conversation_id') + class ToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + acts_as_tool_call message_class: 'Support::Message' + end end + # it 'works with namespaced classes and custom associations' do + # bot_chat = Assistants::BotChat.create!(model: model) + # bot_chat.ask("What's 2 + 2?") + + # expect(bot_chat.bot_messages.count).to eq(2) + # expect(bot_chat.bot_messages.first).to be_a(BotMessage) + # expect(bot_chat.bot_messages.first.role).to eq('user') + # expect(bot_chat.bot_messages.last.role).to eq('assistant') + # expect(bot_chat.bot_messages.last.content).to be_present + # end + it 'creates messages successfully' do - conversation = Support::Conversation.create!(model: model) + conversation = Support::Chat.create!(model: model) - expect { conversation.replies.create!(role: 'user', content: 'Test') }.not_to raise_error - expect(conversation.replies.count).to eq(1) + expect { conversation.messages.create!(role: 'user', content: 'Test') }.not_to raise_error + expect(conversation.messages.count).to eq(1) end end From d21faf735a0fef2e9f7533f3504f891ce933ae2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 12:04:34 +0200 Subject: [PATCH 04/13] Reproduce bug execatly --- spec/ruby_llm/active_record/acts_as_spec.rb | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 6f684269..eeff44ed 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -371,13 +371,13 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition before(:all) do # rubocop:disable RSpec/BeforeAfterAll # Create additional tables for testing edge cases ActiveRecord::Migration.suppress_messages do - ActiveRecord::Migration.create_table :support_chats, force: true do |t| + ActiveRecord::Migration.create_table :support_conversations, force: true do |t| t.string :model_id t.timestamps end ActiveRecord::Migration.create_table :support_messages, force: true do |t| - t.references :chat, foreign_key: { to_table: :support_chats } + t.references :chat, foreign_key: { to_table: :support_conversations } t.string :role t.text :content t.string :model_id @@ -403,17 +403,21 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition ActiveRecord::Migration.drop_table :support_tool_calls end ActiveRecord::Migration.drop_table :support_messages if ActiveRecord::Base.connection.table_exists?(:support_messages) - ActiveRecord::Migration.drop_table :support_chats if ActiveRecord::Base.connection.table_exists?(:support_chats) + ActiveRecord::Migration.drop_table :support_conversations if ActiveRecord::Base.connection.table_exists?(:support_conversations) end end module Support # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration - class Chat < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration + def self.table_name_prefix + "support_" + end + + class Conversation < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration acts_as_chat message_class: 'Support::Message' end class Message < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration - acts_as_message chat_class: 'Support::Chat', tool_call_class: 'Support::ToolCall' + acts_as_message chat: :conversation, chat_class: 'Support::Conversation', tool_call_class: 'Support::ToolCall' end class ToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration @@ -433,7 +437,7 @@ class ToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInB # end it 'creates messages successfully' do - conversation = Support::Chat.create!(model: model) + conversation = Support::Conversation.create!(model: model) expect { conversation.messages.create!(role: 'user', content: 'Test') }.not_to raise_error expect(conversation.messages.count).to eq(1) From 189890dd47f9d82abda08dea92fb10a4c89c0a3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 12:15:58 +0200 Subject: [PATCH 05/13] Fix typo --- spec/ruby_llm/active_record/acts_as_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index eeff44ed..f049b558 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -377,7 +377,7 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition end ActiveRecord::Migration.create_table :support_messages, force: true do |t| - t.references :chat, foreign_key: { to_table: :support_conversations } + t.references :conversation, foreign_key: { to_table: :support_conversations } t.string :role t.text :content t.string :model_id From 3ec6443866ef89f5eef65c46a38d26e0762f6d5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 13:08:28 +0200 Subject: [PATCH 06/13] Rubocop --- spec/ruby_llm/active_record/acts_as_spec.rb | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index f049b558..3a73176a 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -402,14 +402,18 @@ class BotToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinition if ActiveRecord::Base.connection.table_exists?(:support_tool_calls) ActiveRecord::Migration.drop_table :support_tool_calls end - ActiveRecord::Migration.drop_table :support_messages if ActiveRecord::Base.connection.table_exists?(:support_messages) - ActiveRecord::Migration.drop_table :support_conversations if ActiveRecord::Base.connection.table_exists?(:support_conversations) + if ActiveRecord::Base.connection.table_exists?(:support_messages) + ActiveRecord::Migration.drop_table :support_messages + end + if ActiveRecord::Base.connection.table_exists?(:support_conversations) + ActiveRecord::Migration.drop_table :support_conversations + end end end module Support # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration def self.table_name_prefix - "support_" + 'support_' end class Conversation < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration @@ -420,7 +424,7 @@ class Message < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclarat acts_as_message chat: :conversation, chat_class: 'Support::Conversation', tool_call_class: 'Support::ToolCall' end - class ToolCall < ActiveRecord::Base # rubocop:disable Lint/ConstantDefinitionInBlock,RSpec/LeakyConstantDeclaration + class ToolCall < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclaration acts_as_tool_call message_class: 'Support::Message' end end From 34b432259df905adfe81dfd97dcb8f5beca6e2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 15:21:57 +0200 Subject: [PATCH 07/13] Handle foreign keys correctly --- lib/ruby_llm/active_record/acts_as.rb | 34 +++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 40442755..22743427 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -31,8 +31,8 @@ def read_from_database end class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(messages: :messages, message_class: nil, - model: :model, model_class: nil) + def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, + model: :model, model_class: nil, model_foreign_key: nil) include RubyLLM::ActiveRecord::ChatMethods class_attribute :messages_association_name, :model_association_name, :message_class, :model_class @@ -45,12 +45,12 @@ def acts_as_chat(messages: :messages, message_class: nil, has_many messages, -> { order(created_at: :asc) }, class_name: self.message_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: messages_foreign_key, dependent: :destroy belongs_to model, class_name: self.model_class, - foreign_key: ActiveSupport::Inflector.foreign_key(model.to_s.singularize), + foreign_key: model_foreign_key, optional: true delegate :add_message, to: :to_llm @@ -68,7 +68,7 @@ def acts_as_chat(messages: :messages, message_class: nil, end end - def acts_as_model(chats: :chats, chat_class: nil) + def acts_as_model(chats: :chats, chat_class: nil, chats_foreign_key: nil) include RubyLLM::ActiveRecord::ModelMethods class_attribute :chats_association_name, :chat_class @@ -80,18 +80,16 @@ def acts_as_model(chats: :chats, chat_class: nil) validates :provider, presence: true validates :name, presence: true - has_many chats, - class_name: self.chat_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize) + has_many chats, class_name: self.chat_class, foreign_key: chats_foreign_key define_method :chats_association do send(chats_association_name) end end - def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists - tool_calls: :tool_calls, tool_call_class: nil, - model: :model, model_class: nil) + def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_chat: false, # rubocop:disable Metrics/ParameterLists + tool_calls: :tool_calls, tool_call_class: nil, tool_calls_foreign_key: nil, + model: :model, model_class: nil, model_foreign_key: nil) include RubyLLM::ActiveRecord::MessageMethods class_attribute :chat_association_name, :tool_calls_association_name, :model_association_name, @@ -106,12 +104,12 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d belongs_to chat, class_name: self.chat_class, - foreign_key: ActiveSupport::Inflector.foreign_key(chat.to_s.singularize), + foreign_key: chat_foreign_key, touch: touch_chat has_many tool_calls, class_name: self.tool_call_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: tool_calls_foreign_key, dependent: :destroy belongs_to :parent_tool_call, @@ -126,7 +124,7 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d belongs_to model, class_name: self.model_class, - foreign_key: ActiveSupport::Inflector.foreign_key(model.to_s.singularize), + foreign_key: model_foreign_key, optional: true delegate :tool_call?, :tool_result?, to: :to_llm @@ -144,8 +142,8 @@ def acts_as_message(chat: :chat, chat_class: nil, touch_chat: false, # rubocop:d end end - def acts_as_tool_call(message: :message, message_class: nil, - result: :result, result_class: nil) + def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, + result: :result, result_class: nil, result_foreign_key: nil) class_attribute :message_association_name, :result_association_name, :message_class, :result_class self.message_association_name = message @@ -155,11 +153,11 @@ def acts_as_tool_call(message: :message, message_class: nil, belongs_to message, class_name: self.message_class, - foreign_key: ActiveSupport::Inflector.foreign_key(message.to_s.singularize) + foreign_key: message_foreign_key has_one result, class_name: self.result_class, - foreign_key: ActiveSupport::Inflector.foreign_key(table_name.singularize), + foreign_key: result_foreign_key, dependent: :nullify define_method :message_association do From 362b470068013b5aad06f8a310b5e89b5bd9407c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 15:51:11 +0200 Subject: [PATCH 08/13] Fix foreign_key values --- lib/generators/ruby_llm/generator_helpers.rb | 34 +++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index cea5da71..8149bc00 100644 --- a/lib/generators/ruby_llm/generator_helpers.rb +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -52,8 +52,10 @@ def parse_model_mappings def acts_as_chat_declaration params = [] - add_association_params(params, :messages, message_table_name, message_model_name, plural: true) - add_association_params(params, :model, model_table_name, model_model_name) + add_association_params(params, :messages, message_table_name, message_model_name, + owner_table: chat_table_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name, + owner_table: chat_table_name) "acts_as_chat#{" #{params.join(', ')}" if params.any?}" end @@ -61,9 +63,12 @@ def acts_as_chat_declaration def acts_as_message_declaration params = [] - add_association_params(params, :chat, chat_table_name, chat_model_name) - add_association_params(params, :tool_calls, tool_call_table_name, tool_call_model_name, plural: true) - add_association_params(params, :model, model_table_name, model_model_name) + add_association_params(params, :chat, chat_table_name, chat_model_name, + owner_table: message_table_name) + add_association_params(params, :tool_calls, tool_call_table_name, tool_call_model_name, + owner_table: message_table_name, plural: true) + add_association_params(params, :model, model_table_name, model_model_name, + owner_table: message_table_name) "acts_as_message#{" #{params.join(', ')}" if params.any?}" end @@ -71,7 +76,8 @@ def acts_as_message_declaration def acts_as_model_declaration params = [] - add_association_params(params, :chats, chat_table_name, chat_model_name, plural: true) + add_association_params(params, :chats, chat_table_name, chat_model_name, + owner_table: model_table_name, plural: true) "acts_as_model#{" #{params.join(', ')}" if params.any?}" end @@ -79,7 +85,8 @@ def acts_as_model_declaration def acts_as_tool_call_declaration params = [] - add_association_params(params, :message, message_table_name, message_model_name) + add_association_params(params, :message, message_table_name, message_model_name, + owner_table: tool_call_table_name) "acts_as_tool_call#{" #{params.join(', ')}" if params.any?}" end @@ -128,13 +135,24 @@ def table_exists?(table_name) private - def add_association_params(params, default_assoc, table_name, model_name, plural: false) + def add_association_params(params, default_assoc, table_name, model_name, owner_table:, plural: false) assoc = plural ? table_name.to_sym : table_name.singularize.to_sym + # For has_many/has_one: foreign key is on the associated table pointing back to owner + # For belongs_to: foreign key is on the owner table pointing to associated table + if plural || default_assoc.to_s.pluralize == default_assoc.to_s # has_many or has_one + foreign_key = "#{owner_table.singularize}_id" + default_foreign_key = "#{default_assoc.to_s}_id" + else # belongs_to + foreign_key = "#{table_name.singularize}_id" + default_foreign_key = "#{default_assoc.to_s}_id" + end + return if assoc == default_assoc params << "#{default_assoc}: :#{assoc}" params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != assoc.to_s.classify + params << "#{default_assoc.to_s}_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key end # Convert namespaced model names to proper table names From a3da7367fba5daac206e6058680c989ce6ff8e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 16:00:00 +0200 Subject: [PATCH 09/13] Simplify --- lib/generators/ruby_llm/generator_helpers.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index 8149bc00..5caef133 100644 --- a/lib/generators/ruby_llm/generator_helpers.rb +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -148,9 +148,7 @@ def add_association_params(params, default_assoc, table_name, model_name, owner_ default_foreign_key = "#{default_assoc.to_s}_id" end - return if assoc == default_assoc - - params << "#{default_assoc}: :#{assoc}" + params << "#{default_assoc}: :#{assoc}" if assoc != default_assoc params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != assoc.to_s.classify params << "#{default_assoc.to_s}_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key end From 5f3aecbbd9535774598146c3c85e23c1b7ee8f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 16:01:42 +0200 Subject: [PATCH 10/13] Refactor --- lib/generators/ruby_llm/generator_helpers.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index 5caef133..1902408a 100644 --- a/lib/generators/ruby_llm/generator_helpers.rb +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -138,14 +138,13 @@ def table_exists?(table_name) def add_association_params(params, default_assoc, table_name, model_name, owner_table:, plural: false) assoc = plural ? table_name.to_sym : table_name.singularize.to_sym - # For has_many/has_one: foreign key is on the associated table pointing back to owner - # For belongs_to: foreign key is on the owner table pointing to associated table + default_foreign_key = "#{default_assoc.to_s}_id" + # has_many/has_one: foreign key is on the associated table pointing back to owner + # belongs_to: foreign key is on the owner table pointing to associated table if plural || default_assoc.to_s.pluralize == default_assoc.to_s # has_many or has_one foreign_key = "#{owner_table.singularize}_id" - default_foreign_key = "#{default_assoc.to_s}_id" else # belongs_to foreign_key = "#{table_name.singularize}_id" - default_foreign_key = "#{default_assoc.to_s}_id" end params << "#{default_assoc}: :#{assoc}" if assoc != default_assoc From 4a2a30cbe383203b6cc1e424aa51593197661e03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 16:04:14 +0200 Subject: [PATCH 11/13] Linting --- lib/generators/ruby_llm/generator_helpers.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/generators/ruby_llm/generator_helpers.rb b/lib/generators/ruby_llm/generator_helpers.rb index 1902408a..d00bb2f9 100644 --- a/lib/generators/ruby_llm/generator_helpers.rb +++ b/lib/generators/ruby_llm/generator_helpers.rb @@ -135,21 +135,21 @@ def table_exists?(table_name) private - def add_association_params(params, default_assoc, table_name, model_name, owner_table:, plural: false) + def add_association_params(params, default_assoc, table_name, model_name, owner_table:, plural: false) # rubocop:disable Metrics/ParameterLists assoc = plural ? table_name.to_sym : table_name.singularize.to_sym - default_foreign_key = "#{default_assoc.to_s}_id" + default_foreign_key = "#{default_assoc}_id" # has_many/has_one: foreign key is on the associated table pointing back to owner # belongs_to: foreign key is on the owner table pointing to associated table - if plural || default_assoc.to_s.pluralize == default_assoc.to_s # has_many or has_one - foreign_key = "#{owner_table.singularize}_id" - else # belongs_to - foreign_key = "#{table_name.singularize}_id" - end + foreign_key = if plural || default_assoc.to_s.pluralize == default_assoc.to_s # has_many or has_one + "#{owner_table.singularize}_id" + else # belongs_to + "#{table_name.singularize}_id" + end params << "#{default_assoc}: :#{assoc}" if assoc != default_assoc params << "#{default_assoc.to_s.singularize}_class: '#{model_name}'" if model_name != assoc.to_s.classify - params << "#{default_assoc.to_s}_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key + params << "#{default_assoc}_foreign_key: :#{foreign_key}" if foreign_key != default_foreign_key end # Convert namespaced model names to proper table names From 92e4b1e3a6938b088b975a94a25d82ed49cbf18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 16:05:43 +0200 Subject: [PATCH 12/13] Linting --- lib/ruby_llm/active_record/acts_as.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/ruby_llm/active_record/acts_as.rb b/lib/ruby_llm/active_record/acts_as.rb index 22743427..f1688960 100644 --- a/lib/ruby_llm/active_record/acts_as.rb +++ b/lib/ruby_llm/active_record/acts_as.rb @@ -31,7 +31,7 @@ def read_from_database end class_methods do # rubocop:disable Metrics/BlockLength - def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, + def acts_as_chat(messages: :messages, message_class: nil, messages_foreign_key: nil, # rubocop:disable Metrics/ParameterLists model: :model, model_class: nil, model_foreign_key: nil) include RubyLLM::ActiveRecord::ChatMethods @@ -142,7 +142,7 @@ def acts_as_message(chat: :chat, chat_class: nil, chat_foreign_key: nil, touch_c end end - def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, + def acts_as_tool_call(message: :message, message_class: nil, message_foreign_key: nil, # rubocop:disable Metrics/ParameterLists result: :result, result_class: nil, result_foreign_key: nil) class_attribute :message_association_name, :result_association_name, :message_class, :result_class From 1240926d7670abf030cbe52863ae34fe4f62bd85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Lilleb=C3=B8?= Date: Fri, 10 Oct 2025 16:12:30 +0200 Subject: [PATCH 13/13] Remove old comment --- spec/ruby_llm/active_record/acts_as_spec.rb | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 3a73176a..adce2a68 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -429,17 +429,6 @@ class ToolCall < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclara end end - # it 'works with namespaced classes and custom associations' do - # bot_chat = Assistants::BotChat.create!(model: model) - # bot_chat.ask("What's 2 + 2?") - - # expect(bot_chat.bot_messages.count).to eq(2) - # expect(bot_chat.bot_messages.first).to be_a(BotMessage) - # expect(bot_chat.bot_messages.first.role).to eq('user') - # expect(bot_chat.bot_messages.last.role).to eq('assistant') - # expect(bot_chat.bot_messages.last.content).to be_present - # end - it 'creates messages successfully' do conversation = Support::Conversation.create!(model: model)