diff --git a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
index 9d6d574ec..8ff38b2eb 100644
--- a/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
+++ b/app/javascript/dashboard/components-next/captain/assistant/DocumentCard.vue
@@ -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 = () => {
-
+
+
{
{{ displayLink }}
+
+ {{ responsesCountLabel }}
+
({
+ 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: '
',
+};
+
+const TabBarStub = {
+ name: 'TabBar',
+ template:
+ '',
+};
+
+const PaginationFooterStub = {
+ name: 'PaginationFooter',
+ template:
+ '',
+};
+
+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,
+ });
+ });
+});
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue
new file mode 100644
index 000000000..eadb0b480
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue
@@ -0,0 +1,374 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue b/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue
deleted file mode 100644
index 9c95fd2b4..000000000
--- a/app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue
+++ /dev/null
@@ -1,71 +0,0 @@
-
-
-
-
-
diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
index 471d10f3c..bd72d45f3 100644
--- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
+++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue
@@ -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();
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index d3dc538a9..629dbd27c 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -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"
diff --git a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
index 87c04aefe..9c1effd20 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/documents/Index.vue
@@ -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(() => {
-
'';
+ }
+
get formattedMessage() {
return this.formatMessage();
}
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index 3350399eb..20d64005a 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -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('
{
it('should return the same string if not tags or @mentions', () => {
const message = 'Chatwoot is an opensource tool';
diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb
index e55355f3a..9c5b8e27b 100644
--- a/app/services/imap/base_fetch_email_service.rb
+++ b/app/services/imap/base_fetch_email_service.rb
@@ -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
diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
index 69631c468..373e47b3c 100644
--- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb
+++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb
@@ -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)
diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb
index 948d84f04..de794f8e3 100644
--- a/app/services/whatsapp/webhook_teardown_service.rb
+++ b/app/services/whatsapp/webhook_teardown_service.rb
@@ -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)
diff --git a/db/migrate/20260709091147_create_agent_sessions.rb b/db/migrate/20260709091147_create_agent_sessions.rb
new file mode 100644
index 000000000..a2e3e9f0f
--- /dev/null
+++ b/db/migrate/20260709091147_create_agent_sessions.rb
@@ -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
diff --git a/db/migrate/20260713184351_create_captain_faq_suggestions.rb b/db/migrate/20260713184351_create_captain_faq_suggestions.rb
new file mode 100644
index 000000000..6bc03f387
--- /dev/null
+++ b/db/migrate/20260713184351_create_captain_faq_suggestions.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index e01dc34c1..43e7135b9 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
index 273c082b1..d88cc6b48 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/documents_controller.rb
@@ -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
diff --git a/enterprise/app/models/captain/agent_session.rb b/enterprise/app/models/captain/agent_session.rb
new file mode 100644
index 000000000..d02dffcab
--- /dev/null
+++ b/enterprise/app/models/captain/agent_session.rb
@@ -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
diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb
index d3f6cda8a..bf4691e2c 100644
--- a/enterprise/app/models/captain/assistant.rb
+++ b/enterprise/app/models/captain/assistant.rb
@@ -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
diff --git a/enterprise/app/models/captain/faq_observation.rb b/enterprise/app/models/captain/faq_observation.rb
new file mode 100644
index 000000000..15c5e1284
--- /dev/null
+++ b/enterprise/app/models/captain/faq_observation.rb
@@ -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
diff --git a/enterprise/app/models/captain/faq_suggestion.rb b/enterprise/app/models/captain/faq_suggestion.rb
new file mode 100644
index 000000000..047d5e1fe
--- /dev/null
+++ b/enterprise/app/models/captain/faq_suggestion.rb
@@ -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
diff --git a/enterprise/app/models/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb
index 1ef112fb5..427b1e1af 100644
--- a/enterprise/app/models/enterprise/concerns/account.rb
+++ b/enterprise/app/models/enterprise/concerns/account.rb
@@ -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
diff --git a/enterprise/app/models/enterprise/concerns/conversation.rb b/enterprise/app/models/enterprise/concerns/conversation.rb
index a075704d1..c247e01e8 100644
--- a/enterprise/app/models/enterprise/concerns/conversation.rb
+++ b/enterprise/app/models/enterprise/concerns/conversation.rb
@@ -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? }
diff --git a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
index 56260f675..0ab031dbf 100644
--- a/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
+++ b/enterprise/app/views/api/v1/models/captain/_document.json.jbuilder
@@ -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
diff --git a/enterprise/lib/captain/conversation_completion_service.rb b/enterprise/lib/captain/conversation_completion_service.rb
index c45559165..37f9add3e 100644
--- a/enterprise/lib/captain/conversation_completion_service.rb
+++ b/enterprise/lib/captain/conversation_completion_service.rb
@@ -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)
diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid
index ed81039af..e039f60b0 100644
--- a/enterprise/lib/captain/prompts/conversation_completion.liquid
+++ b/enterprise/lib/captain/prompts/conversation_completion.liquid
@@ -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"}
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
index 77cb25f49..4d4b10fcb 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/documents_controller_spec.rb
@@ -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)
diff --git a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
index 80b9ab1d8..9cdfc822c 100644
--- a/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
+++ b/spec/enterprise/lib/captain/conversation_completion_service_spec.rb
@@ -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
diff --git a/spec/enterprise/models/captain/agent_session_spec.rb b/spec/enterprise/models/captain/agent_session_spec.rb
new file mode 100644
index 000000000..b4306a11e
--- /dev/null
+++ b/spec/enterprise/models/captain/agent_session_spec.rb
@@ -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
diff --git a/spec/factories/captain/agent_session.rb b/spec/factories/captain/agent_session.rb
new file mode 100644
index 000000000..a7b369b7d
--- /dev/null
+++ b/spec/factories/captain/agent_session.rb
@@ -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
diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb
index be94f3c44..a5bdeef0b 100644
--- a/spec/services/whatsapp/webhook_teardown_service_spec.rb
+++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb
@@ -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