diff --git a/db/migrate/20260709091147_create_agent_sessions.rb b/db/migrate/20260709091147_create_agent_sessions.rb new file mode 100644 index 000000000..a2e3e9f0f --- /dev/null +++ b/db/migrate/20260709091147_create_agent_sessions.rb @@ -0,0 +1,24 @@ +class CreateAgentSessions < ActiveRecord::Migration[7.1] + def change + create_table :agent_sessions do |t| + t.integer :session_type, null: false + t.references :subject, polymorphic: true, null: false, index: false + t.references :result, polymorphic: true, index: false + t.references :account, null: false, index: true + t.references :assistant, null: false, index: true + t.references :user, index: true + t.string :llm_model + t.float :credits_consumed + t.jsonb :faq_ids, default: [] + t.jsonb :document_ids, default: [] + t.jsonb :scenario_ids, default: [] + t.jsonb :run_context, default: {} + + t.timestamps + end + + add_index :agent_sessions, [:account_id, :session_type, :created_at] + add_index :agent_sessions, [:account_id, :subject_type, :subject_id] + add_index :agent_sessions, [:account_id, :result_type, :result_id] + end +end diff --git a/db/schema.rb b/db/schema.rb index e01dc34c1..f02b613d9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id" end + create_table "agent_sessions", force: :cascade do |t| + t.integer "session_type", null: false + t.string "subject_type", null: false + t.bigint "subject_id", null: false + t.string "result_type" + t.bigint "result_id" + t.bigint "account_id", null: false + t.bigint "assistant_id", null: false + t.bigint "user_id" + t.string "llm_model" + t.float "credits_consumed" + t.jsonb "faq_ids", default: [] + t.jsonb "document_ids", default: [] + t.jsonb "scenario_ids", default: [] + t.jsonb "run_context", default: {} + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7" + t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e" + t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d" + t.index ["account_id"], name: "index_agent_sessions_on_account_id" + t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id" + t.index ["user_id"], name: "index_agent_sessions_on_user_id" + end + create_table "applied_slas", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "sla_policy_id", null: false diff --git a/enterprise/app/models/captain/agent_session.rb b/enterprise/app/models/captain/agent_session.rb new file mode 100644 index 000000000..d02dffcab --- /dev/null +++ b/enterprise/app/models/captain/agent_session.rb @@ -0,0 +1,86 @@ +# == Schema Information +# +# Table name: agent_sessions +# +# id :bigint not null, primary key +# credits_consumed :float +# document_ids :jsonb +# faq_ids :jsonb +# llm_model :string +# result_type :string +# run_context :jsonb +# scenario_ids :jsonb +# session_type :integer not null +# subject_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null +# assistant_id :bigint not null +# result_id :bigint +# subject_id :bigint not null +# user_id :bigint +# +# Indexes +# +# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id) +# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at) +# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id) +# index_agent_sessions_on_account_id (account_id) +# index_agent_sessions_on_assistant_id (assistant_id) +# index_agent_sessions_on_user_id (user_id) +# +class Captain::AgentSession < ApplicationRecord + self.table_name = 'agent_sessions' + + SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze + RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze + + belongs_to :account + belongs_to :assistant, class_name: 'Captain::Assistant' + belongs_to :user, optional: true + belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true + belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true + + enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session + + before_validation :ensure_account + + validate :subject_type_matches_session_type + validate :result_type_matches_session_type, if: -> { result_type.present? } + validate :subject_belongs_to_account + validate :result_belongs_to_account, if: -> { result_id.present? } + + private + + def ensure_account + self.account = assistant&.account + end + + def subject_type_matches_session_type + expected_type = SUBJECT_TYPES[session_type] + return if subject_type == expected_type + + errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions") + end + + def result_type_matches_session_type + expected_type = RESULT_TYPES[session_type] + return if result_type == expected_type + + errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions") + end + + def subject_belongs_to_account + return if subject.nil? || subject.account_id == account_id + + errors.add(:subject, 'must belong to the session account') + end + + def result_belongs_to_account + target_class = result_type.safe_constantize + actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id) + return if actual_account_id == account_id + + errors.add(:result, 'must belong to the session account') + end +end diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index d3f6cda8a..b3134e2f2 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -37,6 +37,7 @@ class Captain::Assistant < ApplicationRecord has_many :messages, as: :sender, dependent: :nullify has_many :copilot_threads, dependent: :destroy_async has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async + has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..1f5376940 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document' has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool' + has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession' has_many :copilot_threads, dependent: :destroy_async has_many :companies, dependent: :destroy_async diff --git a/spec/enterprise/models/captain/agent_session_spec.rb b/spec/enterprise/models/captain/agent_session_spec.rb new file mode 100644 index 000000000..b4306a11e --- /dev/null +++ b/spec/enterprise/models/captain/agent_session_spec.rb @@ -0,0 +1,160 @@ +require 'rails_helper' + +RSpec.describe Captain::AgentSession, type: :model do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + describe 'associations' do + it { is_expected.to belong_to(:account) } + it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') } + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:subject) } + it { is_expected.to belong_to(:result).optional } + end + + describe 'enums' do + it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) } + end + + describe '#subject' do + it 'returns the conversation for an assistant session' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + + expect(session.subject).to eq(conversation) + end + + it 'returns the copilot thread for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread) + + expect(session.subject).to eq(copilot_thread) + end + + it 'returns nil when the subject record no longer exists' do + conversation = create(:conversation, account: account) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation) + conversation.destroy + + expect(session.reload.subject).to be_nil + end + + it 'is not valid when the subject type does not match the session type' do + copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread) + + expect(session).not_to be_valid + expect(session.errors[:subject_type]).to be_present + end + + it 'is not valid when the subject belongs to a different account' do + foreign_conversation = create(:conversation, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation) + + expect(session).not_to be_valid + expect(session.errors[:subject]).to be_present + end + end + + describe '#result' do + it 'returns the message for an assistant session' do + conversation = create(:conversation, account: account) + message = create(:message, account: account, conversation: conversation) + session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message) + + expect(session.result).to eq(message) + end + + it 'returns the copilot message for a copilot session' do + user = create(:user, account: account) + copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant) + copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread) + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, + subject: copilot_thread, result: copilot_message) + + expect(session.result).to eq(copilot_message) + end + + it 'returns nil when result_id is nil' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.result).to be_nil + end + + it 'is not valid when the result belongs to a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message) + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a different account' do + conversation = create(:conversation, account: account) + foreign_message = create(:message, account: create(:account)) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: foreign_message.id, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + + it 'is not valid when result_id/result_type are set directly for a stale id' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, + result_id: 0, result_type: 'Message') + + expect(session).not_to be_valid + expect(session.errors[:result]).to be_present + end + end + + describe 'account' do + it 'is derived from the assistant when created via the assistant association' do + conversation = create(:conversation, account: account) + session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant) + + expect(session.account).to eq(account) + end + + it 'overrides a mismatched explicit account with the assistant account' do + conversation = create(:conversation, account: account) + session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation) + + expect(session).to be_valid + expect(session.account).to eq(account) + end + end + + describe 'defaults' do + it 'defaults faq_ids, document_ids, scenario_ids and run_context' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session.faq_ids).to eq([]) + expect(session.document_ids).to eq([]) + expect(session.scenario_ids).to eq([]) + expect(session.run_context).to eq({}) + end + end + + describe 'factory' do + it 'builds a valid assistant session' do + session = create(:captain_agent_session, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_assistant + expect(session.subject).to be_a(Conversation) + end + + it 'builds a valid copilot session' do + session = create(:captain_agent_session, :copilot, account: account, assistant: assistant) + + expect(session).to be_valid + expect(session).to be_session_copilot + expect(session.subject).to be_a(CopilotThread) + expect(session.user).to be_present + end + end +end diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb new file mode 100644 index 000000000..a7b369b7d --- /dev/null +++ b/spec/factories/captain/agent_session.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :captain_agent_session, class: 'Captain::AgentSession' do + account + association :assistant, factory: :captain_assistant + session_type { :assistant } + subject { create(:conversation, account: account) } + + trait :copilot do + session_type { :copilot } + user + subject { create(:captain_copilot_thread, account: account, user: user) } + end + end +end