feat: capture captain sessions for v2 assistant responses [CW-7485] (#14971)
Records a `Captain::Session` row for every Captain V2 assistant response delivered in a conversation, so we can show how a response was generated and report on credit, FAQ, and document usage. Stacked on #14970 (the `captain_sessions` model). ## What changed - `FaqLookupTool` now records the retrieved FAQ ids (and their backing document ids) into the shared run state, accumulated across tool calls. - `AgentRunnerService` exposes the raw ai-agents run result via `last_run_result`; the `generate_response` return shape is unchanged, so the playground path is unaffected. - New `Captain::Assistant::SessionCaptureService` builds the session: scenario resolved from the answering agent name, model from `assistant.agent_model`, token usage plus the trimmed current-turn conversation history stored in `run_context`. - `ResponseBuilderJob` captures after delivery: `credits_consumed` mirrors the actual charge (1.0 for a billed response, 0.0 for handoffs, where the session points at the customer-facing handoff message). Capture runs outside the delivery transaction and swallows its own failures, so a logging bug can never block or roll back a customer reply. V1 responses and copilot are out of scope; copilot capture comes next. ## How to test On an account with `captain_integration_v2` enabled and an inbox connected to an assistant with approved FAQs, send a customer message on a pending conversation. After the assistant replies, a `Captain::Session` row should exist with the conversation as subject, the reply message as result, the FAQs/documents used, and the run context for that turn. Asking for a human agent should produce a zero-credit session pointing at the handoff message. <img width="2428" height="1058" alt="CleanShot 2026-07-15 at 17 25 40@2x" src="https://github.com/user-attachments/assets/d8e44923-c17b-494f-8c33-c8fa4219438c" />
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
module Captain::Conversation::MessageBuilder
|
||||
private
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @assistant,
|
||||
content: message_content,
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -1,6 +1,7 @@
|
||||
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
include Captain::Conversation::V1ActionClassifier
|
||||
include Captain::Conversation::V1FalsePromiseHandler
|
||||
include Captain::Conversation::MessageBuilder
|
||||
|
||||
MAX_MESSAGE_LENGTH = 10_000
|
||||
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
|
||||
@@ -44,9 +45,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages_with_resolution_markers
|
||||
)
|
||||
runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation)
|
||||
message_history = Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
@response = runner_service.generate_response(message_history: message_history)
|
||||
@run_result = runner_service.last_run_result
|
||||
|
||||
process_response
|
||||
end
|
||||
|
||||
@@ -65,6 +68,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
# left is the customer-facing follow-up message.
|
||||
process_v2_handoff
|
||||
end
|
||||
capture_assistant_session(result_message: @handoff_message, credits_consumed: 0.0)
|
||||
elsif v1_handoff_requested?
|
||||
# V1 only signals via the response string — no state has been touched yet. If
|
||||
# the conversation isn't pending anymore, a human took over mid-run; bail out
|
||||
@@ -73,44 +77,16 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
process_v1_handoff
|
||||
elsif conversation_pending?
|
||||
message = nil
|
||||
ActiveRecord::Base.transaction do
|
||||
create_messages
|
||||
message = create_messages
|
||||
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
|
||||
account.increment_response_usage
|
||||
end
|
||||
capture_assistant_session(result_message: message, credits_consumed: 1.0)
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages
|
||||
@conversation
|
||||
.messages
|
||||
.where(message_type: [:incoming, :outgoing])
|
||||
.where(private: false)
|
||||
.map do |message|
|
||||
message_hash = {
|
||||
content: prepare_multimodal_message_content(message),
|
||||
role: determine_role(message)
|
||||
}
|
||||
|
||||
# Include agent_name if present in additional_attributes
|
||||
message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present?
|
||||
|
||||
message_hash
|
||||
end
|
||||
end
|
||||
|
||||
def collect_previous_messages_with_resolution_markers
|
||||
Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform
|
||||
end
|
||||
|
||||
def determine_role(message)
|
||||
message.message_type == 'incoming' ? 'user' : 'assistant'
|
||||
end
|
||||
|
||||
def prepare_multimodal_message_content(message)
|
||||
Captain::OpenAiMessageBuilderService.new(message: message).generate_content
|
||||
end
|
||||
|
||||
def v1_handoff_requested?
|
||||
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
|
||||
end
|
||||
@@ -157,34 +133,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def create_handoff_message(preserve_waiting_since: false)
|
||||
create_outgoing_message(
|
||||
@handoff_message = create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff'),
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
end
|
||||
|
||||
def create_messages
|
||||
validate_message_content!(@response['response'])
|
||||
create_outgoing_message(@response['response'], agent_name: @response['agent_name'])
|
||||
end
|
||||
|
||||
def validate_message_content!(content)
|
||||
raise ArgumentError, 'Message content cannot be blank' if content.blank?
|
||||
end
|
||||
|
||||
def create_outgoing_message(message_content, agent_name: nil, preserve_waiting_since: false)
|
||||
additional_attrs = {}
|
||||
additional_attrs[:agent_name] = agent_name if agent_name.present?
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @assistant,
|
||||
content: message_content,
|
||||
additional_attributes: additional_attrs,
|
||||
preserve_waiting_since: preserve_waiting_since
|
||||
)
|
||||
# Capture runs outside the delivery transaction and never raises (the service
|
||||
# swallows its own failures): a session-logging bug must never roll back the
|
||||
# customer reply or trigger the top-level handle_error handoff on top of it.
|
||||
def capture_assistant_session(result_message:, credits_consumed:)
|
||||
Captain::Assistant::SessionCaptureService.new(assistant: @assistant, conversation: @conversation, run_result: @run_result,
|
||||
result_message: result_message, credits_consumed: credits_consumed).capture
|
||||
end
|
||||
|
||||
def handle_error(error)
|
||||
|
||||
@@ -31,6 +31,13 @@ module Concerns::Agentable
|
||||
Captain::PromptRenderer.render(template_name, enhanced_context.with_indifferent_access)
|
||||
end
|
||||
|
||||
def agent_model
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
|
||||
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def agent_name
|
||||
@@ -45,13 +52,6 @@ module Concerns::Agentable
|
||||
[] # Default implementation, override if needed
|
||||
end
|
||||
|
||||
def agent_model
|
||||
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
|
||||
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
|
||||
|
||||
installation_model.presence || route[:model]
|
||||
end
|
||||
|
||||
def installation_model
|
||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
|
||||
end
|
||||
|
||||
@@ -5,20 +5,10 @@ class Captain::Assistant::AgentRunnerService
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
include Captain::Assistant::RunnerCallbacksHelper
|
||||
include Captain::Assistant::TracePayloadHelper
|
||||
include Captain::Assistant::RunnerStateHelper
|
||||
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
attr_reader :last_run_result
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@@ -29,9 +19,9 @@ class Captain::Assistant::AgentRunnerService
|
||||
|
||||
def generate_response(message_history: [])
|
||||
message_to_process, context = run_payload(message_history)
|
||||
result = runner.run(message_to_process, context: context, max_turns: 10)
|
||||
@last_run_result = runner.run(message_to_process, context: context, max_turns: 10)
|
||||
|
||||
process_agent_result(result)
|
||||
process_agent_result(@last_run_result)
|
||||
rescue StandardError => e
|
||||
# In rake/local runs, conversation may not be present, so account is optional here.
|
||||
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
|
||||
@@ -111,31 +101,6 @@ class Captain::Assistant::AgentRunnerService
|
||||
}
|
||||
end
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config,
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
|
||||
def build_and_wire_agents
|
||||
assistant_agent = @assistant.agent
|
||||
scenario_agents = @assistant.scenarios.enabled.map(&:agent)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
module Captain::Assistant::RunnerStateHelper
|
||||
CONVERSATION_STATE_ATTRIBUTES = %i[
|
||||
id display_id inbox_id contact_id status priority
|
||||
label_list custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_STATE_ATTRIBUTES = %i[
|
||||
id name email phone_number identifier contact_type
|
||||
custom_attributes additional_attributes
|
||||
].freeze
|
||||
|
||||
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
|
||||
|
||||
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
|
||||
|
||||
private
|
||||
|
||||
def build_state
|
||||
state = {
|
||||
account_id: @assistant.account_id,
|
||||
assistant_id: @assistant.id,
|
||||
assistant_config: @assistant.config,
|
||||
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
|
||||
}
|
||||
state[:source] = @source if @source.present?
|
||||
|
||||
build_conversation_state(state) if @conversation
|
||||
state
|
||||
end
|
||||
|
||||
def build_conversation_state(state)
|
||||
state[:conversation] = slice_attrs(@conversation, CONVERSATION_STATE_ATTRIBUTES)
|
||||
state[:channel_type] = @conversation.inbox&.channel_type
|
||||
state[:contact] = slice_attrs(@conversation.contact, CONTACT_STATE_ATTRIBUTES) if @conversation.contact
|
||||
state[:campaign] = slice_attrs(@conversation.campaign, CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
|
||||
state[:contact_inbox] = slice_attrs(@conversation.contact_inbox, CONTACT_INBOX_STATE_ATTRIBUTES) if @conversation.contact_inbox
|
||||
end
|
||||
|
||||
def slice_attrs(record, keys)
|
||||
record.attributes.symbolize_keys.slice(*keys)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,64 @@
|
||||
class Captain::Assistant::SessionCaptureService
|
||||
SCENARIO_AGENT_REGEX = /\A#{Captain::Scenario::HANDOFF_KEY_PREFIX}_(\d+)_/
|
||||
|
||||
def initialize(assistant:, conversation:, run_result:, result_message:, credits_consumed:)
|
||||
@assistant = assistant
|
||||
@conversation = conversation
|
||||
@run_result = run_result
|
||||
@result_message = result_message
|
||||
@credits_consumed = credits_consumed
|
||||
end
|
||||
|
||||
def capture
|
||||
# TODO: Capture failed runs once error-session semantics are defined. For now,
|
||||
# only successful runs that produce a customer-facing reply or handoff are recorded.
|
||||
return unless @run_result&.success?
|
||||
|
||||
capture!
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @assistant.account).capture_exception
|
||||
Rails.logger.error("[CAPTAIN][SessionCaptureService] Capture failed for conversation=#{@conversation.display_id}: #{e.message}")
|
||||
end
|
||||
|
||||
def capture!
|
||||
model = @assistant.agent_model
|
||||
metadata = context.dig(:state, :cw_metadata) || {}
|
||||
|
||||
Captain::AgentSession.create!(
|
||||
assistant: @assistant,
|
||||
session_type: :assistant,
|
||||
subject: @conversation,
|
||||
result: @result_message,
|
||||
llm_model: "#{Llm::Models.provider_for(model)}-#{model}",
|
||||
credits_consumed: @credits_consumed,
|
||||
faq_ids: metadata[:faq_ids] || [],
|
||||
document_ids: metadata[:document_ids] || [],
|
||||
scenario_ids: scenario_ids,
|
||||
run_context: current_turn_history
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def context
|
||||
@run_result.context || {}
|
||||
end
|
||||
|
||||
def scenario_ids
|
||||
ids = current_turn_history.filter_map do |message|
|
||||
next unless message[:role].to_s == 'assistant'
|
||||
|
||||
message[:agent_name].to_s.match(SCENARIO_AGENT_REGEX)&.[](1)&.to_i
|
||||
end.uniq
|
||||
|
||||
ids & @assistant.scenarios.where(id: ids).pluck(:id)
|
||||
end
|
||||
|
||||
# Trim to the current turn: the last user message and everything after it
|
||||
# (assistant replies, tool calls/results, handoff hops).
|
||||
def current_turn_history
|
||||
history = Array(context[:conversation_history])
|
||||
last_user_index = history.rindex { |message| message[:role].to_s == 'user' }
|
||||
last_user_index ? history[last_user_index..] : history
|
||||
end
|
||||
end
|
||||
@@ -2,11 +2,12 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
description 'Search FAQ responses using semantic similarity to find relevant answers'
|
||||
param :query, type: 'string', desc: 'The question or topic to search for in the FAQ database'
|
||||
|
||||
def perform(_tool_context, query:)
|
||||
def perform(tool_context, query:)
|
||||
log_tool_usage('searching', { query: query })
|
||||
|
||||
# Use existing vector search on approved responses
|
||||
responses = @assistant.responses.approved.search(query).to_a
|
||||
record_retrieved_sources(tool_context, responses)
|
||||
|
||||
if responses.empty?
|
||||
log_tool_usage('no_results', { query: query })
|
||||
@@ -19,6 +20,16 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
|
||||
|
||||
private
|
||||
|
||||
def record_retrieved_sources(tool_context, responses)
|
||||
return if responses.empty?
|
||||
|
||||
metadata = tool_context.state[:cw_metadata] ||= {}
|
||||
metadata[:faq_ids] = Array(metadata[:faq_ids]) | responses.map(&:id)
|
||||
|
||||
document_ids = responses.filter_map { |response| response.documentable_id if response.documentable_type == 'Captain::Document' }
|
||||
metadata[:document_ids] = Array(metadata[:document_ids]) | document_ids
|
||||
end
|
||||
|
||||
def format_responses(responses)
|
||||
responses.map { |response| format_response(response) }.join
|
||||
end
|
||||
|
||||
@@ -22,6 +22,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
|
||||
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(nil)
|
||||
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
|
||||
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
|
||||
allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
|
||||
@@ -72,6 +73,12 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
|
||||
end
|
||||
|
||||
it 'does not create a captain session' do
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'does not run the action classifier when the classifier feature is disabled' do
|
||||
expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
|
||||
|
||||
@@ -490,6 +497,98 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when capturing assistant sessions' do
|
||||
let(:run_context) do
|
||||
{
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
current_agent: 'Assistant',
|
||||
turn_count: 1,
|
||||
conversation_history: [
|
||||
{ role: :user, content: 'Hello' },
|
||||
{ role: :assistant, content: 'Hey, welcome to Captain V2', agent_name: 'Assistant' }
|
||||
],
|
||||
state: { cw_metadata: { faq_ids: [7, 9], document_ids: [3] } }
|
||||
}
|
||||
end
|
||||
let(:usage) do
|
||||
Agents::RunContext::Usage.new.tap do |u|
|
||||
u.input_tokens = 100
|
||||
u.output_tokens = 20
|
||||
u.total_tokens = 120
|
||||
end
|
||||
end
|
||||
let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Hey, welcome to Captain V2' }, usage: usage, context: run_context) }
|
||||
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
|
||||
allow(mock_agent_runner_service).to receive(:last_run_result).and_return(run_result)
|
||||
end
|
||||
|
||||
it 'creates a session for a delivered response' do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session).to have_attributes(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
subject_id: conversation.id,
|
||||
subject_type: 'Conversation',
|
||||
result_id: conversation.messages.outgoing.last.id,
|
||||
result_type: 'Message',
|
||||
llm_model: 'openai-gpt-5.2',
|
||||
credits_consumed: 1.0,
|
||||
faq_ids: [7, 9],
|
||||
document_ids: [3],
|
||||
scenario_ids: [],
|
||||
user_id: nil
|
||||
)
|
||||
expect(session).to be_session_assistant
|
||||
expect(session.run_context.first).to include('role' => 'user', 'content' => 'Hello')
|
||||
end
|
||||
|
||||
it 'creates a zero-credit session when the handoff tool fired' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response) do
|
||||
conversation.update!(status: :open)
|
||||
{ 'response' => 'Let me connect you', 'handoff_tool_called' => true }
|
||||
end
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session.credits_consumed).to eq(0.0)
|
||||
expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id)
|
||||
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
|
||||
end
|
||||
|
||||
it 'creates a zero-credit session when the handoff tool fired but failed to commit' do
|
||||
allow(mock_agent_runner_service).to receive(:generate_response).and_return({
|
||||
'response' => 'I tried to hand off',
|
||||
'handoff_tool_called' => true
|
||||
})
|
||||
|
||||
described_class.perform_now(conversation, assistant)
|
||||
|
||||
session = Captain::AgentSession.last
|
||||
expect(session.credits_consumed).to eq(0.0)
|
||||
expect(session.result_id).to eq(conversation.messages.outgoing.where(private: false).last.id)
|
||||
end
|
||||
|
||||
it 'still delivers the reply when session capture fails' do
|
||||
allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect do
|
||||
described_class.perform_now(conversation, assistant)
|
||||
end.not_to raise_error
|
||||
|
||||
expect(conversation.messages.outgoing.count).to eq(1)
|
||||
expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain V2')
|
||||
expect(conversation.reload.status).to eq('pending')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
# Regression (PR #13417): wrapping create_handoff_message and bot_handoff! in the
|
||||
# same transaction defers the message's after_create_commit until commit, at which
|
||||
# point it clears waiting_since (bot_response). The handoff path must stay outside
|
||||
|
||||
@@ -80,6 +80,21 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
end
|
||||
|
||||
it 'records retrieved faq ids and document ids into Chatwoot metadata' do
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
|
||||
expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id)
|
||||
expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id)
|
||||
end
|
||||
|
||||
it 'accumulates unique ids across multiple calls' do
|
||||
tool.perform(tool_context, query: 'password reset')
|
||||
tool.perform(tool_context, query: 'password reset again')
|
||||
|
||||
expect(tool_context.state.dig(:cw_metadata, :faq_ids)).to contain_exactly(response1.id, response2.id)
|
||||
expect(tool_context.state.dig(:cw_metadata, :document_ids)).to contain_exactly(document.id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when no FAQs found' do
|
||||
@@ -99,6 +114,12 @@ RSpec.describe Captain::Tools::FaqLookupTool, type: :model do
|
||||
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
end
|
||||
|
||||
it 'leaves shared state untouched' do
|
||||
tool.perform(tool_context, query: 'nonexistent topic')
|
||||
|
||||
expect(tool_context.state).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank query' do
|
||||
|
||||
@@ -170,6 +170,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
expect(result).to eq({ 'response' => 'Test response', 'agent_name' => nil, 'handoff_tool_called' => false })
|
||||
end
|
||||
|
||||
it 'exposes the raw run result via last_run_result' do
|
||||
service.generate_response(message_history: message_history)
|
||||
|
||||
expect(service.last_run_result).to eq(mock_result)
|
||||
end
|
||||
|
||||
context 'when handoff tool was called during agent execution' do
|
||||
let(:runner_context) { { captain_v2_handoff_tool_called: true } }
|
||||
let(:mock_result) do
|
||||
@@ -246,6 +252,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
|
||||
service.generate_response(message_history: message_history)
|
||||
end
|
||||
|
||||
it 'leaves last_run_result nil' do
|
||||
service.generate_response(message_history: message_history)
|
||||
|
||||
expect(service.last_run_result).to be_nil
|
||||
end
|
||||
|
||||
context 'when conversation is nil' do
|
||||
subject(:service) { described_class.new(assistant: assistant, conversation: nil) }
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Assistant::SessionCaptureService do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:result_message) { create(:message, account: account, conversation: conversation) }
|
||||
|
||||
let(:usage) do
|
||||
Agents::RunContext::Usage.new.tap do |u|
|
||||
u.input_tokens = 120
|
||||
u.output_tokens = 40
|
||||
u.total_tokens = 160
|
||||
end
|
||||
end
|
||||
|
||||
let(:conversation_history) do
|
||||
[
|
||||
{ role: :user, content: 'Hi, my internet is not working' },
|
||||
{ role: :assistant, content: 'Let me check', agent_name: 'Assistant' },
|
||||
{ role: :user, content: 'CUST001' },
|
||||
{ role: :assistant, content: '', agent_name: 'Assistant', tool_calls: [{ 'id' => 'call_1', 'name' => 'faq_lookup' }] },
|
||||
{ role: :tool, content: 'Restart the modem', tool_call_id: 'call_1' },
|
||||
{ role: :assistant, content: 'Please restart your modem', agent_name: 'Assistant' }
|
||||
]
|
||||
end
|
||||
|
||||
let(:run_context) do
|
||||
{
|
||||
session_id: "#{account.id}_#{conversation.display_id}",
|
||||
current_agent: 'Assistant',
|
||||
turn_count: 2,
|
||||
conversation_history: conversation_history,
|
||||
state: { cw_metadata: { faq_ids: [11, 12], document_ids: [5] } }
|
||||
}
|
||||
end
|
||||
|
||||
let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Please restart your modem' }, usage: usage, context: run_context) }
|
||||
|
||||
let(:service) do
|
||||
described_class.new(
|
||||
assistant: assistant,
|
||||
conversation: conversation,
|
||||
run_result: run_result,
|
||||
result_message: result_message,
|
||||
credits_consumed: 1.0
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(assistant).to receive(:agent_model).and_return('gpt-5.2')
|
||||
end
|
||||
|
||||
describe '#capture' do
|
||||
it 'creates the session' do
|
||||
expect { service.capture }.to change(Captain::AgentSession, :count).by(1)
|
||||
end
|
||||
|
||||
it 'does nothing when there is no run result' do
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: nil,
|
||||
result_message: result_message, credits_consumed: 1.0
|
||||
)
|
||||
|
||||
expect { service.capture }.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'does nothing when the run failed' do
|
||||
failed_result = Agents::RunResult.new(output: nil, error: StandardError.new('run failed'), context: run_context, usage: usage)
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: failed_result,
|
||||
result_message: nil, credits_consumed: 0.0
|
||||
)
|
||||
|
||||
expect { service.capture }.not_to change(Captain::AgentSession, :count)
|
||||
end
|
||||
|
||||
it 'reports failures without raising' do
|
||||
allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect { service.capture }.not_to raise_error
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#capture!' do
|
||||
it 'creates an assistant session with all attributes' do
|
||||
session = service.capture!
|
||||
|
||||
expect(session).to have_attributes(
|
||||
account_id: account.id,
|
||||
assistant_id: assistant.id,
|
||||
subject_id: conversation.id,
|
||||
subject_type: 'Conversation',
|
||||
result_id: result_message.id,
|
||||
result_type: 'Message',
|
||||
llm_model: 'openai-gpt-5.2',
|
||||
credits_consumed: 1.0,
|
||||
faq_ids: [11, 12],
|
||||
document_ids: [5],
|
||||
scenario_ids: [],
|
||||
user_id: nil
|
||||
)
|
||||
expect(session).to be_session_assistant
|
||||
end
|
||||
|
||||
it 'stores the trimmed current turn in run_context' do
|
||||
history = service.capture!.run_context
|
||||
expect(history.size).to eq(4)
|
||||
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
|
||||
end
|
||||
|
||||
it 'stores the full history when it contains no user message' do
|
||||
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
|
||||
|
||||
history = service.capture!.run_context
|
||||
|
||||
expect(history.size).to eq(4)
|
||||
end
|
||||
|
||||
it 'handles a successful run result without context or usage' do
|
||||
run_result = Agents::RunResult.new(output: { 'response' => 'Hello' })
|
||||
service = described_class.new(
|
||||
assistant: assistant, conversation: conversation, run_result: run_result,
|
||||
result_message: result_message, credits_consumed: 1.0
|
||||
)
|
||||
|
||||
session = service.capture!
|
||||
|
||||
expect(session.result).to eq(result_message)
|
||||
expect(session.faq_ids).to eq([])
|
||||
expect(session.document_ids).to eq([])
|
||||
expect(session.run_context).to eq([])
|
||||
end
|
||||
|
||||
it 'extracts every scenario that authored a message in the current turn' do
|
||||
first_scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
second_scenario = create(:captain_scenario, assistant: assistant, account: account)
|
||||
run_context[:conversation_history] = [
|
||||
{ role: :user, content: 'Help with my refund' },
|
||||
{ role: :assistant, content: '', agent_name: first_scenario.handoff_key, tool_calls: [] },
|
||||
{ role: :assistant, content: 'Checking', agent_name: second_scenario.handoff_key },
|
||||
{ role: :assistant, content: 'Done', agent_name: first_scenario.handoff_key }
|
||||
]
|
||||
run_context[:current_agent] = 'Assistant'
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([first_scenario.id, second_scenario.id])
|
||||
end
|
||||
|
||||
it 'leaves scenario ids empty for the primary assistant agent' do
|
||||
run_context[:current_agent] = 'Assistant'
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([])
|
||||
end
|
||||
|
||||
it 'does not capture a scenario belonging to another assistant' do
|
||||
scenario = create(:captain_scenario, assistant: create(:captain_assistant, account: account), account: account)
|
||||
run_context[:conversation_history] = [
|
||||
{ role: :user, content: 'Help' },
|
||||
{ role: :assistant, content: 'No', agent_name: scenario.handoff_key }
|
||||
]
|
||||
|
||||
expect(service.capture!.scenario_ids).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user