feat(captain): allow agents to report Captain messages (#14799)

Adds a cloud-only flow for agents to flag incorrect or problematic
Captain (AI) responses. Right-clicking a Captain message surfaces a
"Report message" option that opens a dialog to pick a problem type and
add a description, persisted to a new captain_message_reports table for
the team to review.


<img width="636" height="542" alt="Screenshot 2026-06-20 at 9 15 56 AM"
src="https://github.com/user-attachments/assets/afaa233d-6bd6-455a-8a33-a3796a3e3ef6"
/>
<img width="580" height="502" alt="Screenshot 2026-06-20 at 9 16 03 AM"
src="https://github.com/user-attachments/assets/2d220d99-98cc-4c5e-a325-778ceb4f7bc9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Pranav
2026-06-20 14:47:08 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent dfd656a7d9
commit 6a262287d2
15 changed files with 466 additions and 1 deletions
@@ -0,0 +1,9 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
@@ -147,8 +147,14 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
const isCaptainMessage = computed(() => {
const senderType = props.sender?.type ?? props.senderType;
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
});
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -390,6 +396,10 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
report:
isOnChatwootCloud.value &&
isCaptainMessage.value &&
!isMessageDeleted.value,
};
});
@@ -304,6 +304,25 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -14,6 +14,7 @@ import {
import MenuItem from '../../../components/widgets/conversation/contextMenu/menuItem.vue';
import { useTrack } from 'dashboard/composables';
import NextButton from 'dashboard/components-next/button/Button.vue';
import ReportCaptainMessageDialog from './ReportCaptainMessageDialog.vue';
export default {
components: {
@@ -21,6 +22,7 @@ export default {
MenuItem,
ContextMenu,
NextButton,
ReportCaptainMessageDialog,
},
props: {
message: {
@@ -152,6 +154,10 @@ export default {
closeDeleteModal() {
this.showDeleteModal = false;
},
openReportDialog() {
this.handleClose();
this.$refs.reportDialog?.open();
},
},
};
</script>
@@ -243,6 +249,16 @@ export default {
variant="icon"
@click.stop="showCannedResponseModal"
/>
<hr v-if="enabledOptions['report']" />
<MenuItem
v-if="enabledOptions['report']"
:option="{
icon: 'warning',
label: $t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.LABEL'),
}"
variant="icon"
@click.stop="openReportDialog"
/>
<hr v-if="enabledOptions['delete']" />
<MenuItem
v-if="enabledOptions['delete']"
@@ -255,6 +271,11 @@ export default {
/>
</div>
</ContextMenu>
<ReportCaptainMessageDialog
v-if="enabledOptions['report']"
ref="reportDialog"
:message-id="messageId"
/>
</div>
</template>
@@ -0,0 +1,119 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import MessageReportsAPI from 'dashboard/api/captain/messageReports';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Select from 'dashboard/components-next/select/Select.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
const props = defineProps({
messageId: { type: [Number, String], required: true },
});
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const REPORT_REASONS = [
'incorrect_information',
'inappropriate_response',
'incomplete_response',
'outdated_information',
'other',
];
const reasonOptions = computed(() =>
REPORT_REASONS.map(value => ({
value,
label: t(`CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.REASONS.${value}`),
}))
);
const form = reactive({ reportReason: '', description: '' });
const isFormInvalid = computed(() => !form.reportReason);
const resetForm = () => {
form.reportReason = '';
form.description = '';
};
const open = () => {
resetForm();
dialogRef.value?.open();
};
const close = () => {
dialogRef.value?.close();
};
const handleConfirm = async () => {
if (isFormInvalid.value) return;
isLoading.value = true;
try {
await MessageReportsAPI.create({
message_id: props.messageId,
report_reason: form.reportReason,
description: form.description.trim() || null,
});
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUCCESS'));
close();
} catch (error) {
useAlert(t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.ERROR'));
} finally {
isLoading.value = false;
}
};
defineExpose({ open, close });
</script>
<template>
<Dialog
ref="dialogRef"
:title="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.TITLE')"
:description="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION')"
:confirm-button-label="t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.SUBMIT')"
:is-loading="isLoading"
:disable-confirm-button="isFormInvalid"
@confirm="handleConfirm"
>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE') }}
</label>
<Select
v-model="form.reportReason"
class="!w-full [&>select]:w-full"
:options="reasonOptions"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.PROBLEM_TYPE_PLACEHOLDER'
)
"
/>
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_LABEL') }}
</label>
<TextArea
v-model="form.description"
class="w-full"
:placeholder="
t(
'CONVERSATION.CONTEXT_MENU.REPORT_MESSAGE.DESCRIPTION_PLACEHOLDER'
)
"
:max-length="500"
show-character-count
auto-height
/>
</div>
</div>
</Dialog>
</template>
+1
View File
@@ -74,6 +74,7 @@ Rails.application.routes.draw do
resources :scenarios
end
resources :assistant_responses
resources :message_reports, only: [:create]
resources :bulk_actions, only: [:create]
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
@@ -0,0 +1,14 @@
class CreateCaptainMessageReports < ActiveRecord::Migration[7.1]
def change
create_table :captain_message_reports do |t|
t.references :account, null: false
t.references :conversation, null: false
t.references :message, null: false
t.references :user, null: false
t.string :report_reason, null: false
t.text :description
t.timestamps
end
end
end
+16 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
ActiveRecord::Schema[7.1].define(version: 2026_06_20_000000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -399,6 +399,21 @@ ActiveRecord::Schema[7.1].define(version: 2026_06_11_184600) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
create_table "captain_message_reports", force: :cascade do |t|
t.bigint "account_id", null: false
t.bigint "conversation_id", null: false
t.bigint "message_id", null: false
t.bigint "user_id", null: false
t.string "report_reason", null: false
t.text "description"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_captain_message_reports_on_account_id"
t.index ["conversation_id"], name: "index_captain_message_reports_on_conversation_id"
t.index ["message_id"], name: "index_captain_message_reports_on_message_id"
t.index ["user_id"], name: "index_captain_message_reports_on_user_id"
end
create_table "captain_scenarios", force: :cascade do |t|
t.string "title"
t.text "description"
@@ -0,0 +1,38 @@
class Api::V1::Accounts::Captain::MessageReportsController < Api::V1::Accounts::BaseController
before_action :ensure_cloud_installation
before_action :set_message
before_action :authorize_conversation
before_action :ensure_captain_message
def create
@message_report = @message.message_reports.create!(
user: Current.user,
report_reason: permitted_params[:report_reason],
description: permitted_params[:description]
)
end
private
def ensure_cloud_installation
render json: { error: 'Not available' }, status: :not_found unless ChatwootApp.chatwoot_cloud?
end
def set_message
@message = Current.account.messages.find(permitted_params[:message_id])
end
def authorize_conversation
authorize @message.conversation, :show?
end
def ensure_captain_message
return if @message.sender_type == 'Captain::Assistant'
render json: { error: 'Only Captain messages can be reported' }, status: :unprocessable_entity
end
def permitted_params
params.permit(:message_id, :report_reason, :description)
end
end
@@ -0,0 +1,46 @@
# == Schema Information
#
# Table name: captain_message_reports
#
# id :bigint not null, primary key
# description :text
# report_reason :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# conversation_id :bigint not null
# message_id :bigint not null
# user_id :bigint not null
#
# Indexes
#
# index_captain_message_reports_on_account_id (account_id)
# index_captain_message_reports_on_conversation_id (conversation_id)
# index_captain_message_reports_on_message_id (message_id)
# index_captain_message_reports_on_user_id (user_id)
#
class Captain::MessageReport < ApplicationRecord
self.table_name = 'captain_message_reports'
REPORT_REASONS = %w[incorrect_information inappropriate_response incomplete_response outdated_information other].freeze
belongs_to :account
# `Captain::Conversation` exists as a job namespace, so the association would
# resolve to that module instead of the top-level model without this override.
belongs_to :conversation, class_name: '::Conversation'
belongs_to :message
belongs_to :user
validates :report_reason, presence: true, inclusion: { in: REPORT_REASONS }
before_validation :ensure_account_and_conversation
private
def ensure_account_and_conversation
return if message.blank?
self.account ||= message.account
self.conversation ||= message.conversation
end
end
@@ -3,5 +3,6 @@ module Enterprise::Concerns::Message
included do
has_one :call, dependent: :nullify
has_many :message_reports, class_name: 'Captain::MessageReport', dependent: :destroy_async
end
end
@@ -0,0 +1,7 @@
json.id @message_report.id
json.message_id @message_report.message_id
json.conversation_id @message_report.conversation_id
json.user_id @message_report.user_id
json.report_reason @message_report.report_reason
json.description @message_report.description
json.created_at @message_report.created_at.to_i
@@ -0,0 +1,117 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::MessageReports', type: :request do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:message) do
create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant)
end
before { create(:inbox_member, user: agent, inbox: inbox) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'POST /api/v1/accounts/:account_id/captain/message_reports' do
let(:valid_params) do
{
message_id: message.id,
report_reason: 'incorrect_information',
description: 'The generated citation is wrong.'
}
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/captain/message_reports", params: valid_params, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when the installation is not on Chatwoot cloud' do
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'self_hosted') }
it 'returns not found' do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:not_found)
end
it 'does not create a report' do
expect do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params, headers: agent.create_new_auth_token, as: :json
end.not_to change(Captain::MessageReport, :count)
end
end
context 'when on Chatwoot cloud' do
before { InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_initialize.update!(value: 'cloud') }
it 'creates a message report for the reporting agent' do
expect do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params, headers: agent.create_new_auth_token, as: :json
end.to change(Captain::MessageReport, :count).by(1)
report = Captain::MessageReport.last
aggregate_failures do
expect(response).to have_http_status(:success)
expect(report.message_id).to eq(message.id)
expect(report.conversation_id).to eq(conversation.id)
expect(report.user_id).to eq(agent.id)
expect(report.report_reason).to eq('incorrect_information')
expect(report.description).to eq('The generated citation is wrong.')
expect(json_response[:report_reason]).to eq('incorrect_information')
end
end
it 'returns not found when the message does not belong to the account' do
other_message = create(:message)
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params.merge(message_id: other_message.id),
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:not_found)
end
it 'returns unprocessable entity for an invalid report reason' do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params.merge(report_reason: 'invalid_reason'),
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'does not allow an agent without access to the conversation to report' do
other_agent = create(:user, account: account, role: :agent)
expect do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params, headers: other_agent.create_new_auth_token, as: :json
end.not_to change(Captain::MessageReport, :count)
expect(response).to have_http_status(:unauthorized)
end
it 'rejects messages that were not sent by a Captain assistant' do
non_captain_message = create(:message, account: account, conversation: conversation)
expect do
post "/api/v1/accounts/#{account.id}/captain/message_reports",
params: valid_params.merge(message_id: non_captain_message.id),
headers: agent.create_new_auth_token, as: :json
end.not_to change(Captain::MessageReport, :count)
expect(response).to have_http_status(:unprocessable_entity)
end
end
end
end
@@ -0,0 +1,40 @@
require 'rails_helper'
RSpec.describe Captain::MessageReport, type: :model do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
it { is_expected.to belong_to(:message) }
it { is_expected.to belong_to(:user) }
it 'resolves the conversation association to the top-level Conversation model' do
# `Captain::Conversation` exists as a job namespace, so without an explicit
# class_name the association would resolve to that module instead.
expect(described_class.reflect_on_association(:conversation).klass).to eq(Conversation)
end
end
describe 'validations' do
it { is_expected.to validate_presence_of(:report_reason) }
it { is_expected.to validate_inclusion_of(:report_reason).in_array(described_class::REPORT_REASONS) }
end
describe 'callbacks' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, account: account, conversation: conversation) }
it 'derives the account and conversation from the message' do
report = described_class.create!(message: message, user: create(:user, account: account), report_reason: 'other')
expect(report.account).to eq(account)
expect(report.conversation).to eq(conversation)
end
end
describe 'factory' do
it 'creates a valid message report' do
expect(build(:captain_message_report)).to be_valid
end
end
end
+8
View File
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :captain_message_report, class: 'Captain::MessageReport' do
report_reason { 'incorrect_information' }
description { 'The generated citation is wrong.' }
association :message
association :user
end
end