Merge branch 'develop' into codex/connected-agent-bot-owner

This commit is contained in:
Sojan Jose
2026-07-07 19:18:29 -07:00
committed by GitHub
88 changed files with 2822 additions and 83 deletions
@@ -2,6 +2,8 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
before_action :fetch_inboxes
def index
# TODO: Remove this opt-in once mobile clients support AgentBot assignees in this payload.
@include_agent_bots = params[:include_agent_bots].present?
agent_ids = @inboxes.map do |inbox|
authorize inbox, :show?
member_ids = inbox.members.pluck(:user_id)
@@ -10,6 +12,7 @@ class Api::V1::Accounts::AssignableAgentsController < Api::V1::Accounts::BaseCon
agent_ids = agent_ids.inject(:&)
agents = Current.account.users.where(id: agent_ids)
@assignable_agents = (agents + Current.account.administrators).uniq
@agent_bots = @include_agent_bots ? AgentBot.accessible_to(Current.account) : []
end
private
@@ -66,6 +66,7 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
config = Llm::Models.feature_config(feature_key)
route = Llm::FeatureRouter.resolve(feature: feature_key, account: Current.account)
config.merge(
default: default_model_for(feature_key),
enabled: account_features[feature_key] == true,
model: route[:model],
selected: route[:model],
@@ -74,4 +75,10 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::Bas
)
end
end
def default_model_for(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && Current.account.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
end
@@ -2,6 +2,7 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
include WidgetHelper
before_action :validate_hmac, only: [:set_user]
before_action :validate_hmac_for_identified_update, only: [:update]
def show; end
@@ -46,6 +47,16 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
@contact.identifier.present? && @contact.identifier != permitted_params[:identifier]
end
# The plain update endpoint is also used for anonymous prechat updates
# (name/email/phone/custom_attributes with no identifier), which must keep
# working on hmac_mandatory inboxes. Only the identity-binding path, where an
# identifier is supplied and the contact can be rebound, requires HMAC.
def validate_hmac_for_identified_update
return if params[:identifier].blank?
validate_hmac
end
def validate_hmac
return unless should_verify_hmac?
@@ -62,11 +73,15 @@ class Api::V1::Widget::ContactsController < Api::V1::Widget::BaseController
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@web_widget.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -1,6 +1,12 @@
module RequestExceptionHandler
extend ActiveSupport::Concern
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS = [
'ActiveRecord::QueryCanceled',
'PG::QueryCanceled',
'canceling statement due to statement timeout'
].freeze
included do
rescue_from ActiveRecord::RecordInvalid, with: :render_record_invalid
end
@@ -18,6 +24,9 @@ module RequestExceptionHandler
rescue ActionController::ParameterMissing => e
log_handled_error(e)
render_could_not_create_error(e.message)
rescue ActiveRecord::QueryCanceled => e
log_handled_error(e)
render_could_not_create_error(database_query_canceled_message)
ensure
# to address the thread variable leak issues in Puma/Thin webserver
Current.reset
@@ -32,7 +41,7 @@ module RequestExceptionHandler
end
def render_could_not_create_error(message)
render json: { error: message }, status: :unprocessable_entity
render json: { error: sanitized_error_message(message) }, status: :unprocessable_entity
end
def render_payment_required(message)
@@ -59,4 +68,19 @@ module RequestExceptionHandler
def log_handled_error(exception)
logger.info("Handled error: #{exception.inspect}")
end
def sanitized_error_message(message)
return database_query_canceled_message if database_query_canceled_message?(message)
message
end
def database_query_canceled_message?(message)
error_message = message.to_s
QUERY_CANCELED_ERROR_MESSAGE_PATTERNS.any? { |pattern| error_message.include?(pattern) }
end
def database_query_canceled_message
I18n.t('errors.database.query_canceled')
end
end
@@ -35,11 +35,15 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
end
def valid_hmac?
params[:identifier_hash] == OpenSSL::HMAC.hexdigest(
expected_hash = OpenSSL::HMAC.hexdigest(
'sha256',
@inbox_channel.hmac_token,
params[:identifier].to_s
)
identifier_hash = params[:identifier_hash].to_s
return false unless identifier_hash.bytesize == expected_hash.bytesize
ActiveSupport::SecurityUtils.secure_compare(identifier_hash, expected_hash)
end
def permitted_params
@@ -6,9 +6,12 @@ class AssignableAgents extends ApiClient {
super('assignable_agents', { accountScoped: true });
}
get(inboxIds) {
get(inboxIds, { includeAgentBots = false } = {}) {
return axios.get(this.url, {
params: { inbox_ids: inboxIds },
params: {
inbox_ids: inboxIds,
...(includeAgentBots ? { include_agent_bots: true } : {}),
},
});
}
}
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -21,6 +25,18 @@ class CaptainAssistant extends ApiClient {
message_history: messageHistory,
});
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
}
export default new CaptainAssistant();
@@ -62,9 +62,10 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId }) {
assignAgent({ conversationId, agentId, assigneeType }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
@@ -26,5 +26,15 @@ describe('#AssignableAgentsAPI', () => {
},
});
});
it('#getAssignableAgents with agent bots', () => {
assignableAgentsAPI.get([1], { includeAgentBots: true });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
});
});
});
@@ -90,11 +90,16 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -182,7 +182,8 @@ const handleCreateAssistant = () => {
</div>
</div>
<div class="flex gap-2">
<div class="flex items-center gap-2">
<slot name="headerActions" />
<slot name="search" />
<div
v-if="!showPaywall && buttonLabel"
@@ -0,0 +1,96 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { LocalStorage } from 'shared/helpers/localStorage';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const route = useRoute();
const router = useRouter();
// Dismissal is remembered per assistant for 24 hours (setFlag's default expiry).
const DISMISS_STORE = 'captain_overview_coverage_banner';
const accountId = computed(() => route.params.accountId);
const assistantId = computed(() => route.params.assistantId);
// Re-read the stored flag whenever the assistant changes, otherwise the banner
// would keep the first assistant's dismissed state after switching.
const dismissed = ref(false);
watch(
[accountId, assistantId],
([account, assistant]) => {
dismissed.value = LocalStorage.getFlag(DISMISS_STORE, account, assistant);
},
{ immediate: true }
);
// Thin coverage paired with a large review backlog: approving the pending FAQs
// is the quickest lever to lift auto-resolution, so nudge the team to act.
const COVERAGE_THRESHOLD = 85;
const PENDING_THRESHOLD = 100;
const showBanner = computed(
() =>
!dismissed.value &&
(props.knowledge?.coverage ?? 0) < COVERAGE_THRESHOLD &&
(props.knowledge?.pending ?? 0) > PENDING_THRESHOLD
);
const dismiss = () => {
LocalStorage.setFlag(DISMISS_STORE, accountId.value, assistantId.value);
dismissed.value = true;
};
const goToPending = () => {
router.push({
name: 'captain_assistants_responses_pending',
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{
$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.TEXT', {
count: knowledge.pending,
coverage: knowledge.coverage,
})
}}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToPending"
>
{{ $t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.COVERAGE_BANNER.DISMISS')"
@click="dismiss"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,73 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
const route = useRoute();
const router = useRouter();
const store = useStore();
const dismissed = ref(false);
const assistantId = computed(() => route.params.assistantId);
const inboxes = useMapGetter('captainInboxes/getRecords');
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
// Only surface once we know the assistant has no connected inboxes.
const showBanner = computed(
() =>
!dismissed.value &&
!uiFlags.value.fetchingList &&
inboxes.value.length === 0
);
watch(
assistantId,
id => {
dismissed.value = false;
if (id) store.dispatch('captainInboxes/get', { assistantId: id });
},
{ immediate: true }
);
const goToInboxes = () => {
router.push({
name: 'captain_assistants_inboxes_index',
params: {
accountId: route.params.accountId,
assistantId: assistantId.value,
},
});
};
</script>
<template>
<div
v-if="showBanner"
class="flex items-center justify-between gap-3 px-3 py-2 text-sm border rounded-xl bg-n-amber-3 border-n-amber-4 text-n-amber-11"
>
<div class="flex items-center gap-2 min-w-0">
<span class="shrink-0 i-lucide-triangle-alert size-4" />
<span class="truncate">
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.TEXT') }}
</span>
</div>
<div class="flex items-center gap-1 shrink-0">
<button
type="button"
class="px-3 py-1 rounded-lg bg-n-amber-4 hover:bg-n-amber-5"
@click="goToInboxes"
>
{{ $t('CAPTAIN.OVERVIEW.INBOX_BANNER.ACTION') }}
</button>
<button
type="button"
class="grid rounded-lg size-7 place-content-center hover:bg-n-amber-4"
:aria-label="$t('CAPTAIN.OVERVIEW.INBOX_BANNER.DISMISS')"
@click="dismissed = true"
>
<span class="i-lucide-x size-4" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,87 @@
<script setup>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { useI18n } from 'vue-i18n';
const props = defineProps({
knowledge: {
type: Object,
default: () => ({ approved: 0, pending: 0, documents: 0, coverage: 0 }),
},
});
const { t } = useI18n();
const route = useRoute();
const approvedPct = computed(() => props.knowledge.coverage ?? 0);
const linkTo = routeName => ({
name: routeName,
params: {
accountId: route.params.accountId,
assistantId: route.params.assistantId,
},
});
const stats = computed(() => [
{
key: 'approved',
value: props.knowledge.approved,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.APPROVED'),
to: linkTo('captain_assistants_responses_index'),
},
{
key: 'pending',
value: props.knowledge.pending,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.PENDING'),
to: linkTo('captain_assistants_responses_pending'),
},
{
key: 'documents',
value: props.knowledge.documents,
label: t('CAPTAIN.OVERVIEW.KNOWLEDGE.DOCUMENTS'),
to: linkTo('captain_assistants_documents_index'),
},
]);
</script>
<template>
<div
class="flex flex-col gap-4 p-5 border rounded-xl bg-n-solid-1 border-n-weak"
>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.TITLE') }}
</span>
<span class="text-sm tabular-nums text-n-slate-11">
{{ $t('CAPTAIN.OVERVIEW.KNOWLEDGE.COVERAGE', { pct: approvedPct }) }}
</span>
</div>
<div class="w-full h-2 overflow-hidden rounded-full bg-n-alpha-2">
<div
class="h-full rounded-full bg-n-brand"
:style="{ width: `${approvedPct}%` }"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<RouterLink
v-for="stat in stats"
:key="stat.key"
:to="stat.to"
class="flex flex-col gap-1 group/stat"
>
<span class="text-xl font-semibold tabular-nums text-n-slate-12">
{{ stat.value }}
</span>
<span
class="inline-flex items-center gap-1 text-xs transition-colors text-n-slate-11 group-hover/stat:text-n-slate-12"
>
{{ stat.label }}
<span
class="transition-opacity opacity-0 i-lucide-arrow-up-right size-3 group-hover/stat:opacity-100"
/>
</span>
</RouterLink>
</div>
</div>
</template>
@@ -0,0 +1,40 @@
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, required: true },
value: { type: String, required: true },
trend: { type: String, default: '' },
hint: { type: String, default: '' },
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
});
const trendClass = computed(() => {
if (props.trendGood === null) return 'text-n-slate-11';
return props.trendGood ? 'text-n-teal-11' : 'text-n-ruby-11';
});
</script>
<template>
<div class="flex flex-col gap-3 p-5 group bg-n-solid-1">
<div class="flex items-center gap-1.5">
<span class="text-sm font-medium text-n-slate-11">{{ label }}</span>
<span
v-if="hint"
v-tooltip="hint"
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
</div>
<div class="flex items-end justify-between gap-2">
<span
class="text-3xl font-semibold tracking-tight tabular-nums text-n-slate-12"
>
{{ value }}
</span>
<span class="text-sm font-medium tabular-nums" :class="trendClass">
{{ trend }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,81 @@
<script setup>
import { computed } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { getHelpUrlForFeature } from 'dashboard/helper/featureHelper';
const { t } = useI18n();
const route = useRoute();
const { isOnChatwootCloud } = useAccount();
const assistantParams = computed(() => ({
accountId: route.params.accountId,
assistantId: route.params.assistantId,
}));
const links = computed(() => [
{
key: 'docs',
title: t('CAPTAIN.OVERVIEW.LINKS.DOCS.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.DOCS.DESCRIPTION'),
icon: 'i-lucide-book-open',
href: getHelpUrlForFeature('captain'),
},
{
key: 'playground',
title: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.PLAYGROUND.DESCRIPTION'),
icon: 'i-lucide-flask-conical',
to: {
name: 'captain_assistants_playground_index',
params: assistantParams.value,
},
},
{
key: 'billing',
title: t('CAPTAIN.OVERVIEW.LINKS.BILLING.TITLE'),
description: t('CAPTAIN.OVERVIEW.LINKS.BILLING.DESCRIPTION'),
icon: 'i-lucide-credit-card',
to: {
name: 'billing_settings_index',
params: { accountId: route.params.accountId },
},
},
]);
</script>
<template>
<div v-if="isOnChatwootCloud" class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<component
:is="link.href ? 'a' : RouterLink"
v-for="link in links"
:key="link.key"
:href="link.href"
:to="link.to"
:target="link.href ? '_blank' : undefined"
:rel="link.href ? 'noopener noreferrer' : undefined"
class="flex items-center gap-3 p-4 transition-colors border rounded-xl bg-n-solid-1 border-n-weak hover:bg-n-alpha-1 group/link"
>
<span
class="grid rounded-lg size-9 shrink-0 place-content-center bg-n-alpha-2 text-n-slate-11"
>
<span :class="link.icon" class="size-4" />
</span>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ link.title }}
</span>
<span class="text-xs truncate text-n-slate-11">
{{ link.description }}
</span>
</div>
<span
:class="
link.href ? 'i-lucide-arrow-up-right' : 'i-lucide-chevron-right'
"
class="ml-auto transition-opacity opacity-0 size-4 text-n-slate-10 group-hover/link:opacity-100"
/>
</component>
</div>
</template>
@@ -0,0 +1,78 @@
<script setup>
import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const modelValue = defineModel({ type: String, default: '30' });
const { t } = useI18n();
const [showDropdown, toggleDropdown] = useToggle();
const DAY_RANGES = ['7', '30', '90'];
const decorate = item => ({
...item,
action: 'select',
isSelected: item.value === modelValue.value,
});
const menuSections = computed(() => {
const dayItems = DAY_RANGES.map(value =>
decorate({
value,
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_DAYS', { count: value }),
})
);
const monthItems = [
decorate({
value: 'this_month',
label: t('CAPTAIN.OVERVIEW.RANGES.THIS_MONTH'),
}),
decorate({
value: 'last_month',
label: t('CAPTAIN.OVERVIEW.RANGES.LAST_MONTH'),
}),
];
return [{ items: dayItems }, { items: monthItems }];
});
const menuItems = computed(() =>
menuSections.value.flatMap(section => section.items)
);
const selectedLabel = computed(
() => menuItems.value.find(item => item.isSelected)?.label || ''
);
const handleAction = ({ value }) => {
toggleDropdown(false);
modelValue.value = value;
};
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
trailing-icon
icon="i-lucide-chevron-down"
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import MessageFormatter from 'shared/helpers/MessageFormatter.js';
const props = defineProps({
range: {
type: String,
default: '30',
},
});
const route = useRoute();
const assistantId = computed(() => route.params.assistantId);
// Markdown summary generated by the model from the assistant's stats (served by
// the captain/assistants/:id/summary endpoint). Numbers are emphasised with
// **bold** so we can highlight them (see prose-strong styling below).
const welcomeMarkdown = ref('');
const isLoading = ref(false);
const fetchSummary = async () => {
isLoading.value = true;
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
});
welcomeMarkdown.value = data.message ?? '';
} catch {
welcomeMarkdown.value = '';
} finally {
isLoading.value = false;
}
};
watch([() => props.range, assistantId], fetchSummary, { immediate: true });
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
// handles paragraphs, line breaks, links, lists and emphasis consistently.
const formattedSummary = computed(
() => new MessageFormatter(welcomeMarkdown.value).formattedMessage
);
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<div v-if="isLoading || welcomeMarkdown" class="flex flex-col gap-3">
<div class="flex items-center gap-1.5 text-n-slate-10">
<span class="i-lucide-sparkles size-3.5" />
<span class="text-xs">
{{ $t('CAPTAIN.OVERVIEW.WELCOME.LABEL') }}
</span>
</div>
<div
v-if="isLoading"
class="flex flex-col gap-5"
:aria-label="$t('CAPTAIN.OVERVIEW.WELCOME.LOADING')"
>
<div class="flex flex-col gap-2.5">
<div class="w-full h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-11/12 h-5 rounded bg-n-slate-3 animate-pulse" />
<div class="w-4/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div class="w-5/6 h-5 rounded bg-n-slate-3 animate-pulse" />
</div>
<div
v-else
v-dompurify-html="formattedSummary"
class="max-w-none prose prose-p:text-lg prose-p:leading-relaxed prose-p:mt-0 prose-p:mb-3 last:prose-p:mb-0 prose-strong:font-bold prose-strong:tabular-nums prose-strong:text-n-brand text-n-slate-12"
/>
</div>
</template>
@@ -65,7 +65,7 @@ const handleAssistantChange = async assistant => {
const currentRouteName = route.name;
const targetRouteName =
currentRouteName || 'captain_assistants_responses_index';
currentRouteName || 'captain_assistants_overview_index';
await fetchDataForRoute(targetRouteName, assistant.id);
@@ -440,6 +440,14 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CAPTAIN'),
activeOn: ['captain_assistants_create_index'],
children: [
{
name: 'Overview',
label: t('SIDEBAR.CAPTAIN_OVERVIEW'),
activeOn: ['captain_assistants_overview_index'],
to: accountScopedRoute('captain_assistants_index', {
navigationPath: 'captain_assistants_overview_index',
}),
},
{
name: 'FAQs',
label: t('SIDEBAR.CAPTAIN_RESPONSES'),
@@ -1,6 +1,7 @@
<script setup>
import { computed } from 'vue';
import ChannelSelector from '../ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const props = defineProps({
channel: {
@@ -20,7 +21,10 @@ const hasFbConfigured = computed(() => {
});
const hasInstagramConfigured = computed(() => {
return window.chatwootConfig?.instagramAppId;
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.instagramAppId
);
});
const hasTiktokConfigured = computed(() => {
@@ -58,6 +62,7 @@ const isActive = computed(() => {
if (key === 'whatsapp_call') {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
props.enabledFeatures.channel_voice &&
!!window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
@@ -62,7 +62,10 @@ export default {
immediate: true,
handler(inboxId) {
if (inboxId) {
this.$store.dispatch('inboxAssignableAgents/fetch', [inboxId]);
this.$store.dispatch('inboxAssignableAgents/fetch', {
inboxIds: [inboxId],
includeAgentBots: true,
});
}
},
},
@@ -2,6 +2,7 @@
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
@@ -57,6 +58,10 @@ const showMetaSection = computed(() => {
);
});
const isAgentBotAssignee = computed(
() => props.chat?.meta?.assignee_type === 'AgentBot'
);
const hasSlaPolicyId = computed(
() => props.chat?.applied_sla?.id && !props.currentContact?.blocked
);
@@ -159,10 +164,15 @@ watch(
>
<span
v-if="showAssignee && assignee.name"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center gap-px truncate"
>
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
{{ assignee.name }}
<Icon
:icon="
isAgentBotAssignee ? 'i-lucide-bot' : 'i-lucide-user-round'
"
class="size-3 text-n-slate-11 flex-shrink-0"
/>
<span class="truncate">{{ assignee.name }}</span>
</span>
<CardPriorityIcon
:priority="chat.priority"
@@ -123,7 +123,13 @@ export default {
$t('EMAIL_TRANSCRIPT.FORM.SEND_TO_CONTACT')
}}</label>
</div>
<div v-if="currentChat.meta.assignee" class="flex items-center gap-2">
<div
v-if="
currentChat.meta.assignee &&
currentChat.meta.assignee_type !== 'AgentBot'
"
class="flex items-center gap-2"
>
<input
id="assignee"
v-model="selectedType"
@@ -33,10 +33,19 @@ defineProps({
v-if="variant === 'agent'"
:name="option.label"
:src="option.thumbnail"
:icon-name="option.iconName"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
>
<template v-if="option.iconName && option.thumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2" />
</div>
</template>
</Avatar>
<p class="menu-label truncate min-w-0 flex-1">
{{ option.label }}
</p>
@@ -26,11 +26,12 @@ const mockNoneAgent = {
};
const mockUseMapGetter = (overrides = {}) => {
const getAssignableAgents = vi.fn(() => allAgentsData);
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableAgents': ref(getAssignableAgents),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -53,6 +54,24 @@ describe('useAgentsList', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: false });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('requests agent bots when explicitly included', () => {
const { agentsList, assignableAgents } = useAgentsList(true, {
includeAgentBots: true,
});
expect(assignableAgents.value).toEqual(allAgentsData);
expect(
useMapGetter('inboxAssignableAgents/getAssignableAgents').value
).toHaveBeenCalledWith(1, { includeAgentBots: true });
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
@@ -10,9 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {Object} [options] - Options for the assignable agents list.
* @param {boolean} [options.includeAgentBots=false] - Whether to include AgentBot assignees. Only pass this from surfaces that thread `assignee_type` through the assignment request.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(includeNoneAgent = true) {
export function useAgentsList(
includeNoneAgent = true,
{ includeAgentBots = false } = {}
) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
@@ -39,7 +44,9 @@ export function useAgentsList(includeNoneAgent = true) {
* @type {import('vue').ComputedRef<Array>}
*/
const assignableAgents = computed(() => {
return inboxId.value ? assignable.value(inboxId.value) : [];
return inboxId.value
? assignable.value(inboxId.value, { includeAgentBots })
: [];
});
/**
@@ -78,3 +78,8 @@ export default {
},
};
export const DEFAULT_REDIRECT_URL = '/app/';
// Temporarily disables Instagram and WhatsApp inbox creation
// (WhatsApp embedded signup popup, Instagram OAuth, WhatsApp Call).
// Flip to false when the channels are brought back.
export const IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED = true;
@@ -38,7 +38,7 @@ export const getAgentsByUpdatedPresence = (
currentAccountId
) => {
const agentsWithDynamicPresenceUpdate = agents.map(item =>
item.id === currentUser.id
item.id === currentUser.id && (item.assignee_type || 'User') === 'User'
? {
...item,
availability_status: currentUser.accounts.find(
@@ -19,6 +19,7 @@ const FEATURE_HELP_URLS = {
webhook: 'https://chwt.app/hc/webhooks',
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain: 'https://chwt.app/captain-docs',
captain_billing: 'https://chwt.app/hc/captain_billing',
};
@@ -392,6 +392,93 @@
"CAPTAIN": {
"NAME": "Captain",
"HEADER_KNOW_MORE": "Know more",
"OVERVIEW": {
"HEADER": "Overview",
"WELCOME": {
"LABEL": "Captain summary",
"LOADING": "Generating summary…"
},
"INBOX_BANNER": {
"TEXT": "This assistant isn't connected to any inbox yet, so it won't respond to conversations.",
"ACTION": "Connect inbox",
"DISMISS": "Dismiss"
},
"COVERAGE_BANNER": {
"TEXT": "{count} FAQs are pending review, keeping coverage at {coverage}%. Approve them so your assistant can resolve more on its own.",
"ACTION": "Review FAQs",
"DISMISS": "Dismiss"
},
"RANGES": {
"LAST_DAYS": "Last {count} days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month"
},
"METRICS": {
"HANDLED": {
"LABEL": "Conversations handled",
"HINT": "Distinct conversations this assistant replied in."
},
"AUTO_RESOLUTION": {
"LABEL": "Auto-resolution rate",
"HINT": "Share of handled conversations closed without a human reply."
},
"HANDOFF": {
"LABEL": "Handoff rate",
"HINT": "Share of handled conversations escalated to a human agent."
},
"HOURS_SAVED": {
"LABEL": "Time saved",
"HINT": "Estimate: Captain replies times ~2 minutes of assumed agent effort per reply. Directional, not measured labor."
},
"REOPEN": {
"LABEL": "Reopen rate",
"HINT": "Auto-resolved conversations that were reopened afterwards."
},
"DEPTH": {
"LABEL": "Messages / conversation",
"HINT": "Average replies the assistant sends per conversation."
}
},
"KNOWLEDGE": {
"TITLE": "Knowledge coverage",
"COVERAGE": "{pct}% approved",
"APPROVED": "Approved FAQs",
"PENDING": "Pending FAQs",
"DOCUMENTS": "Documents"
},
"FLAGGED": {
"TITLE": "Response quality",
"TOTAL": "{count} flagged · {rate}"
},
"CREDITS": {
"TITLE": "Credit usage",
"UNIT": "credits",
"LEGEND": "Daily credits used",
"AXIS_START": "{count}d ago",
"AXIS_END": "Today"
},
"LINKS": {
"DOCS": {
"TITLE": "Captain docs",
"DESCRIPTION": "Guides and how-tos for Captain"
},
"PLAYGROUND": {
"TITLE": "Playground",
"DESCRIPTION": "Test this assistant's replies"
},
"BILLING": {
"TITLE": "Billing",
"DESCRIPTION": "Manage credits and plan"
}
},
"FLAG_REASONS": {
"INCORRECT": "Incorrect info",
"INCOMPLETE": "Incomplete",
"OUTDATED": "Outdated",
"INAPPROPRIATE": "Inappropriate",
"OTHER": "Other"
}
},
"ASSISTANT_SWITCHER": {
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
@@ -324,6 +324,7 @@
"ALL_COMPANIES": "All Companies",
"CAPTAIN": "Captain",
"CAPTAIN_ASSISTANTS": "Assistants",
"CAPTAIN_OVERVIEW": "Overview",
"CAPTAIN_DOCUMENTS": "Documents",
"CAPTAIN_RESPONSES": "FAQs",
"CAPTAIN_TOOLS": "Tools",
@@ -0,0 +1,156 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CaptainAssistant from 'dashboard/api/captain/assistant';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RangeSelector from 'dashboard/components-next/captain/pageComponents/overview/RangeSelector.vue';
import WelcomeCard from 'dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue';
import MetricCard from 'dashboard/components-next/captain/pageComponents/overview/MetricCard.vue';
import KnowledgeCard from 'dashboard/components-next/captain/pageComponents/overview/KnowledgeCard.vue';
import QuickLinks from 'dashboard/components-next/captain/pageComponents/overview/QuickLinks.vue';
import InboxBanner from 'dashboard/components-next/captain/pageComponents/overview/InboxBanner.vue';
import CoverageBanner from 'dashboard/components-next/captain/pageComponents/overview/CoverageBanner.vue';
const { t } = useI18n();
const route = useRoute();
const selectedRange = ref('this_month');
const assistantId = computed(() => route.params.assistantId);
const stats = ref(null);
const fetchStats = async () => {
try {
const { data } = await CaptainAssistant.getStats({
assistantId: assistantId.value,
range: selectedRange.value,
});
stats.value = data;
} catch {
stats.value = null;
}
};
watch([selectedRange, assistantId], fetchStats, { immediate: true });
// `direction` says whether a rising trend is good ('up'), bad ('down'), or
// neutral, so we can colour the delta independently of its sign.
const resolveTrendGood = (trendValue, direction) => {
if (direction === 'neutral' || trendValue === 0) return null;
return direction === 'up' ? trendValue > 0 : trendValue < 0;
};
// Trend units mirror the backend pack mode: a relative percent change ('%') for
// :percent metrics, a percentage-point delta (' pts') for rate metrics packed as
// :point, and a plain number for :absolute counts like conversation depth.
const TREND_SUFFIX = { percent: '%', point: ' pts', absolute: '' };
// Hours-saved is reported in hours, but large values read better as days. Past
// 100h we switch the unit so the card stays legible.
const formatDuration = hours =>
hours >= 100 ? `${Math.round(hours / 24)}d` : `${hours}h`;
const metricFor = (statKey, formatValue, direction, trendKind = 'percent') => {
const data = stats.value?.[statKey];
if (!data) return { value: '—', trend: '', trendGood: null };
const sign = data.trend > 0 ? '+' : '';
return {
value: formatValue(data.current),
trend: `${sign}${data.trend}${TREND_SUFFIX[trendKind]}`,
trendGood: resolveTrendGood(data.trend, direction),
};
};
const metrics = computed(() => [
{
key: 'handled',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDLED.HINT'),
...metricFor('conversations_handled', v => v.toLocaleString(), 'up'),
},
{
key: 'autoResolution',
label: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.AUTO_RESOLUTION.HINT'),
...metricFor('auto_resolution_rate', v => `${v}%`, 'up', 'point'),
},
{
key: 'handoff',
label: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HANDOFF.HINT'),
...metricFor('handoff_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'hoursSaved',
label: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.HOURS_SAVED.HINT'),
...metricFor('hours_saved', formatDuration, 'up'),
},
{
key: 'reopen',
label: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.REOPEN.HINT'),
...metricFor('reopen_rate', v => `${v}%`, 'down', 'point'),
},
{
key: 'depth',
label: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.LABEL'),
hint: t('CAPTAIN.OVERVIEW.METRICS.DEPTH.HINT'),
...metricFor(
'conversation_depth',
v => v.toFixed(1),
'neutral',
'absolute'
),
},
]);
</script>
<template>
<PageLayout
:header-title="$t('CAPTAIN.OVERVIEW.HEADER')"
:is-empty="false"
:show-pagination-footer="false"
:show-know-more="false"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
>
<template #headerActions>
<RangeSelector v-model="selectedRange" />
</template>
<template #paywall>
<CaptainPaywall />
</template>
<template #body>
<div class="flex flex-col gap-6 pb-8">
<InboxBanner />
<CoverageBanner :knowledge="stats?.knowledge" />
<WelcomeCard :range="selectedRange" />
<div
class="grid grid-cols-1 gap-px overflow-hidden border rounded-xl sm:grid-cols-2 lg:grid-cols-3 bg-n-weak border-n-weak"
>
<MetricCard
v-for="metric in metrics"
:key="metric.key"
:label="metric.label"
:value="metric.value"
:trend="metric.trend"
:hint="metric.hint"
:trend-good="metric.trendGood"
/>
</div>
<KnowledgeCard :knowledge="stats?.knowledge" />
<QuickLinks />
</div>
</template>
</PageLayout>
</template>
@@ -6,6 +6,7 @@ import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
import AssistantsIndexPage from './pages/AssistantsIndexPage.vue';
import AssistantEmptyStateIndex from './assistants/Index.vue';
import AssistantOverviewIndex from './assistants/overview/Index.vue';
import AssistantSettingsIndex from './assistants/settings/Settings.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import AssistantPlaygroundIndex from './assistants/playground/Index.vue';
@@ -36,6 +37,12 @@ const metaV2 = {
};
const assistantRoutes = [
{
path: frontendURL('accounts/:accountId/captain/:assistantId/overview'),
component: AssistantOverviewIndex,
name: 'captain_assistants_overview_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
@@ -129,7 +136,7 @@ export const routes = [
return {
name: 'captain_assistants_index',
params: {
navigationPath: 'captain_assistants_responses_index',
navigationPath: 'captain_assistants_overview_index',
...to.params,
},
};
@@ -53,6 +53,7 @@ const routeToLastActiveAssistant = () => {
const { navigationPath } = route.params;
const isAValidRoute = [
'captain_assistants_overview_index', // Overview page
'captain_assistants_responses_index', // Faq page
'captain_assistants_documents_index', // Document page
'captain_assistants_scenarios_index', // Scenario page
@@ -64,7 +65,7 @@ const routeToLastActiveAssistant = () => {
const navigateTo = isAValidRoute
? navigationPath
: 'captain_assistants_responses_index';
: 'captain_assistants_overview_index';
return routeToView(navigateTo, {
accountId: route.params.accountId,
@@ -25,7 +25,7 @@ export default {
},
},
setup() {
const { agentsList } = useAgentsList();
const { agentsList } = useAgentsList(true, { includeAgentBots: true });
return {
agentsList,
};
@@ -81,18 +81,27 @@ export default {
},
assignedAgent: {
get() {
return this.currentChat.meta.assignee;
const assignee = this.currentChat.meta.assignee;
return (
assignee && {
...assignee,
assignee_type: this.currentChat.meta.assignee_type || 'User',
}
);
},
set(agent) {
const agentId = agent ? agent.id : null;
const assigneeType = agent ? agent.assignee_type || 'User' : null;
this.$store.dispatch('setCurrentChatAssignee', {
conversationId: this.currentChat.id,
assignee: agent,
assigneeType,
});
this.$store
.dispatch('assignAgent', {
conversationId: this.currentChat.id,
agentId,
assigneeType,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
@@ -152,7 +161,10 @@ export default {
if (!this.assignedAgent) {
return true;
}
if (this.assignedAgent.id !== this.currentUser.id) {
if (
this.assignedAgent.id !== this.currentUser.id ||
(this.assignedAgent.assignee_type || 'User') !== 'User'
) {
return true;
}
return false;
@@ -183,7 +195,11 @@ export default {
this.assignedAgent = selfAssign;
},
onClickAssignAgent(selectedItem) {
if (this.assignedAgent && this.assignedAgent.id === selectedItem.id) {
if (
this.assignedAgent?.id === selectedItem.id &&
(this.assignedAgent?.assignee_type || 'User') ===
(selectedItem.assignee_type || 'User')
) {
this.assignedAgent = null;
} else {
this.assignedAgent = selectedItem;
@@ -1,4 +1,5 @@
import { useMapGetter } from 'dashboard/composables/store';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
// OAuth/SDK channels need installation-level app credentials to be usable. When
// the credential is missing the channel is "not configured" and is hidden from
@@ -13,11 +14,14 @@ export function useChannelConfig() {
// WhatsApp is onboarded only via Meta embedded signup, which needs both the
// app id (not the 'none' sentinel) and the signup configuration id.
whatsapp: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.whatsappAppId) &&
installationConfig.whatsappAppId !== 'none' &&
Boolean(installationConfig.whatsappConfigurationId),
facebook: () => Boolean(installationConfig.fbAppId),
instagram: () => Boolean(installationConfig.instagramAppId),
instagram: () =>
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
Boolean(installationConfig.instagramAppId),
tiktok: () => Boolean(installationConfig.tiktokAppId),
gmail: () => Boolean(installationConfig.googleOAuthClientId),
outlook: () => Boolean(globalConfig.value.azureAppId),
@@ -6,6 +6,13 @@ import { useDetectedChannels } from '../../inbox-setup/useDetectedChannels';
vi.mock('vue-router');
// Neutralize the temporary Instagram/WhatsApp kill switch so these specs keep
// covering the credential-based gating it currently short-circuits.
vi.mock('dashboard/constants/globals', async importOriginal => ({
...(await importOriginal()),
IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED: false,
}));
// Mounts the composable against a real store and the real useAccount (only
// useRoute and the underlying getters are faked), so a change to how useAccount
// resolves the current account is exercised here too. The real ./constants are
@@ -7,6 +7,7 @@ import ThreeSixtyDialogWhatsapp from './360DialogWhatsapp.vue';
import CloudWhatsapp from './CloudWhatsapp.vue';
import WhatsappEmbeddedSignup from './WhatsappEmbeddedSignup.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import { IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED } from 'dashboard/constants/globals';
const route = useRoute();
const router = useRouter();
@@ -23,6 +24,7 @@ const PROVIDER_TYPES = {
const hasWhatsappAppId = computed(() => {
return (
!IS_INSTAGRAM_WHATSAPP_INBOX_CREATION_DISABLED &&
window.chatwootConfig?.whatsappAppId &&
window.chatwootConfig.whatsappAppId !== 'none'
);
@@ -208,23 +208,31 @@ const actions = {
}
},
assignAgent: async ({ dispatch }, { conversationId, agentId }) => {
assignAgent: async (
{ dispatch },
{ conversationId, agentId, assigneeType }
) => {
try {
const response = await ConversationApi.assignAgent({
conversationId,
agentId,
assigneeType,
});
dispatch('setCurrentChatAssignee', {
conversationId,
assignee: response.data,
assigneeType,
});
} catch (error) {
// Handle error
}
},
setCurrentChatAssignee({ commit }, { conversationId, assignee }) {
commit(types.ASSIGN_AGENT, { conversationId, assignee });
setCurrentChatAssignee(
{ commit },
{ conversationId, assignee, assigneeType }
) {
commit(types.ASSIGN_AGENT, { conversationId, assignee, assigneeType });
},
assignTeam: async ({ dispatch }, { conversationId, teamId }) => {
@@ -108,10 +108,11 @@ export const mutations = {
}
},
[types.ASSIGN_AGENT](_state, { conversationId, assignee }) {
[types.ASSIGN_AGENT](_state, { conversationId, assignee, assigneeType }) {
const chat = getConversationById(_state)(conversationId);
if (chat) {
chat.meta.assignee = assignee;
chat.meta.assignee_type = assigneeType;
}
},
@@ -7,31 +7,52 @@ const state = {
},
};
const recordKey = (inboxId, { includeAgentBots = false } = {}) =>
includeAgentBots ? `${inboxId}:with_agent_bots` : inboxId;
export const types = {
SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG: 'SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG',
SET_INBOX_ASSIGNABLE_AGENTS: 'SET_INBOX_ASSIGNABLE_AGENTS',
};
export const getters = {
getAssignableAgents: $state => inboxId => {
const allAgents = $state.records[inboxId] || [];
const verifiedAgents = allAgents.filter(record => record.confirmed);
return verifiedAgents;
},
getAssignableAgents:
$state =>
(inboxId, options = {}) => {
const includeAgentBots = options.includeAgentBots || false;
const allAgents = $state.records[recordKey(inboxId, options)] || [];
const verifiedAgents = allAgents.filter(
record =>
record.confirmed ||
(includeAgentBots && record.assignee_type === 'AgentBot')
);
return verifiedAgents;
},
getUIFlags($state) {
return $state.uiFlags;
},
};
export const actions = {
async fetch({ commit }, inboxIds) {
async fetch({ commit }, actionPayload) {
const inboxIds = Array.isArray(actionPayload)
? actionPayload
: actionPayload.inboxIds;
const includeAgentBots =
!Array.isArray(actionPayload) && actionPayload.includeAgentBots;
commit(types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true });
try {
const {
data: { payload },
} = await AssignableAgentsAPI.get(inboxIds);
} = await AssignableAgentsAPI.get(inboxIds, { includeAgentBots });
if (includeAgentBots) {
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
members: payload,
});
}
commit(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: inboxIds.join(','),
inboxId: recordKey(inboxIds.join(','), { includeAgentBots }),
members: payload,
});
} catch (error) {
@@ -357,11 +357,12 @@ describe('#actions', () => {
});
await actions.assignAgent(
{ dispatch },
{ conversationId: 1, agentId: 1 }
{ conversationId: 1, agentId: 1, assigneeType: 'AgentBot' }
);
expect(dispatch).toHaveBeenCalledWith('setCurrentChatAssignee', {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
});
});
});
@@ -371,6 +372,7 @@ describe('#actions', () => {
const payload = {
conversationId: 1,
assignee: { id: 1, name: 'User' },
assigneeType: 'AgentBot',
};
await actions.setCurrentChatAssignee({ commit }, payload);
expect(commit).toHaveBeenCalledTimes(1);
@@ -712,8 +712,10 @@ describe('#mutations', () => {
mutations[types.ASSIGN_AGENT](state, {
conversationId: 1,
assignee,
assigneeType: 'AgentBot',
});
expect(state.allConversations[0].meta.assignee).toEqual(assignee);
expect(state.allConversations[0].meta.assignee_type).toEqual('AgentBot');
expect(state.allConversations[1].meta.assignee).toBeUndefined();
});
});
@@ -7,12 +7,21 @@ global.axios = axios;
vi.mock('axios');
describe('#actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('#fetch', () => {
it('sends correct actions if API is success', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch({ commit }, [1]);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
},
});
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[
@@ -24,13 +33,37 @@ describe('#actions', () => {
});
it('sends correct actions if API is error', async () => {
axios.get.mockRejectedValue({ message: 'Incorrect header' });
await expect(actions.fetch({ commit }, { inboxId: 1 })).rejects.toThrow(
Error
);
await expect(actions.fetch({ commit }, [1])).rejects.toThrow(Error);
expect(commit.mock.calls).toEqual([
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: true }],
[types.SET_INBOX_ASSIGNABLE_AGENTS_UI_FLAG, { isFetching: false }],
]);
});
it('requests agent bots only when opted in', async () => {
axios.get.mockResolvedValue({
data: { payload: agentsData },
});
await actions.fetch(
{ commit },
{ inboxIds: [1], includeAgentBots: true }
);
expect(axios.get).toHaveBeenCalledWith('/api/v1/assignable_agents', {
params: {
inbox_ids: [1],
include_agent_bots: true,
},
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1',
members: agentsData,
});
expect(commit).toHaveBeenCalledWith(types.SET_INBOX_ASSIGNABLE_AGENTS, {
inboxId: '1:with_agent_bots',
members: agentsData,
});
});
});
});
@@ -1,4 +1,4 @@
import { getters } from '../../teamMembers';
import { getters } from '../../inboxAssignableAgents';
import agentsData from './fixtures';
describe('#getters', () => {
@@ -8,7 +8,26 @@ describe('#getters', () => {
1: [agentsData[0]],
},
};
expect(getters.getTeamMembers(state)(1)).toEqual([agentsData[0]]);
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
});
it('keeps agent bots scoped to bot-inclusive lists', () => {
const agentBot = {
id: 1,
name: 'Captain',
assignee_type: 'AgentBot',
};
const state = {
records: {
1: [agentBot, agentsData[0]],
'1:with_agent_bots': [agentBot, agentsData[0]],
},
};
expect(getters.getAssignableAgents(state)(1)).toEqual([agentsData[0]]);
expect(
getters.getAssignableAgents(state)(1, { includeAgentBots: true })
).toEqual([agentBot, agentsData[0]]);
});
it('getUIFlags', () => {
@@ -63,6 +63,14 @@ const hasValue = computed(() => {
const hasIcon = computed(() => {
return props.selectedItem?.icon || false;
});
const isAgentBot = computed(
() => props.selectedItem?.assignee_type === 'AgentBot'
);
const selectedThumbnail = computed(
() => props.selectedItem?.thumbnail || props.selectedItem?.avatar_url
);
</script>
<template>
@@ -93,16 +101,25 @@ const hasIcon = computed(() => {
</h4>
</div>
<Avatar
v-if="hasValue && hasThumbnail && !hasIcon"
:src="selectedItem.thumbnail"
v-if="hasValue && hasThumbnail && (isAgentBot || !hasIcon)"
:src="selectedThumbnail"
:status="selectedItem.availability_status"
:name="selectedItem.name"
:icon-name="isAgentBot ? 'i-lucide-bot' : undefined"
:size="24"
hide-offline-status
rounded-full
/>
>
<template v-if="isAgentBot && selectedThumbnail" #badge>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
>
<Icon icon="i-lucide-bot" class="text-n-slate-11 size-2.5" />
</div>
</template>
</Avatar>
<div
v-if="hasValue && hasIcon && showEmojiIcon"
v-else-if="hasValue && hasIcon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
@@ -73,7 +73,13 @@ export default {
this.$refs.searchbar.focus();
},
isActive(option) {
return this.selectedItems.some(item => item && option.id === item.id);
return this.selectedItems.some(item => {
if (!item || option.id !== item.id) return false;
return (
(option.assignee_type || 'User') === (item.assignee_type || 'User')
);
});
},
},
};
@@ -94,7 +100,10 @@ export default {
<div class="flex items-start justify-start flex-auto overflow-auto mt-2">
<div class="w-full max-h-[10rem]">
<WootDropdownMenu>
<WootDropdownItem v-for="option in filteredOptions" :key="option.id">
<WootDropdownItem
v-for="option in filteredOptions"
:key="`${option.assignee_type || 'User'}-${option.id}`"
>
<NextButton
slate
:variant="isActive(option) ? 'faded' : 'ghost'"
@@ -114,16 +123,38 @@ export default {
</span>
</div>
<Avatar
v-if="hasThumbnail && !option.icon"
v-if="
hasThumbnail &&
(!option.icon || option.assignee_type === 'AgentBot')
"
:src="option.thumbnail"
:name="option.name"
:status="option.availability_status"
:icon-name="
option.assignee_type === 'AgentBot'
? 'i-lucide-bot'
: undefined
"
:size="24"
hide-offline-status
rounded-full
/>
>
<template
v-if="option.assignee_type === 'AgentBot' && option.thumbnail"
#badge
>
<div
class="absolute z-20 flex items-center justify-center rounded-full outline outline-1 outline-n-weak bg-n-solid-1 -bottom-0.5 ltr:-right-0.5 rtl:-left-0.5 size-3.5"
>
<Icon
icon="i-lucide-bot"
class="text-n-slate-11 size-2.5"
/>
</div>
</template>
</Avatar>
<div
v-if="option.icon && showEmojiIcon"
v-else-if="option.icon && showEmojiIcon"
class="flex items-center justify-center flex-shrink-0 text-sm rounded-full size-6 outline outline-1 -outline-offset-1 outline-n-weak"
>
<EmojiIcon
+1 -1
View File
@@ -34,7 +34,7 @@
# index_messages_on_conversation_id (conversation_id)
# index_messages_on_created_at (created_at)
# index_messages_on_inbox_id (inbox_id)
# index_messages_on_sender_type_and_sender_id (sender_type,sender_id)
# index_messages_on_sender_and_created (sender_type,sender_id,created_at)
# index_messages_on_source_id (source_id)
#
+1 -1
View File
@@ -30,7 +30,7 @@ class Team < ApplicationRecord
uniqueness: { scope: :account_id }
before_validation do
self.name = name.downcase if attribute_present?('name')
self.name = name.gsub(/[[:cntrl:]]/, '').strip.downcase if attribute_present?('name')
end
# Adds multiple members to the team
@@ -1,5 +1,16 @@
json.payload do
json.array! @assignable_agents do |agent|
json.partial! 'api/v1/models/agent', formats: [:json], resource: agent
owners = @assignable_agents.map { |agent| { type: 'User', resource: agent } }
owners += @agent_bots.map { |agent_bot| { type: 'AgentBot', resource: agent_bot } }
json.array! owners do |owner|
if owner[:type] == 'User'
json.partial! 'api/v1/models/agent', formats: [:json], resource: owner[:resource]
json.assignee_type 'User' if @include_agent_bots
else
json.partial! 'api/v1/models/agent_bot_slim', formats: [:json], resource: owner[:resource]
json.assignee_type 'AgentBot'
json.icon 'i-lucide-bot'
json.availability_status 'offline'
end
end
end
+2
View File
@@ -82,6 +82,8 @@ en:
file_too_large: 'File exceeds the maximum allowed size'
unsupported_content_type: 'File type not supported (only images and videos are allowed)'
unexpected: 'An unexpected error occurred'
database:
query_canceled: 'The request took too long to complete. Please try again.'
saml:
feature_not_enabled: SAML feature not enabled for this account
sso_not_enabled: SAML SSO is not enabled for this installation
+3
View File
@@ -66,6 +66,9 @@ Rails.application.routes.draw do
resources :assistants do
member do
post :playground
get :stats
get :summary
get :drilldown
end
collection do
get :tools
@@ -0,0 +1,21 @@
class AddSenderCreatedIndexToMessages < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
# Adds created_at to the (sender_type, sender_id) index so per-assistant
# windowed lookups (Captain Overview stats) can range-scan the time slice
# instead of reading every lifetime row and filtering at the heap. The new
# index is a left-prefix superset of the old one.
#
# TODO: drop the now-redundant index_messages_on_sender_type_and_sender_id
# once this index has been running in production long enough to confirm it
# fully replaces the old one.
def up
add_index :messages, [:sender_type, :sender_id, :created_at],
name: 'index_messages_on_sender_and_created', algorithm: :concurrently, if_not_exists: true
end
def down
remove_index :messages, name: 'index_messages_on_sender_and_created',
algorithm: :concurrently, if_exists: true
end
end
+2 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_06_30_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -1034,6 +1034,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
t.index ["conversation_id"], name: "index_messages_on_conversation_id"
t.index ["created_at"], name: "index_messages_on_created_at"
t.index ["inbox_id"], name: "index_messages_on_inbox_id"
t.index ["sender_type", "sender_id", "created_at"], name: "index_messages_on_sender_and_created"
t.index ["sender_type", "sender_id"], name: "index_messages_on_sender_type_and_sender_id"
t.index ["source_id"], name: "index_messages_on_source_id"
end
@@ -0,0 +1,162 @@
# Lists the underlying records behind a single Captain assistant stat card, so a
# viewer can drill from an aggregate (e.g. "auto-resolution 42%") into the exact
# conversations or messages that produced it.
#
# The window is resolved by Captain::AssistantStatsWindow from the same `range`
# and `timezone_offset` the stat card used, so the drilldown covers precisely the
# rows the card counted. Records are serialized with the shared reports drilldown
# serializer, so the existing frontend drilldown drawer/card can render them.
class Captain::AssistantDrilldownBuilder
ASSISTANT_SENDER_TYPE = 'Captain::Assistant'.freeze
RESOLVED_EVENT_NAMES = Captain::AssistantStatsBuilder::RESOLVED_EVENT_NAMES
HANDOFF_EVENT_NAMES = Captain::AssistantStatsBuilder::HANDOFF_EVENT_NAMES
# Metrics whose records are individual messages rather than conversations.
MESSAGE_METRICS = %w[hours_saved].freeze
SUPPORTED_METRICS = %w[
conversations_handled auto_resolution_rate handoff_rate hours_saved reopen_rate conversation_depth
].freeze
DEFAULT_PAGE = 1
DEFAULT_PER_PAGE = 25
MAX_PER_PAGE = 100
pattr_initialize :assistant, :params
def self.supported_metric?(metric) = SUPPORTED_METRICS.include?(metric.to_s)
def build
records = paginated_records.to_a
{ meta: meta, payload: records.map { |record| record_serializer(records).serialize(record) } }
end
private
def account = assistant.account
def window
@window ||= Captain::AssistantStatsWindow.new(params[:range], params[:timezone_offset])
end
def range = window.current
def meta
{
metric: metric,
record_type: record_type,
current_page: current_page,
per_page: per_page,
total_count: paginated_records.total_count,
conversation_count: conversation_count,
range: { since: range.first.to_i, until: range.last.to_i }
}
end
def conversation_count
return paginated_records.total_count unless message_metric?
drilldown_scope.except(:includes).reorder(nil).distinct.count(:conversation_id)
end
def paginated_records
@paginated_records ||= drilldown_scope.page(current_page).per(per_page)
end
def drilldown_scope
case metric
when 'conversations_handled' then handled_conversations
when 'auto_resolution_rate' then conversations_for(resolved_events.select(:conversation_id))
when 'handoff_rate' then event_conversations(HANDOFF_EVENT_NAMES)
when 'hours_saved' then public_reply_messages
when 'reopen_rate' then reopened_conversations
when 'conversation_depth' then depth_conversations
else
raise ArgumentError, "Unsupported assistant drilldown metric: #{metric}"
end
end
# Messages the assistant authored in the window; the cohort every metric derives from.
def handled_messages
account.messages.where(sender_type: ASSISTANT_SENDER_TYPE, sender_id: assistant.id, created_at: range)
end
def handled_conversation_ids
handled_messages.select(:conversation_id)
end
def handled_conversations
conversations_for(handled_conversation_ids)
end
# Public agent-facing replies the assistant sent; the rows behind hours_saved.
def public_reply_messages
handled_messages.where(message_type: :outgoing, private: false)
.includes(:sender, conversation: [:assignee, :contact, :inbox])
.reorder(created_at: :desc)
end
# Conversations in the handled cohort that recorded one of the given reporting
# events in the window (resolved or handed-off).
def event_conversations(event_names)
ids = account.reporting_events
.where(name: event_names, created_at: range, conversation_id: handled_conversation_ids)
.select(:conversation_id)
conversations_for(ids)
end
# Captain resolves in the window, excluding bot-resolved rows whose conversation
# was also handed off, mirroring AssistantStatsBuilder#resolved_clause so the
# drilldown lists exactly the conversations the auto-resolution card counted.
def resolved_events
handoff_ids = account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range, conversation_id: handled_conversation_ids)
.where("NOT (name = ? AND conversation_id IN (#{handoff_ids.to_sql}))",
Captain::AssistantStatsBuilder::BOT_RESOLVED_EVENT_NAME)
end
# Auto-resolved conversations that reopened at/after their Captain resolve,
# mirroring AssistantStatsBuilder#reopen_rate's numerator cohort.
def reopened_conversations
ids = account.reporting_events
.where(name: 'conversation_opened')
.where('reporting_events.value > 0')
.where('reporting_events.event_end_time <= ?', range.last)
.joins("INNER JOIN (#{resolved_events.to_sql}) resolves " \
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.select('reporting_events.conversation_id')
conversations_for(ids)
end
# Conversations the assistant sent at least one public reply in; the denominator behind conversation_depth.
def depth_conversations
conversations_for(handled_messages.where(message_type: :outgoing, private: false).select(:conversation_id))
end
def conversations_for(conversation_ids)
account.conversations
.where(id: conversation_ids)
.includes(:assignee, :contact, :inbox)
.order(created_at: :desc)
end
def record_serializer(records)
@record_serializer ||= V2::Reports::DrilldownRecordSerializer.new(account, metric, false, records)
end
def metric = params[:metric].to_s
def message_metric? = MESSAGE_METRICS.include?(metric)
def record_type = message_metric? ? 'message' : 'conversation'
def current_page = [params[:page].to_i, DEFAULT_PAGE].max
def per_page
requested_per_page = params[:per_page].to_i
requested_per_page = DEFAULT_PER_PAGE if requested_per_page <= 0
[requested_per_page, MAX_PER_PAGE].min
end
end
@@ -0,0 +1,219 @@
# Computes per-assistant overview metrics for the Captain Overview page.
# Each metric is returned for the current window and the previous equal-length
# window, plus a derived trend.
#
# Queries are batched to cut round trips: the message-derived counts (handled,
# public replies, depth) are computed for both windows in a single scan via
# conditional FILTER aggregation.
class Captain::AssistantStatsBuilder
RESOLVED_EVENT_NAMES = %w[conversation_captain_inference_resolved conversation_bot_resolved].freeze
HANDOFF_EVENT_NAMES = %w[conversation_captain_inference_handoff conversation_bot_handoff].freeze
BOT_RESOLVED_EVENT_NAME = 'conversation_bot_resolved'.freeze
# Assumed agent effort displaced by each public assistant reply. Reporting data
# only captures reply latency (customer wait time), not handling effort, so hours
# saved is a count-times-assumed-effort estimate rather than a measured duration.
SECONDS_SAVED_PER_REPLY = 2.minutes.to_i
attr_reader :assistant, :account
delegate :range, :period, to: :window
# `range` is either a day count ('7', '30', '90') or a named period
# ('this_month', 'last_month'). `timezone_offset` is the viewer's UTC offset in
# hours (as the reports API sends it), so month/day boundaries anchor to the
# viewer's day rather than UTC. Both windows are resolved by AssistantStatsWindow.
def initialize(assistant, range = Captain::AssistantStatsWindow::DEFAULT_RANGE, timezone_offset = nil)
@assistant = assistant
@account = assistant.account
@window = Captain::AssistantStatsWindow.new(range, timezone_offset)
end
def metrics
messages = message_window_metrics
current = window_metrics(current_range, messages[:current])
previous = window_metrics(previous_range, messages[:previous])
build_metrics(current, previous)
end
private
attr_reader :window
def current_range
window.current
end
def previous_range
window.previous
end
def build_metrics(current, previous)
{
conversations_handled: pack(current[:handled], previous[:handled], :percent),
auto_resolution_rate: pack(current[:auto_resolution], previous[:auto_resolution], :point),
handoff_rate: pack(current[:handoff], previous[:handoff], :point),
hours_saved: pack(current[:hours_saved], previous[:hours_saved], :percent),
reopen_rate: pack(current[:reopen], previous[:reopen], :point),
conversation_depth: pack(current[:depth], previous[:depth], :absolute),
knowledge: knowledge
}
end
# Combines the per-window message counts with the reporting-event metrics for one window.
def window_metrics(range, message_counts)
handled = message_counts[:handled]
public_count = message_counts[:public_count]
depth_conversations = message_counts[:depth_conversations]
resolution = resolution_counts(range)
{
handled: handled,
auto_resolution: rate(resolution[:resolved], handled),
handoff: rate(resolution[:handoff], handled),
hours_saved: (public_count * SECONDS_SAVED_PER_REPLY / 3600.0).round,
reopen: reopen_rate(range),
depth: depth_conversations.zero? ? 0 : (public_count.to_f / depth_conversations).round(1)
}
end
# One scan over the assistant's messages computes handled, public-reply count,
# and depth-conversation count for both windows via conditional aggregation.
def message_window_metrics
public_clause = "message_type = #{Message.message_types[:outgoing]} AND private = false"
cur = window_clause(current_range)
prev = window_clause(previous_range)
row = handled_scope(full_span).reorder(nil).pick(
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev})"),
Arel.sql("COUNT(*) FILTER (WHERE #{cur} AND #{public_clause})"),
Arel.sql("COUNT(*) FILTER (WHERE #{prev} AND #{public_clause})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{cur} AND #{public_clause})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{prev} AND #{public_clause})")
)
{
current: { handled: row[0], public_count: row[2], depth_conversations: row[4] },
previous: { handled: row[1], public_count: row[3], depth_conversations: row[5] }
}
end
# Resolved and handed-off conversation counts for one window, in a single scan
# of the handled set's reporting events.
def resolution_counts(range)
row = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES + HANDOFF_EVENT_NAMES,
created_at: range,
conversation_id: handled_scope(range).select(:conversation_id))
.reorder(nil)
.pick(
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE #{resolved_clause(range)})"),
Arel.sql("COUNT(DISTINCT conversation_id) FILTER (WHERE name IN (#{quoted(HANDOFF_EVENT_NAMES)}))")
)
{ resolved: row[0], handoff: row[1] }
end
# A countable resolve is any inference resolve, or a bot resolve on a conversation
# with no handoff in the window. conversation_bot_resolved fires on any resolve
# without an agent message (reporting_event_listener), so a handed-off conversation
# that goes quiet and gets closed would otherwise count as an auto-resolution too;
# the reports bot_resolutions metric applies the same exclusion (:exclude_bot_handoffs).
def resolved_clause(range)
"name IN (#{quoted(RESOLVED_EVENT_NAMES)}) AND #{bot_resolve_handoff_exclusion(range)}"
end
def bot_resolve_handoff_exclusion(range)
"NOT (name = #{quote(BOT_RESOLVED_EVENT_NAME)} AND conversation_id IN (#{handoff_conversation_ids(range).to_sql}))"
end
def handoff_conversation_ids(range)
account.reporting_events.where(name: HANDOFF_EVENT_NAMES, created_at: range).select(:conversation_id)
end
# Conversations the assistant participated in (authored any message).
def handled_scope(range)
account.messages.where(sender_type: 'Captain::Assistant', sender_id: assistant.id, created_at: range)
end
# Span covering both windows so a single scan can split them with FILTER.
def full_span
[current_range.first, previous_range.first].min..current_range.last
end
def window_clause(range)
"created_at >= #{quote(range.first)} AND created_at <= #{quote(range.last)}"
end
def quote(value)
account.class.connection.quote(value)
end
def quoted(values)
values.map { |value| quote(value) }.join(', ')
end
# Of the conversations Captain auto-resolved, the share reopened afterwards. The cohort is
# derived from the assistant's handled conversations (not current inbox membership) so a later
# inbox reassignment doesn't drop historical resolves, and covers both the evaluated (inference)
# and time-based (bot) resolve paths so the denominator matches auto_resolution_rate.
def reopen_rate(range)
resolved_scope = account.reporting_events
.where(name: RESOLVED_EVENT_NAMES, created_at: range,
conversation_id: handled_scope(range).select(:conversation_id))
.where(bot_resolve_handoff_exclusion(range))
# event_end_time on a reopen is when it actually reopened. Join it to the conversation's own
# Captain resolves and keep only reopens at/after one of them, so a human resolve/reopen earlier
# in the same window isn't mistaken for a reopen-after-Captain-resolve. (Comparing the reopen's
# start time instead would misfire: the inference event is dispatched just after the generic
# conversation_resolved that seeds event_start_time, so it can land after the reopen's start.)
# The reopen itself must also fall inside the window, so a completed range (last_month, the
# previous window) doesn't count reopens that happened after it ended.
reopened = account.reporting_events
.where(name: 'conversation_opened')
.where('reporting_events.value > 0')
.where('reporting_events.event_end_time <= ?', range.last)
.joins("INNER JOIN (#{resolved_scope.to_sql}) resolves " \
'ON resolves.conversation_id = reporting_events.conversation_id ' \
'AND reporting_events.event_end_time >= resolves.event_end_time')
.distinct.count('reporting_events.conversation_id')
rate(reopened, resolved_scope.distinct.count(:conversation_id))
end
# Approved/pending FAQ counts and the document total in a single round trip.
def knowledge
approved, pending, documents = Captain::AssistantResponse.by_assistant(assistant.id).reorder(nil).pick(
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['approved']})"),
Arel.sql("COUNT(*) FILTER (WHERE status = #{Captain::AssistantResponse.statuses['pending']})"),
Arel.sql("(SELECT COUNT(*) FROM captain_documents WHERE assistant_id = #{assistant.id.to_i})")
)
total = approved + pending
{
approved: approved,
pending: pending,
documents: documents,
coverage: total.zero? ? 0 : (approved.to_f / total * 100).round
}
end
def rate(numerator, denominator)
return 0 if denominator.zero?
(numerator.to_f / denominator * 100).round(1)
end
def pack(current, previous, mode)
{ current: current, previous: previous, trend: trend(current, previous, mode) }
end
def trend(current, previous, mode)
case mode
when :percent
previous.zero? ? 0 : ((current - previous).to_f / previous * 100).round(1)
else # :point and :absolute are both current - previous
(current - previous).round(1)
end
end
end
@@ -0,0 +1,78 @@
# Resolves the current and previous comparison windows for Captain assistant
# stats. `range` is either a day count ('7', '30', '90') or a named period
# ('this_month', 'last_month'). The previous window mirrors the current one: the
# preceding N days for day ranges, or the preceding month for month ranges.
# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
# it), so month/day boundaries anchor to the viewer's day rather than UTC.
#
# Shared by Captain::AssistantStatsBuilder (which needs both windows) and
# Captain::AssistantDrilldownBuilder (which drills into the current window), so a
# drilldown always covers exactly the rows its stat card counted.
class Captain::AssistantStatsWindow
include TimezoneHelper
DEFAULT_RANGE = '30'.freeze
ALLOWED_RANGES = %w[7 30 90 this_month last_month].freeze
attr_reader :range
def initialize(range = DEFAULT_RANGE, timezone_offset = nil)
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
@timezone = timezone_name_from_offset(timezone_offset) || Time.zone
end
def current
resolved_ranges[:current]
end
def previous
resolved_ranges[:previous]
end
# Human-readable description of the period the current window covers, for
# grounding the LLM summary in real dates.
def period
{ label: period_label, starts_on: current.first.to_date, ends_on: current.last.to_date }
end
private
def resolved_ranges
@resolved_ranges ||= case range
when 'this_month' then this_month_ranges
when 'last_month' then last_month_ranges
else day_ranges
end
end
# Current time anchored to the viewer's timezone, so calendar boundaries land on
# the viewer's day instead of UTC's.
def now
@now ||= Time.current.in_time_zone(@timezone)
end
def this_month_ranges
start = now.beginning_of_month
elapsed = now - start
previous_start = start - 1.month
# Clamp to the previous month's end so a longer current month can't pull the
# comparison window into the current month and double-count its rows.
previous_end = [previous_start + elapsed, previous_start.end_of_month].min
{ current: start..now, previous: previous_start..previous_end }
end
def last_month_ranges
start = (now - 1.month).beginning_of_month
previous_start = start - 1.month
{ current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
end
def day_ranges
days = range.to_i
{ current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
end
def period_label
{ 'this_month' => 'this month', 'last_month' => 'last month' }[range] || "the last #{range.to_i} days"
end
end
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_assistant, only: [:show, :update, :destroy, :playground]
before_action :set_assistant, only: [:show, :update, :destroy, :playground, :stats, :summary, :drilldown]
def index
@assistants = account_assistants.ordered
@@ -43,8 +43,53 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
@tools = assistant.available_agent_tools
end
def stats
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end
def summary
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
if result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
else
render json: { message: result[:message] }
end
end
def drilldown
return head :unprocessable_entity unless Captain::AssistantDrilldownBuilder.supported_metric?(params[:metric])
render json: Captain::AssistantDrilldownBuilder.new(@assistant, drilldown_params).build
end
private
def drilldown_params
params.permit(:metric, :range, :timezone_offset, :page, :per_page)
end
def cached_or_generated_summary(builder)
cache_key = summary_cache_key(builder.range)
cached = Rails.cache.read(cache_key)
return cached if cached
result = Captain::OverviewSummaryService.new(
account: Current.account,
assistant: @assistant,
first_name: Current.user.name.to_s.split.first,
stats: builder.metrics,
period: builder.period
).perform
# Don't cache transient LLM/config failures, otherwise every reload returns 422 for the next hour.
Rails.cache.write(cache_key, result, expires_in: 1.hour) unless result[:error]
result
end
def summary_cache_key(range)
"captain_overview_summary/#{@assistant.id}/#{Current.user.id}/#{range}/#{Date.current}"
end
def set_assistant
@assistant = account_assistants.find(params[:id])
end
@@ -29,6 +29,8 @@ class CaptainModelOverridesField < Administrate::Field::Base
end
def default_model_id(feature_key)
return Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL if feature_key == 'assistant' && resource.feature_enabled?('captain_integration_v2')
Llm::Models.default_model_for(feature_key)
end
+1 -1
View File
@@ -47,7 +47,7 @@ module Concerns::Agentable
def agent_model
route = Llm::FeatureRouter.resolve(feature: 'assistant', account: account)
return route[:model] if route[:source] == :account_override
return route[:model] if route[:source] == :account_override || account&.feature_enabled?('captain_integration_v2')
installation_model.presence || route[:model]
end
@@ -1,4 +1,9 @@
module Enterprise::Account
# Transitional marker for the Captain V1 to V2 rollout. New cloud accounts get
# this marker so plan reconciliation can enable V2 for them without upgrading
# existing paid accounts. Remove once every account is migrated to V2.
CAPTAIN_V2_DEFAULT_ELIGIBLE = 'captain_v2_default_eligible'.freeze
class << self
def captain_document_sync_intervals
parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
@@ -94,6 +99,15 @@ module Enterprise::Account
private
def enable_default_features
super
if ChatwootApp.self_hosted_enterprise?
enable_features('captain_integration', 'captain_integration_v2')
elsif ChatwootApp.chatwoot_cloud?
internal_attributes[CAPTAIN_V2_DEFAULT_ELIGIBLE] = true
end
end
def sync_assignment_features
if feature_enabled?('assignment_v2')
# Enable advanced_assignment for Business/Enterprise plans
@@ -11,6 +11,14 @@ class Captain::AssistantPolicy < ApplicationPolicy
true
end
def summary?
true
end
def drilldown?
@account_user.administrator?
end
def tools?
@account_user.administrator?
end
@@ -36,7 +36,9 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def perform
account.disable_features(*PREMIUM_PLAN_FEATURES)
account.disable_features('captain_integration_v2') if default_plan?
account.enable_features(*current_plan_features)
account.enable_features('captain_integration_v2') if captain_v2_default_eligible?
account.enable_features(*manually_managed_features)
account.save!
end
@@ -69,4 +71,8 @@ class Enterprise::Billing::ReconcilePlanFeaturesService
def manually_managed_features
@manually_managed_features ||= Internal::Accounts::InternalAttributesService.new(account).manually_managed_features
end
def captain_v2_default_eligible?
!default_plan? && account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE] == true
end
end
@@ -34,7 +34,7 @@ class Llm::BaseAiService
def setup_model
route = feature_route
return @model = route[:model] if account_override_route?(route)
return @model = route[:model] if account_override_route?(route) || captain_v2_assistant?
@model = @fallback_model.presence || installation_model.presence || route&.dig(:model) || DEFAULT_MODEL
end
@@ -49,6 +49,10 @@ class Llm::BaseAiService
route&.dig(:source) == :account_override
end
def captain_v2_assistant?
@llm_feature.to_s == 'assistant' && @llm_account&.feature_enabled?('captain_integration_v2')
end
def installation_model
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value
end
+1
View File
@@ -4,6 +4,7 @@
- sla
- custom_roles
- captain_integration
- captain_integration_v2
- captain_document_auto_sync
- csat_review_notes
- conversation_required_attributes
+83
View File
@@ -0,0 +1,83 @@
# Generates the LLM welcome summary for the Captain Overview page from the
# assistant's stats hash (see Captain::AssistantStatsBuilder). Renders the
# captain_overview_summary.liquid prompt and returns markdown.
class Captain::OverviewSummaryService < Captain::BaseTaskService
pattr_initialize [:account!, :assistant!, :first_name!, :stats!, :period!]
def perform
api_response = make_api_call(
feature: 'editor',
messages: [
{ role: 'system', content: system_prompt },
{ role: 'user', content: 'Write the summary.' }
]
)
return api_response if api_response[:error]
{ message: api_response[:message] }
end
private
def system_prompt
Liquid::Template.parse(prompt_from_file('captain_overview_summary')).render(prompt_variables)
end
def prompt_variables
stat_variables.merge(period_variables)
end
def stat_variables
{
'first_name' => first_name.to_s,
'assistant_name' => assistant.name.to_s,
'conversations_handled' => current(:conversations_handled),
'hours_saved' => current(:hours_saved),
'auto_resolution_rate' => current(:auto_resolution_rate),
'auto_resolution_trend' => trend(:auto_resolution_rate),
'handoff_rate' => current(:handoff_rate),
'handoff_trend' => trend(:handoff_rate),
'reopen_rate' => current(:reopen_rate),
'reopen_trend' => trend(:reopen_rate),
'knowledge_coverage' => stats.dig(:knowledge, :coverage).to_s,
'knowledge_approved' => stats.dig(:knowledge, :approved).to_s,
'knowledge_documents' => stats.dig(:knowledge, :documents).to_s
}
end
def period_variables
{
'today' => formatted_date(Time.zone.today),
'period_label' => period[:label].to_s,
'period_start' => formatted_date(period[:starts_on]),
'period_end' => formatted_date(period[:ends_on])
}
end
def formatted_date(date)
date.strftime('%B %-d, %Y')
end
def current(key)
stats.dig(key, :current).to_s
end
def trend(key)
stats.dig(key, :trend).to_s
end
def event_name
'captain_overview_summary'
end
def use_account_openai_hook?
true
end
# The overview summary is an internal analytics readout, not a customer-facing
# response, so it should not consume or be blocked by the captain_responses quota.
def counts_toward_usage?
false
end
end
@@ -0,0 +1,38 @@
You are writing a short, warm summary of how an AI support assistant named "{{ assistant_name }}" performed over a reporting period, for {{ first_name }}, the person who manages it.
Voice and format:
- Address {{ first_name }} directly and open with "Hey {{ first_name }},". Be conversational, never robotic.
- Always call the assistant by its name, {{ assistant_name }}. Never call it "Captain", "the assistant", or "your assistant".
- This is a static, read-only poster on an analytics dashboard, not a chat. The reader cannot reply or ask you for anything. Never ask a question, invite a reply, offer further help, or say things like "let me know" or "I can dive in".
- Write 2 to 4 sentences in one short paragraph. Add a second short paragraph only for a genuinely useful heads-up.
- Output plain markdown only: no headings, lists, preamble, or sign-off. Do not use em dashes.
- Never state an exact figure. This summary is cached and the live numbers keep moving, so a precise value would quickly look wrong. Round every number down to a clean approximation and soften it with words like "around", "roughly", "about", "nearly", "just over", or "upwards of". For example, render **1,248** as "upwards of **1,200**", **63.2%** as "around **60%**", and **612** hours as "roughly **600** hours". For a small count, use a loose phrase like "a handful" instead of the exact number.
- Wrap the approximate figure in **double asterisks** so the interface can highlight it. Bold only the figures, never whole phrases or the softening word.
Timing: today is {{ today }}. These stats cover {{ period_label }} ({{ period_start }} to {{ period_end }}). You may lightly reference the month, the season, or how far into the period things stand when it genuinely fits, but never invent events or facts.
The stats for this period:
<stats>
- Conversations handled: {{ conversations_handled }}. Distinct conversations {{ assistant_name }} replied in at least once. Raw volume and adoption, not a measure of quality.
- Hours saved: {{ hours_saved }} hours. A rough, directional estimate of agent time saved. A feel-good figure, not exact measured labor.
- Auto-resolution rate: {{ auto_resolution_rate }}% ({{ auto_resolution_trend }} points vs previous period). Of the conversations it handled, the share {{ assistant_name }} resolved on its own with no human reply. The core performance signal; higher is better.
- Handoff rate: {{ handoff_rate }}% ({{ handoff_trend }} points vs previous period). Of the conversations it handled, the share it escalated to a human agent. The inverse of deflection; lower is better.
- Reopen-after-resolve rate: {{ reopen_rate }}% ({{ reopen_trend }} points vs previous period). Of the conversations it auto-resolved, the share later reopened. A quality signal; lower is better, and a high value means it closed conversations the customer was not actually done with.
- Knowledge base: {{ knowledge_approved }} approved FAQ answers, {{ knowledge_documents }} documents, {{ knowledge_coverage }}% coverage (the share of FAQ answers the team has approved). This is setup the team controls, not something {{ assistant_name }} earned. It is a leading indicator: low coverage tends to cause low auto-resolution.
</stats>
Only the auto-resolution, handoff, and reopen rates reflect how {{ assistant_name }} actually performed, and they are the only things worth crediting it for. Conversations handled and hours saved are context. The knowledge base is an input, never a win to praise.
How to judge the numbers (rough bands, do not quote them in the summary):
- Auto-resolution rate: below 30% is low and early-stage, 30 to 50% is decent, above 50% is genuinely strong.
- Handoff rate: above 60% is high, 30 to 60% is moderate, below 30% is strong.
- Reopen-after-resolve rate: below 5% is healthy, 5 to 15% is worth watching, above 15% is a real problem.
- Knowledge coverage: only worth mentioning when below 85% (below 60% is seriously thin), as a likely cause of weak auto-resolution. At 85% or above it is just the healthy baseline, so do not mention or praise it.
- When the conversation volume is small (roughly under 30), rates are noisy, so describe them tentatively and do not over-interpret a perfect or terrible looking percentage.
Writing the summary:
- Cold start: if conversations handled is 0, there is no performance to report. Skip the auto-resolution, handoff, reopen, and hours-saved figures entirely. Instead note the knowledge base and say {{ assistant_name }} is set up and ready to start handling support (or ready to start once some knowledge is added, if the base is empty). Ignore the rest of these points in this case.
- Be honest and proportionate. Do not call a result impressive, strong, excellent, solid, flawless, or perfect unless it clears the "strong" band above. State a low or middling number plainly or as room to grow, never dressed up. A modest summary is fine and often correct.
- Lead with the genuinely strong results if there are any. If nothing clears the strong band, open plainly with the volume of work handled, without overselling it.
- Mention a trend only when it is meaningful, and judge it against the bands rather than the direction alone (a rate that rose but is still in the low band is not yet a win).
- Surface at most one proactive concern when a stat warrants it (a high handoff rate, a low auto-resolution rate, a rising reopen rate, or thin coverage). Skip it entirely when everything looks healthy. Keep it a calm observation about the data, not an alarm.
+10
View File
@@ -1,6 +1,8 @@
module Llm::FeatureRouter
class UnknownFeatureError < StandardError; end
CAPTAIN_V2_ASSISTANT_MODEL = 'gpt-5.2'.freeze
class << self
def resolve(feature:, account: nil)
feature_key = feature.to_s
@@ -8,6 +10,7 @@ module Llm::FeatureRouter
model = account_model_override(account, feature_key)
source = model.present? ? :account_override : :default
model ||= captain_v2_assistant_model(account, feature_key)
model ||= Llm::Models.default_model_for(feature_key)
{
@@ -25,5 +28,12 @@ module Llm::FeatureRouter
return unless model
return model if Llm::Models.valid_model_for?(feature_key, model)
end
def captain_v2_assistant_model(account, feature_key)
return unless feature_key == 'assistant'
return unless account&.feature_enabled?('captain_integration_v2')
CAPTAIN_V2_ASSISTANT_MODEL
end
end
end
@@ -0,0 +1,203 @@
# frozen_string_literal: true
require 'faker'
require 'active_support/testing/time_helpers'
# Seeds Captain assistant activity for the reports/overview test data.
#
# Produces a variety of assistant-handled conversations in a single web inbox so
# every Captain assistant overview metric (handled, auto-resolution, handoff,
# hours saved, reopen rate, conversation depth) has realistic data:
# - :resolved_by_assistant assistant answers and Captain auto-resolves
# - :handled_by_both assistant answers, a human also replies and resolves
# - :handed_off assistant answers, then hands off to a human
# - :resolved_and_reopened Captain resolves, then the conversation reopens
#
# Reporting events are fired through ReportingEventListener directly (mirroring
# ConversationCreator) so the same rows the builder reads from get populated.
class Seeders::Reports::AssistantConversationCreator
include ActiveSupport::Testing::TimeHelpers
OUTCOMES = %i[resolved_by_assistant handled_by_both handed_off resolved_and_reopened].freeze
def initialize(account:, assistant:, inbox:, resources:)
@account = account
@assistant = assistant
@inbox = inbox
@contacts = resources[:contacts]
@agents = inbox.members.to_a.presence || resources[:agents]
end
def create_conversation(created_at:, outcome:)
conversation = nil
travel_to(created_at) do
conversation = build_conversation
conversation.save!
seed_dialogue(conversation, outcome)
end
travel_back
apply_outcome(conversation, created_at, outcome)
conversation
end
private
def build_conversation
contact = @contacts.sample
contact_inbox = @inbox.contact_inboxes.find_or_create_by!(contact: contact, source_id: SecureRandom.hex)
contact_inbox.conversations.create!(
account: @account,
inbox: @inbox,
contact: contact,
priority: [nil, 'high', 'medium', 'low'].sample
)
end
# Builds the message exchange for the conversation while time is frozen at its
# creation moment. Every outcome starts with a customer question and at least
# one public assistant reply so the conversation lands in the assistant's
# handled set; some outcomes add a human reply or a handoff.
def seed_dialogue(conversation, outcome)
customer_message = incoming_message(conversation)
travel(rand((20.seconds)..(5.minutes)))
assistant_reply(conversation, waiting_since: customer_message.created_at)
case outcome
when :handed_off then seed_handoff(conversation)
when :handled_by_both then seed_human_turn(conversation)
else seed_assistant_follow_up(conversation)
end
end
def seed_handoff(conversation)
travel(rand((1.minute)..(10.minutes)))
handoff_to_human(conversation)
travel(rand((1.minute)..(15.minutes)))
human_reply(conversation)
end
def seed_human_turn(conversation)
travel(rand((1.minute)..(15.minutes)))
human_reply(conversation)
end
# Pure assistant threads occasionally take a second turn, giving depth > 1.
def seed_assistant_follow_up(conversation)
return unless rand < 0.6
travel(rand((1.minute)..(10.minutes)))
follow_up = incoming_message(conversation)
travel(rand((20.seconds)..(5.minutes)))
assistant_reply(conversation, waiting_since: follow_up.created_at)
end
def apply_outcome(conversation, created_at, outcome)
resolved_at = created_at + rand((30.minutes)..(8.hours))
case outcome
when :resolved_by_assistant
resolve_by_captain(conversation, resolved_at)
when :handled_by_both
resolve_by_human(conversation, resolved_at)
when :handed_off
resolve_by_human(conversation, resolved_at) if rand < 0.6
when :resolved_and_reopened
resolve_by_captain(conversation, resolved_at)
reopen(conversation, resolved_at + rand((1.hour)..(24.hours)))
end
end
def incoming_message(conversation)
conversation.messages.create!(
account: @account,
inbox: @inbox,
message_type: :incoming,
content: Faker::Lorem.paragraph(sentence_count: rand(1..3)),
sender: conversation.contact
)
end
def assistant_reply(conversation, waiting_since:)
message = conversation.messages.create!(
account: @account,
inbox: @inbox,
message_type: :outgoing,
private: false,
content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
sender: @assistant
)
trigger_reply_time(message, waiting_since)
message
end
def human_reply(conversation)
agent = @agents.sample
conversation.update_column(:assignee_id, agent.id) if conversation.assignee_id.nil? # rubocop:disable Rails/SkipsModelValidations
conversation.messages.create!(
account: @account,
inbox: @inbox,
message_type: :outgoing,
private: false,
content: Faker::Lorem.paragraph(sentence_count: rand(1..4)),
sender: agent
)
end
def resolve_by_captain(conversation, resolved_at)
mark_resolved(conversation, resolved_at)
travel_to(resolved_at) do
trigger_event('conversation_resolved', conversation)
trigger_event('conversation_captain_inference_resolved', conversation)
end
travel_back
end
def resolve_by_human(conversation, resolved_at)
mark_resolved(conversation, resolved_at)
travel_to(resolved_at) do
trigger_event('conversation_resolved', conversation)
end
travel_back
end
def reopen(conversation, reopened_at)
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:status, :open)
conversation.update_column(:updated_at, reopened_at)
# rubocop:enable Rails/SkipsModelValidations
travel_to(reopened_at) do
trigger_event('conversation_opened', conversation)
end
travel_back
end
def handoff_to_human(conversation)
trigger_event('conversation_captain_inference_handoff', conversation)
end
def mark_resolved(conversation, resolved_at)
# rubocop:disable Rails/SkipsModelValidations
conversation.update_column(:status, :resolved)
conversation.update_column(:updated_at, resolved_at)
# rubocop:enable Rails/SkipsModelValidations
end
def trigger_event(name, conversation)
ReportingEventListener.instance.public_send(
name, Events::Base.new(name, Time.current, { conversation: conversation })
)
end
def trigger_reply_time(message, waiting_since)
ReportingEventListener.instance.reply_created(
Events::Base.new('reply_created', Time.current,
{ message: message, conversation: message.conversation, waiting_since: waiting_since })
)
end
end
+100 -2
View File
@@ -17,6 +17,9 @@
# - 5 teams with realistic distribution
# - 30 labels with random assignments
# - 3 inboxes with agent assignments
# - 1 Captain assistant bound to a single web inbox, with knowledge (FAQs + documents)
# and a variety of assistant-handled conversations (auto-resolved, handed off,
# handled with a human, resolved-then-reopened) for the assistant overview page
# - Realistic reporting events with historical timestamps
#
# Note: This seeder clears existing data for the account before seeding.
@@ -24,8 +27,9 @@
require 'faker'
require_relative 'conversation_creator'
require_relative 'message_creator'
require_relative 'assistant_conversation_creator'
# rubocop:disable Rails/Output
# rubocop:disable Rails/Output, Metrics/ClassLength
class Seeders::Reports::ReportDataSeeder
include ActiveSupport::Testing::TimeHelpers
@@ -36,6 +40,11 @@ class Seeders::Reports::ReportDataSeeder
TOTAL_LABELS = 30
TOTAL_INBOXES = 3
MESSAGES_PER_CONVERSATION = 5
# Captain assistant conversations, split across the outcomes the overview page reports on.
TOTAL_ASSISTANT_CONVERSATIONS = 120
ASSISTANT_KNOWLEDGE_APPROVED = 14
ASSISTANT_KNOWLEDGE_PENDING = 6
ASSISTANT_DOCUMENTS = 4
START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
END_DATE = Time.current
@@ -48,6 +57,8 @@ class Seeders::Reports::ReportDataSeeder
@labels = []
@inboxes = []
@contacts = []
@assistant = nil
@assistant_inbox = nil
end
def perform!
@@ -61,8 +72,10 @@ class Seeders::Reports::ReportDataSeeder
create_labels
create_inboxes
create_contacts
create_assistant
create_conversations
create_assistant_conversations
puts "Completed reports data seeding for account: #{@account.name}"
end
@@ -71,6 +84,7 @@ class Seeders::Reports::ReportDataSeeder
def clear_existing_data
puts "Clearing existing data for account: #{@account.id}"
clear_assistant_data
@account.teams.destroy_all
@account.conversations.destroy_all
@account.labels.destroy_all
@@ -80,6 +94,16 @@ class Seeders::Reports::ReportDataSeeder
@account.reporting_events.destroy_all
end
# Delete Captain records directly (assistant associations are destroy_async, which
# would leave rows around mid-reseed); order respects foreign keys.
def clear_assistant_data
assistant_ids = Captain::Assistant.for_account(@account.id).select(:id)
Captain::AssistantResponse.by_account(@account.id).delete_all
Captain::Document.for_account(@account.id).delete_all
CaptainInbox.where(captain_assistant_id: assistant_ids).delete_all
Captain::Assistant.for_account(@account.id).delete_all
end
def create_teams
TOTAL_TEAMS.times do |i|
team = @account.teams.create!(
@@ -208,6 +232,80 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
# One assistant, bound to a single web inbox (the first one), as the overview page expects.
def create_assistant
@account.enable_features!('captain_integration', 'captain_integration_v2')
@assistant_inbox = @inboxes.first
@assistant = Captain::Assistant.create!(
account: @account,
name: "#{Faker::Company.name} Copilot",
description: 'Captain assistant handling website support conversations.',
config: { feature_faq: true, feature_memory: true, product_name: @account.name }
)
CaptainInbox.create!(captain_assistant: @assistant, inbox: @assistant_inbox)
create_assistant_knowledge
puts "Created assistant '#{@assistant.name}' for inbox '#{@assistant_inbox.name}'"
end
def create_assistant_knowledge
ASSISTANT_KNOWLEDGE_APPROVED.times { create_assistant_response(:approved) }
ASSISTANT_KNOWLEDGE_PENDING.times { create_assistant_response(:pending) }
ASSISTANT_DOCUMENTS.times do
Captain::Document.create!(
account: @account,
assistant: @assistant,
name: Faker::Company.catch_phrase,
external_link: "https://#{Faker::Internet.domain_name}/#{Faker::Internet.slug}",
content: Faker::Lorem.paragraphs(number: rand(2..4)).join("\n\n"),
status: :available,
sync_status: :synced
)
end
end
def create_assistant_response(status)
Captain::AssistantResponse.create!(
account: @account,
assistant: @assistant,
question: "#{Faker::Lorem.sentence(word_count: rand(4..8)).chomp('.')}?",
answer: Faker::Lorem.paragraph(sentence_count: rand(2..4)),
status: status
)
end
def create_assistant_conversations
creator = Seeders::Reports::AssistantConversationCreator.new(
account: @account,
assistant: @assistant,
inbox: @assistant_inbox,
resources: { contacts: @contacts, agents: @agents }
)
outcomes = assistant_outcome_distribution
outcomes.each_with_index do |outcome, i|
created_at = Faker::Time.between(from: 65.days.ago, to: END_DATE)
creator.create_conversation(created_at: created_at, outcome: outcome)
print "\rCreating assistant conversations: #{i + 1}/#{outcomes.size}"
end
print "\n"
end
# Weighted mix of outcomes so every overview metric has meaningful numbers, shuffled
# so they interleave across the time span rather than clustering by type.
def assistant_outcome_distribution
counts = {
resolved_by_assistant: (TOTAL_ASSISTANT_CONVERSATIONS * 0.4).round,
handled_by_both: (TOTAL_ASSISTANT_CONVERSATIONS * 0.25).round,
handed_off: (TOTAL_ASSISTANT_CONVERSATIONS * 0.2).round,
resolved_and_reopened: (TOTAL_ASSISTANT_CONVERSATIONS * 0.15).round
}
counts.flat_map { |outcome, count| [outcome] * count }.shuffle
end
def create_conversations
conversation_creator = Seeders::Reports::ConversationCreator.new(
account: @account,
@@ -231,4 +329,4 @@ class Seeders::Reports::ReportDataSeeder
print "\n"
end
end
# rubocop:enable Rails/Output
# rubocop:enable Rails/Output, Metrics/ClassLength
@@ -62,6 +62,24 @@ RSpec.describe 'Assignable Agents API', type: :request do
expect(response_data.size).to eq(2)
expect(response_data.pluck(:role)).to include('agent', 'administrator')
end
context 'with Agent Bots' do
let!(:account_bot) { create(:agent_bot, account: account, name: 'Account bot') }
let!(:global_bot) { create(:agent_bot, account: nil, name: 'Global bot') }
it 'returns assignable agents and accessible agent bots' do
get "/api/v1/accounts/#{account.id}/assignable_agents",
params: { inbox_ids: [inbox1.id, inbox2.id], include_agent_bots: true },
headers: agent1.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
response_data = response.parsed_body['payload']
expect(response_data.pluck('assignee_type')).to include('User', 'AgentBot')
expect(response_data.pluck('name')).to include(agent1.name, admin.name, account_bot.name, global_bot.name)
end
end
end
end
end
@@ -67,6 +67,50 @@ RSpec.describe 'Api::V1::Accounts::Captain::Preferences', type: :request do
source: 'default'
)
end
it 'returns the assistant YAML default for V1 accounts' do
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::Models.default_model_for('assistant'),
selected: Llm::Models.default_model_for('assistant'),
source: 'default'
)
end
it 'returns GPT-5.2 as the assistant default for V2 accounts' do
account.enable_features!('captain_integration_v2')
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
source: 'default'
)
end
it 'keeps the V2 assistant default when an account override is selected' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
get "/api/v1/accounts/#{account.id}/captain/preferences",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(json_response.dig(:features, :assistant)).to include(
default: Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL,
selected: 'gpt-5.1',
source: 'account_override'
)
end
end
end
@@ -51,6 +51,22 @@ RSpec.describe 'Conversation Messages API', type: :request do
expect(json_response['error']).to eq('Validation failed: Content is too long (maximum is 150000 characters)')
end
it 'returns a customer-safe error when the database query is canceled' do
message_builder = instance_double(Messages::MessageBuilder)
allow(Messages::MessageBuilder).to receive(:new).and_return(message_builder)
allow(message_builder).to receive(:perform)
.and_raise(ActiveRecord::QueryCanceled, 'PG::QueryCanceled: ERROR: canceling statement due to statement timeout')
post api_v1_account_conversation_messages_url(account_id: account.id, conversation_id: conversation.display_id),
params: { content: 'test-message', private: true },
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq(I18n.t('errors.database.query_canceled'))
expect(response.parsed_body['error']).not_to include('PG::QueryCanceled')
end
it 'creates an outgoing text message with a specific bot sender' do
agent_bot = create(:agent_bot)
time_stamp = Time.now.utc.to_s
@@ -116,6 +116,78 @@ RSpec.describe '/api/v1/widget/contacts', type: :request do
end
end
describe 'PATCH /api/v1/widget/contact with HMAC enforcement' do
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
let!(:victim) { create(:contact, account: account, identifier: 'victim-identifier', name: 'Victim') }
let(:correct_identifier_hash) { OpenSSL::HMAC.hexdigest('sha256', web_widget.hmac_token, 'victim-identifier') }
context 'when an identifier is supplied on a mandatory-hmac inbox' do
it 'rejects when identifier_hash is omitted' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is blank' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: '', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is null' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier', identifier_hash: nil, name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'rejects when identifier_hash is invalid' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
identifier_hash: 'DEFINITELY_INVALID_AAAAA_NOT_A_REAL_HMAC', name: 'Attacker' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unauthorized)
expect(victim.reload.name).to eq('Victim')
end
it 'succeeds when a valid identifier_hash is provided' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, identifier: 'victim-identifier',
identifier_hash: correct_identifier_hash, name: 'Legit' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when no identifier is supplied (anonymous prechat update)' do
it 'allows updating name/email without an identifier_hash' do
patch '/api/v1/widget/contact',
params: { website_token: web_widget.website_token, email: 'prechat@test.com', name: 'Prechat User' },
headers: { 'X-Auth-Token' => token },
as: :json
expect(victim.reload.email).to be_nil
expect(Contact.from_email('prechat@test.com')).to be_present
expect(response).to have_http_status(:success)
end
end
end
describe 'PATCH /api/v1/widget/contact/set_user' do
let(:params) { { website_token: web_widget.website_token, identifier: 'test' } }
let(:web_widget) { create(:channel_widget, account: account, hmac_mandatory: true) }
@@ -65,6 +65,21 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(editor_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
it 'shows the Captain V2 assistant default in the model selector', if: ChatwootApp.enterprise? do
account.enable_features!('captain_integration_v2')
sign_in(super_admin, scope: :super_admin)
get "/super_admin/accounts/#{account.id}/edit"
document = Nokogiri::HTML(response.body)
assistant_select = document.at_css('select[name="account[captain_models][assistant]"]')
default_model_id = Llm::FeatureRouter::CAPTAIN_V2_ASSISTANT_MODEL
default_model = Llm::Models.model_config(default_model_id)['display_name']
expect(response).to have_http_status(:success)
expect(assistant_select.at_css('option[value=""]').text.squish).to eq("Use default: #{default_model} (#{default_model_id})")
end
end
end
@@ -97,6 +112,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
it 'rejects invalid Captain model overrides' do
sign_in(super_admin, scope: :super_admin)
existing_captain_models = account.captain_models
patch "/super_admin/accounts/#{account.id}",
params: {
@@ -112,7 +128,7 @@ RSpec.describe 'Super Admin accounts API', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
expect(response.body).to include('not a valid model for label_suggestion')
expect(account.reload.captain_models).to be_nil
expect(account.reload.captain_models).to eq(existing_captain_models)
end
end
end
@@ -0,0 +1,271 @@
require 'rails_helper'
RSpec.describe Captain::AssistantStatsBuilder do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:inbox) { create(:inbox, account: account) }
before { create(:captain_inbox, captain_assistant: assistant, inbox: inbox) }
describe '#metrics' do
# Two conversations handled in the current 30-day window, one in the previous.
let(:current_convo_a) { create(:conversation, account: account, inbox: inbox) }
let(:current_convo_b) { create(:conversation, account: account, inbox: inbox) }
let(:previous_convo) { create(:conversation, account: account, inbox: inbox) }
before do
[current_convo_a, current_convo_b].each do |conversation|
create(:message, account: account, inbox: inbox, conversation: conversation,
sender: assistant, message_type: :outgoing, private: false, created_at: 5.days.ago)
end
create(:message, account: account, inbox: inbox, conversation: previous_convo,
sender: assistant, message_type: :outgoing, private: false, created_at: 45.days.ago)
end
it 'returns every metric for the current and previous window' do
metrics = described_class.new(assistant, '30').metrics
expect(metrics.keys).to contain_exactly(
:conversations_handled, :auto_resolution_rate, :handoff_rate,
:hours_saved, :reopen_rate, :conversation_depth, :knowledge
)
expect(metrics[:conversations_handled]).to include(:current, :previous, :trend)
end
it 'counts distinct handled conversations per window and the percent trend' do
handled = described_class.new(assistant, '30').metrics[:conversations_handled]
expect(handled[:current]).to eq(2)
expect(handled[:previous]).to eq(1)
expect(handled[:trend]).to eq(100.0)
end
it 'derives auto-resolution and handoff rates from reporting events on the handled set' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved')
create(:reporting_event, account: account, conversation: current_convo_b,
name: 'conversation_captain_inference_handoff')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'does not count a bot resolve as an auto-resolution when the conversation was handed off' do
# convo_a: handoff, customer goes quiet, resolve lands without an agent message, so the
# listener still emits conversation_bot_resolved for the handed-off conversation. It must
# not count as an auto-resolution, but still counts as a handoff.
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_bot_handoff')
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_bot_resolved')
# convo_b: a clean bot resolve with no handoff still counts, so the exclusion is scoped
# to handed-off conversations and doesn't drop every bot resolve.
create(:reporting_event, account: account, conversation: current_convo_b,
name: 'conversation_bot_resolved')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'still counts an inference resolve when the conversation was also handed off' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_handoff')
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved')
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(50.0)
expect(metrics[:handoff_rate][:current]).to eq(50.0)
end
it 'excludes resolution events that fall outside the current window' do
create(:reporting_event, account: account, conversation: current_convo_a,
name: 'conversation_captain_inference_resolved', created_at: 60.days.ago)
metrics = described_class.new(assistant, '30').metrics
expect(metrics[:auto_resolution_rate][:current]).to eq(0.0)
end
it 'computes conversation depth as public replies per handled conversation' do
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
# 2 public outgoing replies across 2 distinct conversations in the current window.
expect(depth[:current]).to eq(1.0)
end
it 'ignores private notes and incoming messages when counting public replies' do
create(:message, account: account, inbox: inbox, conversation: current_convo_a,
sender: assistant, message_type: :outgoing, private: true, created_at: 5.days.ago)
depth = described_class.new(assistant, '30').metrics[:conversation_depth]
expect(depth[:current]).to eq(1.0)
end
end
describe 'range handling' do
it 'accepts the allowed day and named ranges' do
%w[7 30 90 this_month last_month].each do |allowed|
expect(described_class.new(assistant, allowed).range).to eq(allowed)
end
end
it 'falls back to the default range for values outside the allowed set' do
expect(described_class.new(assistant, '365000').range).to eq('30')
expect(described_class.new(assistant, 'bogus').range).to eq('30')
expect(described_class.new(assistant, nil).range).to eq('30')
end
end
describe '#metrics reopen_rate' do
# A conversation the assistant handled (messaged) inside the current 30-day window.
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
before do
create(:message, account: account, inbox: inbox, conversation: conversation,
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
end
it 'counts a reopen that happened after the captain resolve' do
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
it 'ignores a human resolve/reopen that happened before the captain resolve' do
# Earlier resolve/reopen cycle, then Captain resolves later in the same window.
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 20.days.ago, event_end_time: 18.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_bot_resolved', event_start_time: 5.days.ago, event_end_time: 5.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(0.0)
end
it 'counts an evaluated-path reopen when bot_resolved is skipped and the inference event is newer' do
# Prior human reply => create_bot_resolved_event skips conversation_bot_resolved, so the cohort
# only holds the inference event, which is dispatched a moment after the generic conversation_resolved
# that seeds the reopen's event_start_time. The match must use the reopen's actual reopen time.
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved',
event_start_time: 6.days.ago, event_end_time: 6.days.ago + 1.second)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 3.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
it 'counts both inference and time-based bot resolves in the denominator' do
# conversation: inference-resolved and reopened
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
# other: time-based bot-resolved, never reopened
other = create(:conversation, account: account, inbox: inbox)
create(:message, account: account, inbox: inbox, conversation: other,
sender: assistant, message_type: :outgoing, private: false, created_at: 8.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: other,
name: 'conversation_bot_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(50.0)
end
it 'ignores a reopen that landed after a completed window ended' do
travel_to(Time.utc(2026, 7, 15)) do
convo = create(:conversation, account: account, inbox: inbox)
create(:message, account: account, inbox: inbox, conversation: convo,
sender: assistant, message_type: :outgoing, private: false, created_at: Time.utc(2026, 6, 10))
create(:reporting_event, account: account, inbox: inbox, conversation: convo,
name: 'conversation_bot_resolved', created_at: Time.utc(2026, 6, 12),
event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 6, 12))
# Reopened on July 1, after the June window closed; June's rate must not count it.
create(:reporting_event, account: account, inbox: inbox, conversation: convo,
name: 'conversation_opened', value: 120,
event_start_time: Time.utc(2026, 6, 12), event_end_time: Time.utc(2026, 7, 1))
expect(described_class.new(assistant, 'last_month').metrics[:reopen_rate][:current]).to eq(0.0)
end
end
it 'derives the cohort from handled conversations, not current inbox membership' do
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_captain_inference_resolved', event_start_time: 6.days.ago, event_end_time: 6.days.ago)
create(:reporting_event, account: account, inbox: inbox, conversation: conversation,
name: 'conversation_opened', value: 120, event_start_time: 6.days.ago, event_end_time: 4.days.ago)
# The assistant is later removed from the inbox; the cohort must still resolve via handled messages.
CaptainInbox.where(captain_assistant: assistant).delete_all
expect(described_class.new(assistant, '30').metrics[:reopen_rate][:current]).to eq(100.0)
end
end
describe 'timezone anchoring' do
# 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
it 'anchors the this_month window to the supplied offset, not UTC' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
utc = described_class.new(assistant, 'this_month').period
la = described_class.new(assistant, 'this_month', -7).period
expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
end
end
it 'defaults to UTC when no offset is given' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
end
end
end
describe '#metrics knowledge' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)
create(:captain_assistant_response, assistant: assistant, account: account, status: :pending)
create_list(:captain_document, 2, assistant: assistant, account: account)
end
it 'returns approved, pending, document counts and coverage' do
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge).to eq(approved: 3, pending: 1, documents: 2, coverage: 75)
end
it 'reports zero coverage when there are no responses' do
Captain::AssistantResponse.where(assistant: assistant).delete_all
knowledge = described_class.new(assistant, '30').metrics[:knowledge]
expect(knowledge[:coverage]).to eq(0)
end
end
describe '#period' do
it 'labels a day range and exposes its bounds' do
period = described_class.new(assistant, '30').period
expect(period[:label]).to eq('the last 30 days')
expect(period[:starts_on]).to eq(30.days.ago.to_date)
expect(period[:ends_on]).to eq(Time.zone.today)
end
it 'labels the this_month range' do
expect(described_class.new(assistant, 'this_month').period[:label]).to eq('this month')
end
it 'labels the last_month range' do
expect(described_class.new(assistant, 'last_month').period[:label]).to eq('last month')
end
end
end
@@ -252,6 +252,48 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
describe 'GET /api/v1/accounts/{account.id}/captain/assistants/{id}/summary' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:alice) { create(:user, account: account, role: :administrator, name: 'Alice Adams') }
let(:bob) { create(:user, account: account, role: :administrator, name: 'Bob Brown') }
let(:summary_service) { instance_double(Captain::OverviewSummaryService) }
def get_summary(user)
get "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/summary",
params: { range: '30' },
headers: user.create_new_auth_token,
as: :json
end
before do
# Test env uses a null store; swap in a real store so caching behaviour is observable.
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
allow(Captain::OverviewSummaryService).to receive(:new).and_return(summary_service)
end
it 'caches the summary per viewer so one user never receives another user\'s greeting' do
allow(summary_service).to receive(:perform).and_return({ message: 'Hi Alice' })
get_summary(alice)
get_summary(alice) # served from Alice's cache, no regeneration
get_summary(bob) # distinct cache key, regenerated for Bob
expect(response).to have_http_status(:success)
expect(Captain::OverviewSummaryService).to have_received(:new).twice
end
it 'does not cache failures so a transient error is retried' do
allow(summary_service).to receive(:perform).and_return({ error: 'LLM unavailable' })
get_summary(alice)
get_summary(alice)
expect(response).to have_http_status(:unprocessable_content)
expect(json_response[:error]).to eq('LLM unavailable')
expect(Captain::OverviewSummaryService).to have_received(:new).twice
end
end
describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
let(:assistant) { create(:captain_assistant, account: account) }
let(:valid_params) do
+31
View File
@@ -222,6 +222,37 @@ RSpec.describe Account, type: :model do
end
end
describe 'default features' do
before do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
end
it 'enables Captain V2 for new self-hosted enterprise accounts' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
account = create(:account)
expect(account).to be_feature_enabled('captain_integration')
expect(account).to be_feature_enabled('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.captain_models).to be_nil
end
it 'marks new cloud accounts as eligible for the Captain V2 paid-plan default' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(false)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account = create(:account)
expect(account.internal_attributes[Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE]).to be true
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
describe 'captain document sync cadence' do
let(:account) { create(:account) }
@@ -179,6 +179,14 @@ RSpec.describe Concerns::Agentable do
expect(dummy_instance.send(:agent_model)).to eq('gpt-4.1-nano')
end
it 'returns the Captain V2 default when Captain V2 is enabled' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(dummy_instance.send(:agent_model)).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'returns the assistant feature default model when account is nil' do
agent = dummy_class.new(account: nil)
@@ -22,7 +22,7 @@ RSpec.describe Captain::AssistantPolicy, type: :policy do
end
end
permissions :tools?, :create?, :update?, :destroy?, :sync? do
permissions :tools?, :create?, :update?, :destroy?, :sync?, :drilldown? do
context 'when administrator' do
it { expect(assistant_policy).to permit(administrator_context, assistant) }
end
@@ -175,6 +175,7 @@ describe Enterprise::Billing::HandleStripeEventService do
described_class::STARTUP_PLAN_FEATURES.each do |feature|
account.enable_features(feature)
end
account.enable_features('captain_integration_v2')
account.enable_features(*described_class::BUSINESS_PLAN_FEATURES)
account.enable_features(*described_class::ENTERPRISE_PLAN_FEATURES)
account.save!
@@ -193,6 +194,7 @@ describe Enterprise::Billing::HandleStripeEventService do
all_features.each do |feature|
expect(account).not_to be_feature_enabled(feature)
end
expect(account).not_to be_feature_enabled('captain_integration_v2')
end
end
@@ -218,6 +220,29 @@ describe Enterprise::Billing::HandleStripeEventService do
expect(account).not_to be_feature_enabled(feature)
end
end
it 'does not enable Captain V2 for existing paid accounts during reconciliation' do
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).not_to be_feature_enabled('captain_integration_v2')
end
it 'enables Captain V2 for new cloud accounts marked as default eligible' do
account.update!(
internal_attributes: account.internal_attributes.merge(
Enterprise::Account::CAPTAIN_V2_DEFAULT_ELIGIBLE => true
)
)
allow(subscription).to receive(:[]).with('plan')
.and_return({ 'id' => 'test', 'product' => 'plan_id_startups', 'name' => 'Startups' })
stripe_event_service.new.perform(event: event)
expect(account.reload).to be_feature_enabled('captain_integration_v2')
end
end
context 'with Business plan' do
@@ -11,14 +11,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'disables the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).not_to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration')
expect(account.reload.enabled_features.keys).not_to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).not_to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).not_to include('disable_branding')
end
@@ -56,14 +58,16 @@ RSpec.describe Internal::ReconcilePlanConfigService do
it 'does not disable the premium features for accounts' do
account = create(:account)
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration')
account.enable_features!('disable_branding', 'audit_logs', 'captain_integration', 'captain_integration_v2')
account_with_captain = create(:account)
account_with_captain.enable_features!('captain_integration')
account_with_captain.enable_features!('captain_integration', 'captain_integration_v2')
disable_branding_account = create(:account)
disable_branding_account.enable_features!('disable_branding')
service.perform
expect(account.reload.enabled_features.keys).to include('captain_integration', 'disable_branding', 'audit_logs')
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration')
expect(account.reload.enabled_features.keys).to include(
'captain_integration', 'captain_integration_v2', 'disable_branding', 'audit_logs'
)
expect(account_with_captain.reload.enabled_features.keys).to include('captain_integration', 'captain_integration_v2')
expect(disable_branding_account.reload.enabled_features.keys).to include('disable_branding')
end
@@ -30,6 +30,14 @@ RSpec.describe Llm::BaseAiService do
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-4.1-nano')
end
it 'uses the Captain V2 assistant default ahead of the installation model' do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
account.enable_features!('captain_integration_v2')
expect(described_class.new(feature: 'assistant', account: account).model).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
it 'uses the feature default when feature context has no account override or installation model' do
expect(described_class.new(feature: 'assistant', account: account).model).to eq(Llm::Models.default_model_for('assistant'))
end
+26
View File
@@ -30,6 +30,32 @@ RSpec.describe Llm::FeatureRouter do
)
end
it 'resolves GPT-5.2 as the assistant default when Captain V2 is enabled without storing an account override' do
account.enable_features!('captain_integration_v2')
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
feature: 'assistant',
provider: 'openai',
model: 'gpt-5.2',
source: :default
)
expect(account.reload.captain_models).to be_nil
end
it 'keeps account model overrides ahead of the Captain V2 default' do
account.enable_features!('captain_integration_v2')
account.update!(captain_models: { 'assistant' => 'gpt-5.1' })
resolved = described_class.resolve(feature: 'assistant', account: account)
expect(resolved).to include(
model: 'gpt-5.1',
source: :account_override
)
end
it 'falls back to the feature default when the account override is invalid' do
account.captain_models = { 'editor' => 'invalid-model' }
+26
View File
@@ -50,6 +50,21 @@ RSpec.describe Account do
end
end
describe 'captain defaults for new accounts' do
it 'does not store Captain model overrides or enable premium Captain features' do
InstallationConfig.find_or_initialize_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS').update!(
value: Featurable::FEATURE_LIST,
locked: true
)
account = create(:account)
expect(account).not_to be_feature_enabled('captain_integration')
expect(account).not_to be_feature_enabled('captain_integration_v2')
expect(account.captain_models).to be_nil
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
@@ -337,6 +352,10 @@ RSpec.describe Account do
let(:account) { create(:account) }
describe 'with no saved preferences' do
before do
account.update!(captain_models: nil)
end
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
@@ -346,6 +365,13 @@ RSpec.describe Account do
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
it 'returns GPT-5.2 for assistant when Captain V2 is enabled' do
account.enable_features!('captain_integration_v2')
expect(account.captain_preferences[:models]['assistant']).to eq('gpt-5.2')
expect(account.reload.captain_models).to be_nil
end
end
describe 'with saved model preferences' do
@@ -58,15 +58,6 @@ RSpec.describe CaptainFeaturable do
end
describe 'model accessor methods' do
context 'when no models are explicitly configured' do
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
+25
View File
@@ -7,6 +7,31 @@ RSpec.describe Team do
it { is_expected.to have_many(:team_members) }
end
describe 'name normalization' do
let(:account) { create(:account) }
it 'downcases the name' do
team = create(:team, account: account, name: 'Customer Support')
expect(team.name).to eq('customer support')
end
it 'strips control characters and surrounding whitespace' do
team = create(:team, account: account, name: " Sales\n")
expect(team.name).to eq('sales')
end
it 'removes control characters embedded within the name' do
team = create(:team, account: account, name: "su\npport")
expect(team.name).to eq('support')
end
it 'is invalid when the name reduces to blank after sanitization' do
team = build(:team, account: account, name: "\t\n ")
expect(team).not_to be_valid
expect(team.errors[:name]).to include(I18n.t('errors.validations.presence'))
end
end
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }