From 08260f3be7069601b188f6655fd174661e9562d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 13 Jul 2026 16:17:00 +0400 Subject: [PATCH 1/9] feat: show crawled document details and FAQ counts in Captain (#14863) - Add a document details view that surfaces crawled content, source metadata, and generated FAQ counts. - Rename the document card action to open details and show the FAQ count inline in the list. - Return `responses_count` from the documents API efficiently and expose document content in the show payload. - Update related Captain copy to reflect the new details-oriented flow. **Preview** CleanShot 2026-06-26 at 09 25
15@2x --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com> --- .../captain/assistant/DocumentCard.vue | 30 +- .../document/DocumentDetails.spec.js | 86 ++++ .../document/DocumentDetails.vue | 374 ++++++++++++++++++ .../document/RelatedResponses.vue | 71 ---- .../i18n/locale/en/integrations.json | 25 +- .../dashboard/captain/documents/Index.vue | 29 +- .../shared/helpers/MessageFormatter.js | 5 + .../helpers/specs/MessageFormatter.spec.js | 19 + .../accounts/captain/documents_controller.rb | 27 +- .../v1/models/captain/_document.json.jbuilder | 2 + .../captain/documents_controller_spec.rb | 16 + 11 files changed, 582 insertions(+), 102 deletions(-) create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.spec.js create mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/DocumentDetails.vue delete mode 100644 app/javascript/dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue 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: + '
- ''; + } + 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/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/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/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) From 056b5eb89d41760a055662c2e893f9a6d446206b Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Mon, 13 Jul 2026 18:15:25 +0530 Subject: [PATCH 2/9] fix: avoid full scan in IMAP email dedup on large inboxes (#14981) ## Description `Imap::BaseFetchEmailService#email_already_present?` used `find_by(source_id:)`, which inherits `Message`'s `default_scope { order(created_at: :asc) }`, adding an `ORDER BY created_at ASC LIMIT 1` to what is only a presence check. On inboxes with a large message history, that `ORDER BY` lets Postgres satisfy the sort by walking `index_messages_on_created_at` instead of the selective `index_messages_on_source_id`. For a not-yet-seen `source_id` (every new email) it can scan the whole table before returning, taking seconds per message. The dedup loop runs with no IMAP activity in between, so the idle socket is dropped by the mail server and the fetch job aborts with `closed stream`. The inbox then stops ingesting mail entirely, while smaller inboxes on the same server keep working. `exists?` issues `SELECT 1 ... LIMIT 1` with no `ORDER BY`, so the planner uses `index_messages_on_source_id` regardless of table size. No schema change is required. The fix lives in the shared base class, so it covers both the IMAP and Microsoft fetch paths. Fixes #14682 --- app/services/imap/base_fetch_email_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From df7f1376570474f1a339faeb7a27a16f7f09d1e2 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 18:18:20 +0530 Subject: [PATCH 3/9] feat: add captain sessions model [CW-7485] (#14970) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a `captain_sessions` table to log every Captain run, starting with Assistant Responses and Copilot Responses. Each session records the assistant, model, credits consumed, the FAQs/documents/scenario that contributed to the response, and the full run context — giving customers visibility into how a response was generated and giving us durable stats on credit, FAQ, and document usage (which today only exist as ephemeral trace metadata and an aggregate account counter). ## What changed - New `Captain::Session` model with a `session_type` enum (`assistant`, `copilot`). The subject (`Conversation` / `CopilotThread`) and result (`Message` / `CopilotMessage`) classes are inferred from the session type, so the table stores plain `subject_id` / `result_id` ids. `result_id` is nullable so failed runs that still consumed credits can be logged. - Composite indexes on `[session_type, subject_id]`, `[session_type, result_id]`, and `[account_id, session_type, created_at]` for lookup and usage-stats queries. - Factory and model specs. This PR is schema + model only; the writer/instrumentation that records sessions from the assistant and copilot flows will follow. --------- Co-authored-by: Sony Mathew --- .../20260709091147_create_agent_sessions.rb | 24 +++ db/schema.rb | 25 +++ .../app/models/captain/agent_session.rb | 86 ++++++++++ enterprise/app/models/captain/assistant.rb | 1 + .../app/models/enterprise/concerns/account.rb | 1 + .../models/captain/agent_session_spec.rb | 160 ++++++++++++++++++ spec/factories/captain/agent_session.rb | 14 ++ 7 files changed, 311 insertions(+) create mode 100644 db/migrate/20260709091147_create_agent_sessions.rb create mode 100644 enterprise/app/models/captain/agent_session.rb create mode 100644 spec/enterprise/models/captain/agent_session_spec.rb create mode 100644 spec/factories/captain/agent_session.rb 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/schema.rb b/db/schema.rb index e01dc34c1..f02b613d9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 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..b3134e2f2 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -37,6 +37,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/enterprise/concerns/account.rb b/enterprise/app/models/enterprise/concerns/account.rb index 1ef112fb5..1f5376940 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -13,6 +13,7 @@ module Enterprise::Concerns::Account has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse' 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/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 From 9c444315a6e3325621032c6738d1ea75af482862 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Mon, 13 Jul 2026 18:20:36 +0530 Subject: [PATCH 4/9] feat: add `api_and_webhooks` feature flag reconciled from billing plan (#14972) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces a new `api_and_webhooks` account feature flag that will control access to the token-authenticated API and account webhooks. The flag is part of the Startup plan features, so paid plans — including trials of paid plans — get it through the billing reconcile, while accounts on the default (Hacker) plan don't, with `manually_managed_features` available as a per-account override. The flag defaults to enabled, and nothing enforces it yet, so this PR is behavior-neutral — enforcement lands in a follow-up. ## What changed - Added `api_and_webhooks` to `features.yml` (first flag on the `feature_flags_ext_1` column, default enabled). - Added the flag to `STARTUP_PLAN_FEATURES` in `Enterprise::Billing::ReconcilePlanFeaturesService`, so all paid tiers get it and the default plan loses it on reconcile. - Added the flag to the manually manageable features list so it can be granted per account via Super Admin. ```rb # Enables the api_and_webhooks feature for all existing accounts and marks it # as manually managed so cloud billing reconciles never strip it. # # NOT committed to source control — run manually on production. # # Usage: # bundle exec rails runner enable_api_and_webhooks.rb # ACCOUNT_ID=123 bundle exec rails runner enable_api_and_webhooks.rb # # Idempotent: accounts already grandfathered are skipped; safe to re-run. probe = Internal::Accounts::InternalAttributesService.new(Account.new) abort 'api_and_webhooks is not in valid_feature_list — deploy the feature flag PR first.' unless probe.valid_feature_list.include?('api_and_webhooks') account_id = ENV.fetch('ACCOUNT_ID', nil) accounts = account_id.present? ? Account.where(id: account_id) : Account.all abort "Account with ID #{account_id} not found" if account_id.present? && accounts.empty? total = accounts.count puts "Grandfathering api_and_webhooks for #{total} account(s)..." puts "Started at: #{Time.current}" updated = 0 skipped = 0 errored = 0 accounts.find_each(batch_size: 500) do |account| service = Internal::Accounts::InternalAttributesService.new(account) features = service.manually_managed_features if features.include?('api_and_webhooks') && account.feature_enabled?('api_and_webhooks') skipped += 1 else service.manually_managed_features = features + ['api_and_webhooks'] unless features.include?('api_and_webhooks') account.enable_features!('api_and_webhooks') updated += 1 end processed = updated + skipped + errored puts "Processed #{processed}/#{total}..." if (processed % 1000).zero? rescue StandardError => e errored += 1 puts "Account #{account.id}: FAILED - #{e.message}" end puts "Done! Updated: #{updated}, Skipped: #{skipped}, Errored: #{errored}, Total: #{total}" ``` --- config/features.yml | 4 ++ .../reconcile_plan_features_service.rb | 1 + .../accounts/internal_attributes_service.rb | 2 +- lib/tasks/feature_defaults.rake | 64 +++++++++++++++++++ .../reconcile_plan_features_service_spec.rb | 53 +++++++++++++++ spec/models/account_spec.rb | 2 + 6 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 lib/tasks/feature_defaults.rake create mode 100644 spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb diff --git a/config/features.yml b/config/features.yml index 62bcab2da..39c8a53af 100644 --- a/config/features.yml +++ b/config/features.yml @@ -257,3 +257,7 @@ display_name: Data Import enabled: false column: feature_flags_ext_1 +- name: api_and_webhooks + display_name: API and Webhooks + enabled: true + column: feature_flags_ext_1 diff --git a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb index 205bc348e..435b1f3d1 100644 --- a/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb +++ b/enterprise/app/services/enterprise/billing/reconcile_plan_features_service.rb @@ -18,6 +18,7 @@ class Enterprise::Billing::ReconcilePlanFeaturesService advanced_search linear_integration channel_voice + api_and_webhooks ].freeze BUSINESS_PLAN_FEATURES = %w[ diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb index 593cea799..00c3d3636 100644 --- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb +++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb @@ -54,7 +54,7 @@ class Internal::Accounts::InternalAttributesService def valid_feature_list Enterprise::Billing::ReconcilePlanFeaturesService::BUSINESS_PLAN_FEATURES + Enterprise::Billing::ReconcilePlanFeaturesService::ENTERPRISE_PLAN_FEATURES + - %w[inbound_emails] + %w[inbound_emails api_and_webhooks] end # Account notes functionality removed for now diff --git a/lib/tasks/feature_defaults.rake b/lib/tasks/feature_defaults.rake new file mode 100644 index 000000000..6b6e0443a --- /dev/null +++ b/lib/tasks/feature_defaults.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# rubocop:disable Metrics/BlockLength +namespace :feature_defaults do + desc 'Interactively toggle a feature on/off in ACCOUNT_LEVEL_FEATURE_DEFAULTS (affects new account signups only)' + task toggle: :environment do + config = InstallationConfig.find_by!(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + + loop do + features = config.value + print_feature_list(features) + + print "\nEnter the number of the feature to toggle (or 'q' to quit): " + input = $stdin.gets.chomp + break if input.casecmp('q').zero? + + feature = select_feature(features, input) + if feature.nil? + puts 'Invalid selection.' + next + end + + toggle_feature(config, features, feature) + end + + puts 'Done.' + end + + def print_feature_list(features) + puts "\n#{'#'.ljust(4)}#{'name'.ljust(35)}#{'display_name'.ljust(30)}enabled" + features.each_with_index do |feature, index| + puts "#{(index + 1).to_s.ljust(4)}#{feature['name'].to_s.ljust(35)}#{feature['display_name'].to_s.ljust(30)}#{feature['enabled']}" + end + end + + def select_feature(features, input) + index = Integer(input, exception: false) + return nil if index.nil? || !index.between?(1, features.length) + + features[index - 1] + end + + def toggle_feature(config, features, feature) + print "#{feature['name']} is currently enabled: #{feature['enabled']}. Type 'true' or 'false' to set (anything else cancels): " + input = $stdin.gets.chomp + + case input + when 'true' + new_state = true + when 'false' + new_state = false + else + puts 'Cancelled.' + return + end + + feature['enabled'] = new_state + config.value = features + config.save! + GlobalConfig.clear_cache + puts "Updated #{feature['name']} to enabled: #{new_state}" + end +end +# rubocop:enable Metrics/BlockLength diff --git a/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb new file mode 100644 index 000000000..64be87ff4 --- /dev/null +++ b/spec/enterprise/services/enterprise/billing/reconcile_plan_features_service_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +describe Enterprise::Billing::ReconcilePlanFeaturesService do + let(:account) { create(:account) } + + before do + create(:installation_config, { + name: 'CHATWOOT_CLOUD_PLANS', + value: [ + { 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] }, + { 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] } + ] + }) + end + + describe '#perform' do + context 'with api_and_webhooks feature' do + it 'enables the feature for a paid plan with an active subscription' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'enables the feature for a paid plan on trial' do + account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' }) + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + + it 'disables the feature on the default plan' do + account.enable_features!('api_and_webhooks') + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' }) + + described_class.new(account: account).perform + + expect(account.reload).not_to be_feature_enabled('api_and_webhooks') + end + + it 'keeps the feature enabled when manually managed' do + account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' }) + Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks'] + + described_class.new(account: account).perform + + expect(account.reload).to be_feature_enabled('api_and_webhooks') + end + end + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index a4932d635..00b464f73 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -108,6 +108,8 @@ RSpec.describe Account do it 'configures the account feature flag extension column' do expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1') + expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1, + feature_api_and_webhooks: 1 << 2) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1) expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2) end From 1b6a80d84d19310217c83c5d8140d08ba9b675f9 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:45:40 +0530 Subject: [PATCH 5/9] fix(captain): improve conversation completion evaluation (#14967) # Pull Request Template ## Description Please include a summary of the change and issue(s) fixed. Also, mention relevant motivation, context, and any dependencies that this change requires. Fixes https://linear.app/chatwoot/issue/AI-136/check-conversation-status-while-auto-resolving - After 60mins of inactivity, we run a job that decides if pending conversations are resolvable or need handoff - the prompt was a bit conservative and didn't have conversation state context ## Type of change Please delete options that are not relevant. - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. locally ran a sample eval ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew --- .../conversation_completion_service.rb | 58 +++++++++- .../prompts/conversation_completion.liquid | 25 ++++- .../conversation_completion_service_spec.rb | 104 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) 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/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 From 102f19fe417ff68e49ec60077ff07c2355ec5811 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:31:08 +0530 Subject: [PATCH 6/9] feat(captain): add FAQ suggestion data model (1/3) (#14977) Resolved conversations need a separate suggestion layer so repeated FAQ signals can be grouped without creating untrusted knowledge entries. This PR adds the persistence foundation only; it introduces no user-facing behavior by itself. ## Closes - [CW-7495](https://linear.app/chatwoot/issue/CW-7495/backend-llm-changes-to-make-conversation-faqs-as-signalssuggestions) (stacked PR 1/3; the issue is complete after the full stack lands) ## What changed - Added `captain_faq_suggestions` with question, answer, embedding, source count, and review status. - Added `captain_faq_observations` to retain conversation-level signals. - Added Captain assistant, account, and conversation associations. - Added vector and lookup indexes for semantic grouping. ## How to test This layer has no standalone UI behavior. Apply the migration and confirm Captain assistants can persist open FAQ suggestions with attached conversation observations. --- ...13184351_create_captain_faq_suggestions.rb | 48 +++++++++++++++++ db/schema.rb | 35 ++++++++++++- enterprise/app/models/captain/assistant.rb | 1 + .../app/models/captain/faq_observation.rb | 42 +++++++++++++++ .../app/models/captain/faq_suggestion.rb | 51 +++++++++++++++++++ .../app/models/enterprise/concerns/account.rb | 2 + .../enterprise/concerns/conversation.rb | 1 + 7 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260713184351_create_captain_faq_suggestions.rb create mode 100644 enterprise/app/models/captain/faq_observation.rb create mode 100644 enterprise/app/models/captain/faq_suggestion.rb 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 f02b613d9..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" @@ -417,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/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index b3134e2f2..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, 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 1f5376940..427b1e1af 100644 --- a/enterprise/app/models/enterprise/concerns/account.rb +++ b/enterprise/app/models/enterprise/concerns/account.rb @@ -11,6 +11,8 @@ 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' 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? } From 280756b483b941d355ab2e09e546c83608fc12d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:30:19 +0400 Subject: [PATCH 7/9] fix(meta): disable Instagram replies on Cloud during restriction (#15005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can no longer send replies in Instagram conversations on Chatwoot Cloud while the temporary Meta platform restriction is active. The reply box locks into Private Note mode — the same behavior as an expired 24-hour reply window — so teams can still collaborate internally, with the existing amber restriction banner above the conversation explaining why. Self-hosted installations are unaffected. Follow-up to #14974. ## How to test 1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any Instagram conversation. 2. The composer should be locked to Private Note mode: the Reply/Private Note toggle is disabled, and sending creates a private note — even for conversations within the 24-hour reply window. 3. Switching between conversations should keep the composer in Private Note mode for Instagram conversations. 4. On a self-hosted environment, Instagram conversations should behave as before (reply allowed within the messaging window). Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../widgets/conversation/ReplyBox.vue | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) 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(); From 3e03f8da1e8049a5b94d295073cf4763d7d90d7d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:35:10 +0400 Subject: [PATCH 8/9] chore(whatsapp): log warning when Cloud API template sync fails (#15004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp Cloud API template sync currently fails silently — if the Graph API call errors (expired token, rate limit, permission issue), the channel simply keeps its stale templates with no trace in the logs. This adds a warning log when the template fetch fails, so failed syncs are visible and debuggable. ## What changed - `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates` now logs a warning with the account id, inbox id, HTTP status code, and Meta's error message when the response is not successful. - The inbox id uses safe navigation since sync also runs from the channel's `after_create` callback, before the inbox record exists. - The request URL is intentionally not logged, as it contains the access token as a query param. ## How to reproduce 1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`. 2. Trigger a template sync (Inbox settings → sync templates, or wait for the scheduler). 3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed for account ... inbox ...` warning appears in the Rails logs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- app/services/whatsapp/providers/whatsapp_cloud_service.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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) From 9328f8739ce420bb031550f87d83e5f4bc32b61d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:27 +0530 Subject: [PATCH 9/9] fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a manually-configured WhatsApp Cloud inbox left its phone-number-level webhook override still pointing at Chatwoot on Meta's side. The number kept routing inbound events to us after the inbox was gone, which blocked the customer's own app — subscribed separately on the same WABA — from receiving messages, since the phone-level override takes priority over the app-level subscription. Deleting the inbox now releases the override, as it already did for embedded-signup inboxes. ## What changed The setup and teardown paths gated on opposite halves of the same condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the override for `whatsapp_cloud` inboxes where `source != 'embedded_signup'` (i.e. manual ones), while `Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared it when `source == 'embedded_signup'`. The two sets are disjoint, so manual inboxes were exactly the ones that set an override on create and never cleared it on destroy. Embedded-signup inboxes were unaffected because `EmbeddedSignupService` calls `setup_webhooks` explicitly. Dropping the `source` check from the teardown guard is the whole fix. Manual `whatsapp_cloud` channels can't persist without `api_key`, `phone_number_id` and `business_account_id` (`validate_provider_config` verifies all three against Meta), so the remaining presence guards and both API calls have everything they need. The WABA-level `DELETE /subscribed_apps` now also fires for manual inboxes when the last one on a WABA is removed, which is symmetric with manual setup subscribing the app in the first place; the token only unsubscribes the app it belongs to, so a customer's separate app subscription is untouched. This fixes the leak going forward. Numbers already stranded still need the override cleared with the customer's own token, since we no longer hold their `api_key` once the inbox is deleted. ## How to reproduce 1. Create a WhatsApp Cloud inbox using manual API keys (not embedded signup). 2. Confirm the override is set: `GET /v22.0/{phone_number_id}?fields=webhook_configuration` shows `phone_number` pointing at your Chatwoot install. 3. Delete the inbox. 4. Before this change, the override still points at Chatwoot. After it, `webhook_configuration` no longer carries the phone-level override and events fall back to the WABA/app-level subscription. --------- Co-authored-by: Muhsin Keloth --- .../whatsapp/webhook_teardown_service.rb | 6 ++-- .../whatsapp/webhook_teardown_service_spec.rb | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) 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/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