Compare commits

..
Author SHA1 Message Date
aakashb95 5cdd2bc14c fix(captain): mark discarded generations 2026-07-22 23:55:13 +05:30
aakashb95 8c6b70670c fix(captain): discard stale v2 responses 2026-07-22 23:21:16 +05:30
43 changed files with 279 additions and 136 deletions
+1 -2
View File
@@ -142,8 +142,7 @@ class ConversationFinder
conversation_ids = current_account.mentions.where(user: current_user).pluck(:conversation_id)
@conversations = @conversations.where(id: conversation_ids)
when 'participating'
participating_ids = current_user.participating_conversations.where(account_id: current_account.id).select(:id)
@conversations = @conversations.where(id: participating_ids)
@conversations = current_user.participating_conversations.where(account_id: current_account.id)
when 'unattended'
@conversations = @conversations.unattended
end
@@ -33,7 +33,9 @@ import {
// constants
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { REPLY_POLICY } from 'shared/constants/links';
import wootConstants from 'dashboard/constants/globals';
import wootConstants, {
META_RESTRICTION_STATUS_URL,
} from 'dashboard/constants/globals';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
@@ -93,6 +95,7 @@ export default {
currentUserId: 'getCurrentUserID',
listLoadingStatus: 'getAllMessagesLoaded',
currentAccountId: 'getCurrentAccountId',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
isOpen() {
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
@@ -170,6 +173,13 @@ export default {
instagramInbox
);
},
isInstagramRestrictionBannerVisible() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
instagramRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
replyWindowBannerMessage() {
if (this.isAWhatsAppChannel) {
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
@@ -454,7 +464,15 @@ export default {
>
<div ref="topBannerRef">
<Banner
v-if="!currentChat.can_reply"
v-if="isInstagramRestrictionBannerVisible"
color-scheme="warning"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="$t('CONVERSATION.INSTAGRAM_RESTRICTION_BANNER')"
:href-link="instagramRestrictionStatusUrl"
:href-link-text="$t('CONVERSATION.INSTAGRAM_RESTRICTION_STATUS_LINK')"
/>
<Banner
v-else-if="!currentChat.can_reply"
color-scheme="alert"
class="mx-2 mt-2 overflow-hidden rounded-lg"
:banner-message="replyWindowBannerMessage"
@@ -78,3 +78,5 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
export const META_RESTRICTION_STATUS_URL =
'https://status.chatwoot.com/incident/948346';
@@ -44,6 +44,8 @@
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
"INSTAGRAM_RESTRICTION_BANNER": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"INSTAGRAM_RESTRICTION_STATUS_LINK": "View status update",
"REPLYING_TO": "You are replying to:",
"REMOVE_SELECTION": "Remove Selection",
"DOWNLOAD": "Download",
@@ -58,7 +58,9 @@
"ERROR_MESSAGE": "There was an error connecting to Instagram, please try again",
"ERROR_AUTH": "There was an error connecting to Instagram, please try again",
"NEW_INBOX_SUGGESTION": "This Instagram account was previously linked to a different inbox and has now been migrated here. All new messages will appear here. The old inbox will no longer be able to send or receive messages for this account.",
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore."
"DUPLICATE_INBOX_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. You won’t be able to send/receive Instagram messages from this inbox anymore.",
"SETTINGS_RESTRICTED_WARNING": "Instagram is currently restricted. Some messages or actions may be delayed or unavailable while we restore full support.",
"STATUS_LINK": "View status update"
},
"TIKTOK": {
"CONTINUE_WITH_TIKTOK": "Continue with TikTok",
@@ -4,6 +4,8 @@ import { shouldBeUrl } from 'shared/helpers/Validators';
import { useAlert } from 'dashboard/composables';
import { useVuelidate } from '@vuelidate/core';
import Avatar from 'next/avatar/Avatar.vue';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
@@ -44,9 +46,11 @@ import SelectInput from 'dashboard/components-next/select/Select.vue';
import Widget from 'dashboard/modules/widget-preview/components/Widget.vue';
import AccessToken from 'dashboard/routes/dashboard/settings/profile/AccessToken.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { META_RESTRICTION_STATUS_URL } from 'dashboard/constants/globals';
export default {
components: {
Banner,
BotConfiguration,
CollaboratorsPage,
ConfigurationPage,
@@ -80,6 +84,7 @@ export default {
WhatsappManualMigrationBanner,
Widget,
AccessToken,
Icon,
},
mixins: [inboxMixin],
setup() {
@@ -343,6 +348,12 @@ export default {
instagramUnauthorized() {
return this.isAnInstagramChannel && this.inbox.reauthorization_required;
},
showInstagramRestrictionSettingsBanner() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
metaRestrictionStatusUrl() {
return META_RESTRICTION_STATUS_URL;
},
tiktokUnauthorized() {
return this.isATiktokChannel && this.inbox.reauthorization_required;
},
@@ -809,6 +820,29 @@ export default {
:class="bannerMaxWidth"
@start="openWhatsAppManualMigrationDialog"
/>
<Banner
v-if="showInstagramRestrictionSettingsBanner"
color="amber"
class="mx-6 mb-4 max-w-4xl"
>
<div class="flex items-start gap-3 text-start">
<Icon
icon="i-lucide-triangle-alert"
class="flex-shrink-0 size-4 mt-0.5"
/>
<span>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.SETTINGS_RESTRICTED_WARNING') }}
<a
:href="metaRestrictionStatusUrl"
class="link underline"
rel="noopener noreferrer nofollow"
target="_blank"
>
{{ $t('INBOX_MGMT.ADD.INSTAGRAM.STATUS_LINK') }}
</a>
</span>
</div>
</Banner>
<div
v-if="selectedTabKey === 'inbox-settings'"
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::AssistantResponsesController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :metrics, :faq_stats, :summary, :drilldown]
@@ -1,13 +0,0 @@
class Api::V1::Accounts::Captain::BaseController < Api::V1::Accounts::BaseController
before_action :ensure_captain_enabled
private
# Captain is a premium feature; block API access when neither the v1 nor v2 flag is enabled for the account.
# `current_account` resolves and memoizes the account, so this does not depend on before_action ordering.
def ensure_captain_enabled
return if current_account.feature_enabled?('captain_integration') || current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
end
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :validate_params
before_action :type_matches?
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::CopilotMessagesController < Api::V1::Accounts::BaseController
before_action :set_copilot_thread
def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::CopilotThreadsController < Api::V1::Accounts::BaseController
before_action :ensure_message, only: :create
def index
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_current_page, only: [:index]
@@ -1,4 +1,4 @@
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::Captain::BaseController
class Api::V1::Accounts::Captain::InboxesController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant
@@ -1,5 +1,4 @@
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Captain::BaseController
before_action :ensure_captain_v2_enabled
class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::BaseController
before_action -> { check_authorization(Captain::Scenario) }
before_action :set_assistant
before_action :set_scenario, only: [:show, :update, :destroy]
@@ -25,13 +24,6 @@ class Api::V1::Accounts::Captain::ScenariosController < Api::V1::Accounts::Capta
private
# Scenarios are a Captain v2-only feature; the shared base guard allows either flag, so require v2 here.
def ensure_captain_v2_enabled
return if current_account.feature_enabled?('captain_integration_v2')
render json: { error: 'Captain is not enabled for this account' }, status: :forbidden
end
def set_assistant
@assistant = account_assistants.find(params[:assistant_id])
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_custom_roles_feature_enabled
before_action :fetch_custom_role, only: [:show, :update, :destroy]
before_action :check_authorization
@@ -29,8 +28,4 @@ class Api::V1::Accounts::CustomRolesController < Api::V1::Accounts::EnterpriseAc
def fetch_custom_role
@custom_role = Current.account.custom_roles.find_by(id: params[:id])
end
def ensure_custom_roles_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('custom_roles')
end
end
@@ -1,5 +1,4 @@
class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
before_action :ensure_sla_feature_enabled
before_action :fetch_sla, only: [:show, :update, :destroy]
before_action :check_authorization
@@ -30,8 +29,4 @@ class Api::V1::Accounts::SlaPoliciesController < Api::V1::Accounts::EnterpriseAc
def fetch_sla
@sla_policy = Current.account.sla_policies.find_by(id: params[:id])
end
def ensure_sla_feature_enabled
raise Pundit::NotAuthorizedError unless Current.account.feature_enabled?('sla')
end
end
@@ -14,9 +14,6 @@ module Enterprise::Api::V1::Accounts::AgentsController
private
def associate_agent_with_custom_role
# Custom roles are a premium feature; ignore custom_role_id assignment when the feature is disabled.
return unless Current.account.feature_enabled?('custom_roles')
@agent.current_account_user.update!(custom_role_id: params[:custom_role_id])
end
end
@@ -16,9 +16,6 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
def permitted_update_params
# SLA is a premium feature; only accept sla_policy_id assignment when it is enabled for the account.
return super unless Current.account.feature_enabled?('sla')
super.merge(params.permit(:sla_policy_id))
end
@@ -7,16 +7,19 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
def perform(conversation, assistant)
def perform(conversation, assistant, trigger_message_id = nil)
@conversation = conversation
@inbox = conversation.inbox
@assistant = assistant
@trigger_message_id = trigger_message_id if captain_v2_enabled?
return unless conversation_pending?
Current.executed_by = @assistant
if captain_v2_enabled?
return unless trigger_message_current?
generate_response_with_v2
else
generate_and_process_response
@@ -45,11 +48,18 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def generate_response_with_v2
runner_service = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation)
runner_service = Captain::Assistant::AgentRunnerService.new(
assistant: @assistant,
conversation: @conversation,
trigger_message_id: @trigger_message_id
)
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
return if runner_service.response_discarded?
return unless trigger_message_current?
process_response
end
@@ -77,16 +87,24 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
process_v1_handoff
elsif conversation_pending?
message = nil
ActiveRecord::Base.transaction do
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)
process_standard_response
end
end
def process_standard_response
message = nil
ActiveRecord::Base.transaction do
next unless trigger_message_current?
message = create_messages
Rails.logger.info("[CAPTAIN][ResponseBuilderJob] Incrementing response usage for #{account.id}")
account.increment_response_usage
end
return unless message
capture_assistant_session(result_message: message, credits_consumed: 1.0)
end
def v1_handoff_requested?
legacy_v1_handoff_token? || classifier_v1_handoff_requested?
end
@@ -117,6 +135,8 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def process_v2_handoff
return unless trigger_message_current?
# HandoffTool already ran bot_handoff! + OOO inside the agent loop. Preserve
# waiting_since so this message doesn't clear the timestamp it left in place.
I18n.with_locale(@assistant.account.locale) do
@@ -152,7 +172,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
@response ||= {}
@response['action_source'] ||= 'error'
@response['action_reason'] ||= error_action_reason(error)
process_v1_handoff if conversation_pending?
process_v1_handoff if conversation_pending? && (!captain_v2_enabled? || trigger_message_current?)
true
end
@@ -181,4 +201,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
status == 'pending' || status == Conversation.statuses[:pending]
end
def trigger_message_current?
return true if @trigger_message_id.blank?
latest_incoming_message_id = Conversation.uncached do
@conversation.messages.incoming.maximum(:id)
end
latest_incoming_message_id == @trigger_message_id
end
end
@@ -9,11 +9,12 @@ class Captain::Assistant::AgentRunnerService
attr_reader :last_run_result
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil, trigger_message_id: nil)
@assistant = assistant
@conversation = conversation
@callbacks = callbacks
@source = source
@trigger_message_id = trigger_message_id
@handoff_tool_called = false
end
@@ -31,6 +32,10 @@ class Captain::Assistant::AgentRunnerService
error_response(e.message)
end
def response_discarded?
@response_discarded == true
end
private
def build_context(message_history)
@@ -151,10 +156,9 @@ class Captain::Assistant::AgentRunnerService
track_handoff_usage(tool_name, handoff_tool_name, context_wrapper)
end
if ChatwootApp.otel_enabled?
runner.on_run_complete do |_agent_name, _result, context_wrapper|
write_credits_used_metadata(context_wrapper)
end
runner.on_run_complete do |_agent_name, _result, context_wrapper|
@response_discarded = trigger_message_stale?
write_run_metadata(context_wrapper) if ChatwootApp.otel_enabled?
end
runner
end
@@ -169,11 +173,22 @@ class Captain::Assistant::AgentRunnerService
@handoff_tool_called = true
end
def write_credits_used_metadata(context_wrapper)
def write_run_metadata(context_wrapper)
root_span = context_wrapper&.context&.dig(:__otel_tracing, :root_span)
return unless root_span
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), @handoff_tool_called ? 'false' : 'true')
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'discarded'), response_discarded?.to_s)
root_span.set_attribute(format(ATTR_LANGFUSE_METADATA, 'credit_used'), (!@handoff_tool_called && !response_discarded?).to_s)
end
def trigger_message_stale?
return false if @trigger_message_id.blank? || @conversation.blank?
latest_incoming_message_id = Conversation.uncached do
@conversation.messages.incoming.maximum(:id)
end
latest_incoming_message_id != @trigger_message_id
end
def runner
@@ -13,7 +13,8 @@ class Captain::Assistant::InstrumentationAttributeProvider
def generation_attributes(_context_wrapper, _chat, message)
{
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message)
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'generation_stage') => generation_stage(message),
format(ATTR_LANGFUSE_OBSERVATION_METADATA, 'discarded') => @service.send(:trigger_message_stale?).to_s
}
end
@@ -23,6 +23,7 @@ module Captain::Assistant::RunnerStateHelper
timezone: @conversation&.inbox&.timezone.presence || 'UTC'
}
state[:source] = @source if @source.present?
state[:trigger_message_id] = @trigger_message_id if @trigger_message_id.present?
build_conversation_state(state) if @conversation
state
@@ -1,8 +1,6 @@
module Enterprise::ActionService
def add_sla(sla_policy_id)
return if sla_policy_id.blank?
# SLA is a premium feature; automation rules must not keep applying SLAs once it is disabled.
return unless @account.feature_enabled?('sla')
sla_policy = @account.sla_policies.find_by(id: sla_policy_id.first)
return if sla_policy.nil?
@@ -31,6 +31,7 @@ module Enterprise::MessageTemplates::HookExecutionService
def schedule_captain_response
job_args = [conversation, conversation.inbox.captain_assistant]
job_args << message.id if conversation.account.feature_enabled?('captain_integration_v2')
if message.attachments.blank?
Captain::Conversation::ResponseBuilderJob.perform_later(*job_args)
@@ -50,14 +51,7 @@ module Enterprise::MessageTemplates::HookExecutionService
end
def should_process_captain_response?
conversation.pending? && message.incoming? && inbox.captain_assistant.present? && captain_feature_enabled?
end
# Captain is a premium feature; stop the runtime responder when it has been revoked, even if an
# assistant is still linked to the inbox.
def captain_feature_enabled?
account = conversation.account
account.feature_enabled?('captain_integration') || account.feature_enabled?('captain_integration_v2')
conversation.pending? && message.incoming? && inbox.captain_assistant.present?
end
def perform_handoff
@@ -82,9 +76,7 @@ module Enterprise::MessageTemplates::HookExecutionService
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
# Only let Captain suppress the fallback templates when it can actually reply; otherwise a revoked
# feature would leave the contact with neither a Captain response nor a greeting/OOO/email-collect.
def captain_handling_conversation?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present? && captain_feature_enabled?
conversation.pending? && inbox.respond_to?(:captain_assistant) && inbox.captain_assistant.present?
end
end
@@ -5,6 +5,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
def perform(tool_context, reason: nil)
conversation = find_conversation(tool_context.state)
return 'Conversation not found' unless conversation
return 'Handoff skipped because a newer customer message arrived' unless trigger_message_current?(tool_context.state, conversation)
# Log the handoff with reason
log_tool_usage('tool_handoff', {
@@ -23,6 +24,17 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool
private
def trigger_message_current?(state, conversation)
trigger_message_id = state&.dig(:trigger_message_id)
return true if trigger_message_id.blank?
latest_incoming_message_id = Conversation.uncached do
conversation.messages.incoming.maximum(:id)
end
latest_incoming_message_id == trigger_message_id
end
def trigger_handoff(tool_context, conversation, reason)
# post the reason as a private note
note = conversation.messages.create!(
@@ -9,8 +9,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::AssistantResponses', type: :request
let(:another_assistant) { create(:captain_assistant, account: account) }
let(:another_document) { create(:captain_document, account: account, assistant: assistant) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -5,8 +5,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -24,8 +24,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
)
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotMessagesController', type: :r
let(:copilot_thread) { create(:captain_copilot_thread, account: account, user: user) }
let!(:copilot_message) { create(:captain_copilot_message, copilot_thread: copilot_thread, account: account) }
before { account.enable_features!('captain_integration') }
describe 'GET /api/v1/accounts/{account.id}/captain/copilot_threads/{thread.id}/copilot_messages' do
context 'when it is an authenticated user' do
it 'returns all messages' do
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::CopilotThreads', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:conversation) { create(:conversation, account: account) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -13,8 +13,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
}.with_indifferent_access
end
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -9,8 +9,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Inboxes', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
before { account.enable_features!('captain_integration') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,8 +6,6 @@ RSpec.describe 'Api::V1::Accounts::Captain::Scenarios', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
let(:assistant) { create(:captain_assistant, account: account) }
before { account.enable_features!('captain_integration_v2') }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
@@ -6,8 +6,6 @@ RSpec.describe 'Custom Roles API', type: :request do
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:custom_role) { create(:custom_role, account: account, name: 'Manager') }
before { account.enable_features!('custom_roles') }
describe 'GET #index' do
context 'when it is an authenticated administrator' do
it 'returns all custom roles in the account' do
@@ -6,7 +6,6 @@ RSpec.describe 'Enterprise SLA API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!('sla')
create(:sla_policy, account: account, name: 'SLA 1')
end
@@ -5,8 +5,6 @@ RSpec.describe 'Enterprise Agents API', type: :request do
let(:admin) { create(:user, account: account, role: :administrator) }
let!(:custom_role) { create(:custom_role, account: account) }
before { account.enable_features!('custom_roles') }
describe 'POST /api/v1/accounts/{account.id}/agents' do
let(:params) { { email: 'test@example.com', name: 'Test User', role: 'agent', custom_role_id: custom_role.id } }
@@ -13,7 +13,6 @@ RSpec.describe 'Enterprise Conversations API', type: :request do
let(:agent) { create(:user, account: account, role: :agent) }
before do
account.enable_features!('sla')
create(:inbox_member, user: agent, inbox: conversation.inbox)
end
@@ -13,6 +13,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
let(:assistant_model) { Llm::Models.default_model_for('assistant') }
let(:trigger_message) { conversation.messages.find_by!(content: 'Hello') }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
@@ -23,6 +24,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
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(mock_agent_runner_service).to receive(:response_discarded?).and_return(false)
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)
@@ -358,14 +360,28 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
it 'uses Captain::Assistant::AgentRunnerService' do
expect(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
conversation: conversation
conversation: conversation,
trigger_message_id: trigger_message.id
)
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
described_class.perform_now(conversation, assistant)
described_class.perform_now(conversation, assistant, trigger_message.id)
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2')
end
it 'discards a response when a newer message arrives during generation' do
allow(mock_agent_runner_service).to receive(:generate_response) do
create(:message, conversation: conversation, content: 'New context', message_type: :incoming)
{ 'response' => 'Stale response', 'handoff_tool_called' => false }
end
allow(mock_agent_runner_service).to receive(:response_discarded?).and_return(true)
described_class.perform_now(conversation, assistant, trigger_message.id)
expect(conversation.messages.outgoing.count).to eq(0)
expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
end
it 'passes message history with resolution markers to agent runner service' do
same_second = Time.current.change(usec: 0)
conversation.messages.find_by!(content: 'Hello').update!(created_at: same_second, updated_at: same_second)
@@ -28,6 +28,37 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
describe '#perform' do
context 'when conversation exists' do
context 'with a trigger message id' do
let(:trigger_message) do
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
end
let(:same_second) { Time.current.change(usec: 0) }
let(:tool_context) do
Struct.new(:state).new({ conversation: { id: conversation.id }, trigger_message_id: trigger_message.id })
end
it 'hands off when the trigger message is still the latest message' do
trigger_message
expect do
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
expect(result).to include('Conversation handed off')
end.to change(Message, :count).by(1)
end
it 'skips the handoff when a newer message has arrived' do
trigger_message
conversation.update!(status: :pending)
create(:message, conversation: conversation, account: account, inbox: inbox, message_type: :incoming, created_at: same_second)
expect do
result = tool.perform(tool_context, reason: 'Customer needs specialized support')
expect(result).to eq('Handoff skipped because a newer customer message arrived')
end.not_to change(Message, :count)
expect(conversation.reload.status).to eq('pending')
end
end
context 'with reason provided' do
it 'creates a private note with reason and hands off conversation' do
reason = 'Customer needs specialized support'
@@ -52,6 +52,12 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(service.instance_variable_get(:@callbacks)).to eq(callbacks)
end
it 'accepts the trigger message id' do
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
expect(service.instance_variable_get(:@trigger_message_id)).to eq(123)
end
end
describe '#generate_response' do
@@ -99,6 +105,18 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
service.generate_response(message_history: message_history)
end
it 'adds the trigger message id to the runner state' do
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: 123)
expect(mock_runner).to receive(:run).with(
'I need help with my account',
context: hash_including(state: hash_including(trigger_message_id: 123)),
max_turns: 10
)
service.generate_response(message_history: message_history)
end
context 'when the latest user message is multimodal' do
let(:multimodal_message_history) do
[
@@ -446,6 +464,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
attributes = provider.generation_attributes(nil, nil, message)
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
expect(attributes['langfuse.observation.metadata.discarded']).to eq('false')
end
it 'marks tool call generations separately from final responses' do
@@ -456,6 +475,18 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
end
it 'marks a generation as discarded when a newer message has arrived' do
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
runner_service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
attribute_provider = Captain::Assistant::InstrumentationAttributeProvider.new(runner_service)
message = instance_double(RubyLLM::Message, tool_calls: {})
create(:message, conversation: conversation, message_type: :incoming)
attributes = attribute_provider.generation_attributes(nil, nil, message)
expect(attributes['langfuse.observation.metadata.discarded']).to eq('true')
end
end
describe '#build_state' do
@@ -574,6 +605,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
tool_complete_callback.call(Captain::Tools::HandoffTool.new(assistant).name, 'ok', context_wrapper)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
run_complete_callback.call('assistant', nil, context_wrapper)
end
@@ -588,6 +620,7 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
tool_complete_callback = block
runner
end
allow(runner).to receive(:on_run_complete).and_return(runner)
service.send(:add_usage_metadata_callback, runner)
@@ -599,15 +632,24 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
expect(context_wrapper.context[:captain_v2_handoff_tool_called]).to be true
end
it 'does not register OTEL run callback when OTEL is disabled' do
service = described_class.new(assistant: assistant, conversation: conversation)
it 'tracks discarded responses when OTEL is disabled' do
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
runner = instance_double(Agents::AgentRunner)
run_complete_callback = nil
allow(ChatwootApp).to receive(:otel_enabled?).and_return(false)
allow(runner).to receive(:on_tool_complete).and_return(runner)
expect(runner).not_to receive(:on_run_complete)
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
create(:message, conversation: conversation, message_type: :incoming)
run_complete_callback.call('assistant', nil, Struct.new(:context).new({}))
expect(service.response_discarded?).to be true
end
it 'sets credit_used=true when handoff tool is not used' do
@@ -629,9 +671,38 @@ RSpec.describe Captain::Assistant::AgentRunnerService do
service.send(:add_usage_metadata_callback, runner)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'false')
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'true')
run_complete_callback.call('assistant', nil, context_wrapper)
end
it 'marks the trace discarded and does not use credit when a newer message arrived' do
trigger_message = create(:message, conversation: conversation, message_type: :incoming)
service = described_class.new(assistant: assistant, conversation: conversation, trigger_message_id: trigger_message.id)
runner = instance_double(Agents::AgentRunner)
run_complete_callback = nil
span_class = Class.new do
def set_attribute(*); end
end
root_span = instance_double(span_class)
context_wrapper = Struct.new(:context).new({ __otel_tracing: { root_span: root_span } })
allow(ChatwootApp).to receive(:otel_enabled?).and_return(true)
allow(runner).to receive(:on_tool_complete).and_return(runner)
allow(runner).to receive(:on_run_complete) do |&block|
run_complete_callback = block
runner
end
service.send(:add_usage_metadata_callback, runner)
create(:message, conversation: conversation, message_type: :incoming)
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.discarded', 'true')
expect(root_span).to receive(:set_attribute).with('langfuse.trace.metadata.credit_used', 'false')
run_complete_callback.call('assistant', nil, context_wrapper)
expect(service.response_discarded?).to be true
end
end
describe 'constants' do
@@ -8,8 +8,6 @@ describe ActionService do
let(:conversation) { create(:conversation, account: account) }
let(:action_service) { described_class.new(conversation) }
before { account.enable_features!('sla') }
context 'when sla_policy_id is present' do
it 'adds the sla policy to the conversation and create applied_sla entry' do
action_service.add_sla([sla_policy.id])
@@ -8,7 +8,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
let(:assistant) { create(:captain_assistant, account: account) }
before do
account.enable_features!('captain_integration')
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
end
@@ -23,9 +22,13 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
it 'schedules captain response job for incoming messages on pending conversations' do
expect(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later).with(conversation, assistant)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(true)
allow(Captain::Conversation::ResponseBuilderJob).to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming, account: account)
message = create(:message, conversation: conversation, message_type: :incoming, account: account)
expect(Captain::Conversation::ResponseBuilderJob).to have_received(:perform_later).with(conversation, assistant, message.id)
end
end
@@ -203,29 +206,6 @@ RSpec.describe MessageTemplates::HookExecutionService do
end
end
context 'when the Captain feature is disabled but an assistant is still linked' do
before do
account.disable_features!('captain_integration', 'captain_integration_v2')
conversation.update!(status: :pending)
end
it 'does not schedule captain response job' do
expect(Captain::Conversation::ResponseBuilderJob).not_to receive(:perform_later)
create(:message, conversation: conversation, message_type: :incoming, account: account)
end
it 'falls back to the greeting message instead of leaving the contact without a reply' do
inbox.update!(greeting_enabled: true, greeting_message: 'Hello! How can we help you?', enable_email_collect: false)
expect do
create(:message, conversation: conversation, message_type: :incoming, account: account)
end.to change { conversation.reload.messages.template.count }.by(1)
expect(conversation.reload.messages.template.last.content).to eq('Hello! How can we help you?')
end
end
context 'when Captain is not configured' do
before do
CaptainInbox.where(inbox: inbox).destroy_all