This adds a `captain_sessions` table to log every Captain run, starting with Assistant Responses and Copilot Responses. Each session records the assistant, model, credits consumed, the FAQs/documents/scenario that contributed to the response, and the full run context — giving customers visibility into how a response was generated and giving us durable stats on credit, FAQ, and document usage (which today only exist as ephemeral trace metadata and an aggregate account counter). ## What changed - New `Captain::Session` model with a `session_type` enum (`assistant`, `copilot`). The subject (`Conversation` / `CopilotThread`) and result (`Message` / `CopilotMessage`) classes are inferred from the session type, so the table stores plain `subject_id` / `result_id` ids. `result_id` is nullable so failed runs that still consumed credits can be logged. - Composite indexes on `[session_type, subject_id]`, `[session_type, result_id]`, and `[account_id, session_type, created_at]` for lookup and usage-stats queries. - Factory and model specs. This PR is schema + model only; the writer/instrumentation that records sessions from the assistant and copilot flows will follow. --------- Co-authored-by: Sony Mathew <sony@chatwoot.com>
161 lines
6.6 KiB
Ruby
161 lines
6.6 KiB
Ruby
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
|