Merge branch 'develop' of github.com:chatwoot/chatwoot into feat/api-webhook-feature-flag

This commit is contained in:
Shivam Mishra
2026-07-14 15:39:31 +05:30
30 changed files with 1310 additions and 124 deletions
@@ -66,6 +66,10 @@ const props = defineProps({
type: Number,
default: null,
},
responsesCount: {
type: Number,
default: 0,
},
isSelected: {
type: Boolean,
default: false,
@@ -112,10 +116,10 @@ const showSyncStatus = computed(() => !isPdf.value);
const menuItems = computed(() => {
const allOptions = [
{
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_RELATED_RESPONSES'),
value: 'viewRelatedQuestions',
action: 'viewRelatedQuestions',
icon: 'i-ph-tree-view-duotone',
label: t('CAPTAIN.DOCUMENTS.OPTIONS.VIEW_DETAILS'),
value: 'viewDetails',
action: 'viewDetails',
icon: 'i-lucide-eye',
},
];
@@ -143,6 +147,9 @@ const menuItems = computed(() => {
});
const createdAtLabel = computed(() => dynamicTime(props.createdAt));
const responsesCountLabel = computed(() =>
t('CAPTAIN.DOCUMENTS.FAQ_COUNT', { n: props.responsesCount })
);
const displayLink = computed(() =>
isPdf.value
@@ -158,6 +165,10 @@ const handleAction = ({ action, value }) => {
emit('action', { action, value, id: props.id });
};
const handleViewDetails = () => {
emit('action', { action: 'viewDetails', id: props.id });
};
const handleRetry = () => {
emit('action', { action: 'sync', id: props.id });
};
@@ -177,9 +188,13 @@ const handleRetry = () => {
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
<button
type="button"
class="p-0 text-base text-left bg-transparent border-0 outline-transparent text-n-slate-12 line-clamp-1 underline-offset-2 hover:underline focus-visible:underline"
@click="handleViewDetails"
>
{{ name }}
</span>
</button>
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
@@ -228,6 +243,9 @@ const handleRetry = () => {
<Icon :icon="linkIcon" class="shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</span>
<span class="text-sm shrink-0 text-n-slate-11">
{{ responsesCountLabel }}
</span>
<DocumentSyncStatus
v-if="showSyncStatus"
:status="syncStatus"
@@ -0,0 +1,86 @@
import { flushPromises, shallowMount } from '@vue/test-utils';
import DocumentDetails from './DocumentDetails.vue';
const { dispatch, getterValues } = vi.hoisted(() => ({
dispatch: vi.fn(),
getterValues: {
'captainResponses/getUIFlags': { value: { fetchingList: false } },
'captainResponses/getRecords': { value: [] },
'captainResponses/getMeta': { value: { totalCount: 26, page: 1 } },
},
}));
vi.mock('dashboard/composables/store', () => ({
useStore: () => ({ dispatch }),
useMapGetter: key => getterValues[key],
}));
vi.mock('dashboard/composables', () => ({ useAlert: vi.fn() }));
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
const captainDocument = {
id: 42,
name: 'FAQ source',
external_link: 'https://example.com/docs',
assistant: { id: 7 },
content: 'Document content',
pdf_document: false,
};
const DialogStub = {
name: 'Dialog',
template: '<div><slot /></div>',
};
const TabBarStub = {
name: 'TabBar',
template:
'<button data-test="faq-tab" @click="$emit(\'tabChanged\', { key: \'faqs\' })" />',
};
const PaginationFooterStub = {
name: 'PaginationFooter',
template:
'<button data-test="next-page" @click="$emit(\'update:currentPage\', 2)" />',
};
describe('DocumentDetails', () => {
beforeEach(() => {
vi.clearAllMocks();
dispatch.mockResolvedValue([]);
});
it('requests another FAQ page when the document has more than 25 FAQs', async () => {
const wrapper = shallowMount(DocumentDetails, {
props: { captainDocument },
global: {
directives: { dompurifyHtml: {} },
stubs: {
Dialog: DialogStub,
TabBar: TabBarStub,
PaginationFooter: PaginationFooterStub,
},
},
});
await flushPromises();
expect(dispatch).toHaveBeenCalledWith('captainResponses/get', {
page: 1,
assistantId: 7,
documentId: 42,
});
await wrapper.get('[data-test="faq-tab"]').trigger('click');
await wrapper.get('[data-test="next-page"]').trigger('click');
expect(dispatch).toHaveBeenLastCalledWith('captainResponses/get', {
page: 2,
assistantId: 7,
documentId: 42,
});
});
});
@@ -0,0 +1,374 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { messageTimestamp } from 'shared/helpers/timeHelper';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import MessageFormatter from 'shared/helpers/MessageFormatter';
import {
isSafeHttpLink,
formatDocumentLink,
getDocumentDisplayPath,
} from 'shared/helpers/documentHelper';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const TAB_KEYS = {
CONTENT: 'content',
FAQS: 'faqs',
};
const RESPONSES_PER_PAGE = 25;
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const documentDetails = computed(() => props.captainDocument);
const showRawContent = ref(false);
const activeTabIndex = ref(0);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const currentPage = computed(() => meta.value.page || 1);
const showPaginationFooter = computed(
() => totalCount.value > RESPONSES_PER_PAGE
);
const documentContent = computed(() => documentDetails.value?.content?.trim());
const documentContentLength = computed(
() => documentContent.value?.length || 0
);
const isPdf = computed(() => documentDetails.value?.pdf_document);
const displayUrl = computed(() => documentDetails.value?.display_url);
const externalLink = computed(() => documentDetails.value?.external_link);
const sourceHref = computed(() => displayUrl.value || externalLink.value);
const hasSafeLink = computed(() => isSafeHttpLink(sourceHref.value));
const displayLink = computed(() => {
if (isPdf.value) return formatDocumentLink(externalLink.value);
return getDocumentDisplayPath(displayUrl.value || externalLink.value);
});
const contentTabLabel = computed(() =>
isPdf.value
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TAB')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TAB')
);
const tabs = computed(() => [
{ key: TAB_KEYS.CONTENT, label: contentTabLabel.value },
{
key: TAB_KEYS.FAQS,
label: t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE'),
count: totalCount.value,
},
]);
const activeTabKey = computed(() => tabs.value[activeTabIndex.value]?.key);
const isUnreadableContent = computed(() => {
if (!documentContent.value) return false;
const content = documentContent.value;
const sample = content.slice(0, 2000);
const characters = Array.from(sample);
const nonPrintableCharacters = characters.filter(character => {
const charCode = character.charCodeAt(0);
return (
(charCode <= 31 && ![9, 10, 13].includes(charCode)) ||
(charCode >= 127 && charCode <= 159)
);
});
const nonPrintableRatio =
nonPrintableCharacters.length / Math.max(characters.length, 1);
const replacementCharacterRatio =
characters.filter(character => character === '\uFFFD').length /
Math.max(characters.length, 1);
const hasPdfObjectMarkers =
content.includes(' obj') &&
content.includes(' endobj') &&
content.includes(' stream');
return (
content.startsWith('%PDF') ||
hasPdfObjectMarkers ||
nonPrintableRatio > 0.02 ||
replacementCharacterRatio > 0.05
);
});
const formattedDocumentContent = computed(() => {
if (!documentContent.value || isUnreadableContent.value) return '';
const formatter = new MessageFormatter(documentContent.value);
formatter.disableImageRendering();
return formatter.formattedMessage;
});
const updatedAtLabel = computed(() => {
if (!documentDetails.value?.updated_at) return null;
return messageTimestamp(
documentDetails.value.updated_at,
'MMM d, yyyy h:mm a'
);
});
const syncedAtLabel = computed(() => {
if (!documentDetails.value?.last_synced_at) return null;
return messageTimestamp(
documentDetails.value.last_synced_at,
'MMM d, yyyy h:mm a'
);
});
const handleClose = () => {
emit('close');
};
const handleCopyContent = async () => {
try {
await copyTextToClipboard(documentContent.value);
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_SUCCESS'));
} catch {
useAlert(t('CAPTAIN.DOCUMENTS.DETAILS.COPY_ERROR'));
}
};
const handleTabChanged = tab => {
activeTabIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
const fetchResponses = (page = 1) => {
return store.dispatch('captainResponses/get', {
page,
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
};
const handlePageChange = page => {
fetchResponses(page);
};
onMounted(() => {
fetchResponses();
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="documentDetails.name || documentDetails.external_link"
:description="t('CAPTAIN.DOCUMENTS.DETAILS.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-6 min-h-48">
<section class="flex flex-col gap-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.SOURCE') }}
</span>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center min-w-0 gap-1 text-sm text-n-slate-12 hover:underline"
>
<Icon icon="i-lucide-external-link" class="size-3 shrink-0" />
<span class="truncate">{{ displayLink }}</span>
</a>
<span v-else class="text-sm truncate text-n-slate-12">
{{ displayLink }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.GENERATED_FAQS') }}
</span>
<span class="text-sm text-n-slate-12">
{{ totalCount }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs font-medium uppercase text-n-slate-10">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.LAST_UPDATED') }}
</span>
<span class="text-sm text-n-slate-12">
{{
syncedAtLabel ||
updatedAtLabel ||
t('CAPTAIN.DOCUMENTS.DETAILS.NOT_AVAILABLE')
}}
</span>
</div>
</div>
</section>
<TabBar
:tabs="tabs"
:initial-active-tab="activeTabIndex"
@tab-changed="handleTabChanged"
/>
<div class="h-[32rem] overflow-y-auto">
<section
v-if="activeTabKey === TAB_KEYS.CONTENT"
class="flex flex-col gap-3"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex flex-col gap-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{
isPdf
? t('CAPTAIN.DOCUMENTS.DETAILS.PDF_TITLE')
: t('CAPTAIN.DOCUMENTS.DETAILS.CONTENT_TITLE')
}}
</h4>
<span
v-if="documentContent && !isPdf"
class="text-xs text-n-slate-10"
>
{{
t('CAPTAIN.DOCUMENTS.DETAILS.CHARACTER_COUNT', {
count: documentContentLength.toLocaleString(),
})
}}
</span>
</div>
<div
v-if="documentContent && !isPdf"
class="flex flex-wrap items-center justify-end gap-4"
>
<Button
:label="
showRawContent
? t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_PREVIEW')
: t('CAPTAIN.DOCUMENTS.DETAILS.VIEW_RAW')
"
sm
slate
link
@click="showRawContent = !showRawContent"
/>
<Button
:label="t('CAPTAIN.DOCUMENTS.DETAILS.COPY_CONTENT')"
icon="i-lucide-copy"
sm
slate
link
@click="handleCopyContent"
/>
</div>
</div>
<div
v-if="isPdf"
class="rounded-lg border border-n-weak bg-n-alpha-1 p-4 text-sm text-n-slate-11"
>
<p class="mb-3">
{{ t('CAPTAIN.DOCUMENTS.DETAILS.PDF_DESCRIPTION') }}
</p>
<a
v-if="hasSafeLink"
:href="sourceHref"
:title="sourceHref"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 font-medium text-n-blue-11 hover:underline"
>
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
<Icon icon="i-lucide-external-link" class="size-3" />
</a>
<span v-else class="inline-flex items-center gap-1 text-n-slate-12">
<Icon icon="i-ph-file-pdf" class="size-4" />
{{ displayLink }}
</span>
</div>
<template v-else-if="documentContent">
<div
v-if="isUnreadableContent && !showRawContent"
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.UNREADABLE_CONTENT') }}
</div>
<div
v-else
class="h-[26rem] overflow-y-auto rounded-lg border border-n-weak bg-n-alpha-1 p-4"
>
<pre
v-if="showRawContent || isUnreadableContent"
class="m-0 whitespace-pre-wrap break-words text-xs leading-5 text-n-slate-12"
><code>{{ documentContent }}</code></pre>
<div
v-else
v-dompurify-html="formattedDocumentContent"
class="prose prose-sm max-w-none break-words text-n-slate-12 prose-p:my-2 prose-headings:mb-2 prose-headings:mt-4 prose-a:text-n-blue-11 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-img:hidden"
/>
</div>
</template>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.DETAILS.EMPTY_CONTENT') }}
</div>
</section>
<section
v-if="activeTabKey === TAB_KEYS.FAQS"
class="flex flex-col gap-3"
>
<div v-if="responses.length" class="flex flex-col gap-3">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
<div
v-else
class="rounded-lg border border-dashed border-n-weak p-4 text-sm text-n-slate-11"
>
{{ t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.EMPTY') }}
</div>
<footer v-if="showPaginationFooter" class="sticky bottom-0 z-10">
<PaginationFooter
:current-page="currentPage"
:total-items="totalCount"
:items-per-page="RESPONSES_PER_PAGE"
class="!px-0"
@update:current-page="handlePageChange"
/>
</footer>
</section>
</div>
</div>
</Dialog>
</template>
@@ -1,71 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import ResponseCard from '../../assistant/ResponseCard.vue';
const props = defineProps({
captainDocument: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responses = useMapGetter('captainResponses/getRecords');
const meta = useMapGetter('captainResponses/getMeta');
const isFetching = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.totalCount || 0);
const handleClose = () => {
emit('close');
};
onMounted(() => {
store.dispatch('captainResponses/get', {
assistantId: props.captainDocument.assistant.id,
documentId: props.captainDocument.id,
});
});
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="`${t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.TITLE')} (${totalCount})`"
:description="t('CAPTAIN.DOCUMENTS.RELATED_RESPONSES.DESCRIPTION')"
:show-cancel-button="false"
:show-confirm-button="false"
overflow-y-auto
width="3xl"
@close="handleClose"
>
<div
v-if="isFetching"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<div v-else class="flex flex-col gap-3 min-h-48">
<ResponseCard
v-for="response in responses"
:id="response.id"
:key="response.id"
:question="response.question"
:status="response.status"
:answer="response.answer"
:assistant="response.assistant"
:created-at="response.created_at"
:updated-at="response.updated_at"
compact
/>
</div>
</Dialog>
</template>
@@ -146,6 +146,7 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
}),
currentContact() {
const senderId = this.currentChat?.meta?.sender?.id;
@@ -173,6 +174,9 @@ export default {
return this.isATwilioWhatsAppChannel && !this.isPrivate;
},
isPrivate() {
if (this.isInstagramReplyRestricted) {
return true;
}
if (
this.currentChat.can_reply ||
this.isAWhatsAppChannel ||
@@ -197,10 +201,16 @@ export default {
);
return !!stripped.trim();
},
// Instagram replies are disabled on Chatwoot Cloud during the temporary
// Meta platform restriction; private notes remain available.
isInstagramReplyRestricted() {
return this.isOnChatwootCloud && this.isAnInstagramChannel;
},
isReplyRestricted() {
return (
!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox)
this.isInstagramReplyRestricted ||
(!this.currentChat?.can_reply &&
!(this.isAWhatsAppChannel || this.isAPIInbox))
);
},
inboxId() {
@@ -470,7 +480,10 @@ export default {
return;
}
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
if (
!this.isInstagramReplyRestricted &&
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
) {
this.replyType = REPLY_EDITOR_MODES.REPLY;
} else {
this.replyType = REPLY_EDITOR_MODES.NOTE;
@@ -937,7 +950,10 @@ export default {
this.$store.dispatch('draftMessages/setReplyEditorMode', {
mode,
});
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
if (
!this.isInstagramReplyRestricted &&
(canReply || this.isAWhatsAppChannel || this.isAPIInbox)
)
this.replyType = mode;
if (this.isRecordingAudio) {
this.toggleAudioRecorder();
@@ -811,6 +811,7 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"FAQ_COUNT": "{n} FAQ | {n} FAQs",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
@@ -870,7 +871,27 @@
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
"EMPTY": "No FAQs have been generated from this document yet."
},
"DETAILS": {
"DESCRIPTION": "Review the crawled content and the FAQs generated from this source.",
"SOURCE": "Source",
"GENERATED_FAQS": "Generated FAQs",
"LAST_UPDATED": "Last updated",
"NOT_AVAILABLE": "Not available",
"CONTENT_TAB": "Crawled content",
"PDF_TAB": "PDF details",
"CONTENT_TITLE": "Crawled content",
"PDF_TITLE": "PDF file",
"PDF_DESCRIPTION": "Review the original PDF source.",
"CHARACTER_COUNT": "{count} characters extracted",
"COPY_CONTENT": "Copy",
"COPY_SUCCESS": "Crawled content copied to clipboard",
"COPY_ERROR": "Could not copy crawled content",
"VIEW_RAW": "View raw",
"VIEW_PREVIEW": "View preview",
"UNREADABLE_CONTENT": "Readable content could not be extracted from this document. You can view the raw extracted content.",
"EMPTY_CONTENT": "No crawled content is available for this document yet."
},
"FORM_DESCRIPTION": "Enter the URL of the document to add it as a knowledge source and choose the assistant to associate it with.",
"CREATE": {
@@ -911,7 +932,7 @@
},
"OPTIONS": {
"VIEW_RELATED_RESPONSES": "View Related Responses",
"VIEW_DETAILS": "View details",
"SYNC_NOW": "Refresh now",
"RETRY_SYNC": "Retry refresh",
"DELETE_DOCUMENT": "Delete Document"
@@ -17,7 +17,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
import DocumentDetails from 'dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue';
import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponents/document/CreateDocumentDialog.vue';
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
@@ -51,22 +51,22 @@ const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
};
const showRelatedResponses = ref(false);
const showDocumentDetails = ref(false);
const showCreateDialog = ref(false);
const createDocumentDialog = ref(null);
const relationQuestionDialog = ref(null);
const documentDetailsDialog = ref(null);
const handleShowRelatedDocument = () => {
showRelatedResponses.value = true;
nextTick(() => relationQuestionDialog.value.dialogRef.open());
const handleShowDocumentDetails = () => {
showDocumentDetails.value = true;
nextTick(() => documentDetailsDialog.value.dialogRef.open());
};
const handleCreateDocument = () => {
showCreateDialog.value = true;
nextTick(() => createDocumentDialog.value.dialogRef.open());
};
const handleRelatedResponseClose = () => {
showRelatedResponses.value = false;
const handleDocumentDetailsClose = () => {
showDocumentDetails.value = false;
};
const handleCreateDialogClose = () => {
@@ -235,8 +235,8 @@ const handleAction = ({ action, id }) => {
nextTick(() => {
if (action === 'delete') {
handleDelete();
} else if (action === 'viewRelatedQuestions') {
handleShowRelatedDocument();
} else if (action === 'viewDetails') {
handleShowDocumentDetails();
} else if (action === 'sync') {
handleSync(id);
}
@@ -416,6 +416,7 @@ onUnmounted(() => {
:last-sync-error-code="doc.last_sync_error_code"
:sync-in-progress="doc.sync_in_progress"
:sync-stale-after-hours="syncIntervalHours"
:responses-count="doc.responses_count"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
@@ -427,11 +428,11 @@ onUnmounted(() => {
</div>
</template>
<RelatedResponses
v-if="showRelatedResponses"
ref="relationQuestionDialog"
<DocumentDetails
v-if="showDocumentDetails"
ref="documentDetailsDialog"
:captain-document="selectedDocument"
@close="handleRelatedResponseClose"
@close="handleDocumentDetailsClose"
/>
<CreateDocumentDialog
v-if="showCreateDialog"
@@ -104,6 +104,11 @@ class MessageFormatter {
return this.md.render(updatedMessage);
}
disableImageRendering() {
this.md.disable(['add-image-sizing']);
this.md.renderer.rules.image = () => '';
}
get formattedMessage() {
return this.formatMessage();
}
@@ -68,6 +68,25 @@ describe('#MessageFormatter', () => {
});
});
describe('#disableImageRendering', () => {
it('omits nested and reference images with relative URLs', () => {
const message = `Before ![nested [alt]](/relative.png)
![reference][logo]
[logo]: /logo.png
After`;
const formatter = new MessageFormatter(message);
formatter.disableImageRendering();
expect(formatter.formattedMessage).not.toContain('<img');
expect(formatter.formattedMessage).toContain('Before');
expect(formatter.formattedMessage).toContain('After');
});
});
describe('tweets', () => {
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
@@ -38,7 +38,8 @@ class Imap::BaseFetchEmailService
end
def email_already_present?(channel, message_id)
channel.inbox.messages.find_by(source_id: message_id).present? || deleted_message_tracker.deleted?(message_id)
# exists? avoids Message's default_scope ORDER BY, which full-scans large inboxes
channel.inbox.messages.exists?(source_id: message_id) || deleted_message_tracker.deleted?(message_id)
end
def deleted_message_tracker
@@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def fetch_whatsapp_templates(url)
response = HTTParty.get(url)
return [] unless response.success?
unless response.success?
Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \
"inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}"
return []
end
next_url = next_url(response)
@@ -155,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
def error_message(response)
# https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response
response.parsed_response&.dig('error', 'message')
response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash)
end
def voice_message?(type, attachment)
@@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService
def should_teardown_webhook?
@channel.provider == 'whatsapp_cloud' &&
provider_config['source'] == 'embedded_signup' &&
provider_config['api_key'].present? &&
(provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?)
end
@@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService
Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}"
end
# The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one.
# Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe.
# The subscription is shared across the WABA, so only unsubscribe when this is the last inbox.
def unsubscribe_app_if_last_inbox(api_client)
return unless provider_config['source'] == 'embedded_signup'
waba_id = provider_config['business_account_id']
return if waba_id.blank?
return if waba_sibling_exists?(waba_id)
@@ -0,0 +1,24 @@
class CreateAgentSessions < ActiveRecord::Migration[7.1]
def change
create_table :agent_sessions do |t|
t.integer :session_type, null: false
t.references :subject, polymorphic: true, null: false, index: false
t.references :result, polymorphic: true, index: false
t.references :account, null: false, index: true
t.references :assistant, null: false, index: true
t.references :user, index: true
t.string :llm_model
t.float :credits_consumed
t.jsonb :faq_ids, default: []
t.jsonb :document_ids, default: []
t.jsonb :scenario_ids, default: []
t.jsonb :run_context, default: {}
t.timestamps
end
add_index :agent_sessions, [:account_id, :session_type, :created_at]
add_index :agent_sessions, [:account_id, :subject_type, :subject_id]
add_index :agent_sessions, [:account_id, :result_type, :result_id]
end
end
@@ -0,0 +1,48 @@
class CreateCaptainFaqSuggestions < ActiveRecord::Migration[7.1]
def change
create_faq_suggestions
create_faq_observations
end
private
def create_faq_suggestions
create_table :captain_faq_suggestions do |t|
t.string :question, null: false
t.text :answer, null: false
t.vector :embedding, limit: 1536
t.references :assistant, null: false, index: true
t.references :account, null: false, index: true
t.string :language, null: false, default: 'en'
t.integer :source_count, null: false, default: 0
t.integer :status, null: false, default: 0
t.timestamps
end
add_index :captain_faq_suggestions, [:account_id, :assistant_id, :status, :language],
name: 'idx_cap_faq_suggestions_on_account_assistant_status_language'
add_index :captain_faq_suggestions, :embedding, using: :ivfflat,
name: 'vector_idx_captain_faq_suggestions_embedding',
opclass: :vector_cosine_ops
end
def create_faq_observations
create_table :captain_faq_observations do |t|
t.references :account, null: false, index: true
t.references :conversation, null: false, index: true
t.references :faq_suggestion, index: true
t.string :generated_question, null: false
t.text :generated_answer, null: false
t.string :language, null: false, default: 'en'
t.integer :status, null: false, default: 0
t.timestamps
end
add_index :captain_faq_observations, [:conversation_id, :faq_suggestion_id],
unique: true,
where: 'faq_suggestion_id IS NOT NULL',
name: 'idx_captain_faq_observations_on_conversation_and_suggestion'
end
end
+59 -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_07_10_000000) do
ActiveRecord::Schema[7.1].define(version: 2026_07_13_184351) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -146,6 +146,31 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
t.index ["account_id"], name: "index_agent_capacity_policies_on_account_id"
end
create_table "agent_sessions", force: :cascade do |t|
t.integer "session_type", null: false
t.string "subject_type", null: false
t.bigint "subject_id", null: false
t.string "result_type"
t.bigint "result_id"
t.bigint "account_id", null: false
t.bigint "assistant_id", null: false
t.bigint "user_id"
t.string "llm_model"
t.float "credits_consumed"
t.jsonb "faq_ids", default: []
t.jsonb "document_ids", default: []
t.jsonb "scenario_ids", default: []
t.jsonb "run_context", default: {}
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "result_type", "result_id"], name: "idx_on_account_id_result_type_result_id_ca66c00cd7"
t.index ["account_id", "session_type", "created_at"], name: "idx_on_account_id_session_type_created_at_c20a14bd4e"
t.index ["account_id", "subject_type", "subject_id"], name: "idx_on_account_id_subject_type_subject_id_6d60963b3d"
t.index ["account_id"], name: "index_agent_sessions_on_account_id"
t.index ["assistant_id"], name: "index_agent_sessions_on_assistant_id"
t.index ["user_id"], name: "index_agent_sessions_on_user_id"
end
create_table "applied_slas", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "sla_policy_id", null: false
@@ -392,6 +417,39 @@ ActiveRecord::Schema[7.1].define(version: 2026_07_10_000000) do
t.index ["status"], name: "index_captain_documents_on_status"
end
create_table "captain_faq_observations", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "faq_suggestion_id"
t.string "generated_question", null: false
t.text "generated_answer", null: false
t.string "language", default: "en", null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_faq_observations_on_account_id"
t.index ["conversation_id", "faq_suggestion_id"], name: "idx_captain_faq_observations_on_conversation_and_suggestion", unique: true, where: "(faq_suggestion_id IS NOT NULL)"
t.index ["conversation_id"], name: "index_captain_faq_observations_on_conversation_id"
t.index ["faq_suggestion_id"], name: "index_captain_faq_observations_on_faq_suggestion_id"
end
create_table "captain_faq_suggestions", force: :cascade do |t|
t.string "question", null: false
t.text "answer", null: false
t.vector "embedding", limit: 1536
t.bigint "assistant_id", null: false
t.bigint "account_id", null: false
t.string "language", default: "en", null: false
t.integer "source_count", default: 0, null: false
t.integer "status", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_faq_suggestions_on_account_id"
t.index ["account_id", "assistant_id", "status", "language"], name: "idx_cap_faq_suggestions_on_account_assistant_status_language"
t.index ["assistant_id"], name: "index_captain_faq_suggestions_on_assistant_id"
t.index ["embedding"], name: "vector_idx_captain_faq_suggestions_embedding", opclass: :vector_cosine_ops, using: :ivfflat
end
create_table "captain_inboxes", force: :cascade do |t|
t.bigint "captain_assistant_id", null: false
t.bigint "inbox_id", null: false
@@ -9,16 +9,10 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
RESULTS_PER_PAGE = 25
def index
base_query = @documents
base_query = base_query.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
base_query = apply_source_filter(base_query, permitted_params[:source])
base_query = apply_filter(base_query, permitted_params[:filter])
base_query = apply_search(base_query, permitted_params[:search_key])
base_query = apply_sort(base_query, permitted_params[:sort])
@documents_count = base_query.count
@documents = filtered_documents
@documents_count = @documents.count
@sync_interval_hours = current_sync_interval&.in_hours&.to_i
@documents = base_query.page(@current_page).per(RESULTS_PER_PAGE)
@documents = with_responses_count(@documents).page(@current_page).per(RESULTS_PER_PAGE)
end
def show; end
@@ -59,6 +53,21 @@ class Api::V1::Accounts::Captain::DocumentsController < Api::V1::Accounts::BaseC
@documents = Current.account.captain_documents.with_attached_pdf_file.includes(:assistant)
end
def filtered_documents
documents = @documents
documents = documents.where(assistant_id: permitted_params[:assistant_id]) if permitted_params[:assistant_id].present?
documents = apply_source_filter(documents, permitted_params[:source])
documents = apply_filter(documents, permitted_params[:filter])
documents = apply_search(documents, permitted_params[:search_key])
apply_sort(documents, permitted_params[:sort])
end
def with_responses_count(scope)
scope.left_joins(:responses)
.select('captain_documents.*, COUNT(captain_assistant_responses.id) AS responses_count')
.group('captain_documents.id')
end
def set_document
@document = @documents.find(permitted_params[:id])
end
@@ -0,0 +1,86 @@
# == Schema Information
#
# Table name: agent_sessions
#
# id :bigint not null, primary key
# credits_consumed :float
# document_ids :jsonb
# faq_ids :jsonb
# llm_model :string
# result_type :string
# run_context :jsonb
# scenario_ids :jsonb
# session_type :integer not null
# subject_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
# result_id :bigint
# subject_id :bigint not null
# user_id :bigint
#
# Indexes
#
# idx_on_account_id_result_type_result_id_ca66c00cd7 (account_id,result_type,result_id)
# idx_on_account_id_session_type_created_at_c20a14bd4e (account_id,session_type,created_at)
# idx_on_account_id_subject_type_subject_id_6d60963b3d (account_id,subject_type,subject_id)
# index_agent_sessions_on_account_id (account_id)
# index_agent_sessions_on_assistant_id (assistant_id)
# index_agent_sessions_on_user_id (user_id)
#
class Captain::AgentSession < ApplicationRecord
self.table_name = 'agent_sessions'
SUBJECT_TYPES = { 'assistant' => 'Conversation', 'copilot' => 'CopilotThread' }.freeze
RESULT_TYPES = { 'assistant' => 'Message', 'copilot' => 'CopilotMessage' }.freeze
belongs_to :account
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :user, optional: true
belongs_to :subject, ->(session) { where(account_id: session.account_id) }, polymorphic: true
belongs_to :result, ->(session) { where(account_id: session.account_id) }, polymorphic: true, optional: true
enum :session_type, { assistant: 0, copilot: 1 }, prefix: :session
before_validation :ensure_account
validate :subject_type_matches_session_type
validate :result_type_matches_session_type, if: -> { result_type.present? }
validate :subject_belongs_to_account
validate :result_belongs_to_account, if: -> { result_id.present? }
private
def ensure_account
self.account = assistant&.account
end
def subject_type_matches_session_type
expected_type = SUBJECT_TYPES[session_type]
return if subject_type == expected_type
errors.add(:subject_type, "must be #{expected_type} for #{session_type} sessions")
end
def result_type_matches_session_type
expected_type = RESULT_TYPES[session_type]
return if result_type == expected_type
errors.add(:result_type, "must be #{expected_type} for #{session_type} sessions")
end
def subject_belongs_to_account
return if subject.nil? || subject.account_id == account_id
errors.add(:subject, 'must belong to the session account')
end
def result_belongs_to_account
target_class = result_type.safe_constantize
actual_account_id = target_class && target_class.unscoped.where(id: result_id).pick(:account_id)
return if actual_account_id == account_id
errors.add(:result, 'must belong to the session account')
end
end
@@ -28,6 +28,7 @@ class Captain::Assistant < ApplicationRecord
belongs_to :account
has_many :documents, class_name: 'Captain::Document', dependent: :destroy_async
has_many :responses, class_name: 'Captain::AssistantResponse', dependent: :destroy_async
has_many :faq_suggestions, class_name: 'Captain::FaqSuggestion', dependent: :destroy_async
has_many :captain_inboxes,
class_name: 'CaptainInbox',
foreign_key: :captain_assistant_id,
@@ -37,6 +38,7 @@ class Captain::Assistant < ApplicationRecord
has_many :messages, as: :sender, dependent: :nullify
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
has_many :agent_sessions, class_name: 'Captain::AgentSession', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :feature_contact_attributes, :product_name
@@ -0,0 +1,42 @@
# == Schema Information
#
# Table name: captain_faq_observations
#
# id :bigint not null, primary key
# generated_answer :text not null
# generated_question :string not null
# language :string default("en"), not null
# status :integer default("attached"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# faq_suggestion_id :bigint
#
class Captain::FaqObservation < ApplicationRecord
self.table_name = 'captain_faq_observations'
belongs_to :account
belongs_to :conversation, class_name: '::Conversation'
belongs_to :faq_suggestion, class_name: 'Captain::FaqSuggestion', optional: true, inverse_of: :observations
enum status: { attached: 0, discarded: 1 }
validates :generated_question, :generated_answer, :language, presence: true
validates :faq_suggestion, presence: true, if: :attached?
validate :faq_suggestion_belongs_to_account
before_validation :ensure_account
private
def ensure_account
self.account = conversation&.account
end
def faq_suggestion_belongs_to_account
return if faq_suggestion.blank? || faq_suggestion.account_id == account_id
errors.add(:faq_suggestion, :invalid)
end
end
@@ -0,0 +1,51 @@
# == Schema Information
#
# Table name: captain_faq_suggestions
#
# id :bigint not null, primary key
# answer :text not null
# embedding :vector(1536)
# language :string default("en"), not null
# question :string not null
# source_count :integer default(0), not null
# status :integer default("open"), not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# assistant_id :bigint not null
#
class Captain::FaqSuggestion < ApplicationRecord
self.table_name = 'captain_faq_suggestions'
belongs_to :assistant, class_name: 'Captain::Assistant'
belongs_to :account
has_many :observations,
class_name: 'Captain::FaqObservation',
dependent: :delete_all,
inverse_of: :faq_suggestion
has_neighbors :embedding, normalize: true
enum status: { open: 0, approved: 1, dismissed: 2 }
validates :question, :answer, :language, presence: true
before_validation :ensure_account
after_commit :update_embedding, on: [:create, :update]
scope :ordered, -> { order(source_count: :desc, updated_at: :desc) }
scope :by_language, ->(language) { where(language: language) }
private
def ensure_account
self.account = assistant&.account
end
def update_embedding
return unless open?
return unless saved_change_to_question? || saved_change_to_answer? || embedding.nil?
return if previously_new_record? && embedding.present?
Captain::Llm::UpdateEmbeddingJob.perform_later(self, "#{question}: #{answer}")
end
end
@@ -11,8 +11,11 @@ module Enterprise::Concerns::Account
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
has_many :captain_faq_observations, dependent: :destroy_async, class_name: 'Captain::FaqObservation'
has_many :captain_faq_suggestions, dependent: :destroy_async, class_name: 'Captain::FaqSuggestion'
has_many :captain_documents, dependent: :destroy_async, class_name: 'Captain::Document'
has_many :captain_custom_tools, dependent: :destroy_async, class_name: 'Captain::CustomTool'
has_many :captain_agent_sessions, dependent: :destroy_async, class_name: 'Captain::AgentSession'
has_many :copilot_threads, dependent: :destroy_async
has_many :companies, dependent: :destroy_async
@@ -7,6 +7,7 @@ module Enterprise::Concerns::Conversation
has_many :sla_events, dependent: :destroy_async
has_many :calls, dependent: :destroy_async
has_many :captain_responses, class_name: 'Captain::AssistantResponse', dependent: :nullify, as: :documentable
has_many :captain_faq_observations, class_name: 'Captain::FaqObservation', dependent: :delete_all
scope :with_sla_applicable_contact, -> { left_joins(:contact).where(contacts: { blocked: [false, nil] }) }
before_validation :validate_sla_policy, if: -> { sla_policy_id_changed? }
@@ -9,6 +9,8 @@ json.external_link resource.external_link
json.display_url resource.display_url
json.file_size resource.file_size
json.pdf_document resource.pdf_document?
responses_count = resource.respond_to?(:responses_count) ? resource.responses_count : resource.responses.count
json.responses_count responses_count.to_i
json.id resource.id
json.name resource.name
json.status resource.status
@@ -12,7 +12,7 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
pattr_initialize [:account!, :conversation_display_id!]
def perform
content = format_messages_as_string
content = format_evaluation_input
return default_incomplete_response('No messages found') if content.blank?
response = make_api_call(
@@ -35,12 +35,58 @@ class Captain::ConversationCompletionService < Captain::BaseTaskService
Rails.root.join('enterprise/lib/captain/prompts', "#{file_name}.liquid").read
end
def format_messages_as_string
messages = conversation_messages(start_from: 0)
messages.map do |msg|
sender_type = msg[:role] == 'user' ? 'Customer' : 'Assistant'
"#{sender_type}: #{msg[:content]}"
def format_evaluation_input
messages = conversation_message_records(start_from: 0)
return if messages.blank?
[
"Conversation status: #{conversation.status}",
format_messages_as_string(messages)
].join("\n\n")
end
def conversation_message_records(start_from: 0)
messages = []
character_count = start_from
conversation.messages
.where(message_type: [:incoming, :outgoing])
.where(private: false)
.reorder('id desc')
.each do |message|
content = message.content_for_llm
next if content.blank?
break if character_count + content.length > TOKEN_LIMIT
messages.prepend({ message: message, content: content })
character_count += content.length
end
messages
end
def format_messages_as_string(messages)
transcript = messages.map do |message_context|
"#{message_sender_label(message_context[:message])}: #{message_context[:content]}"
end.join("\n")
"Conversation transcript:\n#{transcript}"
end
def message_sender_label(message)
return 'Customer' if message.incoming?
return 'Captain' if captain_reply?(message)
return 'Bot' if bot_reply?(message)
'Assistant'
end
def captain_reply?(message)
message.outgoing? && message.sender_type == 'Captain::Assistant'
end
def bot_reply?(message)
message.outgoing? && message.sender_type.in?(['AgentBot', 'Captain::Assistant'])
end
def parse_response(message)
@@ -2,18 +2,39 @@ You are evaluating whether a customer support conversation is complete and can b
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
You will receive:
- Conversation status
- Conversation transcript where messages are labeled as Customer, Captain, Bot, or Assistant
This evaluator runs for inactive pending conversations. Focus on the latest pending exchange or latest unresolved customer request. Older messages may be present only for context.
If the conversation status is "pending", the conversation is still with Captain. Do not assume a handoff happened because Captain mentioned one.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant asked a question or requested information that the customer hasn't provided
- The customer asked a question that wasn't fully answered
- The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet
- The customer raised multiple questions or issues and not all were addressed
- In the latest pending exchange, Captain, Bot, or Assistant said it handed off, will hand off, escalated, will escalate, or that a human/team/another party will continue the work
- In the latest pending exchange, Captain, Bot, or Assistant promised future action or follow-up instead of resolving the customer's request
- In the latest pending exchange, the customer is waiting for another party's action, response, status update, or investigation result
- The latest customer message is only an attachment placeholder such as "[Attachment]" and there is no later text explaining what it contains or showing the issue was answered
- The customer says they were not helped, asks why nobody replied, repeats the unresolved issue after a previous answer, or otherwise indicates dissatisfaction with the current help
Do NOT treat these as incomplete by themselves:
- A generic greeting or broad optional offer from Captain/Bot/Assistant, such as "How can I help?", "What would you like to know?", or "Anything else?", when the customer has not made a recognizable request
- A customer greeting, single-word reply, name, phone number, or gibberish with no recognizable question/request, followed only by Captain/Bot/Assistant asking what the customer needs
- An optional invitation for the customer to ask more questions after the assistant already answered the actual request
- Older handoff, escalation, or follow-up messages from a previous exchange when the latest customer message starts a new topic, has no recognizable request, or has already been answered
Important handoff rule:
- A handoff, escalation, transfer, acknowledgement, or promise of future follow-up is not a resolution by itself
- If conversation status is "pending" and Captain/Bot/Assistant says it handed off, will hand off, or that another party will continue the work in the latest pending exchange, keep the conversation INCOMPLETE.
A conversation is COMPLETE only if ALL of these are true:
- The assistant's answer fully addressed the customer's question or issue and is self-contained — it requires no further action from the customer
- There are no unanswered questions, unmet requests, or outstanding follow-ups from either side
- Note: customers often do not explicitly say thanks or confirm resolution. If the assistant gave a complete, self-contained answer and the customer had no follow-up, that is sufficient. Do not require explicit gratitude or confirmation.
- If the customer sent only one or two short messages (single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and the
assistant has responded asking for clarification, the conversation is COMPLETE.
- If the customer sent only one or two short text messages (greetings, single words, names, phone numbers, or gibberish) with no recognizable question or request across the entire conversation, and Captain/Bot/Assistant has responded asking what they need or offering help, the conversation is COMPLETE.
Analyze the conversation and respond with ONLY a JSON object (no other text):
{"complete": true, "reason": "brief explanation"}
@@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:payload].length).to eq(5)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
it 'returns the generated FAQ count for each document' do
document = create(:captain_document, assistant: assistant, account: account)
create_list(:captain_assistant_response, 2,
assistant: assistant, account: account, documentable: document)
get "/api/v1/accounts/#{account.id}/captain/documents",
headers: agent.create_new_auth_token, as: :json
matching_document = json_response[:payload].find { |item| item[:id] == document.id }
expect(matching_document[:responses_count]).to eq(2)
end
end
context 'when filtering by assistant_id' do
@@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:external_link]).to eq(document.external_link)
end
it 'returns the crawled content for the document' do
expect(json_response[:content]).to eq(document.content)
end
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
@@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do
end
end
context 'when building evaluation context' do
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' },
input_tokens: 100,
output_tokens: 20
)
end
it 'includes conversation status and speaker labels' do
conversation.update!(status: :pending, waiting_since: 2.hours.ago)
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund')
create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'I will transfer this to support for review.'
)
expect(mock_chat).to receive(:ask) do |content|
expect(content).to include(
'Conversation status: pending',
'Conversation transcript:',
'Customer: I need help with a refund',
'Captain: I will transfer this to support for review.'
)
mock_response
end
result = service.perform
expect(result[:complete]).to be false
end
it 'includes pending captain handoff evidence in the transcript' do
conversation.update!(status: :pending)
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order')
create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'I will transfer this to a specialist and they will follow up here.'
)
expect(mock_chat).to receive(:ask) do |content|
expect(content).to include(
'Conversation status: pending',
'Captain: I will transfer this to a specialist and they will follow up here.'
)
mock_response
end
result = service.perform
expect(result[:complete]).to be false
end
it 'reuses computed message content while formatting the transcript' do
content_for_llm_calls_by_message_id = Hash.new(0)
allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance
content_for_llm_calls_by_message_id[method.receiver.id] += 1
method.call(*args)
end
incoming_message = create(
:message,
:with_attachment,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :incoming,
content: nil
)
outgoing_message = create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'What do you need help with?'
)
allow(mock_chat).to receive(:ask).and_return(mock_response)
service.perform
expect(content_for_llm_calls_by_message_id).to include(
incoming_message.id => 1,
outgoing_message.id => 1
)
end
end
context 'when conversation has no messages' do
it 'returns incomplete with appropriate reason' do
result = service.perform
@@ -0,0 +1,160 @@
require 'rails_helper'
RSpec.describe Captain::AgentSession, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:subject) }
it { is_expected.to belong_to(:result).optional }
end
describe 'enums' do
it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
end
describe '#subject' do
it 'returns the conversation for an assistant session' do
conversation = create(:conversation, account: account)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
expect(session.subject).to eq(conversation)
end
it 'returns the copilot thread for a copilot session' do
user = create(:user, account: account)
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
expect(session.subject).to eq(copilot_thread)
end
it 'returns nil when the subject record no longer exists' do
conversation = create(:conversation, account: account)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
conversation.destroy
expect(session.reload.subject).to be_nil
end
it 'is not valid when the subject type does not match the session type' do
copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
expect(session).not_to be_valid
expect(session.errors[:subject_type]).to be_present
end
it 'is not valid when the subject belongs to a different account' do
foreign_conversation = create(:conversation, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
expect(session).not_to be_valid
expect(session.errors[:subject]).to be_present
end
end
describe '#result' do
it 'returns the message for an assistant session' do
conversation = create(:conversation, account: account)
message = create(:message, account: account, conversation: conversation)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
expect(session.result).to eq(message)
end
it 'returns the copilot message for a copilot session' do
user = create(:user, account: account)
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
subject: copilot_thread, result: copilot_message)
expect(session.result).to eq(copilot_message)
end
it 'returns nil when result_id is nil' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session.result).to be_nil
end
it 'is not valid when the result belongs to a different account' do
conversation = create(:conversation, account: account)
foreign_message = create(:message, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
it 'is not valid when result_id/result_type are set directly for a different account' do
conversation = create(:conversation, account: account)
foreign_message = create(:message, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
result_id: foreign_message.id, result_type: 'Message')
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
it 'is not valid when result_id/result_type are set directly for a stale id' do
conversation = create(:conversation, account: account)
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
result_id: 0, result_type: 'Message')
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
end
describe 'account' do
it 'is derived from the assistant when created via the assistant association' do
conversation = create(:conversation, account: account)
session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
expect(session.account).to eq(account)
end
it 'overrides a mismatched explicit account with the assistant account' do
conversation = create(:conversation, account: account)
session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
expect(session).to be_valid
expect(session.account).to eq(account)
end
end
describe 'defaults' do
it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session.faq_ids).to eq([])
expect(session.document_ids).to eq([])
expect(session.scenario_ids).to eq([])
expect(session.run_context).to eq({})
end
end
describe 'factory' do
it 'builds a valid assistant session' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session).to be_valid
expect(session).to be_session_assistant
expect(session.subject).to be_a(Conversation)
end
it 'builds a valid copilot session' do
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
expect(session).to be_valid
expect(session).to be_session_copilot
expect(session.subject).to be_a(CopilotThread)
expect(session.user).to be_present
end
end
end
+14
View File
@@ -0,0 +1,14 @@
FactoryBot.define do
factory :captain_agent_session, class: 'Captain::AgentSession' do
account
association :assistant, factory: :captain_assistant
session_type { :assistant }
subject { create(:conversation, account: account) }
trait :copilot do
session_type { :copilot }
user
subject { create(:captain_copilot_thread, account: account, user: user) }
end
end
end
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
end
end
context 'when channel is whatsapp_cloud but not embedded_signup' do
context 'when channel is whatsapp_cloud with manual setup' do
before do
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.update!(
provider: 'whatsapp_cloud',
provider_config: { 'source' => 'manual' }
provider_config: {
'source' => 'manual',
'phone_number_id' => 'manual_phone_id',
'business_account_id' => 'manual_waba_id',
'api_key' => 'manual_api_key'
}
)
end
it 'does not attempt to unsubscribe webhook' do
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
it 'clears the phone number callback override' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
service.perform
expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
end
# The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
it 'does not unsubscribe the app from the WABA' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override)
allow(api_client).to receive(:unsubscribe_app_from_waba)
service.perform
expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
end
end