+import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import NextButton from 'dashboard/components-next/button/Button.vue';
+import MessageList from './MessageList.vue';
+import CaptainAssistant from 'dashboard/api/captain/assistant';
+
+const { assistantId } = defineProps({
+ assistantId: {
+ type: Number,
+ required: true,
+ },
+});
+
+const { t } = useI18n();
+const messages = ref([]);
+const newMessage = ref('');
+const isLoading = ref(false);
+
+const formatMessagesForApi = () => {
+ return messages.value.map(message => ({
+ role: message.sender,
+ content: message.content,
+ }));
+};
+
+const resetConversation = () => {
+ messages.value = [];
+ newMessage.value = '';
+};
+
+const sendMessage = async () => {
+ if (!newMessage.value.trim() || isLoading.value) return;
+
+ const userMessage = {
+ content: newMessage.value,
+ sender: 'user',
+ timestamp: new Date().toISOString(),
+ };
+ messages.value.push(userMessage);
+ const currentMessage = newMessage.value;
+ newMessage.value = '';
+
+ try {
+ isLoading.value = true;
+ const { data } = await CaptainAssistant.playground({
+ assistantId,
+ messageContent: currentMessage,
+ messageHistory: formatMessagesForApi(),
+ });
+
+ messages.value.push({
+ content: data.response,
+ sender: 'assistant',
+ timestamp: new Date().toISOString(),
+ });
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error('Error getting assistant response:', error);
+ } finally {
+ isLoading.value = false;
+ }
+};
+
+
+
+
+
+
+
+ {{ t('CAPTAIN.PLAYGROUND.HEADER') }}
+
+
+
+
+ {{ t('CAPTAIN.PLAYGROUND.DESCRIPTION') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN.PLAYGROUND.CREDIT_NOTE') }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue b/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue
new file mode 100644
index 000000000..1d6529a45
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/assistant/MessageList.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue
new file mode 100644
index 000000000..3a668a757
--- /dev/null
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/assistant/EditAssistantForm.vue
@@ -0,0 +1,306 @@
+
+
+
+
+
diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json
index 8af57975c..ed56b1669 100644
--- a/app/javascript/dashboard/i18n/locale/en/integrations.json
+++ b/app/javascript/dashboard/i18n/locale/en/integrations.json
@@ -333,6 +333,14 @@
"RESET": "Reset",
"SELECT_ASSISTANT": "Select Assistant"
},
+ "PLAYGROUND": {
+ "USER": "You",
+ "ASSISTANT": "Assistant",
+ "MESSAGE_PLACEHOLDER": "Type your message...",
+ "HEADER": "Playground",
+ "DESCRIPTION": "Use this playground to send messages to your assistant and check if it responds accurately, quickly, and in the tone you expect.",
+ "CREDIT_NOTE": "Messages sent here will count toward your Captain credits."
+ },
"PAYWALL": {
"TITLE": "Upgrade to use Captain AI",
"AVAILABLE_ON": "Captain is not available on the free plan.",
@@ -371,20 +379,41 @@
"ERROR_MESSAGE": "There was an error creating the assistant, please try again."
},
"FORM": {
+ "UPDATE": "Update",
+ "SECTIONS": {
+ "BASIC_INFO": "Basic Information",
+ "SYSTEM_MESSAGES": "System Messages",
+ "INSTRUCTIONS": "Instructions",
+ "FEATURES": "Features",
+ "TOOLS": "Tools "
+ },
"NAME": {
- "LABEL": "Assistant Name",
- "PLACEHOLDER": "Enter a name for the assistant",
- "ERROR": "Please provide a name for the assistant"
+ "LABEL": "Name",
+ "PLACEHOLDER": "Enter assistant name"
},
"DESCRIPTION": {
- "LABEL": "Assistant Description",
- "PLACEHOLDER": "Describe how and where this assistant will be used",
- "ERROR": "A description is required"
+ "LABEL": "Description",
+ "PLACEHOLDER": "Enter assistant description"
},
"PRODUCT_NAME": {
"LABEL": "Product Name",
- "PLACEHOLDER": "Enter the name of the product this assistant is designed for",
- "ERROR": "The product name is required"
+ "PLACEHOLDER": "Enter product name"
+ },
+ "WELCOME_MESSAGE": {
+ "LABEL": "Welcome Message",
+ "PLACEHOLDER": "Enter welcome message"
+ },
+ "HANDOFF_MESSAGE": {
+ "LABEL": "Handoff Message",
+ "PLACEHOLDER": "Enter handoff message"
+ },
+ "RESOLUTION_MESSAGE": {
+ "LABEL": "Resolution Message",
+ "PLACEHOLDER": "Enter resolution message"
+ },
+ "INSTRUCTIONS": {
+ "LABEL": "Instructions",
+ "PLACEHOLDER": "Enter instructions for the assistant"
},
"FEATURES": {
"TITLE": "Features",
@@ -395,7 +424,8 @@
"EDIT": {
"TITLE": "Update the assistant",
"SUCCESS_MESSAGE": "The assistant has been successfully updated",
- "ERROR_MESSAGE": "There was an error updating the assistant, please try again."
+ "ERROR_MESSAGE": "There was an error updating the assistant, please try again.",
+ "NOT_FOUND": "Could not find the assistant. Please try again."
},
"OPTIONS": {
"EDIT_ASSISTANT": "Edit Assistant",
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/Edit.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/Edit.vue
new file mode 100644
index 000000000..8b64e2663
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/Edit.vue
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+ {{ t('CAPTAIN.ASSISTANTS.EDIT.NOT_FOUND') }}
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
index 242e6f086..800783a7b 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/Index.vue
@@ -36,8 +36,10 @@ const handleCreate = () => {
};
const handleEdit = () => {
- dialogType.value = 'edit';
- nextTick(() => createAssistantDialog.value.dialogRef.open());
+ router.push({
+ name: 'captain_assistants_edit',
+ params: { assistantId: selectedAssistant.value.id },
+ });
};
const handleViewConnectedInboxes = () => {
diff --git a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
index cf7751751..17afeca14 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
@@ -2,6 +2,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { frontendURL } from '../../../helper/URLHelper';
import AssistantIndex from './assistants/Index.vue';
+import AssistantEdit from './assistants/Edit.vue';
import AssistantInboxesIndex from './assistants/inboxes/Index.vue';
import DocumentsIndex from './documents/Index.vue';
import ResponsesIndex from './responses/Index.vue';
@@ -20,6 +21,19 @@ export const routes = [
],
},
},
+ {
+ path: frontendURL('accounts/:accountId/captain/assistants/:assistantId'),
+ component: AssistantEdit,
+ name: 'captain_assistants_edit',
+ meta: {
+ permissions: ['administrator', 'agent'],
+ featureFlag: FEATURE_FLAGS.CAPTAIN,
+ installationTypes: [
+ INSTALLATION_TYPES.CLOUD,
+ INSTALLATION_TYPES.ENTERPRISE,
+ ],
+ },
+ },
{
path: frontendURL(
'accounts/:accountId/captain/assistants/:assistantId/inboxes'
diff --git a/app/jobs/conversations/update_message_status_job.rb b/app/jobs/conversations/update_message_status_job.rb
index 1e6333b41..6fcef4361 100644
--- a/app/jobs/conversations/update_message_status_job.rb
+++ b/app/jobs/conversations/update_message_status_job.rb
@@ -15,7 +15,7 @@ class Conversations::UpdateMessageStatusJob < ApplicationJob
conversation.messages.where(status: %w[sent delivered])
.where.not(message_type: 'incoming')
.where('messages.created_at <= ?', timestamp).find_each do |message|
- message.update!(status: status)
+ Messages::StatusUpdateService.new(message, status).perform
end
end
end
diff --git a/app/services/facebook/send_on_facebook_service.rb b/app/services/facebook/send_on_facebook_service.rb
index 5dde19c5a..5e9212edd 100644
--- a/app/services/facebook/send_on_facebook_service.rb
+++ b/app/services/facebook/send_on_facebook_service.rb
@@ -16,7 +16,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
rescue Facebook::Messenger::FacebookError => e
# TODO : handle specific errors or else page will get disconnected
handle_facebook_error(e)
- message.update!(status: :failed, external_error: e.message)
+ Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
def send_message_to_facebook(delivery_params)
@@ -24,7 +24,7 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
return if parsed_result.nil?
if parsed_result['error'].present?
- message.update!(status: :failed, external_error: external_error(parsed_result))
+ Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_result)).perform
Rails.logger.info "Facebook::SendOnFacebookService: Error sending message to Facebook : Page - #{channel.page_id} : #{parsed_result}"
end
@@ -35,11 +35,11 @@ class Facebook::SendOnFacebookService < Base::SendOnChannelService
result = Facebook::Messenger::Bot.deliver(delivery_params, page_id: channel.page_id)
JSON.parse(result)
rescue JSON::ParserError
- message.update!(status: :failed, external_error: 'Facebook was unable to process this request')
+ Messages::StatusUpdateService.new(message, 'failed', 'Facebook was unable to process this request').perform
Rails.logger.error "Facebook::SendOnFacebookService: Error parsing JSON response from Facebook : Page - #{channel.page_id} : #{result}"
nil
rescue Net::OpenTimeout
- message.update!(status: :failed, external_error: 'Request timed out, please try again later')
+ Messages::StatusUpdateService.new(message, 'failed', 'Request timed out, please try again later').perform
Rails.logger.error "Facebook::SendOnFacebookService: Timeout error sending message to Facebook : Page - #{channel.page_id}"
nil
end
diff --git a/app/services/instagram/base_send_service.rb b/app/services/instagram/base_send_service.rb
index dee41c23f..ff5f9216e 100644
--- a/app/services/instagram/base_send_service.rb
+++ b/app/services/instagram/base_send_service.rb
@@ -61,7 +61,7 @@ class Instagram::BaseSendService < Base::SendOnChannelService
else
external_error = external_error(parsed_response)
Rails.logger.error("Instagram response: #{external_error} : #{message_content}")
- message.update!(status: :failed, external_error: external_error)
+ Messages::StatusUpdateService.new(message, 'failed', external_error).perform
nil
end
end
diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb
index f8d704128..03ebe0ab7 100644
--- a/app/services/line/send_on_line_service.rb
+++ b/app/services/line/send_on_line_service.rb
@@ -14,10 +14,10 @@ class Line::SendOnLineService < Base::SendOnChannelService
if response.code == '200'
# If the request is successful, update the message status to delivered
- message.update!(status: :delivered)
+ Messages::StatusUpdateService.new(message, 'delivered').perform
else
# If the request is not successful, update the message status to failed and save the external error
- message.update!(status: :failed, external_error: external_error(parsed_json))
+ Messages::StatusUpdateService.new(message, 'failed', external_error(parsed_json)).perform
end
end
diff --git a/app/services/messages/status_update_service.rb b/app/services/messages/status_update_service.rb
new file mode 100644
index 000000000..4868a201e
--- /dev/null
+++ b/app/services/messages/status_update_service.rb
@@ -0,0 +1,34 @@
+class Messages::StatusUpdateService
+ attr_reader :message, :status, :external_error
+
+ def initialize(message, status, external_error = nil)
+ @message = message
+ @status = status
+ @external_error = external_error
+ end
+
+ def perform
+ return false unless valid_status_transition?
+
+ update_message_status
+ end
+
+ private
+
+ def update_message_status
+ # Update status and set external_error only when failed
+ message.update!(
+ status: status,
+ external_error: (status == 'failed' ? external_error : nil)
+ )
+ end
+
+ def valid_status_transition?
+ return false unless Message.statuses.key?(status)
+
+ # Don't allow changing from 'read' to 'delivered'
+ return false if message.read? && status == 'delivered'
+
+ true
+ end
+end
diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb
index 3fc420bb2..5bd262759 100644
--- a/app/services/twilio/send_on_twilio_service.rb
+++ b/app/services/twilio/send_on_twilio_service.rb
@@ -9,7 +9,7 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService
begin
twilio_message = channel.send_message(**message_params)
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
- message.update!(status: :failed, external_error: e.message)
+ Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
message.update!(source_id: twilio_message.sid) if twilio_message
end
diff --git a/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder b/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder
new file mode 100644
index 000000000..3798b6c1f
--- /dev/null
+++ b/app/views/api/v1/accounts/conversations/messages/update.json.jbuilder
@@ -0,0 +1 @@
+json.partial! 'api/v1/models/message', message: @message
diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb
index 20cc70d5e..14b668637 100644
--- a/app/workers/email_reply_worker.rb
+++ b/app/workers/email_reply_worker.rb
@@ -11,6 +11,6 @@ class EmailReplyWorker
ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: message.account).capture_exception
- message.update!(status: :failed, external_error: e.message)
+ Messages::StatusUpdateService.new(message, 'failed', e.message).perform
end
end
diff --git a/config/routes.rb b/config/routes.rb
index 46199db12..c623ff053 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -53,6 +53,9 @@ Rails.application.routes.draw do
end
namespace :captain do
resources :assistants do
+ member do
+ post :playground
+ end
resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id
end
resources :documents, only: [:index, :show, :create, :destroy]
@@ -98,7 +101,7 @@ Rails.application.routes.draw do
post :filter
end
scope module: :conversations do
- resources :messages, only: [:index, :create, :destroy] do
+ resources :messages, only: [:index, :create, :destroy, :update] do
member do
post :translate
post :retry
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
index e424a8a62..35b6ffe1d 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb
@@ -2,7 +2,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
before_action :current_account
before_action -> { check_authorization(Captain::Assistant) }
- before_action :set_assistant, only: [:show, :update, :destroy]
+ before_action :set_assistant, only: [:show, :update, :destroy, :playground]
def index
@assistants = account_assistants.ordered
@@ -23,6 +23,15 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
head :no_content
end
+ def playground
+ response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
+ params[:message_content],
+ message_history
+ )
+
+ render json: response
+ end
+
private
def set_assistant
@@ -34,6 +43,19 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def assistant_params
- params.require(:assistant).permit(:name, :description, config: [:product_name, :feature_faq, :feature_memory])
+ params.require(:assistant).permit(:name, :description,
+ config: [
+ :product_name, :feature_faq, :feature_memory,
+ :welcome_message, :handoff_message, :resolution_message,
+ :instructions
+ ])
+ end
+
+ def playground_params
+ params.require(:assistant).permit(:message_content, message_history: [:role, :content])
+ end
+
+ def message_history
+ (playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
end
end
diff --git a/enterprise/app/helpers/captain/chat_helper.rb b/enterprise/app/helpers/captain/chat_helper.rb
index 3cb5bf8fe..146e8f813 100644
--- a/enterprise/app/helpers/captain/chat_helper.rb
+++ b/enterprise/app/helpers/captain/chat_helper.rb
@@ -36,6 +36,7 @@ module Captain::ChatHelper
end
def handle_response(response)
+ Rails.logger.debug { "[CAPTAIN][ChatCompletion] #{response}" }
message = response.dig('choices', 0, 'message')
if message['tool_calls']
process_tool_calls(message['tool_calls'])
@@ -46,20 +47,26 @@ module Captain::ChatHelper
def process_tool_calls(tool_calls)
append_tool_calls(tool_calls)
- process_tool_call(tool_calls.first)
- end
-
- def process_tool_call(tool_call)
- return unless tool_call['function']['name'] == 'search_documentation'
-
- tool_call_id = tool_call['id']
- query = JSON.parse(tool_call['function']['arguments'])['search_query']
- sections = fetch_documentation(query)
- append_tool_response(sections, tool_call_id)
+ tool_calls.each do |tool_call|
+ process_tool_call(tool_call)
+ end
request_chat_completion
end
+ def process_tool_call(tool_call)
+ tool_call_id = tool_call['id']
+
+ if tool_call['function']['name'] == 'search_documentation'
+ query = JSON.parse(tool_call['function']['arguments'])['search_query']
+ sections = fetch_documentation(query)
+ append_tool_response(sections, tool_call_id)
+ else
+ append_tool_response('', tool_call_id)
+ end
+ end
+
def fetch_documentation(query)
+ Rails.logger.debug { "[CAPTAIN][DocumentationSearch] #{query}" }
@assistant
.responses
.approved
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index c8c511a57..5bc9defa4 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -60,7 +60,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def create_handoff_message
- create_outgoing_message('Transferring to another agent for further assistance.')
+ create_outgoing_message(@assistant.config['handoff_message'] || 'Transferring to another agent for further assistance.')
end
def create_messages
diff --git a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
index 657d31bd3..37a2729ec 100644
--- a/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
+++ b/enterprise/app/jobs/captain/inbox_pending_conversations_resolution_job.rb
@@ -5,12 +5,13 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
# limiting the number of conversations to be resolved to avoid any performance issues
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation|
+ resolution_message = conversation.inbox.captain_assistant.config['resolution_message']
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
- content: I18n.t('conversations.activity.auto_resolution_message')
+ content: resolution_message || I18n.t('conversations.activity.auto_resolution_message')
}
)
conversation.resolved!
diff --git a/enterprise/app/policies/captain/assistant_policy.rb b/enterprise/app/policies/captain/assistant_policy.rb
index e70af4ba4..2a03fc0ea 100644
--- a/enterprise/app/policies/captain/assistant_policy.rb
+++ b/enterprise/app/policies/captain/assistant_policy.rb
@@ -18,4 +18,8 @@ class Captain::AssistantPolicy < ApplicationPolicy
def destroy?
@account_user.administrator?
end
+
+ def playground?
+ true
+ end
end
diff --git a/enterprise/app/services/captain/llm/assistant_chat_service.rb b/enterprise/app/services/captain/llm/assistant_chat_service.rb
index 3d2b0da44..1e45cb1d8 100644
--- a/enterprise/app/services/captain/llm/assistant_chat_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_chat_service.rb
@@ -22,7 +22,7 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
def system_message
{
role: 'system',
- content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'])
+ content: Captain::Llm::SystemPromptsService.assistant_response_generator(@assistant.config['product_name'], @assistant.config)
}
end
end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 8a8753f60..b1e627275 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -103,7 +103,7 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
- def assistant_response_generator(product_name)
+ def assistant_response_generator(product_name, config = {})
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
@@ -111,6 +111,7 @@ class Captain::Llm::SystemPromptsService
[Response Guideline]
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.
- Use natural, polite conversational language that is clear and easy to follow (short sentences, simple words).
+ - Always detect the language from input and reply in the same language. Do not use any other language.
- Be concise and relevant: Most of your responses should be a sentence or two, unless you're asked to go deeper. Don't monopolize the conversation.
- Use discourse markers to ease comprehension. Never use the list format.
- Do not generate a response more than three sentences.
@@ -136,6 +137,7 @@ class Captain::Llm::SystemPromptsService
- Do not share anything outside of the context provided.
- Add the reasoning why you arrived at the answer
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
+ #{config['instructions'] || ''}
```json
{
reasoning: '',
diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb
index f2e66f997..41b3a415d 100644
--- a/lib/webhooks/trigger.rb
+++ b/lib/webhooks/trigger.rb
@@ -42,7 +42,7 @@ class Webhooks::Trigger
end
def update_message_status(error)
- message.update!(status: :failed, external_error: error.message)
+ Messages::StatusUpdateService.new(message, 'failed', error.message).perform
end
def message
diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
index 94d4cf272..f7ff042e5 100644
--- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb
@@ -84,7 +84,6 @@ RSpec.describe 'Conversation Messages API', type: :request do
context 'when api inbox' do
let(:api_channel) { create(:channel_api, account: account) }
let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
- let(:inbox_member) { create(:inbox_member, user: agent, inbox: api_inbox) }
let(:conversation) { create(:conversation, inbox: api_inbox, account: account) }
it 'reopens the conversation with new incoming message' do
@@ -294,4 +293,67 @@ RSpec.describe 'Conversation Messages API', type: :request do
end
end
end
+
+ describe 'PATCH /api/v1/accounts/{account.id}/conversations/:conversation_id/messages/:id' do
+ let(:api_channel) { create(:channel_api, account: account) }
+ let(:api_inbox) { create(:inbox, channel: api_channel, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:conversation) { create(:conversation, inbox: api_inbox, account: account) }
+ let!(:message) { create(:message, conversation: conversation, account: account, status: :sent) }
+
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ patch api_v1_account_conversation_message_url(account_id: account.id, conversation_id: conversation.display_id, id: message.id)
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated agent' do
+ context 'when agent has non-API inbox' do
+ let(:inbox) { create(:inbox, account: account) }
+ let(:agent) { create(:user, account: account, role: :agent) }
+ let!(:conversation) { create(:conversation, inbox: inbox, account: account) }
+
+ before { create(:inbox_member, inbox: inbox, user: agent) }
+
+ it 'returns forbidden' do
+ patch api_v1_account_conversation_message_url(
+ account_id: account.id,
+ conversation_id: conversation.display_id,
+ id: message.id
+ ), params: { status: 'failed', external_error: 'err' }, headers: agent.create_new_auth_token, as: :json
+ expect(response).to have_http_status(:forbidden)
+ end
+ end
+
+ context 'when agent has API inbox' do
+ before { create(:inbox_member, inbox: api_inbox, user: agent) }
+
+ it 'uses StatusUpdateService to perform status update' do
+ service = instance_double(Messages::StatusUpdateService)
+ expect(Messages::StatusUpdateService).to receive(:new)
+ .with(message, 'failed', 'err123')
+ .and_return(service)
+ expect(service).to receive(:perform)
+ patch api_v1_account_conversation_message_url(
+ account_id: account.id,
+ conversation_id: conversation.display_id,
+ id: message.id
+ ), params: { status: 'failed', external_error: 'err123' }, headers: agent.create_new_auth_token, as: :json
+ end
+
+ it 'updates status to failed with external_error' do
+ patch api_v1_account_conversation_message_url(
+ account_id: account.id,
+ conversation_id: conversation.display_id,
+ id: message.id
+ ), params: { status: 'failed', external_error: 'err123' }, headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq('err123')
+ end
+ end
+ end
+ end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
index c3c83e457..1f6d83d80 100644
--- a/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
+++ b/spec/enterprise/controllers/api/v1/accounts/captain/assistants_controller_spec.rb
@@ -175,4 +175,67 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end
end
end
+
+ describe 'POST /api/v1/accounts/{account.id}/captain/assistants/{id}/playground' do
+ let(:assistant) { create(:captain_assistant, account: account) }
+ let(:valid_params) do
+ {
+ message_content: 'Hello assistant',
+ message_history: [
+ { role: 'user', content: 'Previous message' },
+ { role: 'assistant', content: 'Previous response' }
+ ]
+ }
+ end
+
+ context 'when it is an un-authenticated user' do
+ it 'returns unauthorized' do
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
+ params: valid_params,
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when it is an agent' do
+ it 'generates a response' do
+ chat_service = instance_double(Captain::Llm::AssistantChatService)
+ allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
+ allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
+
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
+ params: valid_params,
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(chat_service).to have_received(:generate_response).with(
+ valid_params[:message_content],
+ valid_params[:message_history]
+ )
+ expect(json_response[:content]).to eq('Assistant response')
+ end
+ end
+
+ context 'when message_history is not provided' do
+ it 'uses empty array as default' do
+ params_without_history = { message_content: 'Hello assistant' }
+ chat_service = instance_double(Captain::Llm::AssistantChatService)
+ allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service)
+ allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
+
+ post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
+ params: params_without_history,
+ headers: agent.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(chat_service).to have_received(:generate_response).with(
+ params_without_history[:message_content],
+ []
+ )
+ end
+ end
+ end
end
diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
index 90e9360d3..09f62272d 100644
--- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
+++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb
@@ -4,11 +4,13 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
include ActiveJob::TestHelper
let!(:inbox) { create(:inbox) }
+
let!(:resolvable_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 2.hours.ago, status: :pending) }
let!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 10.minutes.ago, status: :pending) }
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
before do
+ create(:captain_inbox, inbox: inbox, captain_assistant: create(:captain_assistant, account: inbox.account))
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
end
diff --git a/spec/services/messages/status_update_service_spec.rb b/spec/services/messages/status_update_service_spec.rb
new file mode 100644
index 000000000..ce8fc2163
--- /dev/null
+++ b/spec/services/messages/status_update_service_spec.rb
@@ -0,0 +1,46 @@
+require 'rails_helper'
+
+describe Messages::StatusUpdateService do
+ let(:account) { create(:account) }
+ let(:conversation) { create(:conversation, account: account) }
+ let(:message) { create(:message, conversation: conversation, account: account) }
+
+ describe '#perform' do
+ context 'when status is valid' do
+ it 'updates the status of the message' do
+ service = described_class.new(message, 'delivered')
+ service.perform
+ expect(message.reload.status).to eq('delivered')
+ end
+
+ it 'clears external_error when status is not failed' do
+ message.update!(status: 'failed', external_error: 'previous error')
+ service = described_class.new(message, 'delivered')
+ service.perform
+ expect(message.reload.status).to eq('delivered')
+ expect(message.reload.external_error).to be_nil
+ end
+
+ it 'updates external_error when status is failed' do
+ service = described_class.new(message, 'failed', 'some error')
+ service.perform
+ expect(message.reload.status).to eq('failed')
+ expect(message.reload.external_error).to eq('some error')
+ end
+ end
+
+ context 'when status is invalid' do
+ it 'returns false for invalid status' do
+ service = described_class.new(message, 'invalid_status')
+ expect(service.perform).to be false
+ end
+
+ it 'prevents transition from read to delivered' do
+ message.update!(status: 'read')
+ service = described_class.new(message, 'delivered')
+ expect(service.perform).to be false
+ expect(message.reload.status).to eq('read')
+ end
+ end
+ end
+end