Compare commits

...
Author SHA1 Message Date
Aakash BakhleandGitHub 8aa49f69d2 fix: prefer system API key for completion service (#13799)
# Pull Request Template

## Description

Add an api_key override so internal conversation completions prefers
using the system API key and do not consume customer OpenAI credits.
## 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.

specs and locally

## 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
2026-03-13 13:10:10 +05:30
Shivam MishraandGitHub 550b408656 fix: restrict existing user sign-in to account members (#13793)
SAML sign-in now only links an existing user when that user already
belongs to the account that initiated SSO. New users can still be
created for SAML-enabled accounts, and invited members can continue to
sign in through their IdP, but SAML will no longer auto-attach an
unrelated existing user record during login.

**What changed**
- Added an account-membership check before SAML reuses an existing user
by email.
- Kept first-time SAML user creation unchanged for valid new users.
- Added builder and request specs covering the allowed and rejected
login paths.
2026-03-13 12:22:25 +05:30
b103747584 fix: skip Enter key submission during IME composition in AI inputs (#13779)
# Pull Request Template

## Description

CJK language users (Chinese, Japanese, Korean, etc.) use IME where Enter
confirms character selection. AI input components were intercepting
Enter unconditionally, making them unusable for IME users.

Add `event.isComposing` check to CopilotEditor, CopilotInput, and
AssistantPlayground so Enter during active IME composition is ignored.


## 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.

Before:

Add a Japenese keyboard, then go to AI follow-ups, type some word,
selecting it with enter submits the follow up. So CJK users cannot use
follow-ups.



https://github.com/user-attachments/assets/53517432-d97b-47fc-a802-81675e31d5c9



After:

Type a word, press enter to choose it, press enter again to unselect it
and enter again to send


https://github.com/user-attachments/assets/6c2a420b-7ee6-4c71-82a6-d9f1d7bbf31a



## 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: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:26:51 +05:30
d6d38cdd7d feat: captain decides if conversation should be resolved or kept open (#13336)
# Pull Request Template

## Description

captain decides if conversation should be resolved or open

Fixes
https://linear.app/chatwoot/issue/AI-91/make-captain-resolution-time-configurable

Update: Added 2 entries in reporting events:
`conversation_captain_handoff` and `conversation_captain_resolved`

## Type of change

Please delete options that are not relevant.

- [x] New feature (non-breaking change which adds functionality)
- [x] This change requires a documentation update

## 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.

LLM call decides that conversation is resolved, drops a private note
<img width="1228" height="438" alt="image"
src="https://github.com/user-attachments/assets/fb2cf1e9-4b2b-458b-a1e2-45c53d6a0158"
/>

LLM call decides conversation is still open as query was not resolved
<img width="1215" height="573" alt="image"
src="https://github.com/user-attachments/assets/2d1d5322-f567-487e-954e-11ab0798d11c"
/>


## 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: Sojan Jose <sojan@pepalo.com>
2026-03-13 10:03:58 +05:30
Sojan JoseandGitHub 199dcd382e fix: Skip redundant contact saves in ContactIdentifyAction (#13778)
When the SDK sends identify calls with identical payloads (common on
every page load), `save!` fires even though no attributes changed. While
Rails skips the actual UPDATE SQL, it still opens a transaction, runs
all callbacks (including validation queries like `Contact Exists?`), and
triggers `after_commit` hooks — all for a no-op.

This adds a `changed?` guard before `save!` to skip it entirely when no
attributes have actually changed.

**How to test**

- Trigger an identify call via the SDK with a contact's existing
attributes (same name, email, custom_attributes, etc.)
- The contact should not fire a save (no transaction, no callbacks)
- Trigger an identify call with a changed attribute — save should work
normally

**What changed**

- `ContactIdentifyAction#update_contact`: guard `save!` with `changed?`
check
- Added specs to verify `save!` is skipped for unchanged params and
avatar job still enqueues independently
2026-03-11 21:40:38 -07:00
PranavandGitHub c6f82783ba chore: Remove message touch:true, use combined update query (#13770)
Removes touch: true from the belongs_to :conversation association on
Message and consolidates the conversation timestamp update into the
existing set_conversation_activity callback.

Previously, every message save triggered two separate UPDATE queries on
the conversation — one from Rails' touch (updating updated_at) and
another from set_conversation_activity (updating last_activity_at). This
combines both into a single update_columns call, reducing write load on
the conversations table on every message creation.

### What changed
- Removed touch: true from belongs_to :conversation in Message
- Added updated_at: created_at to the existing update_columns call in
set_conversation_activity
2026-03-11 07:31:46 -07:00
Tanmay Deep SharmaandGitHub 9b3f0029a4 fix: override minimatch to patch ReDoS vulnerability (#13769)
## Description

Remediates high severity ReDoS vulnerability in minimatch
(CVE-2026-27903) flagged by Vanta/Dependabot.
minimatch is a transitive dev-only dependency (via eslint and
tailwindcss build tooling) — not shipped to production. Added pnpm
overrides to force patched versions:
- minimatch@<4 → 3.1.5
- minimatch@>=9.0.0 <9.0.7 → 9.0.9

Closes:
https://linear.app/chatwoot/issue/CW-6595/vanta-remediate-high-vulnerabilities-identified-in-packages-are

## Type of change

- [ ] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

- No production impact — minimatch is only used in dev tooling, not at
runtime
- pnpm install completes successfully

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-11 16:48:48 +05:30
6e46be36c8 fix: Add fix to only allow confirmed agents to used in Agent Assingments at Macros/Automations (#13225)
# Pull Request Template

## Description

Unconfirmed agents (pending email verification) were incorrectly
appearing in the "assign agent" dropdown for macros and automations.
This fix filters out unconfirmed agents from these dropdowns and adds
backend validation to prevent assignment of unconfirmed agents.

Fixes #13223

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

**Backend tests:**
```bash
docker compose run --rm rails bundle exec rspec spec/services/action_service_spec.rb
```
- Added tests for confirmed agent assignment (should succeed)
- Added tests for unconfirmed agent assignment (should be skipped)

**Frontend tests:**
```bash
docker compose run --rm rails pnpm test app/javascript/dashboard/composables/spec/useMacros.spec.js
```
- Updated mocks to use `getVerifiedAgents` getter

**Manual testing:**
1. Create an unconfirmed agent via platform
2. Navigate to Settings → Macros → New Macro → Add "Assign Agent" action
3. Verify unconfirmed agent does NOT appear in dropdown
4. Navigate to Settings → Automations → New Automation → Add "Assign
Agent" action
5. Verify unconfirmed agent does NOT appear in dropdown

## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] 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
- [ ] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-11 02:01:53 -07:00
Aakash BakhleandGitHub 87f5af4caa fix: playground captain v2 scenarios (#13747)
# Pull Request Template

## Description

Playground now uses v2. It was only wired to use v1. Traces get `source:
playground` on langfuse when playground has been used.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## 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 and specs
<img width="1806" height="1276" alt="image"
src="https://github.com/user-attachments/assets/41ef4eb3-52b1-4b8e-9a4f-e8510c90cb39"
/>


## 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
2026-03-11 14:05:16 +05:30
dbe35252bc fix: Use handoff_key for scenarios (#13755)
# Pull Request Template

## Description

Ensure agent function names stay within OpenAI's 64-char limit
(ai-agents prepends "handoff_to_").

Add HANDOFF_TITLE_SLUG_MAX_LENGTH and
handoff_key generation: persisted records use `scenario_{id}_agent`; new
records use a truncated title slug.
Assistant scenario keys and agent_name now reference the generated
handoff key.

fixes :

`Invalid 'messages[9].tool_calls[0].function.name': string too long.
Expected a string with maximum length 64, but got a string with length
95 instead.`

## Type of change


- [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.

Tested locally
<img width="1806" height="1044" alt="image"
src="https://github.com/user-attachments/assets/40cd7a3d-3d97-43a8-bd56-d3f5d63abbda"
/>

## 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: Shivam Mishra <scm.mymail@gmail.com>
2026-03-11 14:01:25 +05:30
Tanmay Deep SharmaandGitHub de8aa48b83 feat: make assignment_v2 feature available to all accounts (#13764)
## Description

Makes the assignment_v2 feature flag available to all installations by
removing the chatwoot_internal restriction. Previously this feature was
hidden from self-hosted installations; this change surfaces it in the
feature flags UI so any Chatwoot instance can enable it.

## Type of change

- [ ] New feature (non-breaking change which adds functionality)

## Checklist:

- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-11 13:46:53 +05:30
48 changed files with 1356 additions and 208 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ class ContactIdentifyAction
# blank identifier or email will throw unique index error
# TODO: replace reject { |_k, v| v.blank? } with compact_blank when rails is upgraded
@contact.discard_invalid_attrs if discard_invalid_attrs
@contact.save!
@contact.save! if @contact.changed?
enqueue_avatar_job
end
@@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false);
const formatMessagesForApi = () => {
return messages.value.map(message => ({
role: message.sender,
content: message.content,
}));
return messages.value.map(message => {
const payload = {
role: message.sender,
content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
};
const resetConversation = () => {
@@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({
content: data.response,
sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(),
});
} catch (error) {
@@ -71,6 +80,12 @@ const sendMessage = async () => {
isLoading.value = false;
}
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
</script>
<template>
@@ -104,7 +119,7 @@ const sendMessage = async () => {
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<NextButton
ghost
@@ -29,6 +29,12 @@ const handleInput = () => {
nextTick(adjustHeight);
};
const handleEnterKey = event => {
if (event.isComposing) return;
event.preventDefault();
sendMessage();
};
onMounted(() => {
nextTick(adjustHeight);
});
@@ -43,7 +49,7 @@ onMounted(() => {
class="w-full reset-base bg-n-alpha-3 ltr:pl-4 ltr:pr-12 rtl:pl-12 rtl:pr-4 py-3 text-sm border border-n-weak rounded-lg focus:outline-0 focus:outline-none focus:ring-2 focus:ring-n-blue-11 focus:border-n-blue-11 resize-none overflow-hidden max-h-[200px] mb-0 text-n-slate-12"
rows="1"
@input="handleInput"
@keydown.enter.exact.prevent="sendMessage"
@keydown.enter.exact="handleEnterKey"
/>
<button
class="absolute ltr:right-1 rtl:left-1 top-1/2 -translate-y-1/2 h-9 w-10 flex items-center justify-center text-n-slate-11 hover:text-n-blue-11"
@@ -107,7 +107,8 @@ function onKeydown(view, event) {
emit('keydown');
// Handle Enter key to send message (Shift+Enter for new line)
if (event.key === 'Enter' && !event.shiftKey) {
// Skip if IME composition is active (CJK character confirmation)
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) {
event.preventDefault();
handleSubmit();
return true; // Prevent ProseMirror's default Enter handling
@@ -38,7 +38,7 @@ describe('useAutomation', () => {
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'agents/getVerifiedAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
@@ -111,7 +111,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
'agents/getVerifiedAgents': { value: mockAgents },
});
});
@@ -167,7 +167,7 @@ describe('useMacros', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
'agents/getVerifiedAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
@@ -20,7 +20,7 @@ import {
export default function useAutomationValues() {
const getters = useStoreGetters();
const { t } = useI18n();
const agents = useMapGetter('agents/getAgents');
const agents = useMapGetter('agents/getVerifiedAgents');
const campaigns = useMapGetter('campaigns/getAllCampaigns');
const contacts = useMapGetter('contacts/getContacts');
const inboxes = useMapGetter('inboxes/getInboxes');
@@ -13,7 +13,7 @@ export const useMacros = () => {
const labels = computed(() => getters['labels/getLabels'].value);
const teams = computed(() => getters['teams/getTeams'].value);
const agents = computed(() => getters['agents/getAgents'].value);
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
/**
* Get dropdown values based on the specified type
+39 -12
View File
@@ -3,19 +3,20 @@ class ReportingEventListener < BaseListener
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
event_end_time = event.timestamp
time_to_resolve = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_resolved',
value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
create_bot_resolved_event(conversation, reporting_event)
@@ -69,41 +70,51 @@ class ReportingEventListener < BaseListener
def conversation_bot_handoff(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# check if a conversation_bot_handoff event exists for this conversation
bot_handoff_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
return if bot_handoff_event.present?
time_to_handoff = conversation.updated_at.to_i - conversation.created_at.to_i
time_to_handoff = event_end_time.to_i - conversation.created_at.to_i
reporting_event = ReportingEvent.new(
name: 'conversation_bot_handoff',
value: time_to_handoff,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, conversation.updated_at),
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at, event_end_time),
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
end
def conversation_captain_inference_resolved(event)
create_captain_inference_event(event, 'conversation_captain_inference_resolved')
end
def conversation_captain_inference_handoff(event)
create_captain_inference_event(event, 'conversation_captain_inference_handoff')
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
event_end_time = event.timestamp
# Find the most recent resolved event for this conversation
last_resolved_event = ReportingEvent.where(
conversation_id: conversation.id,
name: 'conversation_resolved'
).order(event_end_time: :desc).first
).where('event_end_time <= ?', event_end_time).order(event_end_time: :desc).first
# For first-time openings, value is 0
# For reopenings, calculate time since resolution
if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
time_since_resolved = event_end_time.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, event_end_time)
start_time = last_resolved_event.event_end_time
else
time_since_resolved = 0
@@ -111,12 +122,12 @@ class ReportingEventListener < BaseListener
start_time = conversation.created_at
end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
end
private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time, event_end_time)
reporting_event = ReportingEvent.new(
name: 'conversation_opened',
value: time_since_resolved,
@@ -126,11 +137,27 @@ class ReportingEventListener < BaseListener
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time,
event_end_time: conversation.updated_at
event_end_time: event_end_time
)
reporting_event.save!
end
def create_captain_inference_event(event, event_name)
conversation = extract_conversation_and_account(event)[0]
time_to_event = event.timestamp.to_i - conversation.created_at.to_i
ReportingEvent.create!(
name: event_name,
value: time_to_event,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: event.timestamp
)
end
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation
+19 -13
View File
@@ -81,6 +81,8 @@ class Message < ApplicationRecord
# when you have a temperory id in your frontend and want it echoed back via action cable
attr_accessor :echo_id
# Transient flag used to skip waiting_since clearing for specific bot/system messages.
attr_accessor :preserve_waiting_since
enum message_type: { incoming: 0, outgoing: 1, activity: 2, template: 3 }
enum content_type: {
@@ -125,7 +127,7 @@ class Message < ApplicationRecord
belongs_to :account
belongs_to :inbox
belongs_to :conversation, touch: true
belongs_to :conversation
belongs_to :sender, polymorphic: true, optional: true
has_many :attachments, dependent: :destroy, autosave: true, before_add: :validate_attachments_limit
@@ -323,20 +325,24 @@ class Message < ApplicationRecord
end
def update_waiting_since
waiting_present = conversation.waiting_since.present?
clear_waiting_since_on_outgoing_response if conversation.waiting_since.present? && !private
set_waiting_since_on_incoming_message
end
if waiting_present && !private
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
elsif bot_response?
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil)
end
def clear_waiting_since_on_outgoing_response
if human_response?
Rails.configuration.dispatcher.dispatch(
REPLY_CREATED, Time.zone.now, waiting_since: conversation.waiting_since, message: self
)
conversation.update(waiting_since: nil)
return
end
# Bot responses also clear waiting_since (simpler than checking on next customer message)
conversation.update(waiting_since: nil) if bot_response? && !preserve_waiting_since
end
def set_waiting_since_on_incoming_message
# Set waiting_since when customer sends a message (if currently blank)
conversation.update(waiting_since: created_at) if incoming? && conversation.waiting_since.blank?
end
@@ -429,7 +435,7 @@ class Message < ApplicationRecord
def set_conversation_activity
# rubocop:disable Rails/SkipsModelValidations
conversation.update_columns(last_activity_at: created_at)
conversation.update_columns(last_activity_at: created_at, updated_at: Time.current)
# rubocop:enable Rails/SkipsModelValidations
end
+3 -1
View File
@@ -47,7 +47,9 @@ class ActionService
@agent = @account.users.find_by(id: agent_ids)
@conversation.update!(assignee_id: @agent.id) if @agent.present?
return unless @agent.present? && @agent.confirmed?
@conversation.update!(assignee_id: @agent.id)
end
def remove_label(labels)
-1
View File
@@ -191,7 +191,6 @@
- name: assignment_v2
display_name: Assignment V2
enabled: false
chatwoot_internal: true
- name: twilio_content_templates
display_name: Twilio Content Templates
enabled: false
+2
View File
@@ -235,8 +235,10 @@ en:
activity:
captain:
resolved: 'Conversation was marked resolved by %{user_name} due to inactivity'
resolved_with_reason: 'Conversation was marked resolved by %{user_name} (%{reason})'
resolved_by_tool: 'Conversation was marked resolved by %{user_name}: %{reason}'
open: 'Conversation was marked open by %{user_name}'
open_with_reason: 'Conversation was marked open by %{user_name} (%{reason})'
auto_opened_after_agent_reply: 'Conversation was marked open automatically after an agent reply'
agent_bot:
error_moved_to_open: 'Conversation was marked open by system due to an error with the agent bot.'
+15 -6
View File
@@ -1,4 +1,6 @@
class SamlUserBuilder
class AuthenticationFailed < StandardError; end
def initialize(auth_hash, account_id)
@auth_hash = auth_hash
@account_id = account_id
@@ -16,13 +18,20 @@ class SamlUserBuilder
def find_or_create_user
user = User.from_email(auth_attribute('email'))
if user
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
return user
end
return create_user unless user
return existing_user_for_account(user) if user_belongs_to_account?(user)
create_user
raise AuthenticationFailed, I18n.t('auth.saml.authentication_failed')
end
def existing_user_for_account(user)
confirm_user_if_required(user)
convert_existing_user_to_saml(user)
user
end
def user_belongs_to_account?(user)
user.account_users.exists?(account_id: @account_id)
end
def confirm_user_if_required(user)
@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
additional_message: params[:message_content],
message_history: message_history
)
response = if captain_v2_enabled?
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
message_history: playground_message_history
)
else
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
additional_message: playground_params[:message_content],
message_history: message_history
)
end
render json: response
end
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content])
params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end
def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } }
(playground_params[:message_history] || []).map do |message|
{
role: message[:role],
content: message[:content],
agent_name: message[:agent_name]
}.compact
end
end
def playground_message_history
history = message_history
current_message = playground_params[:message_content]
return history if current_message.blank?
current_user_message = { role: 'user', content: current_message }
return history if history.last == current_user_message
history + [current_user_message]
end
def captain_v2_enabled?
@assistant.account.feature_enabled?('captain_integration_v2')
end
end
@@ -39,7 +39,7 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
error = params[:message] || 'authentication-failed'
if for_mobile?(relay_state)
redirect_to_mobile_error(error, relay_state)
redirect_to_mobile_error(error)
else
redirect_to login_page_url(error: "saml-#{error}")
end
@@ -51,23 +51,15 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
account_id = extract_saml_account_id
relay_state = saml_relay_state
unless saml_enabled_for_account?(account_id)
return redirect_to_mobile_error('saml-not-enabled') if for_mobile?(relay_state)
return redirect_to login_page_url(error: 'saml-not-enabled')
end
return handle_saml_auth_error(relay_state, 'saml-not-enabled') unless saml_enabled_for_account?(account_id)
@resource = SamlUserBuilder.new(auth_hash, account_id).perform
if @resource.persisted?
return sign_in_user_on_mobile if for_mobile?(relay_state)
return sign_in_saml_user(relay_state) if @resource.persisted?
sign_in_user
else
return redirect_to_mobile_error('saml-authentication-failed') if for_mobile?(relay_state)
redirect_to login_page_url(error: 'saml-authentication-failed')
end
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
rescue SamlUserBuilder::AuthenticationFailed
handle_saml_auth_error(relay_state, 'saml-authentication-failed')
end
def extract_saml_account_id
@@ -82,6 +74,18 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
relay_state.to_s.casecmp('mobile').zero?
end
def sign_in_saml_user(relay_state)
return sign_in_user_on_mobile if for_mobile?(relay_state)
sign_in_user
end
def handle_saml_auth_error(relay_state, error)
return redirect_to_mobile_error(error) if for_mobile?(relay_state)
redirect_to login_page_url(error: error)
end
def redirect_to_mobile_error(error)
mobile_deep_link_base = GlobalConfigService.load('MOBILE_DEEP_LINK_BASE', 'chatwootapp')
redirect_to "#{mobile_deep_link_base}://auth/saml?error=#{ERB::Util.url_encode(error)}", allow_other_host: true
@@ -5,7 +5,6 @@ module Captain::ChatHelper
def request_chat_completion
log_chat_completion_request
chat = build_chat
add_messages_to_chat(chat)
@@ -86,7 +85,8 @@ module Captain::ChatHelper
temperature: temperature,
metadata: {
assistant_id: @assistant&.id,
channel_type: resolved_channel_type
channel_type: resolved_channel_type,
source: @source
}.compact
}
end
@@ -130,7 +130,6 @@ module Captain::ChatHelper
end
def log_chat_completion_request
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \
"for messages #{@messages} with #{@tools&.length || 0} tools")
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
end
end
@@ -1,15 +1,16 @@
class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON = 'no outstanding questions'.freeze
CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON = 'pending clarification from customer'.freeze
queue_as :low
def perform(inbox)
return if inbox.account.captain_disable_auto_resolve
Current.executed_by = inbox.captain_assistant
resolvable_conversations = inbox.conversations.pending.where('last_activity_at < ? ', Time.now.utc - 1.hour).limit(Limits::BULK_ACTIONS_LIMIT)
resolvable_conversations.each do |conversation|
create_outgoing_message(conversation, inbox)
conversation.resolved!
if inbox.account.feature_enabled?('captain_tasks')
perform_with_evaluation(inbox)
else
perform_time_based(inbox)
end
ensure
Current.reset
@@ -17,18 +18,118 @@ class Captain::InboxPendingConversationsResolutionJob < ApplicationJob
private
def create_outgoing_message(conversation, inbox)
def perform_time_based(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
create_resolution_message(conversation, inbox)
conversation.resolved!
end
end
def perform_with_evaluation(inbox)
Current.executed_by = inbox.captain_assistant
resolvable_pending_conversations(inbox).each do |conversation|
evaluation = evaluate_conversation(conversation, inbox)
next unless still_resolvable_after_evaluation?(conversation)
if evaluation[:complete]
resolve_conversation(conversation, inbox, evaluation[:reason])
else
handoff_conversation(conversation, inbox, evaluation[:reason])
end
end
end
def evaluate_conversation(conversation, inbox)
Captain::ConversationCompletionService.new(
account: inbox.account,
conversation_display_id: conversation.display_id
).perform
end
def resolvable_pending_conversations(inbox)
inbox.conversations.pending
.where('last_activity_at < ?', auto_resolve_cutoff_time)
.limit(Limits::BULK_ACTIONS_LIMIT)
end
def still_resolvable_after_evaluation?(conversation)
conversation.reload
conversation.pending? && conversation.last_activity_at < auto_resolve_cutoff_time
rescue ActiveRecord::RecordNotFound
false
end
def auto_resolve_cutoff_time
Time.now.utc - 1.hour
end
def resolve_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-resolved: #{reason}")
create_resolution_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_RESOLVE_ACTIVITY_REASON,
reason_type: :inference
) { conversation.resolved! }
conversation.dispatch_captain_inference_resolved_event
end
def handoff_conversation(conversation, inbox, reason)
create_private_note(conversation, inbox, "Auto-handoff: #{reason}")
create_handoff_message(conversation, inbox)
conversation.with_captain_activity_context(
reason: CAPTAIN_INFERENCE_HANDOFF_ACTIVITY_REASON,
reason_type: :inference
) { conversation.bot_handoff! }
conversation.dispatch_captain_inference_handoff_event
send_out_of_office_message_if_applicable(conversation.reload)
end
def send_out_of_office_message_if_applicable(conversation)
# Campaign conversations should never receive OOO templates — the campaign itself
# serves as the initial outreach, and OOO would be confusing in that context.
return if conversation.campaign.present?
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(conversation)
end
def create_private_note(conversation, inbox, content)
conversation.messages.create!(
message_type: :outgoing,
private: true,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: content
)
end
def create_resolution_message(conversation, inbox)
I18n.with_locale(inbox.account.locale) do
resolution_message = inbox.captain_assistant.config['resolution_message']
conversation.messages.create!(
{
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
}
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: resolution_message.presence || I18n.t('conversations.activity.auto_resolution_message'),
sender: inbox.captain_assistant
)
end
end
def create_handoff_message(conversation, inbox)
handoff_message = inbox.captain_assistant.config['handoff_message']
return if handoff_message.blank?
conversation.messages.create!(
message_type: :outgoing,
sender: inbox.captain_assistant,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
content: handoff_message,
preserve_waiting_since: true
)
end
end
+1 -1
View File
@@ -106,7 +106,7 @@ class Captain::Assistant < ApplicationRecord
scenarios: scenarios.enabled.map do |scenario|
{
title: scenario.title,
key: scenario.title.parameterize.underscore,
key: scenario.handoff_key,
description: scenario.description
}
end,
+39 -1
View File
@@ -24,6 +24,19 @@ class Captain::Scenario < ApplicationRecord
include Concerns::CaptainToolsHelpers
include Concerns::Agentable
# OpenAI enforces a 64-char limit on function names. The ai-agents gem
# prepends "handoff_to_" (11 chars), so we keep a safety margin and cap
# the full tool name to MAX_HANDOFF_TOOL_NAME_LENGTH (60 chars).
# Format: "scenario_{id}_{slug}_agent" for persisted records (stable + readable),
# and "scenario_draft_{slug}_agent" for unsaved records, with slug truncated
# based on the available length budget.
HANDOFF_TOOL_PREFIX = 'handoff_to_'.freeze
HANDOFF_KEY_PREFIX = 'scenario'.freeze
HANDOFF_KEY_SUFFIX = 'agent'.freeze
MAX_HANDOFF_TOOL_NAME_LENGTH = 60
MAX_AGENT_NAME_LENGTH = MAX_HANDOFF_TOOL_NAME_LENGTH - HANDOFF_TOOL_PREFIX.length
MAX_HANDOFF_SLUG_LENGTH = 24
self.table_name = 'captain_scenarios'
belongs_to :assistant, class_name: 'Captain::Assistant'
@@ -42,6 +55,10 @@ class Captain::Scenario < ApplicationRecord
before_save :resolve_tool_references
def handoff_key
[handoff_id_key, compact_handoff_slug, HANDOFF_KEY_SUFFIX].compact.join('_')
end
def prompt_context
{
title: title,
@@ -56,7 +73,28 @@ class Captain::Scenario < ApplicationRecord
private
def agent_name
"#{title} Agent".parameterize(separator: '_')
handoff_key
end
def handoff_id_key
return "#{HANDOFF_KEY_PREFIX}_#{id}" if id.present?
"#{HANDOFF_KEY_PREFIX}_draft"
end
def compact_handoff_slug
slug = title.to_s.parameterize(separator: '_').presence
return nil if slug.blank?
max_slug_length = [MAX_HANDOFF_SLUG_LENGTH, dynamic_slug_max_length].min
return nil if max_slug_length <= 0
slug.first(max_slug_length).sub(/_+\z/, '').presence
end
def dynamic_slug_max_length
# handoff_to_#{scenario_<id>_<slug>_agent}
MAX_AGENT_NAME_LENGTH - handoff_id_key.length - HANDOFF_KEY_SUFFIX.length - 2
end
def agent_tools
@@ -6,18 +6,30 @@ module Enterprise::ActivityMessageHandler
key = captain_activity_key
return unless key
I18n.t(key, user_name: Current.executed_by.name, reason: Current.captain_resolve_reason, locale: locale)
I18n.t(key, user_name: Current.executed_by.name, reason: captain_status_reason, locale: locale)
end
private
def captain_status_reason
captain_activity_reason.presence
end
def captain_activity_key
if resolved? && Current.captain_resolve_reason.present?
'conversations.activity.captain.resolved_by_tool'
elsif resolved?
'conversations.activity.captain.resolved'
elsif open?
'conversations.activity.captain.open'
end
return captain_resolved_activity_key if resolved?
return captain_open_activity_key if open?
end
def captain_resolved_activity_key
return 'conversations.activity.captain.resolved_by_tool' if captain_activity_reason_type == :tool && captain_status_reason.present?
return 'conversations.activity.captain.resolved_with_reason' if captain_status_reason.present?
'conversations.activity.captain.resolved'
end
def captain_open_activity_key
return 'conversations.activity.captain.open_with_reason' if captain_status_reason.present?
'conversations.activity.captain.open'
end
end
@@ -1,8 +1,30 @@
module Enterprise::Conversation
attr_accessor :captain_activity_reason, :captain_activity_reason_type
def dispatch_captain_inference_resolved_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_RESOLVED)
end
def dispatch_captain_inference_handoff_event
dispatch_captain_inference_event(Events::Types::CONVERSATION_CAPTAIN_INFERENCE_HANDOFF)
end
def list_of_keys
super + %w[sla_policy_id]
end
def with_captain_activity_context(reason:, reason_type:)
previous_reason = captain_activity_reason
previous_reason_type = captain_activity_reason_type
self.captain_activity_reason = reason
self.captain_activity_reason_type = reason_type
yield
ensure
self.captain_activity_reason = previous_reason
self.captain_activity_reason_type = previous_reason_type
end
# Include select additional_attributes keys (call related) for update events
def allowed_keys?
return true if super
@@ -13,4 +35,10 @@ module Enterprise::Conversation
changed_attr_keys = attrs_change[1].keys
changed_attr_keys.intersect?(%w[call_status])
end
private
def dispatch_captain_inference_event(event_name)
dispatcher_dispatch(event_name)
end
end
@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {})
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
@assistant = assistant
@conversation = conversation
@callbacks = callbacks
@source = source
end
def generate_response(message_history: [])
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
process_agent_result(result)
rescue StandardError => e
# when running the agent runner service in a rake task, the conversation might not have an account associated
# for regular production usage, it will run just fine
# In rake/local runs, conversation may not be present, so account is optional here.
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
assistant_id: @assistant.id,
assistant_config: @assistant.config
}
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation
state
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox
state[:contact_inbox] =
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
end
def build_and_wire_agents
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
ATTR_LANGFUSE_TRACE_INPUT => trace_input,
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s)
@@ -1,11 +1,12 @@
class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil)
def initialize(assistant: nil, conversation_id: nil, source: nil)
super()
@assistant = assistant
@conversation_id = conversation_id
@source = source
@messages = [system_message]
@response = ''
@@ -0,0 +1,4 @@
class Captain::ConversationCompletionSchema < RubyLLM::Schema
boolean :complete, description: 'Whether the conversation is complete and can be closed'
string :reason, description: 'Brief explanation of why the conversation is complete or incomplete'
end
@@ -0,0 +1,76 @@
# Evaluates whether a conversation is complete and can be auto-resolved.
# Used by InboxPendingConversationsResolutionJob to determine if inactive
# conversations should be resolved or handed off to human agents.
#
# NOTE: This service intentionally does NOT count toward Captain usage limits.
# The response excludes the :message key that Enterprise::Captain::BaseTaskService
# checks for usage tracking. This is an internal operational evaluation,
# not a customer-facing value-add, so we don't charge for it.
class Captain::ConversationCompletionService < Captain::BaseTaskService
RESPONSE_SCHEMA = Captain::ConversationCompletionSchema
pattr_initialize [:account!, :conversation_display_id!]
def perform
content = format_messages_as_string
return default_incomplete_response('No messages found') if content.blank?
response = make_api_call(
model: InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_MODEL')&.value.presence || GPT_MODEL,
messages: [
{ role: 'system', content: prompt_from_file('conversation_completion') },
{ role: 'user', content: content }
],
schema: RESPONSE_SCHEMA
)
return default_incomplete_response(response[:error]) if response[:error].present?
parse_response(response[:message])
end
private
def prompt_from_file(file_name)
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]}"
end.join("\n")
end
def parse_response(message)
return default_incomplete_response('Invalid response format') unless message.is_a?(Hash)
{
complete: message['complete'] == true,
reason: message['reason'] || 'No reason provided'
}
end
def default_incomplete_response(reason)
{ complete: false, reason: reason }
end
# Prefer the system API key over the account's OpenAI hook key.
# This is an internal operational evaluation, not a customer-triggered feature,
# so it should not consume the customer's OpenAI credits on hosted platforms.
# Falls back to the account hook for self-hosted deployments without a system key.
def api_key
@api_key ||= system_api_key.presence || openai_hook&.settings&.dig('api_key')
end
def event_name
'captain.conversation_completion'
end
def build_follow_up_context?
false
end
end
Captain::ConversationCompletionService.prepend_mod_with('Captain::ConversationCompletionService')
@@ -0,0 +1,21 @@
You are evaluating whether a customer support conversation is complete and can be safely closed. When in doubt, keep the conversation OPEN. It is far better to hand off to a human agent unnecessarily than to close a conversation where the customer still needs help.
The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language.
A conversation is INCOMPLETE (keep open) if ANY of these apply:
- The assistant suggested the customer try something or take an action — they may still be attempting it
- The assistant directed the customer to an external resource, link, or contact — they may still be following up
- 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
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.
Analyze the conversation and respond with ONLY a JSON object (no other text):
{"complete": true, "reason": "brief explanation"}
or
{"complete": false, "reason": "brief explanation"}
@@ -10,12 +10,7 @@ class Captain::Tools::ResolveConversationTool < Captain::Tools::BasePublicTool
log_tool_usage('resolve_conversation', { conversation_id: conversation.id, reason: reason })
Current.captain_resolve_reason = reason
begin
conversation.resolved!
ensure
Current.captain_resolve_reason = nil
end
conversation.with_captain_activity_context(reason: reason, reason_type: :tool) { conversation.resolved! }
"Conversation ##{conversation.display_id} resolved#{" (Reason: #{reason})" if reason}"
end
@@ -0,0 +1,12 @@
# Overrides the quota check from Enterprise::Captain::BaseTaskService
# so that conversation completion evaluation always runs regardless of
# the customer's Captain usage quota. This is an internal operational
# check, not a customer-facing feature — it should never be blocked
# by quota exhaustion.
module Enterprise::Captain::ConversationCompletionService
private
def responses_available?
true
end
end
+8 -6
View File
@@ -36,7 +36,7 @@ class Captain::BaseTaskService
"#{endpoint}/v1"
end
def make_api_call(model:, messages:, tools: [])
def make_api_call(model:, messages:, schema: nil, tools: [])
# Community edition prerequisite checks
# Enterprise module handles these with more specific error messages (cloud vs self-hosted)
return { error: I18n.t('captain.disabled'), error_code: 403 } unless captain_tasks_enabled?
@@ -46,7 +46,7 @@ class Captain::BaseTaskService
instrumentation_method = tools.any? ? :instrument_tool_session : :instrument_llm_call
response = send(instrumentation_method, instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages, tools: tools)
execute_ruby_llm_request(model: model, messages: messages, schema: schema, tools: tools)
end
return response unless build_follow_up_context? && response[:message].present?
@@ -54,9 +54,9 @@ class Captain::BaseTaskService
response.merge(follow_up_context: build_follow_up_context(messages, response))
end
def execute_ruby_llm_request(model:, messages:, tools: [])
def execute_ruby_llm_request(model:, messages:, schema: nil, tools: [])
Llm::Config.with_api_key(api_key, api_base: api_base) do |context|
chat = build_chat(context, model: model, messages: messages, tools: tools)
chat = build_chat(context, model: model, messages: messages, schema: schema, tools: tools)
conversation_messages = messages.reject { |m| m[:role] == 'system' }
return { error: 'No conversation messages provided', error_code: 400, request_messages: messages } if conversation_messages.empty?
@@ -69,10 +69,11 @@ class Captain::BaseTaskService
{ error: e.message, request_messages: messages }
end
def build_chat(context, model:, messages:, tools: [])
def build_chat(context, model:, messages:, schema: nil, tools: [])
chat = context.chat(model: model)
system_msg = messages.find { |m| m[:role] == 'system' }
chat.with_instructions(system_msg[:content]) if system_msg
chat.with_schema(schema) if schema
if tools.any?
tools.each { |tool| chat = chat.with_tool(tool) }
@@ -131,7 +132,8 @@ class Captain::BaseTaskService
.reorder('id desc')
.each do |message|
content = message.content_for_llm
break unless content.present? && character_count + content.length <= TOKEN_LIMIT
next if content.blank?
break if character_count + content.length > TOKEN_LIMIT
messages.prepend({ role: (message.incoming? ? 'user' : 'assistant'), content: content })
character_count += content.length
-2
View File
@@ -4,7 +4,6 @@ module Current
thread_mattr_accessor :account_user
thread_mattr_accessor :executed_by
thread_mattr_accessor :contact
thread_mattr_accessor :captain_resolve_reason
def self.reset
Current.user = nil
@@ -12,6 +11,5 @@ module Current
Current.account_user = nil
Current.executed_by = nil
Current.contact = nil
Current.captain_resolve_reason = nil
end
end
+2
View File
@@ -21,6 +21,8 @@ module Events::Types
# FIXME: deprecate the opened and resolved events in future in favor of status changed event.
CONVERSATION_OPENED = 'conversation.opened'
CONVERSATION_RESOLVED = 'conversation.resolved'
CONVERSATION_CAPTAIN_INFERENCE_RESOLVED = 'conversation.captain_inference_resolved'
CONVERSATION_CAPTAIN_INFERENCE_HANDOFF = 'conversation.captain_inference_handoff'
CONVERSATION_STATUS_CHANGED = 'conversation.status_changed'
CONVERSATION_CONTACT_CHANGED = 'conversation.contact_changed'
@@ -66,7 +66,7 @@ module Integrations::LlmInstrumentationCompletionHelpers
return if message.blank?
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message.is_a?(String) ? message : message.to_json)
end
def set_usage_metrics(span, result)
+3 -1
View File
@@ -160,7 +160,9 @@
"overrides": {
"vite-node": "2.0.1",
"vite": "5.4.21",
"vitest": "3.0.5"
"vitest": "3.0.5",
"minimatch@<4": "3.1.5",
"minimatch@>=9.0.0 <9.0.7": "9.0.9"
}
},
"lint-staged": {
+21 -27
View File
@@ -8,6 +8,8 @@ overrides:
vite-node: 2.0.1
vite: 5.4.21
vitest: 3.0.5
minimatch@<4: 3.1.5
minimatch@>=9.0.0 <9.0.7: 9.0.9
importers:
@@ -1731,8 +1733,8 @@ packages:
brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
brace-expansion@2.0.2:
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
@@ -3255,15 +3257,11 @@ packages:
min-document@2.19.0:
resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==}
minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
minimatch@9.0.5:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
minimist@1.2.8:
@@ -5312,7 +5310,7 @@ snapshots:
ignore: 5.2.4
import-fresh: 3.3.0
js-yaml: 4.1.0
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -5326,7 +5324,7 @@ snapshots:
ignore: 5.2.4
import-fresh: 3.3.0
js-yaml: 4.1.0
minimatch: 3.1.2
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -5463,7 +5461,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.3.5
minimatch: 3.1.2
minimatch: 3.1.5
transitivePeerDependencies:
- supports-color
@@ -6373,7 +6371,7 @@ snapshots:
balanced-match: 1.0.2
concat-map: 0.0.1
brace-expansion@2.0.1:
brace-expansion@2.0.2:
dependencies:
balanced-match: 1.0.2
@@ -6817,7 +6815,7 @@ snapshots:
dependencies:
'@one-ini/wasm': 0.1.1
commander: 10.0.1
minimatch: 9.0.1
minimatch: 9.0.9
semver: 7.6.3
ee-first@1.1.1: {}
@@ -7084,7 +7082,7 @@ snapshots:
hasown: 2.0.2
is-core-module: 2.15.1
is-glob: 4.0.3
minimatch: 3.1.2
minimatch: 3.1.5
object.fromentries: 2.0.8
object.groupby: 1.0.3
object.values: 1.2.0
@@ -7164,7 +7162,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 3.1.2
minimatch: 3.1.5
natural-compare: 1.4.0
optionator: 0.9.3
strip-ansi: 6.0.1
@@ -7394,7 +7392,7 @@ snapshots:
dependencies:
foreground-child: 3.3.0
jackspeak: 3.4.3
minimatch: 9.0.5
minimatch: 9.0.9
minipass: 7.1.2
package-json-from-dist: 1.0.0
path-scurry: 1.11.1
@@ -7404,7 +7402,7 @@ snapshots:
fs.realpath: 1.0.0
inflight: 1.0.6
inherits: 2.0.4
minimatch: 3.1.2
minimatch: 3.1.5
once: 1.4.0
path-is-absolute: 1.0.1
@@ -8143,17 +8141,13 @@ snapshots:
dependencies:
dom-walk: 0.1.2
minimatch@3.1.2:
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.11
minimatch@9.0.1:
minimatch@9.0.9:
dependencies:
brace-expansion: 2.0.1
minimatch@9.0.5:
dependencies:
brace-expansion: 2.0.1
brace-expansion: 2.0.2
minimist@1.2.8: {}
@@ -9251,7 +9245,7 @@ snapshots:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.4.5
minimatch: 9.0.5
minimatch: 9.0.9
text-segmentation@1.0.3:
dependencies:
@@ -145,5 +145,24 @@ describe ContactIdentifyAction do
expect(contact.phone_number).to be_nil
end
end
context 'when params have not changed' do
it 'skips save and does not issue an UPDATE query' do
contact.update!(name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' })
params = { name: 'test', identifier: 'test_id', custom_attributes: { test: 'test', test1: 'test1' } }
# any_instance is needed because merge lookup can reassign @contact to a different Ruby object
expect_any_instance_of(Contact).not_to receive(:save!) # rubocop:disable RSpec/AnyInstance
described_class.new(contact: contact, params: params).perform
end
it 'still enqueues avatar job even when attributes have not changed' do
contact.update!(name: 'test')
params = { name: 'test', avatar_url: 'https://chatwoot-assets.local/sample.png' }
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(contact, params[:avatar_url]).once
described_class.new(contact: contact, params: params).perform
end
end
end
end
@@ -74,7 +74,7 @@ RSpec.describe SamlUserBuilder do
end
context 'when user already exists' do
let!(:existing_user) { create(:user, email: email) }
let!(:existing_user) { create(:user, email: email, account: account) }
it 'does not create a new user' do
expect { builder.perform }.not_to change(User, :count)
@@ -85,7 +85,7 @@ RSpec.describe SamlUserBuilder do
expect(user).to eq(existing_user)
end
it 'adds existing user to the account if not already added' do
it 'keeps the existing user in the account' do
user = builder.perform
expect(user.accounts).to include(account)
end
@@ -105,11 +105,38 @@ RSpec.describe SamlUserBuilder do
end
it 'does not duplicate account association' do
existing_user.account_users.create!(account: account, role: 'agent')
expect { builder.perform }.not_to change(AccountUser, :count)
end
context 'when the user does not belong to the target account' do
let!(:other_account) { create(:account) }
let!(:existing_user) { create(:user, email: email, account: other_account) }
it 'raises an authentication failure' do
expect { builder.perform }.to raise_error do |error|
expect(error.class.name).to eq('SamlUserBuilder::AuthenticationFailed')
expect(error.message).to eq(I18n.t('auth.saml.authentication_failed'))
end
end
it 'does not add the user to the target account' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
it 'does not convert the user provider to saml' do
expect do
builder.perform
rescue SamlUserBuilder::AuthenticationFailed
nil
end.not_to(change { existing_user.reload.provider })
end
end
context 'when user is not confirmed' do
let(:unconfirmed_email) { 'unconfirmed_saml_user@example.com' }
let(:unconfirmed_auth_hash) do
@@ -131,7 +158,7 @@ RSpec.describe SamlUserBuilder do
end
let(:unconfirmed_builder) { described_class.new(unconfirmed_auth_hash, account.id) }
let!(:existing_user) do
user = build(:user, email: unconfirmed_email)
user = build(:user, email: unconfirmed_email, account: account)
user.confirmed_at = nil
user.save!(validate: false)
user
@@ -147,7 +174,7 @@ RSpec.describe SamlUserBuilder do
end
context 'when user is already confirmed' do
let!(:existing_user) { create(:user, email: email, confirmed_at: Time.current) }
let!(:existing_user) { create(:user, email: email, account: account, confirmed_at: Time.current) }
it 'keeps already confirmed user confirmed' do
expect(existing_user.confirmed?).to be true
@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
message_content: 'Hello assistant',
message_history: [
{ role: 'user', content: 'Previous message' },
{ role: 'assistant', content: 'Previous response' }
{ role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
]
}
end
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
context 'when it is an un-authenticated user' do
it 'returns unauthorized' do
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
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)
context 'when captain v2 is disabled' do
it 'generates a response with the legacy assistant chat service' do
allow(Captain::Llm::AssistantChatService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
)
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(Captain::Llm::AssistantChatService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_without_history,
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
)
end
end
context 'when captain v2 is enabled' do
before do
account.enable_features('captain_integration_v2')
end
it 'generates a response with the agent runner service' do
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
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(agent_runner_service).to have_received(:generate_response).with(
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
)
expect(json_response[:response]).to eq('Assistant response')
end
it 'does not duplicate the latest user message if it is already in history' do
params_with_latest_message = {
message_content: 'Hello assistant',
message_history: [{ role: 'user', content: 'Hello assistant' }]
}
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_with_latest_message,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent_runner_service).to have_received(:generate_response).with(
message_history: params_with_latest_message[:message_history]
)
end
end
end
end
@@ -57,5 +57,22 @@ RSpec.describe 'Enterprise SAML OmniAuth Callbacks', type: :request do
expect(response).to redirect_to(%r{/app/login\?email=.+&sso_auth_token=.+$})
end
end
it 'rejects an existing user from another account' do
with_modified_env FRONTEND_URL: 'http://www.example.com' do
other_account = create(:account)
existing_user = create(:user, email: 'existing@example.com', account: other_account, provider: 'email')
set_saml_config('existing@example.com')
get "/omniauth/saml/callback?account_id=#{account.id}"
expect(response).to redirect_to('http://www.example.com/auth/saml/callback')
follow_redirect!
expect(response).to redirect_to('http://www.example.com/app/login?error=saml-authentication-failed')
expect(existing_user.reload.provider).to eq('email')
expect(existing_user.accounts).not_to include(account)
end
end
end
end
@@ -2,16 +2,14 @@ require 'rails_helper'
RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
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!(:recent_pending_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.minute.ago, status: :pending) }
let!(:open_conversation) { create(:conversation, inbox: inbox, last_activity_at: 1.hour.ago, status: :open) }
let!(:captain_assistant) { create(:captain_assistant, account: inbox.account) }
before do
create(:captain_inbox, inbox: inbox, captain_assistant: captain_assistant)
stub_const('Limits::BULK_ACTIONS_LIMIT', 2)
stub_const('Limits::BULK_ACTIONS_LIMIT', 3)
inbox.reload
end
@@ -20,49 +18,307 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do
.to have_enqueued_job.on_queue('low')
end
it 'resolves only the eligible pending conversations' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
expect(recent_pending_conversation.reload.status).to eq('pending')
expect(open_conversation.reload.status).to eq('open')
end
it 'creates exactly one outgoing message with configured content' do
custom_message = 'This is a custom resolution message.'
captain_assistant.update!(config: { 'resolution_message' => custom_message })
expect do
context 'when captain_tasks is disabled' do
it 'resolves pending conversations inactive for over 1 hour' do
described_class.perform_now(inbox)
end.to change { resolvable_pending_conversation.messages.outgoing.reload.count }.by(1)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(custom_message)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
end
it 'does not resolve recent pending conversations' do
described_class.perform_now(inbox)
expect(recent_pending_conversation.reload.status).to eq('pending')
end
it 'does not affect open conversations' do
described_class.perform_now(inbox)
expect(open_conversation.reload.status).to eq('open')
end
it 'does not call ConversationCompletionService' do
allow(Captain::ConversationCompletionService).to receive(:new)
described_class.perform_now(inbox)
expect(Captain::ConversationCompletionService).not_to have_received(:new)
end
end
it 'creates an outgoing message with default auto resolution message if not configured' do
captain_assistant.update!(config: {})
context 'when captain_tasks is enabled' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
end
described_class.perform_now(inbox)
outgoing_message = resolvable_pending_conversation.messages.outgoing.last
expect(outgoing_message.content).to eq(
I18n.t('conversations.activity.auto_resolution_message')
)
end
it 'only evaluates eligible pending conversations (inactive > 1 hour)' do
allow(Captain::ConversationCompletionService).to receive(:new).and_call_original
it 'adds the correct activity message after resolution by Captain' do
described_class.perform_now(inbox)
expected_content = I18n.t('conversations.activity.captain.resolved', user_name: captain_assistant.name)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
# Mock the service to return complete for all conversations
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Test' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
described_class.perform_now(inbox)
# Only resolvable conversation should be evaluated (not recent or open)
expect(Captain::ConversationCompletionService).to have_received(:new).with(
account: inbox.account,
conversation_display_id: resolvable_pending_conversation.display_id
)
expect(recent_pending_conversation.reload.status).to eq('pending')
expect(open_conversation.reload.status).to eq('open')
end
it 'skips auto-action if conversation receives new activity after evaluation' do
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform) do
resolvable_pending_conversation.update!(last_activity_at: Time.current)
{ complete: true, reason: 'Customer question was answered' }
end
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('pending')
expect(resolvable_pending_conversation.messages.outgoing).to be_empty
end
end
context 'when LLM evaluation returns complete' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: true, reason: 'Customer question was answered' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'resolves the conversation' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('resolved')
end
it 'creates a private note with the reason' do
described_class.perform_now(inbox)
private_note = resolvable_pending_conversation.messages.where(private: true).last
expect(private_note.content).to eq('Auto-resolved: Customer question was answered')
end
it 'creates resolution message with configured content' do
custom_message = 'This is a custom resolution message.'
captain_assistant.update!(config: { 'resolution_message' => custom_message })
inbox.reload
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(custom_message)
end
it 'creates resolution message with default if not configured' do
captain_assistant.update!(config: {})
inbox.reload
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(I18n.t('conversations.activity.auto_resolution_message'))
end
it 'adds the correct activity message after resolution' do
described_class.perform_now(inbox)
expected_content = I18n.t(
'conversations.activity.captain.resolved_with_reason',
user_name: captain_assistant.name,
reason: 'no outstanding questions'
)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
)
end
it 'creates a captain inference resolved reporting event' do
perform_enqueued_jobs do
described_class.perform_now(inbox)
end
inference_event = ReportingEvent.find_by(
conversation_id: resolvable_pending_conversation.id,
name: 'conversation_captain_inference_resolved'
)
expect(inference_event).to be_present
end
end
context 'when LLM evaluation returns incomplete' do
let(:handoff_reason) { 'Assistant asked for order number but customer did not respond' }
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'hands off the conversation to agents (status becomes open)' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('open')
end
it 'creates a private note with the reason' do
described_class.perform_now(inbox)
private_note = resolvable_pending_conversation.messages.where(private: true).last
expect(private_note.content).to eq("Auto-handoff: #{handoff_reason}")
end
it 'creates handoff message with configured content' do
handoff_message = 'Connecting you to a human agent...'
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
described_class.perform_now(inbox)
public_message = resolvable_pending_conversation.messages.where(private: false).outgoing.last
expect(public_message.content).to eq(handoff_message)
expect(public_message.additional_attributes['preserve_waiting_since']).to be_nil
end
it 'preserves existing waiting_since when handoff message is configured' do
handoff_message = 'Connecting you to a human agent...'
original_waiting_since = 3.hours.ago
captain_assistant.update!(config: { 'handoff_message' => handoff_message })
resolvable_pending_conversation.update!(waiting_since: original_waiting_since)
allow(MessageTemplates::Template::OutOfOffice).to receive(:perform_if_applicable)
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
end
it 'does not create handoff message if not configured' do
captain_assistant.update!(config: {})
inbox.reload
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
expect do
described_class.perform_now(inbox)
end.not_to(change { resolvable_pending_conversation.messages.where(private: false).count })
end
it 'adds the correct activity message after handoff' do
described_class.perform_now(inbox)
expected_content = I18n.t(
'conversations.activity.captain.open_with_reason',
user_name: captain_assistant.name,
reason: 'pending clarification from customer'
)
expect(Conversations::ActivityMessageJob)
.to have_been_enqueued.with(
resolvable_pending_conversation,
{
account_id: resolvable_pending_conversation.account_id,
inbox_id: resolvable_pending_conversation.inbox_id,
message_type: :activity,
content: expected_content
}
)
end
it 'creates a captain inference handoff reporting event' do
perform_enqueued_jobs do
described_class.perform_now(inbox)
end
inference_event = ReportingEvent.find_by(
conversation_id: resolvable_pending_conversation.id,
name: 'conversation_captain_inference_handoff'
)
expect(inference_event).to be_present
end
end
context 'when handoff occurs outside business hours' do
let(:handoff_reason) { 'Customer has not responded to clarifying question' }
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: handoff_reason })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
inbox.update!(working_hours_enabled: true, out_of_office_message: 'We are currently unavailable.')
end
it 'sends OOO message for non-campaign conversations' do
travel_to '01.11.2020 13:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
ooo_message = resolvable_pending_conversation.messages.template.last
expect(ooo_message).to be_present
expect(ooo_message.content).to eq('We are currently unavailable.')
end
end
it 'does not send OOO message for campaign conversations' do
campaign = create(:campaign, account: inbox.account, inbox: inbox)
resolvable_pending_conversation.update!(campaign: campaign)
travel_to '01.11.2020 13:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.messages.template).to be_empty
end
end
it 'does not send OOO message during business hours' do
travel_to '26.10.2020 10:00'.to_datetime do
resolvable_pending_conversation.update!(last_activity_at: 2.hours.ago)
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.messages.template).to be_empty
end
end
end
context 'when LLM evaluation fails' do
before do
allow(inbox.account).to receive(:feature_enabled?).and_call_original
allow(inbox.account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
mock_service = instance_double(Captain::ConversationCompletionService)
allow(mock_service).to receive(:perform).and_return({ complete: false, reason: 'API Error' })
allow(Captain::ConversationCompletionService).to receive(:new).and_return(mock_service)
end
it 'hands off as safe default' do
described_class.perform_now(inbox)
expect(resolvable_pending_conversation.reload.status).to eq('open')
end
end
it 'does not resolve conversations when auto-resolve is disabled at execution time' do
@@ -0,0 +1,190 @@
require 'rails_helper'
RSpec.describe Captain::ConversationCompletionService do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
let(:service) { described_class.new(account: account, conversation_display_id: conversation.display_id) }
let(:mock_chat) { instance_double(RubyLLM::Chat) }
let(:mock_context) { instance_double(RubyLLM::Context, chat: mock_chat) }
before do
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
allow(Llm::Config).to receive(:with_api_key).and_yield(mock_context)
allow(mock_chat).to receive(:with_instructions)
allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
end
describe '#perform' do
context 'when conversation is complete' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
create(:message, conversation: conversation, message_type: :incoming, content: 'Thanks!')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns complete: true with reason' do
result = service.perform
expect(result[:complete]).to be true
expect(result[:reason]).to eq('Customer question was fully answered')
end
end
context 'when conversation is incomplete' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => false, 'reason' => 'Assistant asked for order number but customer did not respond' },
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Where is my order?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'Can you please share your order number?')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns complete: false with reason' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('Assistant asked for order number but customer did not respond')
end
end
context 'when conversation has no messages' do
it 'returns incomplete with appropriate reason' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('No messages found')
end
end
context 'when LLM returns non-hash response' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: 'unexpected string response',
input_tokens: 100,
output_tokens: 20
)
end
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'returns incomplete as safe default' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('Invalid response format')
end
end
context 'when API call fails' do
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
allow(mock_chat).to receive(:ask).and_raise(StandardError.new('API Error'))
end
it 'returns incomplete with error message' do
result = service.perform
expect(result[:complete]).to be false
expect(result[:reason]).to eq('API Error')
end
end
context 'when captain_tasks feature is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
end
it 'does not evaluate the conversation as complete' do
result = service.perform
expect(result[:complete]).not_to be true
end
end
context 'when account has its own OpenAI hook' do
before do
create(:message, conversation: conversation, message_type: :incoming, content: 'Hello')
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
end
it 'uses the system API key instead of the account hook key' do
expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_yield(mock_context)
allow(mock_chat).to receive(:ask).and_return(
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
)
service.perform
end
it 'falls back to the account hook key when no system key exists' do
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_API_KEY').update!(value: nil)
expect(Llm::Config).to receive(:with_api_key).with('customer-own-key', api_base: anything).and_yield(mock_context)
allow(mock_chat).to receive(:ask).and_return(
instance_double(RubyLLM::Message, content: { 'complete' => true, 'reason' => 'Done' }, input_tokens: 10, output_tokens: 5)
)
service.perform
end
end
context 'when customer quota is exhausted' do
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => true, 'reason' => 'Customer question was fully answered' },
input_tokens: 100,
output_tokens: 20
)
end
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
allow(account).to receive(:usage_limits).and_return({
captain: { responses: { current_available: 0 } }
})
create(:message, conversation: conversation, message_type: :incoming, content: 'What are your hours?')
create(:message, conversation: conversation, message_type: :outgoing, content: 'We are open 9-5 Monday to Friday.')
allow(mock_chat).to receive(:ask).and_return(mock_response)
end
it 'still runs the evaluation bypassing quota check' do
result = service.perform
expect(result[:error]).to be_nil
expect(result[:complete]).to be true
expect(result[:reason]).to eq('Customer question was fully answered')
end
it 'does not increment usage' do
expect(account).not_to receive(:increment_response_usage)
service.perform
end
end
end
end
@@ -63,6 +63,20 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do
tool.perform(tool_context, reason: 'Test reason')
end
it 'creates a conversation_bot_handoff reporting event' do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
Current.executed_by = assistant
perform_enqueued_jobs do
tool.perform(tool_context, reason: 'Customer needs specialized support')
end
reporting_event = ReportingEvent.find_by(conversation_id: conversation.id, name: 'conversation_bot_handoff')
expect(reporting_event).to be_present
ensure
Current.reset
end
it 'logs tool usage with reason' do
reason = 'Customer needs help'
expect(tool).to receive(:log_tool_usage).with(
@@ -29,10 +29,14 @@ RSpec.describe Captain::Tools::ResolveConversationTool do
)
end
it 'clears captain_resolve_reason after execution' do
tool.perform(tool_context, reason: 'Possible spam')
it 'creates a conversation_resolved reporting event' do
create(:captain_inbox, captain_assistant: assistant, inbox: inbox)
expect(Current.captain_resolve_reason).to be_nil
expect do
perform_enqueued_jobs do
tool.perform(tool_context, reason: 'Possible spam')
end
end.to change { ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_resolved').count }.by(1)
end
end
@@ -8,8 +8,7 @@ describe CaptainListener do
let(:assistant) { create(:captain_assistant, account: account, config: { feature_memory: true, feature_faq: true }) }
describe '#conversation_resolved' do
let(:agent) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let(:event_name) { :conversation_resolved }
let(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
@@ -42,6 +42,45 @@ RSpec.describe Captain::Scenario, type: :model do
end
end
describe '#handoff_key' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'uses id plus readable slug for persisted scenarios' do
scenario = create(:captain_scenario, assistant: assistant, account: account,
title: 'Handle complex refund requests requiring manager approval steps')
expect(scenario.handoff_key).to start_with("scenario_#{scenario.id}_")
expect(scenario.handoff_key).to end_with('_agent')
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'uses a truncated slug key for unsaved scenarios' do
scenario = build(:captain_scenario, assistant: assistant, account: account,
title: 'Troubleshoot payment gateway errors for recurring subscription charges')
expect(scenario.handoff_key).to match(/\Ascenario_draft_[a-z0-9_]+_agent\z/)
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'stays within length budget even for large ids' do
scenario = build(:captain_scenario, assistant: assistant, account: account,
title: 'A very long scenario title used only for budget verification')
allow(scenario).to receive(:id).and_return(1_234_567_890_123_456_789)
expect("handoff_to_#{scenario.handoff_key}".length).to be <= 60
end
it 'exposes handoff keys in assistant prompt context' do
scenario = create(:captain_scenario, assistant: assistant, account: account)
prompt_context = assistant.send(:prompt_context)
scenario_config = prompt_context[:scenarios].find { |entry| entry[:title] == scenario.title }
expect(scenario_config[:key]).to eq(scenario.handoff_key)
end
end
describe 'tool validation and population' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
+111 -4
View File
@@ -27,12 +27,24 @@ describe ReportingEventListener do
end
it 'creates conversation_resolved event with business hour value' do
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
event = Events::Base.new('conversation.resolved', updated_at, conversation: new_conversation)
listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
end
end
it 'uses event timestamp even when conversation updated_at changes later' do
resolved_at = conversation.created_at + 20.minutes
allow(conversation).to receive(:updated_at).and_return(resolved_at + 10.minutes)
event = Events::Base.new('conversation.resolved', resolved_at, conversation: conversation)
listener.conversation_resolved(event)
reporting_event = account.reporting_events.where(name: 'conversation_resolved').first
expect(reporting_event.value).to eq 1200
expect(reporting_event.event_end_time).to be_within(1.second).of(resolved_at)
end
describe 'conversation_bot_resolved' do
# create an agent bot
let!(:agent_bot_inbox) { create(:inbox, account: account) }
@@ -261,11 +273,55 @@ describe ReportingEventListener do
end
it 'creates conversation_bot_handoff event with business hour value' do
event = Events::Base.new('conversation.bot_handoff', Time.zone.now, conversation: new_conversation)
event = Events::Base.new('conversation.bot_handoff', updated_at, conversation: new_conversation)
listener.conversation_bot_handoff(event)
expect(account.reporting_events.where(name: 'conversation_bot_handoff')[0]['value_in_business_hours']).to be 144_000.0
end
end
it 'uses event timestamp even when conversation updated_at changes later' do
handoff_at = conversation.created_at + 10.minutes
allow(conversation).to receive(:updated_at).and_return(handoff_at + 15.minutes)
event = Events::Base.new('conversation.bot_handoff', handoff_at, conversation: conversation)
listener.conversation_bot_handoff(event)
reporting_event = account.reporting_events.where(name: 'conversation_bot_handoff').first
expect(reporting_event.value).to eq 600
expect(reporting_event.event_end_time).to be_within(1.second).of(handoff_at)
end
end
describe '#conversation_captain_inference_resolved' do
it 'creates conversation_captain_inference_resolved event' do
expect(account.reporting_events.where(name: 'conversation_captain_inference_resolved').count).to be 0
decision_time = conversation.created_at + 60.seconds
event = Events::Base.new('conversation.captain_inference_resolved', decision_time, conversation: conversation)
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
listener.conversation_captain_inference_resolved(event)
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_resolved').first
expect(reporting_event).to be_present
expect(reporting_event.value).to eq 60
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
end
end
describe '#conversation_captain_inference_handoff' do
it 'creates conversation_captain_inference_handoff event' do
expect(account.reporting_events.where(name: 'conversation_captain_inference_handoff').count).to be 0
decision_time = conversation.created_at + 90.seconds
event = Events::Base.new('conversation.captain_inference_handoff', decision_time, conversation: conversation)
allow(conversation).to receive(:updated_at).and_return(decision_time + 5.minutes)
listener.conversation_captain_inference_handoff(event)
reporting_event = account.reporting_events.where(name: 'conversation_captain_inference_handoff').first
expect(reporting_event).to be_present
expect(reporting_event.value).to eq 90
expect(reporting_event.event_end_time).to be_within(1.second).of(decision_time)
end
end
describe '#conversation_opened' do
@@ -274,7 +330,8 @@ describe ReportingEventListener do
it 'creates conversation_opened event with value 0' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
opened_at = Time.zone.now
event = Events::Base.new('conversation.opened', opened_at, conversation: new_conversation)
listener.conversation_opened(event)
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
@@ -282,7 +339,7 @@ describe ReportingEventListener do
expect(opened_event.value).to eq 0
expect(opened_event.value_in_business_hours).to eq 0
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
expect(opened_event.event_end_time).to be_within(1.second).of(opened_at)
end
end
@@ -334,6 +391,17 @@ describe ReportingEventListener do
expect(reopened_event.user_id).to eq(user.id)
end
it 'uses event timestamp even when conversation updated_at changes later' do
allow(reopened_conversation).to receive(:updated_at).and_return(reopened_time + 20.minutes)
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
context 'when business hours enabled for inbox' do
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
@@ -406,6 +474,45 @@ describe ReportingEventListener do
end
end
context 'when latest resolved event is after the conversation opened event timestamp' do
let(:previous_resolved_time) { Time.zone.parse('March 22, 2022 09:00') }
let(:future_resolved_time) { Time.zone.parse('March 22, 2022 11:00') }
let(:reopened_time) { Time.zone.parse('March 22, 2022 10:00') }
let(:reopened_conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
end
before do
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: reopened_conversation.id,
user_id: user.id,
event_start_time: reopened_conversation.created_at,
event_end_time: previous_resolved_time)
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: reopened_conversation.id,
user_id: user.id,
event_start_time: previous_resolved_time,
event_end_time: future_resolved_time)
end
it 'ignores future resolved events when computing reopen duration' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600)
expect(reopened_event.event_start_time).to be_within(1.second).of(previous_resolved_time)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
end
context 'when agent bot resolves and conversation is reopened' do
# This implicitly tests that the first_response time is correctly calculated
# By checking that a conversation reopened event is created with the correct values
+19
View File
@@ -366,6 +366,25 @@ RSpec.describe Message do
expect(conversation.waiting_since).to be_nil
end
end
context 'when bot response should preserve waiting_since' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
it 'does not clear waiting_since when preserve_waiting_since is set' do
original_waiting_since = 45.minutes.ago
conversation.update!(waiting_since: original_waiting_since)
create(
:message,
conversation: conversation,
message_type: :outgoing,
sender: agent_bot,
preserve_waiting_since: true
)
expect(conversation.reload.waiting_since).to be_within(1.second).of(original_waiting_since)
end
end
end
context 'with webhook_data' do
+20
View File
@@ -50,6 +50,26 @@ describe ActionService do
action_service.assign_agent(['nil'])
expect(conversation.reload.assignee).to be_nil
end
context 'when agent is confirmed' do
it 'assigns the agent to the conversation' do
inbox_member
action_service.assign_agent([agent.id])
expect(conversation.reload.assignee).to eq(agent)
end
end
context 'when agent is unconfirmed' do
let(:unconfirmed_agent) { create(:user, account: account, role: :agent, skip_confirmation: false) }
let(:unconfirmed_inbox_member) { create(:inbox_member, inbox: conversation.inbox, user: unconfirmed_agent) }
it 'does not assign unconfirmed agent to the conversation' do
unconfirmed_inbox_member
original_assignee = conversation.assignee
action_service.assign_agent([unconfirmed_agent.id])
expect(conversation.reload.assignee).to eq(original_assignee)
end
end
end
describe '#assign_team' do