From 9c68eed6764bca19500bf7c21c6bc466e695a8d9 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 15 Jul 2026 22:43:30 +0530 Subject: [PATCH 01/48] fix(captain): send custom tool API key headers (#15008) Captain custom tools configured with API key authentication now send the configured key in the requested HTTP header. Existing tools begin working without needing to be recreated or reconfigured, while credentials remain protected across redirects. ## How to reproduce 1. Create a Captain custom tool using API Key authentication. 2. Configure `X-API-Key` as the header name and save the tool. 3. Invoke the tool and inspect the incoming request. 4. Before this change, the API key header is absent; after this change, the configured endpoint receives it. ## What changed The UI persists API key authentication as `name` and `key`, but the request builder also required an unused `location: header` property. The request builder now treats API key authentication as header-based, matching the only mode exposed by the UI. Custom authentication headers are also registered as sensitive with `SafeFetch`. They are retained for the configured endpoint and same-origin redirects, but stripped when a redirect crosses origins to prevent credential leakage. Factory, request, and redirect specs cover the real UI payload and both public and private-network fetch paths. --- enterprise/app/models/concerns/toolable.rb | 6 +- enterprise/lib/captain/tools/http_tool.rb | 8 ++- lib/safe_fetch/request_options.rb | 12 ++-- .../lib/captain/tools/http_tool_spec.rb | 18 ++++- .../models/captain/custom_tool_spec.rb | 11 +-- spec/factories/captain/custom_tool.rb | 2 +- spec/lib/safe_fetch_spec.rb | 69 +++++++++++++++++++ 7 files changed, 102 insertions(+), 24 deletions(-) diff --git a/enterprise/app/models/concerns/toolable.rb b/enterprise/app/models/concerns/toolable.rb index 828cd50c5..66bdf5d66 100644 --- a/enterprise/app/models/concerns/toolable.rb +++ b/enterprise/app/models/concerns/toolable.rb @@ -55,11 +55,7 @@ module Concerns::Toolable when 'bearer' { 'Authorization' => "Bearer #{auth_config['token']}" } when 'api_key' - if auth_config['location'] == 'header' - { auth_config['name'] => auth_config['key'] } - else - {} - end + { auth_config['name'] => auth_config['key'] } else {} end diff --git a/enterprise/lib/captain/tools/http_tool.rb b/enterprise/lib/captain/tools/http_tool.rb index 18ebcf53a..70576fb21 100644 --- a/enterprise/lib/captain/tools/http_tool.rb +++ b/enterprise/lib/captain/tools/http_tool.rb @@ -32,13 +32,15 @@ class Captain::Tools::HttpTool < Agents::Tool # fetching (resolution, timeouts, response size limits, and redirect handling). def execute_http_request(url, body, tool_context) json_body = body if @custom_tool.http_method == 'POST' + auth_headers = @custom_tool.build_auth_headers response_body = +'' SafeFetch.fetch( url, method: @custom_tool.http_method == 'POST' ? :post : :get, body: json_body, - headers: request_headers(tool_context, json_body), + headers: request_headers(tool_context, json_body, auth_headers), + sensitive_headers: auth_headers.keys, http_basic_authentication: @custom_tool.build_basic_auth_credentials, max_bytes: MAX_RESPONSE_SIZE, validate_content_type: false @@ -46,8 +48,8 @@ class Captain::Tools::HttpTool < Agents::Tool response_body end - def request_headers(tool_context, json_body) - headers = @custom_tool.build_auth_headers + def request_headers(tool_context, json_body, auth_headers) + headers = auth_headers.dup headers.merge!(@custom_tool.build_metadata_headers(tool_context&.state || {})) headers['Content-Type'] = 'application/json' if json_body.present? headers diff --git a/lib/safe_fetch/request_options.rb b/lib/safe_fetch/request_options.rb index 6d11ebd19..54f5a559c 100644 --- a/lib/safe_fetch/request_options.rb +++ b/lib/safe_fetch/request_options.rb @@ -6,6 +6,7 @@ class SafeFetch::RequestOptions open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT, read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT, headers: nil, + sensitive_headers: [], http_basic_authentication: nil, allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES, allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES, @@ -13,7 +14,7 @@ class SafeFetch::RequestOptions }.freeze attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers, - :http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url + :http_basic_authentication, :method, :open_timeout, :read_timeout, :sensitive_headers, :uri, :url def initialize(url:, **options) config = DEFAULTS.merge(options) @@ -25,6 +26,7 @@ class SafeFetch::RequestOptions @open_timeout = config[:open_timeout] @read_timeout = config[:read_timeout] @headers = normalize_headers(config[:headers]) + @sensitive_headers = normalize_sensitive_headers(config[:sensitive_headers]) @http_basic_authentication = config[:http_basic_authentication] @allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes]) @allowed_content_types = Array(config[:allowed_content_types]) @@ -84,6 +86,10 @@ class SafeFetch::RequestOptions value&.to_h end + def normalize_sensitive_headers(value) + (SafeFetch::DEFAULT_SENSITIVE_HEADERS + Array(value)).map { |header| header.to_s.downcase }.uniq + end + def request_proc proc do |request| credentials = http_basic_authentication.presence || basic_authentication_for(request.uri) @@ -91,10 +97,6 @@ class SafeFetch::RequestOptions end end - def sensitive_headers - SafeFetch::DEFAULT_SENSITIVE_HEADERS - end - def basic_authentication_for(request_uri) uri_basic_authentication(request_uri) || original_uri_basic_authentication(request_uri) end diff --git a/spec/enterprise/lib/captain/tools/http_tool_spec.rb b/spec/enterprise/lib/captain/tools/http_tool_spec.rb index e05308a7d..3ee19b530 100644 --- a/spec/enterprise/lib/captain/tools/http_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/http_tool_spec.rb @@ -129,7 +129,7 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do before do custom_tool.update!( auth_type: 'api_key', - auth_config: { 'key' => 'api_key_123', 'location' => 'header', 'name' => 'X-API-Key' }, + auth_config: { 'key' => 'api_key_123', 'name' => 'X-API-Key' }, endpoint_url: 'https://example.com/data', response_template: nil ) @@ -145,6 +145,22 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do expect(WebMock).to have_requested(:get, 'https://example.com/data') .with(headers: { 'X-API-Key' => 'api_key_123' }) end + + it 'strips the API key header on cross-origin redirects' do + redirect_url = 'http://example.com/data' + redirected_headers = nil + stub_request(:get, 'https://example.com/data').to_return(status: 302, headers: { 'Location' => redirect_url }) + stub_request(:get, redirect_url) + .with do |request| + redirected_headers = request.headers.transform_keys(&:downcase) + true + end + .to_return(status: 200, body: '{"authenticated": false}') + + tool.perform(tool_context) + + expect(redirected_headers).not_to include('x-api-key') + end end context 'with response template' do diff --git a/spec/enterprise/models/captain/custom_tool_spec.rb b/spec/enterprise/models/captain/custom_tool_spec.rb index 60b66778f..ab23b8aa4 100644 --- a/spec/enterprise/models/captain/custom_tool_spec.rb +++ b/spec/enterprise/models/captain/custom_tool_spec.rb @@ -201,7 +201,7 @@ RSpec.describe Captain::CustomTool, type: :model do expect(tool.auth_type).to eq('api_key') expect(tool.auth_config['key']).to eq('test_api_key') - expect(tool.auth_config['location']).to eq('header') + expect(tool.auth_config['name']).to eq('X-API-Key') end end @@ -259,19 +259,12 @@ RSpec.describe Captain::CustomTool, type: :model do expect(tool.build_auth_headers).to eq({ 'Authorization' => 'Bearer test_bearer_token_123' }) end - it 'returns API key header when location is header' do + it 'returns API key header' do tool = create(:captain_custom_tool, :with_api_key, account: account) expect(tool.build_auth_headers).to eq({ 'X-API-Key' => 'test_api_key' }) end - it 'returns empty hash for API key when location is not header' do - tool = create(:captain_custom_tool, account: account, auth_type: 'api_key', - auth_config: { key: 'test_key', location: 'query', name: 'api_key' }) - - expect(tool.build_auth_headers).to eq({}) - end - it 'returns empty hash for basic auth' do tool = create(:captain_custom_tool, :with_basic_auth, account: account) diff --git a/spec/factories/captain/custom_tool.rb b/spec/factories/captain/custom_tool.rb index 2bfcbf360..d001755b9 100644 --- a/spec/factories/captain/custom_tool.rb +++ b/spec/factories/captain/custom_tool.rb @@ -27,7 +27,7 @@ FactoryBot.define do trait :with_api_key do auth_type { 'api_key' } - auth_config { { key: 'test_api_key', location: 'header', name: 'X-API-Key' } } + auth_config { { key: 'test_api_key', name: 'X-API-Key' } } end trait :with_templates do diff --git a/spec/lib/safe_fetch_spec.rb b/spec/lib/safe_fetch_spec.rb index a124be774..1593a7c52 100644 --- a/spec/lib/safe_fetch_spec.rb +++ b/spec/lib/safe_fetch_spec.rb @@ -249,6 +249,34 @@ RSpec.describe SafeFetch do expect { described_class.fetch(redirect_url) { nil } }.not_to raise_error end end + + it 'strips caller-provided sensitive headers on private network cross-origin redirects' do + redirect_url = 'http://example.com/redirect.png' + private_url = 'http://private.example.com/image.png' + redirected_headers = nil + allow(Resolv).to receive(:getaddresses).with('private.example.com').and_return(['10.0.0.5']) + stub_request(:get, redirect_url).to_return(status: 302, headers: { 'Location' => private_url }) + stub_request(:get, private_url) + .with do |request| + redirected_headers = request.headers.transform_keys(&:downcase) + true + end + .to_return( + status: 200, + body: File.new(Rails.root.join('spec/assets/avatar.png')), + headers: { 'Content-Type' => 'image/png' } + ) + + with_modified_env('SAFE_FETCH_ALLOW_PRIVATE_NETWORK' => 'true') do + described_class.fetch( + redirect_url, + headers: { 'X-API-Key' => 'secret-key' }, + sensitive_headers: ['X-API-Key'] + ) { nil } + end + + expect(redirected_headers).not_to include('x-api-key') + end end context 'with content-type allowlist' do @@ -400,6 +428,47 @@ RSpec.describe SafeFetch do expect(redirected_headers).not_to include('authorization', 'cookie') end + it 'strips caller-provided sensitive headers on cross-origin redirects' do + redirect_url = 'https://example.com/image.png' + redirected_headers = nil + headers = { 'X-API-Key' => 'secret-key' } + + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => redirect_url }) + stub_request(:get, redirect_url) + .with do |request| + redirected_headers = request.headers.transform_keys(&:downcase) + true + end + .to_return(status: 200, body: '', headers: {}) + + described_class.fetch( + url, + headers: headers, + sensitive_headers: ['X-API-Key'], + validate_content_type: false + ) { nil } + + expect(redirected_headers).not_to include('x-api-key') + end + + it 'preserves caller-provided sensitive headers on same-origin redirects' do + redirect_url = 'http://example.com/redirected.png' + + stub_request(:get, url).to_return(status: 302, headers: { 'Location' => '/redirected.png' }) + stub_request(:get, redirect_url) + .with(headers: { 'X-API-Key' => 'secret-key' }) + .to_return(status: 200, body: '', headers: {}) + + described_class.fetch( + url, + headers: { 'X-API-Key' => 'secret-key' }, + sensitive_headers: ['X-API-Key'], + validate_content_type: false + ) { nil } + + expect(WebMock).to have_requested(:get, redirect_url).with(headers: { 'X-API-Key' => 'secret-key' }) + end + it 'raises UnsupportedMethodError for unsupported HTTP methods' do expect { described_class.fetch(url, method: :options) { nil } }.to raise_error do |error| expect(error.class.name).to eq('SafeFetch::UnsupportedMethodError') From 354c2cab6bb8deb37a6df3e16086d1cf4cfe3678 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:30:17 +0530 Subject: [PATCH 02/48] fix(captain): handle resolved conversation context (#14433) # Pull Request Template ## Description Fixes: https://github.com/chatwoot/chatwoot/issues/13880 Uses approaches discussed from: https://github.com/chatwoot/chatwoot/pull/13883 Activity messages pertaining to resolve are included along with an instruction for the LLM to choose whether to consider them or not along ## 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. locally and with specs ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [x] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Sony Mathew --- .../concerns/activity_message_handler.rb | 21 ++++++-- .../conversation/response_builder_job.rb | 6 ++- .../message_history_builder_service.rb | 50 +++++++++++++++++++ .../prompts/snippets/core_rules.liquid | 2 + .../conversations/messages_controller_spec.rb | 8 ++- .../widget/conversations_controller_spec.rb | 3 +- .../api/v1/widget/messages_controller_spec.rb | 3 +- .../conversation/response_builder_job_spec.rb | 42 +++++++++++++++- ...nding_conversations_resolution_job_spec.rb | 6 ++- spec/models/conversation_spec.rb | 6 ++- 10 files changed, 134 insertions(+), 13 deletions(-) create mode 100644 enterprise/app/services/captain/conversation/message_history_builder_service.rb diff --git a/app/models/concerns/activity_message_handler.rb b/app/models/concerns/activity_message_handler.rb index 0300bd2d1..b4197ac2d 100644 --- a/app/models/concerns/activity_message_handler.rb +++ b/app/models/concerns/activity_message_handler.rb @@ -54,7 +54,20 @@ module ActivityMessageHandler user_status_change_activity_content(user_name) end - ::Conversations::ActivityMessageJob.perform_later(self, activity_message_params(content)) if content + return if content.blank? + + ::Conversations::ActivityMessageJob.perform_later( + self, + activity_message_params( + content, + content_attributes: { + activity: { + type: 'conversation_status_changed', + status: status + } + } + ) + ) end def auto_resolve_message_key(minutes) @@ -87,8 +100,10 @@ module ActivityMessageHandler end end - def activity_message_params(content) - { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } + def activity_message_params(content, content_attributes: nil) + params = { account_id: account_id, inbox_id: inbox_id, message_type: :activity, content: content } + params[:content_attributes] = content_attributes if content_attributes.present? + params end def create_muted_message diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb index 7978ae947..282d94862 100644 --- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb +++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb @@ -45,7 +45,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob def generate_response_with_v2 @response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response( - message_history: collect_previous_messages + message_history: collect_previous_messages_with_resolution_markers ) process_response end @@ -99,6 +99,10 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob end end + def collect_previous_messages_with_resolution_markers + Captain::Conversation::MessageHistoryBuilderService.new(conversation: @conversation).perform + end + def determine_role(message) message.message_type == 'incoming' ? 'user' : 'assistant' end diff --git a/enterprise/app/services/captain/conversation/message_history_builder_service.rb b/enterprise/app/services/captain/conversation/message_history_builder_service.rb new file mode 100644 index 000000000..c70b876ad --- /dev/null +++ b/enterprise/app/services/captain/conversation/message_history_builder_service.rb @@ -0,0 +1,50 @@ +class Captain::Conversation::MessageHistoryBuilderService + RESOLUTION_MARKER = ''.freeze + + pattr_initialize [:conversation!] + + def perform + conversation_messages_for_context.filter_map do |message| + message_hash = message_hash_for_context(message) + next if message_hash.blank? + + message_hash[:agent_name] = message.additional_attributes['agent_name'] if message.additional_attributes&.dig('agent_name').present? + message_hash + end + end + + private + + def conversation_messages_for_context + conversation.messages + .where(private: false, message_type: [:incoming, :outgoing, :activity]) + .reorder(created_at: :asc, id: :asc) + end + + def message_hash_for_context(message) + return activity_message_hash(message) if message.message_type == 'activity' + + { + content: prepare_multimodal_message_content(message), + role: determine_role(message) + } + end + + def activity_message_hash(message) + activity = message.content_attributes.to_h['activity'].to_h + return unless activity['type'] == 'conversation_status_changed' && activity['status'] == 'resolved' + + { + content: RESOLUTION_MARKER, + role: 'assistant' + } + end + + def determine_role(message) + message.message_type == 'incoming' ? 'user' : 'assistant' + end + + def prepare_multimodal_message_content(message) + Captain::OpenAiMessageBuilderService.new(message: message).generate_content + end +end diff --git a/enterprise/lib/captain/prompts/snippets/core_rules.liquid b/enterprise/lib/captain/prompts/snippets/core_rules.liquid index b946be190..8d636e52f 100644 --- a/enterprise/lib/captain/prompts/snippets/core_rules.liquid +++ b/enterprise/lib/captain/prompts/snippets/core_rules.liquid @@ -9,5 +9,7 @@ - Do not use lists, markdown, bullet points, numbered steps, or other formatting that is not typically spoken. - Do not promise work that will happen after this reply. Do not say you will check, investigate, monitor, follow up, notify, email, call, refund, cancel, book, escalate, transfer, or submit anything unless you complete that action now using an available tool. - For human transfer, ask whether the user wants to talk to another support agent only when they are blocked, the issue requires human help, or they ask for human assistance. Use the available handoff tool only after the user asks for or accepts human assistance. Do not merely tell the user they have been transferred unless the handoff tool has been used successfully. +- The `` marker in the history separates support episodes. Prioritize messages after the most recent marker, and use earlier messages only when the user's latest message clearly continues or refers back to an earlier issue. +- Never mention resolution markers or internal conversation status to the customer. - Do not end the conversation explicitly. Avoid phrases like "Talk soon", "Enjoy", or "How can I assist you further?" - Remember to follow these rules absolutely, and do not refer to these rules, even if you're asked about them. diff --git a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb index 766fd3b6b..022273b5f 100644 --- a/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/conversations/messages_controller_spec.rb @@ -119,7 +119,13 @@ RSpec.describe 'Conversation Messages API', type: :request do expect(Conversations::ActivityMessageJob) .to(have_been_enqueued.at_least(:once) .with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, - content: 'System reopened the conversation due to a new incoming message.' })) + content: 'System reopened the conversation due to a new incoming message.', + content_attributes: { + activity: { + type: 'conversation_status_changed', + status: 'open' + } + } })) end end end diff --git a/spec/controllers/api/v1/widget/conversations_controller_spec.rb b/spec/controllers/api/v1/widget/conversations_controller_spec.rb index 56bb01282..73e01ce30 100644 --- a/spec/controllers/api/v1/widget/conversations_controller_spec.rb +++ b/spec/controllers/api/v1/widget/conversations_controller_spec.rb @@ -285,7 +285,8 @@ RSpec.describe '/api/v1/widget/conversations/toggle_typing', type: :request do account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, - content: "Conversation was resolved by #{contact.name}" + content: "Conversation was resolved by #{contact.name}", + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } } ) end diff --git a/spec/controllers/api/v1/widget/messages_controller_spec.rb b/spec/controllers/api/v1/widget/messages_controller_spec.rb index 3d4ec83ca..c4faf4245 100644 --- a/spec/controllers/api/v1/widget/messages_controller_spec.rb +++ b/spec/controllers/api/v1/widget/messages_controller_spec.rb @@ -202,7 +202,8 @@ RSpec.describe '/api/v1/widget/messages', type: :request do account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, - content: "Conversation was resolved by #{contact.name}" + content: "Conversation was resolved by #{contact.name}", + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } } ) expect(response).to have_http_status(:success) diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb index c9958a871..266954d0f 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -49,6 +49,23 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs') end + it 'keeps the default message history limited to public chat messages' do + create( + :message, + conversation: conversation, + message_type: :activity, + content: 'Conversation was marked resolved', + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } + ) + create(:message, conversation: conversation, content: 'Private note', message_type: :outgoing, private: true) + + expect(mock_llm_chat_service).to receive(:generate_response).with( + message_history: [{ content: 'Hello', role: 'user' }] + ).and_return({ 'response' => 'Hey, welcome to Captain Specs' }) + + described_class.perform_now(conversation, assistant) + end + it 'increments usage response' do described_class.perform_now(conversation, assistant) account.reload @@ -342,9 +359,30 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(conversation.messages.last.content).to eq('Hey, welcome to Captain V2') end - it 'passes message history to agent runner service' do + it 'passes message history with resolution markers to agent runner service' do + same_second = Time.current.change(usec: 0) + conversation.messages.find_by!(content: 'Hello').update!(created_at: same_second, updated_at: same_second) + create( + :message, + conversation: conversation, + message_type: :activity, + content: 'Conversation was marked resolved by Alice', + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } }, + created_at: same_second, + updated_at: same_second + ) + create(:message, conversation: conversation, message_type: :activity, content: 'Assigned to agent', created_at: same_second, + updated_at: same_second) + create(:message, conversation: conversation, content: 'Fresh question', message_type: :incoming, created_at: same_second, + updated_at: same_second) + expected_messages = [ - { content: 'Hello', role: 'user' } + { content: 'Hello', role: 'user' }, + { + content: Captain::Conversation::MessageHistoryBuilderService::RESOLUTION_MARKER, + role: 'assistant' + }, + { content: 'Fresh question', role: 'user' } ] expect(mock_agent_runner_service).to receive(:generate_response).with( diff --git a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb index f432aae62..857e35214 100644 --- a/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb +++ b/spec/enterprise/jobs/captain/inbox_pending_conversations_resolution_job_spec.rb @@ -154,7 +154,8 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do account_id: resolvable_pending_conversation.account_id, inbox_id: resolvable_pending_conversation.inbox_id, message_type: :activity, - content: expected_content + content: expected_content, + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } } ) end @@ -252,7 +253,8 @@ RSpec.describe Captain::InboxPendingConversationsResolutionJob, type: :job do account_id: resolvable_pending_conversation.account_id, inbox_id: resolvable_pending_conversation.inbox_id, message_type: :activity, - content: expected_content + content: expected_content, + content_attributes: { activity: { type: 'conversation_status_changed', status: 'open' } } } ) end diff --git a/spec/models/conversation_spec.rb b/spec/models/conversation_spec.rb index 43bbab56f..c67aa0604 100644 --- a/spec/models/conversation_spec.rb +++ b/spec/models/conversation_spec.rb @@ -264,7 +264,8 @@ RSpec.describe Conversation do expect(Conversations::ActivityMessageJob) .to(have_been_enqueued.at_least(:once) .with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, - content: "Conversation was marked resolved by #{old_assignee.name}" })) + content: "Conversation was marked resolved by #{old_assignee.name}", + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } })) expect(Conversations::ActivityMessageJob) .to(have_been_enqueued.at_least(:once) .with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity, @@ -287,7 +288,8 @@ RSpec.describe Conversation do expect { conversation2.update(status: :resolved) } .to have_enqueued_job(Conversations::ActivityMessageJob) .with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity, - content: system_resolved_message }) + content: system_resolved_message, + content_attributes: { activity: { type: 'conversation_status_changed', status: 'resolved' } } }) end end From 6d74ff94777f5bb2b8a53a31516f33377249a600 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 16 Jul 2026 10:44:22 +0530 Subject: [PATCH 03/48] fix(captain): make assistant switcher scrollable (#15027) --- .../captain/pageComponents/switcher/AssistantSwitcher.vue | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue index c78842241..01562385f 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/switcher/AssistantSwitcher.vue @@ -88,7 +88,7 @@ const openCreateAssistantDialog = () => {