From 280756b483b941d355ab2e09e546c83608fc12d7 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:30:19 +0400 Subject: [PATCH 01/23] fix(meta): disable Instagram replies on Cloud during restriction (#15005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents can no longer send replies in Instagram conversations on Chatwoot Cloud while the temporary Meta platform restriction is active. The reply box locks into Private Note mode — the same behavior as an expired 24-hour reply window — so teams can still collaborate internally, with the existing amber restriction banner above the conversation explaining why. Self-hosted installations are unaffected. Follow-up to #14974. ## How to test 1. On a Chatwoot Cloud environment (`isOnChatwootCloud` true), open any Instagram conversation. 2. The composer should be locked to Private Note mode: the Reply/Private Note toggle is disabled, and sending creates a private note — even for conversations within the 24-hour reply window. 3. Switching between conversations should keep the composer in Private Note mode for Instagram conversations. 4. On a self-hosted environment, Instagram conversations should behave as before (reply allowed within the messaging window). Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- .../widgets/conversation/ReplyBox.vue | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index 471d10f3c..bd72d45f3 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -146,6 +146,7 @@ export default { currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', + isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), currentContact() { const senderId = this.currentChat?.meta?.sender?.id; @@ -173,6 +174,9 @@ export default { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, isPrivate() { + if (this.isInstagramReplyRestricted) { + return true; + } if ( this.currentChat.can_reply || this.isAWhatsAppChannel || @@ -197,10 +201,16 @@ export default { ); return !!stripped.trim(); }, + // Instagram replies are disabled on Chatwoot Cloud during the temporary + // Meta platform restriction; private notes remain available. + isInstagramReplyRestricted() { + return this.isOnChatwootCloud && this.isAnInstagramChannel; + }, isReplyRestricted() { return ( - !this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox) + this.isInstagramReplyRestricted || + (!this.currentChat?.can_reply && + !(this.isAWhatsAppChannel || this.isAPIInbox)) ); }, inboxId() { @@ -470,7 +480,10 @@ export default { return; } - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { + if ( + !this.isInstagramReplyRestricted && + (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + ) { this.replyType = REPLY_EDITOR_MODES.REPLY; } else { this.replyType = REPLY_EDITOR_MODES.NOTE; @@ -937,7 +950,10 @@ export default { this.$store.dispatch('draftMessages/setReplyEditorMode', { mode, }); - if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + if ( + !this.isInstagramReplyRestricted && + (canReply || this.isAWhatsAppChannel || this.isAPIInbox) + ) this.replyType = mode; if (this.isRecordingAudio) { this.toggleAudioRecorder(); From 3e03f8da1e8049a5b94d295073cf4763d7d90d7d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 14 Jul 2026 13:35:10 +0400 Subject: [PATCH 02/23] chore(whatsapp): log warning when Cloud API template sync fails (#15004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp Cloud API template sync currently fails silently — if the Graph API call errors (expired token, rate limit, permission issue), the channel simply keeps its stale templates with no trace in the logs. This adds a warning log when the template fetch fails, so failed syncs are visible and debuggable. ## What changed - `Whatsapp::Providers::WhatsappCloudService#fetch_whatsapp_templates` now logs a warning with the account id, inbox id, HTTP status code, and Meta's error message when the response is not successful. - The inbox id uses safe navigation since sync also runs from the channel's `after_create` callback, before the inbox record exists. - The request URL is intentionally not logged, as it contains the access token as a query param. ## How to reproduce 1. Set up a WhatsApp Cloud inbox with an invalid/expired `api_key`. 2. Trigger a template sync (Inbox settings → sync templates, or wait for the scheduler). 3. Previously nothing was logged; now a `[WHATSAPP] Template sync failed for account ... inbox ...` warning appears in the Rails logs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> --- app/services/whatsapp/providers/whatsapp_cloud_service.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/whatsapp/providers/whatsapp_cloud_service.rb b/app/services/whatsapp/providers/whatsapp_cloud_service.rb index 69631c468..373e47b3c 100644 --- a/app/services/whatsapp/providers/whatsapp_cloud_service.rb +++ b/app/services/whatsapp/providers/whatsapp_cloud_service.rb @@ -40,7 +40,11 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def fetch_whatsapp_templates(url) response = HTTParty.get(url) - return [] unless response.success? + unless response.success? + Rails.logger.warn "[WHATSAPP] Template sync failed for account #{whatsapp_channel.account_id} " \ + "inbox #{whatsapp_channel.inbox&.id}: #{response.code} #{error_message(response)}" + return [] + end next_url = next_url(response) @@ -155,7 +159,7 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi def error_message(response) # https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes/#sample-response - response.parsed_response&.dig('error', 'message') + response.parsed_response.dig('error', 'message') if response.parsed_response.is_a?(Hash) end def voice_message?(type, attachment) From 9328f8739ce420bb031550f87d83e5f4bc32b61d Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:05:27 +0530 Subject: [PATCH 03/23] fix: clear whatsapp webhook override when manual cloud inbox is deleted (#15010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deleting a manually-configured WhatsApp Cloud inbox left its phone-number-level webhook override still pointing at Chatwoot on Meta's side. The number kept routing inbound events to us after the inbox was gone, which blocked the customer's own app — subscribed separately on the same WABA — from receiving messages, since the phone-level override takes priority over the app-level subscription. Deleting the inbox now releases the override, as it already did for embedded-signup inboxes. ## What changed The setup and teardown paths gated on opposite halves of the same condition. `Channel::Whatsapp#should_auto_setup_webhooks?` sets the override for `whatsapp_cloud` inboxes where `source != 'embedded_signup'` (i.e. manual ones), while `Whatsapp::WebhookTeardownService#should_teardown_webhook?` only cleared it when `source == 'embedded_signup'`. The two sets are disjoint, so manual inboxes were exactly the ones that set an override on create and never cleared it on destroy. Embedded-signup inboxes were unaffected because `EmbeddedSignupService` calls `setup_webhooks` explicitly. Dropping the `source` check from the teardown guard is the whole fix. Manual `whatsapp_cloud` channels can't persist without `api_key`, `phone_number_id` and `business_account_id` (`validate_provider_config` verifies all three against Meta), so the remaining presence guards and both API calls have everything they need. The WABA-level `DELETE /subscribed_apps` now also fires for manual inboxes when the last one on a WABA is removed, which is symmetric with manual setup subscribing the app in the first place; the token only unsubscribes the app it belongs to, so a customer's separate app subscription is untouched. This fixes the leak going forward. Numbers already stranded still need the override cleared with the customer's own token, since we no longer hold their `api_key` once the inbox is deleted. ## How to reproduce 1. Create a WhatsApp Cloud inbox using manual API keys (not embedded signup). 2. Confirm the override is set: `GET /v22.0/{phone_number_id}?fields=webhook_configuration` shows `phone_number` pointing at your Chatwoot install. 3. Delete the inbox. 4. Before this change, the override still points at Chatwoot. After it, `webhook_configuration` no longer carries the phone-level override and events fall back to the WABA/app-level subscription. --------- Co-authored-by: Muhsin Keloth --- .../whatsapp/webhook_teardown_service.rb | 6 ++-- .../whatsapp/webhook_teardown_service_spec.rb | 31 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/services/whatsapp/webhook_teardown_service.rb b/app/services/whatsapp/webhook_teardown_service.rb index 948d84f04..de794f8e3 100644 --- a/app/services/whatsapp/webhook_teardown_service.rb +++ b/app/services/whatsapp/webhook_teardown_service.rb @@ -23,7 +23,6 @@ class Whatsapp::WebhookTeardownService def should_teardown_webhook? @channel.provider == 'whatsapp_cloud' && - provider_config['source'] == 'embedded_signup' && provider_config['api_key'].present? && (provider_config['phone_number_id'].present? || provider_config['business_account_id'].present?) end @@ -38,8 +37,11 @@ class Whatsapp::WebhookTeardownService Rails.logger.error "[WHATSAPP] Phone-level webhook clear failed for channel #{@channel.id}: #{e.message}" end - # The app subscription is shared by every inbox on the WABA, so only unsubscribe when this is the last one. + # Embedded signup only — a manual token's subscribed app is the customer's, not ours to unsubscribe. + # The subscription is shared across the WABA, so only unsubscribe when this is the last inbox. def unsubscribe_app_if_last_inbox(api_client) + return unless provider_config['source'] == 'embedded_signup' + waba_id = provider_config['business_account_id'] return if waba_id.blank? return if waba_sibling_exists?(waba_id) diff --git a/spec/services/whatsapp/webhook_teardown_service_spec.rb b/spec/services/whatsapp/webhook_teardown_service_spec.rb index be94f3c44..a5bdeef0b 100644 --- a/spec/services/whatsapp/webhook_teardown_service_spec.rb +++ b/spec/services/whatsapp/webhook_teardown_service_spec.rb @@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do end end - context 'when channel is whatsapp_cloud but not embedded_signup' do + context 'when channel is whatsapp_cloud with manual setup' do before do + allow(channel).to receive(:setup_webhooks).and_return(true) + channel.update!( provider: 'whatsapp_cloud', - provider_config: { 'source' => 'manual' } + provider_config: { + 'source' => 'manual', + 'phone_number_id' => 'manual_phone_id', + 'business_account_id' => 'manual_waba_id', + 'api_key' => 'manual_api_key' + } ) end - it 'does not attempt to unsubscribe webhook' do - expect(Whatsapp::FacebookApiClient).not_to receive(:new) + it 'clears the phone number callback override' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id') service.perform + + expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id') + end + + # The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove. + it 'does not unsubscribe the app from the WABA' do + api_client = instance_double(Whatsapp::FacebookApiClient) + allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client) + allow(api_client).to receive(:clear_phone_number_callback_override) + allow(api_client).to receive(:unsubscribe_app_from_waba) + + service.perform + + expect(api_client).not_to have_received(:unsubscribe_app_from_waba) end end From 2ac55c8728747ba655a0cb2d8d1aee8a0578a70f Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:38:43 +0530 Subject: [PATCH 04/23] fix(captain): honor mandatory handoff guidelines (#15003) Ensures Captain follows explicit mandatory-transfer rules from active Response Guidelines and Guardrails instead of allowing the generic consent-first fallback to override those rules. ## What changed - Made explicit transfer requirements take precedence over generic consent-first handoff defaults only when their condition matches. - Added explicit Response Guideline and Guardrail transfer rules to the human-handoff protocol. - Added focused prompt regression coverage. ## How to reproduce Configure a Response Guideline or Guardrail that requires immediate transfer for a specific condition, then send a request matching that condition. Captain should invoke the human-handoff path without asking the user to consent again. Unmatched requests continue to use the existing consent-first fallback. The assistant prompt renderer, agent prompt context, and focused regression specs pass locally. --- enterprise/lib/captain/prompts/assistant.liquid | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/enterprise/lib/captain/prompts/assistant.liquid b/enterprise/lib/captain/prompts/assistant.liquid index 821d9d472..a8f1dada3 100644 --- a/enterprise/lib/captain/prompts/assistant.liquid +++ b/enterprise/lib/captain/prompts/assistant.liquid @@ -48,6 +48,8 @@ Always respect these boundaries: {% endfor %} {% endif -%} +When a Response Guideline or Guardrail explicitly requires transfer for a matched condition, follow it instead of the generic consent-first handoff defaults below. + # Decision Framework ## 1. Analyze the Request @@ -88,7 +90,8 @@ Handle the request yourself in the following way Transfer to a human agent when: - User explicitly requests human assistance - User accepts an offer to speak with a human +- A Response Guideline or Guardrail explicitly requires transfer for the matched condition - The issue requires specialized knowledge or permissions you don't have - Multiple attempts to help have been unsuccessful -If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance or accepts your offer to speak with a human. When using the tool, provide a clear reason that helps the human agent understand the context. +If you cannot find needed information after checking the available information and clarifying context, ask whether the user wants to talk to another support agent. Use the `captain--tools--handoff` tool only after the user explicitly requests human assistance, accepts your offer to speak with a human, or a Response Guideline or Guardrail explicitly requires transfer for the matched condition. When using the tool, provide a clear reason that helps the human agent understand the context. From 8b4f3e226e7b597434aa8ca4db12ccd4ffea3e3d Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 15 Jul 2026 01:59:41 +0400 Subject: [PATCH 05/23] revert: "fix(meta): disable Instagram replies on Cloud during restriction" (#15020) Reverts chatwoot/chatwoot#15005 --- .../widgets/conversation/ReplyBox.vue | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index bd72d45f3..471d10f3c 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -146,7 +146,6 @@ export default { currentUser: 'getCurrentUser', lastEmail: 'getLastEmailInSelectedChat', globalConfig: 'globalConfig/get', - isOnChatwootCloud: 'globalConfig/isOnChatwootCloud', }), currentContact() { const senderId = this.currentChat?.meta?.sender?.id; @@ -174,9 +173,6 @@ export default { return this.isATwilioWhatsAppChannel && !this.isPrivate; }, isPrivate() { - if (this.isInstagramReplyRestricted) { - return true; - } if ( this.currentChat.can_reply || this.isAWhatsAppChannel || @@ -201,16 +197,10 @@ export default { ); return !!stripped.trim(); }, - // Instagram replies are disabled on Chatwoot Cloud during the temporary - // Meta platform restriction; private notes remain available. - isInstagramReplyRestricted() { - return this.isOnChatwootCloud && this.isAnInstagramChannel; - }, isReplyRestricted() { return ( - this.isInstagramReplyRestricted || - (!this.currentChat?.can_reply && - !(this.isAWhatsAppChannel || this.isAPIInbox)) + !this.currentChat?.can_reply && + !(this.isAWhatsAppChannel || this.isAPIInbox) ); }, inboxId() { @@ -480,10 +470,7 @@ export default { return; } - if ( - !this.isInstagramReplyRestricted && - (canReply || this.isAWhatsAppChannel || this.isAPIInbox) - ) { + if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) { this.replyType = REPLY_EDITOR_MODES.REPLY; } else { this.replyType = REPLY_EDITOR_MODES.NOTE; @@ -950,10 +937,7 @@ export default { this.$store.dispatch('draftMessages/setReplyEditorMode', { mode, }); - if ( - !this.isInstagramReplyRestricted && - (canReply || this.isAWhatsAppChannel || this.isAPIInbox) - ) + if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) this.replyType = mode; if (this.isRecordingAudio) { this.toggleAudioRecorder(); From 13db36609d77ebbbbe13b180f7cd65d3de31c228 Mon Sep 17 00:00:00 2001 From: Gaurav Singhal Date: Tue, 14 Jul 2026 20:00:39 -0700 Subject: [PATCH 06/23] fix: hide agent bot access tokens from agents (#14830) ## Summary Keeps Agent Bot list and show access available to agents while restricting account bot access tokens to administrators. ## Why Agents need Agent Bot metadata for existing product workflows, but the bot access token can be replayed against bot-authorized APIs and should not be exposed to them. ## What changed - serialize `access_token` only for administrators - verify agents can read Agent Bot metadata without receiving the token - verify administrators still receive the token from index and show responses ## Validation `bundle exec rspec spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb` 27 examples, 0 failures. Related follow-up: [CW-7595](https://linear.app/chatwoot/issue/CW-7595/standardize-one-time-credential-disclosure-across-chatwoot-apis) --------- Co-authored-by: Gaurav Singhal Co-authored-by: Sojan Jose --- .../api/v1/models/_agent_bot.json.jbuilder | 2 +- .../v1/accounts/agent_bots_controller_spec.rb | 30 ++++++++++++++++--- 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/app/views/api/v1/models/_agent_bot.json.jbuilder b/app/views/api/v1/models/_agent_bot.json.jbuilder index d5dbc91b4..2137ca107 100644 --- a/app/views/api/v1/models/_agent_bot.json.jbuilder +++ b/app/views/api/v1/models/_agent_bot.json.jbuilder @@ -6,6 +6,6 @@ json.outgoing_url resource.outgoing_url unless resource.system_bot? json.bot_type resource.bot_type json.bot_config resource.bot_config json.account_id resource.account_id -json.access_token resource.access_token if resource.access_token.present? +json.access_token resource.access_token if resource.access_token.present? && Current.account_user&.administrator? json.secret resource.secret if !resource.system_bot? && Current.account_user&.administrator? json.system_bot resource.system_bot? diff --git a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb index 61fcf30ac..a98f787e0 100644 --- a/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/agent_bots_controller_spec.rb @@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do end end - context 'when it is an authenticated user' do + context 'when it is an authenticated agent' do it 'returns all the agent_bots in account along with global agent bots' do global_bot = create(:agent_bot) get "/api/v1/accounts/#{account.id}/agent_bots", @@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(agent_bot.name) expect(response.body).to include(global_bot.name) - expect(response.body).to include(agent_bot.access_token.token) + expect(response.body).not_to include(agent_bot.access_token.token) expect(response.body).not_to include(global_bot.access_token.token) end @@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do expect(account_bot_response).to include('thumbnail') end end + + context 'when it is an authenticated administrator' do + it 'returns the account bot access token' do + get "/api/v1/accounts/#{account.id}/agent_bots", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include(agent_bot.access_token.token) + end + end end describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do @@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do end end - context 'when it is an authenticated user' do + context 'when it is an authenticated agent' do it 'shows the agent bot' do get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}", headers: agent.create_new_auth_token, @@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response).to have_http_status(:success) expect(response.body).to include(agent_bot.name) - expect(response.body).to include(agent_bot.access_token.token) + expect(response.body).not_to include(agent_bot.access_token.token) end it 'will show a global agent bot' do @@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do expect(response.parsed_body).not_to include('outgoing_url') end end + + context 'when it is an authenticated administrator' do + it 'returns the account bot access token' do + get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}", + headers: admin.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:success) + expect(response.body).to include(agent_bot.access_token.token) + end + end end describe 'POST /api/v1/accounts/{account.id}/agent_bots' do From 49c442751d9c919bf72e65e745dec2955450f61c Mon Sep 17 00:00:00 2001 From: Gaurav Singhal Date: Tue, 14 Jul 2026 23:02:13 -0700 Subject: [PATCH 07/23] fix: require admin for dashboard app mutations (#14831) ## Summary Restricts account-wide Dashboard App creation, updates, and deletion to administrators while keeping read access available to authenticated account users. ## Why Dashboard Apps are account-level integrations displayed in conversation views. Agents should be able to use them, but only administrators should be able to change their configuration. ## What changed - authorize Dashboard App actions through `DashboardAppPolicy` - allow index and show access for authenticated account users - restrict create, update, and destroy actions to administrators - add request coverage for administrator and agent mutation behavior ## Validation `bundle exec rspec spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb` 17 examples, 0 failures. `bundle exec rubocop app/controllers/api/v1/accounts/dashboard_apps_controller.rb app/policies/dashboard_app_policy.rb` 2 files inspected, no offenses detected. --------- Co-authored-by: Gaurav Singhal Co-authored-by: Sojan Jose --- .../v1/accounts/dashboard_apps_controller.rb | 1 + app/policies/dashboard_app_policy.rb | 21 ++++++++ .../dashboard_apps_controller_spec.rb | 50 +++++++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 app/policies/dashboard_app_policy.rb diff --git a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb index a8d7ebcb9..4226db1cc 100644 --- a/app/controllers/api/v1/accounts/dashboard_apps_controller.rb +++ b/app/controllers/api/v1/accounts/dashboard_apps_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::DashboardAppsController < Api::V1::Accounts::BaseController + before_action :check_authorization before_action :fetch_dashboard_apps, except: [:create] before_action :fetch_dashboard_app, only: [:show, :update, :destroy] diff --git a/app/policies/dashboard_app_policy.rb b/app/policies/dashboard_app_policy.rb new file mode 100644 index 000000000..af7bec82a --- /dev/null +++ b/app/policies/dashboard_app_policy.rb @@ -0,0 +1,21 @@ +class DashboardAppPolicy < ApplicationPolicy + def index? + true + end + + def show? + true + end + + def create? + @account_user.administrator? + end + + def update? + @account_user.administrator? + end + + def destroy? + @account_user.administrator? + end +end diff --git a/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb b/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb index 100f914bb..820010a62 100644 --- a/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/dashboard_apps_controller_spec.rb @@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do end end - context 'when it is an authenticated user' do - let(:user) { create(:user, account: account) } + context 'when it is an authenticated administrator' do + let(:user) { create(:user, account: account, role: :administrator) } it 'creates the dashboard app' do expect do @@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(response).to have_http_status(:unprocessable_entity) end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not create account-wide dashboard apps' do + expect do + post "/api/v1/accounts/#{account.id}/dashboard_apps", + headers: agent.create_new_auth_token, + params: payload, + as: :json + end.not_to change(DashboardApp, :count) + + expect(response).to have_http_status(:unauthorized) + end + end end describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } } - let(:user) { create(:user, account: account) } + let(:user) { create(:user, account: account, role: :administrator) } let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) } context 'when it is an unauthenticated user' do @@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type] end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not update account-wide dashboard apps' do + patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}", + headers: agent.create_new_auth_token, + params: payload, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(dashboard_app.reload.title).not_to eq('CRM Dashboard') + end + end end describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do - let(:user) { create(:user, account: account) } + let(:user) { create(:user, account: account, role: :administrator) } let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) } context 'when it is an unauthenticated user' do @@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do expect(user.dashboard_apps.count).to be 0 end end + + context 'when it is an authenticated agent' do + let(:agent) { create(:user, account: account, role: :agent) } + + it 'does not delete account-wide dashboard apps' do + delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}", + headers: agent.create_new_auth_token, + as: :json + + expect(response).to have_http_status(:unauthorized) + expect(DashboardApp.exists?(dashboard_app.id)).to be(true) + end + end end end From fd625981e96aadc58d0a7991a4ba2bd3e1225106 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:35:57 +0530 Subject: [PATCH 08/23] fix(captain): keep custom tools available in Captain V2 (#15015) Captain V2 assistants can now use every enabled custom tool from their account through the main assistant. The change keeps existing custom tool access when an assistant has no migrated scenarios, so switching from V1 does not remove the capability without warning. ## How to reproduce 1. Create and enable an account custom tool. 2. Use an assistant with no custom instructions and no generated scenarios. 3. Enable Captain V2 for the account. 4. Before this change, the main assistant receives only FAQ lookup and handoff. After this change, it also receives the enabled account custom tool. ## What changed The main V2 assistant now loads enabled custom tools through its account association. Scenario agents still load only the tools named in their scenario instructions. The account custom tool limit keeps the added tool count bounded. Focused model coverage verifies enabled tools, disabled tools, account isolation, FAQ lookup, and handoff. Existing V1 assistant, V2 scenario, and V2 runner coverage passes. RuboCop passes. --- enterprise/app/models/captain/assistant.rb | 3 +- .../models/captain/assistant_spec.rb | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 spec/enterprise/models/captain/assistant_spec.rb diff --git a/enterprise/app/models/captain/assistant.rb b/enterprise/app/models/captain/assistant.rb index bf4691e2c..dc0969cd4 100644 --- a/enterprise/app/models/captain/assistant.rb +++ b/enterprise/app/models/captain/assistant.rb @@ -98,7 +98,8 @@ class Captain::Assistant < ApplicationRecord def agent_tools [ self.class.resolve_tool_class('faq_lookup').new(self), - self.class.resolve_tool_class('handoff').new(self) + self.class.resolve_tool_class('handoff').new(self), + *account.captain_custom_tools.enabled.map { |custom_tool| custom_tool.tool(self) } ] end diff --git a/spec/enterprise/models/captain/assistant_spec.rb b/spec/enterprise/models/captain/assistant_spec.rb new file mode 100644 index 000000000..e282124ae --- /dev/null +++ b/spec/enterprise/models/captain/assistant_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe Captain::Assistant do + describe '#agent_tools' do + let(:account) { create(:account) } + let(:assistant) { create(:captain_assistant, account: account) } + + it 'includes enabled custom tools from the assistant account' do + custom_tool = create(:captain_custom_tool, account: account) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).to include(custom_tool.slug) + expect(tools.find { |tool| tool.name == custom_tool.slug }).to be_a(Captain::Tools::HttpTool) + end + + it 'excludes disabled custom tools' do + custom_tool = create(:captain_custom_tool, :disabled, account: account) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).not_to include(custom_tool.slug) + end + + it 'excludes custom tools from other accounts' do + custom_tool = create(:captain_custom_tool) + + tools = assistant.send(:agent_tools) + + expect(tools.map(&:name)).not_to include(custom_tool.slug) + end + + it 'keeps the built-in FAQ lookup and handoff tools' do + tools = assistant.send(:agent_tools) + + expect(tools).to include( + an_instance_of(Captain::Tools::FaqLookupTool), + an_instance_of(Captain::Tools::HandoffTool) + ) + end + end +end From 6d38b4d39ccc84a93d82f59ca8af9778215cf5e9 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:51:51 +0530 Subject: [PATCH 09/23] fix(captain): improve complex migration instructions (#15002) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves Captain V1 → V2 migration for complex legacy instructions so mandatory triggers, workflows, language rules, and escalation behavior remain active while query-dependent product knowledge is prepared as pending FAQ candidates. ## What changed - Added explicit preservation rules for mandatory triggers, verification steps, escalation conditions, exceptions, and language behavior. - Added an auditor that checks the draft and fixes any issues before manual review. - Kept the existing migration application contract and schema limits unchanged - Added focused regression coverage for the complex-prompt classifier contract. ## How to reproduce Generate a migration draft for an assistant with dense legacy instructions containing mandatory handoff triggers, verification rules, product facts, and multi-step workflows. The resulting draft should keep actions active, place query-dependent facts in FAQ candidates, and avoid silently dropping or reversing source requirements. Focused Captain migration specs and RuboCop checks pass locally. --- .../assistant_migration/draft_applier.rb | 25 +- .../assistant_migration/faq_applier.rb | 36 +++ .../instruction_auditor.rb | 48 ++++ .../instruction_auditor_schema.rb | 52 +++++ .../instruction_classifier.rb | 59 ++++- .../instruction_classifier_schema.rb | 6 +- .../prompts/instruction_auditor.liquid | 69 ++++++ .../prompts/instruction_classifier.liquid | 213 ++++++++---------- .../assistant_migration/draft_applier_spec.rb | 65 +++++- .../instruction_classifier_spec.rb | 88 ++++++++ 10 files changed, 523 insertions(+), 138 deletions(-) create mode 100644 enterprise/app/services/captain/assistant_migration/faq_applier.rb create mode 100644 enterprise/app/services/captain/assistant_migration/instruction_auditor.rb create mode 100644 enterprise/app/services/captain/assistant_migration/instruction_auditor_schema.rb create mode 100644 enterprise/lib/captain/prompts/instruction_auditor.liquid create mode 100644 spec/enterprise/services/captain/assistant_migration/instruction_classifier_spec.rb diff --git a/enterprise/app/services/captain/assistant_migration/draft_applier.rb b/enterprise/app/services/captain/assistant_migration/draft_applier.rb index df03624b4..e59acc205 100644 --- a/enterprise/app/services/captain/assistant_migration/draft_applier.rb +++ b/enterprise/app/services/captain/assistant_migration/draft_applier.rb @@ -24,13 +24,15 @@ class Captain::AssistantMigration::DraftApplier description: description_change, response_guidelines: array_change(:response_guidelines, response_guidelines), guardrails: array_change(:guardrails, guardrails), - config: config_change + config: config_change, + faq_responses: faq_responses_change }.compact end def apply_changes(changes) assistant.transaction do assistant.update!(assistant_update_attributes(changes)) if assistant_update_attributes(changes).present? + apply_faq_response_changes(changes[:faq_responses]) if changes[:faq_responses].present? end end @@ -60,11 +62,11 @@ class Captain::AssistantMigration::DraftApplier end def response_guidelines - (item_values(:response_guidelines) + scenario_response_guidelines).uniq + (Array(assistant.response_guidelines) + item_values(:response_guidelines) + scenario_response_guidelines).uniq end def guardrails - item_values(:guardrails) + (Array(assistant.guardrails) + item_values(:guardrails)).uniq end def array_change(field, values) @@ -144,6 +146,21 @@ class Captain::AssistantMigration::DraftApplier scenario_candidates.filter_map { |candidate| candidate[:response_guideline].presence } end + def faq_responses_change + faq_applier.changes + end + + def apply_faq_response_changes(changes) + faq_applier.apply(changes) + end + + def faq_applier + @faq_applier ||= Captain::AssistantMigration::FaqApplier.new( + assistant: assistant, + candidates: normalized_faq_document_candidates + ) + end + def scenario_tool_ids(tool_ids) Array(tool_ids).filter_map { |tool_id| tool_id.to_s.squish.presence }.uniq end @@ -186,7 +203,7 @@ class Captain::AssistantMigration::DraftApplier candidate = candidate.deep_symbolize_keys question = candidate[:question].to_s.squish - answer = candidate[:answer].to_s.squish + answer = candidate[:answer].to_s.strip raise ArgumentError, 'FAQ document candidates must include a question and answer' if question.blank? || answer.blank? { 'question' => question, 'answer' => answer } diff --git a/enterprise/app/services/captain/assistant_migration/faq_applier.rb b/enterprise/app/services/captain/assistant_migration/faq_applier.rb new file mode 100644 index 000000000..dcd526a5a --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/faq_applier.rb @@ -0,0 +1,36 @@ +class Captain::AssistantMigration::FaqApplier + pattr_initialize [:assistant!, :candidates!] + + def changes + @changes ||= candidates.each_with_object({ create: [] }) do |candidate, result| + categorize(candidate, result) + end.compact_blank.presence + end + + def apply(changes) + Array(changes[:create]).each do |candidate| + assistant.responses.create!(candidate.slice('question', 'answer', 'status')) + end + end + + private + + def categorize(candidate, result) + existing_answers = assistant.responses.approved.where(question: candidate['question']).pluck(:answer) + planned_answers = result[:create].filter_map do |response| + response['answer'] if response['question'] == candidate['question'] + end + answers = existing_answers + planned_answers + + ensure_no_conflict!(candidate, answers) + return if answers.include?(candidate['answer']) + + result[:create] << candidate.merge('status' => 'approved') + end + + def ensure_no_conflict!(candidate, answers) + return if answers.all?(candidate['answer']) + + raise ArgumentError, "FAQ candidate conflicts with an existing FAQ: #{candidate['question']}" + end +end diff --git a/enterprise/app/services/captain/assistant_migration/instruction_auditor.rb b/enterprise/app/services/captain/assistant_migration/instruction_auditor.rb new file mode 100644 index 000000000..023ab741f --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_auditor.rb @@ -0,0 +1,48 @@ +class Captain::AssistantMigration::InstructionAuditor < Captain::BaseTaskService + AUDITOR_MODEL = 'gpt-5.2'.freeze + pattr_initialize [:assistant!, :source_payload!, :draft!, :available_additions!] + + def perform + make_api_call( + model: AUDITOR_MODEL, + messages: messages, + schema: Captain::AssistantMigration::InstructionAuditorSchema.for(available_additions) + ) + end + + private + + def account + assistant.account + end + + def messages + [ + { role: 'system', content: system_prompt }, + { + role: 'user', + content: JSON.pretty_generate(source: source_payload, generated_draft: draft, available_additions: available_additions) + } + ] + end + + def system_prompt + Captain::PromptRenderer.render('instruction_auditor') + end + + def event_name + 'assistant_migration_instruction_auditor' + end + + def captain_tasks_enabled? + true + end + + def counts_toward_usage? + false + end + + def build_follow_up_context? + false + end +end diff --git a/enterprise/app/services/captain/assistant_migration/instruction_auditor_schema.rb b/enterprise/app/services/captain/assistant_migration/instruction_auditor_schema.rb new file mode 100644 index 000000000..78230ba29 --- /dev/null +++ b/enterprise/app/services/captain/assistant_migration/instruction_auditor_schema.rb @@ -0,0 +1,52 @@ +class Captain::AssistantMigration::InstructionAuditorSchema < RubyLLM::Schema + STRING_ARRAYS = { + response_guidelines: ['Missing active behavior to append to the generated response guidelines.', 10], + guardrails: ['Missing active boundaries or prohibitions to append to the generated guardrails.', 10], + needs_review: ['Missing source behavior blocked by an unavailable tool or runtime capability.', 10] + }.freeze + + def self.for(available_additions) + Class.new(RubyLLM::Schema).tap do |schema| + add_string_arrays(schema, available_additions) + add_scenarios(schema, available_additions[:scenario_candidates]) + add_faqs(schema, available_additions[:faq_document_candidates]) + end + end + + def self.add_string_arrays(schema, available_additions) + STRING_ARRAYS.each do |name, (description, limit)| + next unless available_additions[name].positive? + + schema.array(name, description: description, max_items: [available_additions[name], limit].min, of: :string) + end + end + + def self.add_scenarios(schema, available) + return unless available.positive? + + schema.array :scenario_candidates, + description: 'Missing distinct multi-step workflows to append to the generated scenario candidates.', + max_items: [available, 5].min do + object do + string :title, max_length: 80 + string :description, max_length: 500 + string :instruction, max_length: 2000 + string :response_guideline, max_length: 1000 + array :tool_ids, max_items: 10, of: :string + end + end + end + + def self.add_faqs(schema, available) + return unless available.positive? + + schema.array :faq_document_candidates, + description: 'Missing factual product or business knowledge to append to the pending FAQ candidates.', + max_items: [available, 15].min do + object do + string :question, max_length: 255 + string :answer, max_length: 2000 + end + end + end +end diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb index 989989855..6efaf54cd 100644 --- a/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier.rb @@ -6,15 +6,26 @@ class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskServ pattr_initialize [:assistant!] def perform - response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA) - return error_response(response) if response[:error] + classifier_response = make_api_call(model: CLASSIFIER_MODEL, messages: messages, schema: RESPONSE_SCHEMA) + return error_response(classifier_response) if classifier_response[:error] + + generated_draft = normalized_payload(classifier_response[:message]) + auditor_response = Captain::AssistantMigration::InstructionAuditor.new( + assistant: assistant, + source_payload: assistant_payload, + draft: generated_draft, + available_additions: available_additions(generated_draft) + ).perform + return error_response(auditor_response) if auditor_response[:error] { assistant: assistant_metadata, - draft: normalized_payload(response[:message]), - usage: response[:usage], - request_messages: response[:request_messages] + draft: audited_payload(generated_draft, auditor_response[:message]), + usage: combined_usage(classifier_response, auditor_response), + request_messages: classifier_response[:request_messages] } + rescue ArgumentError => e + error_response(error: e.message, request_messages: auditor_response&.dig(:request_messages)) end private @@ -101,15 +112,49 @@ class Captain::AssistantMigration::InstructionClassifier < Captain::BaseTaskServ scenario_candidates: [], conversation_messages: {}, faq_document_candidates: [], - needs_review: [], - classification_notes: [] + needs_review: [] ) end + def combined_usage(*responses) + %w[prompt_tokens completion_tokens total_tokens].index_with do |key| + responses.sum { |response| response.dig(:usage, key).to_i } + end + end + + def available_additions(draft) + { + response_guidelines: 20 - draft[:response_guidelines].length, + guardrails: 20 - draft[:guardrails].length, + scenario_candidates: 15 - draft[:scenario_candidates].length, + faq_document_candidates: 25 - draft[:faq_document_candidates].length, + needs_review: 20 - draft[:needs_review].length + } + end + + def audited_payload(generated_draft, audit_message) + audit = audit_message.is_a?(Hash) ? audit_message.deep_symbolize_keys : {} + generated_draft.merge( + response_guidelines: merged_items(generated_draft, audit, :response_guidelines, 20), + guardrails: merged_items(generated_draft, audit, :guardrails, 20), + scenario_candidates: merged_items(generated_draft, audit, :scenario_candidates, 15), + faq_document_candidates: merged_items(generated_draft, audit, :faq_document_candidates, 25), + needs_review: merged_items(generated_draft, audit, :needs_review, 20) + ) + end + + def merged_items(generated_draft, audit, key, limit) + items = (Array(generated_draft[key]) + Array(audit[key])).uniq + raise ArgumentError, "Audited #{key} exceeds #{limit} items" if items.length > limit + + items + end + def assistant_metadata # rubocop:disable Metrics/AbcSize { id: assistant.id, name: assistant.name, + description: assistant.description.to_s, account_id: assistant.account_id, account_name: assistant.account.name, inbox_count: assistant.captain_inboxes.size, diff --git a/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb index 3e42bb49d..abbee3779 100644 --- a/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb +++ b/enterprise/app/services/captain/assistant_migration/instruction_classifier_schema.rb @@ -68,8 +68,8 @@ class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema end array :faq_document_candidates, - description: 'Pending FAQ candidates for factual or product-specific knowledge such as pricing, policy, setup, troubleshooting, ' \ - 'or operational details. These candidates remain inactive until reviewed and approved.', + description: 'FAQ candidates for reusable query-dependent facts such as pricing, policy, setup, troubleshooting, ' \ + 'or operational details.', max_items: 25 do object do string :question, @@ -86,6 +86,4 @@ class Captain::AssistantMigration::InstructionClassifierSchema < RubyLLM::Schema description: 'Unclear, conflicting, risky, duplicated, or uncertain content that needs human review. ' \ 'Include the reason in the item text.', max_items: 20 - - array :classification_notes, description: 'Short notes about important migration decisions or risks.', max_items: 10, of: :string end diff --git a/enterprise/lib/captain/prompts/instruction_auditor.liquid b/enterprise/lib/captain/prompts/instruction_auditor.liquid new file mode 100644 index 000000000..027b0a978 --- /dev/null +++ b/enterprise/lib/captain/prompts/instruction_auditor.liquid @@ -0,0 +1,69 @@ +You are the second and final content-coverage pass for a Captain V1-to-V2 assistant migration. + +The input contains the original source data and an already structured generated_draft. Return only missing items to append to that draft, +matching the provided audit schema. Empty arrays mean no addition is needed. Do not return a complete draft, critique, verdict, wrapper, +coverage report, or fields outside the schema. + +## Contract + +- This is a monotonic coverage audit. Never repeat, rewrite, replace, or delete content already present in generated_draft. +- Only source.instructions contains the legacy custom instructions being migrated. Other source fields are existing runtime context. +- Existing response guidelines, guardrails, scenarios, and configured welcome/handoff/resolution messages remain active and are preserved. +- Use only information in the input. Never add plausible facts, steps, links, tools, triggers, or policies. +- Preserve the source language and exact names, trigger values, thresholds, exceptions, links, prices, dates, and ordering requirements. +- Consolidate related missing requirements into complete standalone additions. Schema limits are ceilings, not targets. +- available_additions gives the exact remaining capacity for each destination. Never return more additions than that capacity, and never + return a field omitted from the response schema. +- Treat semantically equivalent content as already covered even when wording differs. Do not add stylistic restatements or stronger versions + of behavior that is already present. If an existing array is near its maximum, add only unquestionably missing source requirements and + combine related missing requirements into one complete addition. + +## Coverage Audit + +Review source.instructions clause by clause against all fields in generated_draft. + +1. Missing Active Behavior + - Add every source-required action, prohibition, language rule, verification, trigger, exception, ordering rule, escalation condition, + or workflow that is not already active in generated response_guidelines, guardrails, or scenario response_guidelines. + - Words such as always, immediately, never, only, before, after, unless, and except are mandatory. + - FAQ question and answer text is active factual knowledge, but it does not preserve mandatory behavior. + If mandatory behavior appears only there, add the missing active guideline or guardrail. needs_review is inactive. + - Keep the minimum factual trigger, threshold, allowlist, or exception needed to execute the action or enforce the prohibition. + - When factual policy contains a mandatory boundary, add the boundary as an active guardrail while leaving the full policy in FAQ. + Examples include never promising refunds outside a stated window and never recommending cooking a product that must remain raw. + - A conditional response procedure remains active behavior. For example, acknowledging a known problem and explaining that the team is + working on it is active; the current known-problem status itself is factual FAQ knowledge. + +2. Missing FAQ Knowledge + - Add reusable query-dependent facts absent from faq_document_candidates: prices, limits, locations, product capabilities, exact links, + policies, setup steps, troubleshooting knowledge, schedules, and operational details. + - “If asked, tell/inform/explain/send” is a factual answer, not a separate active workflow, unless it also requires another action or + imposes a prohibition. + - Questions must concern the product or business. Answers must not contain tool use, routing, escalation, internal workflows, or + assistant-behavior instructions. + - Do not add FAQs for missing placeholders, generic assistant capabilities, or facts already covered by an existing candidate. + +3. Missing Scenario Candidates + - Add a scenario only when a source-defined multi-step intake, qualification, troubleshooting, booking, recommendation, lead-capture, + or fulfillment workflow is absent from both scenario candidates and equivalent active handling. + - Do not add scenarios for tone, factual answers, simple handoff triggers, or one-step clarification. + - Every added scenario needs a complete same-language response_guideline under 1,000 characters and only supplied tool IDs. + +4. Missing Review Notes + - Add needs_review only when a source-defined behavior or workflow cannot run because a required named tool or runtime signal is unavailable. + - Do not require words such as “must” or “always”; preserve any unavailable customer-facing workflow for review. + - Name the missing capability and the affected source behavior precisely. Relevant gaps include historical-record lookup, timers or inactivity + detection, business-hours detection, and live-agent availability. + - A needs_review item never replaces representable behavior. Add every source-faithful action or boundary that can remain active, and add a + review note only for the portion blocked by the unavailable capability. + - Do not add review notes for wording cleanup, configured conversation messages, missing fixed copy, general uncertainty, or behavior already + covered by the generated draft. + +## Final Check + +- No mandatory action or prohibition remains FAQ-only. +- No reusable factual knowledge is absent from FAQ candidates. +- No source-defined workflow blocked by an unavailable capability is omitted from needs_review. +- No addition duplicates content already active or pending. +- No unsupported behavior, fact, tool, link, or resolution is introduced. +- Return only the missing additions matching the audit schema. diff --git a/enterprise/lib/captain/prompts/instruction_classifier.liquid b/enterprise/lib/captain/prompts/instruction_classifier.liquid index abc58ff60..a1d66a28f 100644 --- a/enterprise/lib/captain/prompts/instruction_classifier.liquid +++ b/enterprise/lib/captain/prompts/instruction_classifier.liquid @@ -1,137 +1,114 @@ -You are migrating Captain assistant instructions into a structured configuration. +You are migrating a Captain V1 assistant into Captain V2. + +The original custom instructions remain stored unchanged. Your job is only to derive the V2 fields below: -Classify the existing assistant instructions into these sections: 1. Business/Product Context 2. Response Guidelines 3. Guardrails -4. Scenario Candidates +4. Scenario Candidates with flattened Response Guidelines 5. Conversation Messages -6. FAQs/Documents Candidates -7. Needs Review +6. FAQ Candidates +7. Needs Review Notes -## General Rules +## Core Rules -- Preserve behavior as closely as possible. -- Do not duplicate the same content across sections. -- Return clean migrated values only. Do not include source excerpts, source labels, citations, or "Source:" text in any migrated field. -- Do not rewrite customer-facing message copy unless necessary to classify an exact copy from instructions. -- Do not include confidence labels, review labels, bracketed reviewer comments, or schema labels inside migrated values. -- For Business/Product Context, Response Guidelines, and Guardrails, return each item as a plain standalone sentence. - Do not prefix items with numbers, bullets, section labels, or list markers such as "1.", "-", or "*". -- When several instructions share the same trigger, condition, or subject, combine them into one concise item instead - of repeating the same trigger across multiple items. Preserve every required action, prohibition, and routing - outcome from the source instruction when combining. -- If unsure, place content in Needs Review and include the reason in that item. -- Return data that matches the provided schema. +- Preserve every customer-facing behavior from the custom instructions. Do not invent, reverse, weaken, or silently omit requirements. +- Treat words such as always, immediately, never, only, before, after, unless, and except as mandatory. +- Preserve exact triggers, exceptions, ordering, verification steps, allowlists, escalation conditions, and outcomes. +- Schema limits are ceilings, not targets. Consolidate related requirements into complete standalone items. +- Prefer fewer complete items over one item per source sentence. Combine related tone, style, formatting, source, and escalation rules. + If response_guidelines or guardrails would reach its maximum item count, consolidate them and recheck that no source behavior was displaced. +- The custom instructions define behavior. The existing description, config messages, feature settings, and tools are runtime context. +- Do not copy existing config values into generated fields or create review work merely because an existing config field is present or absent. +- Use only information in the input. Return clean values without source labels, reviewer comments, confidence labels, or citations to the source prompt. +- Avoid duplicating content across fields, except for the minimal condition, threshold, or exception required to keep mandatory behavior active + while its supporting factual explanation is stored in a FAQ candidate. Scenario response guidelines are flattened automatically, so do not + also copy them into response_guidelines. ## Business/Product Context -- Business/Product Context maps to the root assistant description and is injected into the root orchestrator prompt. -- Return exactly one Business/Product Context item. -- Start with the existing assistant description and preserve its meaning. -- Enrich it only with relevant business or product context found in the custom instructions. -- Produce one coherent description rather than appending a second context block or repeating the existing description. -- Keep it at most 500 characters because that is the assistant description limit in the UI and model. -- Prefer roughly 300-450 characters when the source needs detail, leaving room below the hard limit. -- Finish the description cleanly. Never end mid-word, mid-clause, after an opening bracket, or with a dangling separator. -- Make it a compact summary of assistant identity, product scope, high-level mission, and high-level source or routing priorities. -- Do not include detailed workflows, step-by-step procedures, long support-scope inventories, attribute glossaries, - policy details, scenario-specific handling, tool instructions, or customer-facing message copy. +- Return exactly one coherent description of at most 500 characters. +- Preserve the existing description and enrich it only with identity, product scope, mission, and high-level business context. +- Do not put workflows, policies, response rules, factual inventories, or message copy in the description. +- Finish cleanly; never truncate a word, clause, or sentence. -## Conversation Messages +## Response Guidelines and Guardrails -- Existing welcome_message, handoff_message, and resolution_message config values are provided separately. -- Treat welcome_message, handoff_message, and resolution_message as conversation message config fields. -- Extract exact welcome, handoff, or resolution message copy from instructions into conversation_messages when present. -- Only classify handoff copy as conversation_messages.handoff_message when it is generic enough to reuse for any human handoff. -- If handoff copy is scenario-specific, keep it inside that scenario instruction; if it is only a rule about when or how to hand off, classify it as a Response Guideline or Guardrail. -- Do not extract a conversation message from an instruction about what to say, from a placeholder template, - from conditional copy, from role/team-specific copy, or from text that only applies inside one workflow. -- If a message contains placeholders such as a blank name, team name, bracketed variable, business-hours state, - or dynamic runtime condition, do not place it in conversation_messages. Keep it in the relevant workflow or - Needs Review. -- Do not copy message values from existing config into conversation_messages. -- Do not decide whether existing config values should be overwritten. Migration code handles applying extracted - conversation_messages only when the corresponding config value is blank. +- Response Guidelines are active behavior: tone, customer language, formatting, clarification, verification, information collection, + escalation actions, and any minimal factual condition required to perform them correctly. +- Guardrails are active boundaries: prohibitions, source restrictions, safety limits, refusal rules, mandatory transfer triggers, + and things the assistant must not do. +- A source rule that says to ask, collect, verify, compare, refuse, route, escalate, transfer, or follow steps must stay active + in Response Guidelines, Guardrails, or a flattened Scenario Guideline. A FAQ cannot implicitly preserve an action. +- Preserve exact behavioral trigger values when they control an action. For example, an error code that requires immediate + transfer belongs in an active guideline or guardrail. +- Do not emit contradictory language rules. An explicit instruction to reply in the customer's language overrides a descriptive + language label in the assistant description. +- Put query-dependent facts in FAQ candidates. Prices, limits, locations, feature availability, product capabilities, links, + policy answers, setup steps, and troubleshooting knowledge remain facts when phrased as "tell", "inform", "explain", or "send". +- Mandatory prohibitions are not FAQ-only. When a factual policy includes required or forbidden behavior, keep the prohibition active + with every condition, threshold, and exception needed to enforce it, and put the supporting policy explanation in a FAQ candidate. + For example, "never promise refunds after 30 days" remains an active guardrail with the 30-day threshold, while the refund policy + becomes a FAQ. Likewise, "never recommend cooking the product" remains an active guardrail while preparation guidance becomes a FAQ. +- Treat explicit policy boundaries such as "not guaranteed", "not allowed", "only available", or "only eligible" as behavioral + constraints even when the source states them as facts. Create an active guardrail that forbids promising or claiming an outcome + outside the stated condition, window, or exception, while keeping the complete policy in a FAQ candidate. +- Final test: move an item exclusively to FAQ candidates only when it answers a product question without requiring, forbidding, + or constraining assistant behavior. +- Factual values are allowed in active behavior when they select or constrain a required action or prohibition, such as error 5215 + requiring immediate transfer or a 30-day threshold after which the assistant must not promise a refund. +- When an action needs supporting facts, keep the action active and place the supporting facts in a FAQ candidate. + For example, actively require specialist-name verification and put the specialist roster in a FAQ candidate. +- Mandatory verification example: if the source provides a specialist roster and says to verify a name supplied by + the customer, output both (a) an active guideline requiring the name check and (b) a pending FAQ containing the roster. + The roster FAQ alone is incomplete because it does not tell the assistant to perform the check. +- When clarification depends on a fact, keep only the clarification/action in the guideline. Example: "clarify whether + they mean the legacy card or card deposits; transfer for deposit access" is active behavior, while the card's + discontinued status is FAQ knowledge. ## Scenario Candidates -- In the current architecture, a scenario becomes a specialized sub-agent with its own title, description, - instructions, and optional tools. -- During this migration, scenario candidates are also temporarily flattened into response guidelines so existing - assistant behavior is preserved before scenario records are created. -- For every scenario candidate, write a response_guideline that is the flattened version of that scenario for - the root assistant's response guidelines. -- The response_guideline must be in the same language as the original scenario or source instruction. -- The response_guideline must preserve the intended customer-visible behavior, trigger, information to collect, - and routing/escalation outcome. -- The response_guideline must not include tool syntax, tool:// links, markdown tool links, tool names, label - updates, priority updates, private-note instructions, custom-tool instructions, or internal implementation details. -- If the scenario uses internal tools such as labels, priorities, private notes, or custom tools, describe only - the customer-visible behavior and expected routing/escalation outcome in response_guideline. -- If human handoff is needed, describe it in natural language such as route/escalate/transfer to a human; do not - mention the handoff tool in response_guideline. -- Keep scenario titles, descriptions, instructions, and response_guidelines clear, self-contained, and reviewable. -- Only create scenario candidates for distinct user-intent workflows that should be routed to a specialized agent. - A candidate must be narrow enough to become a named specialist assistant with domain-specific handling instructions. -- Good scenario candidates include multi-step intake workflows, qualification flows, specialized troubleshooting - workflows, booking flows, lead-capture flows, recommendation flows, fulfillment workflows, or tool-use procedures - for a specific user intent. -- A scenario candidate should answer "yes" to this test: would a named specialist sub-agent improve handling - beyond the base assistant's global FAQ, guardrail, response-guideline, and human-handoff behavior? -- Do not create scenario candidates for global escalation rules, generic handoff policy, missing-information - behavior, source-boundary rules, refusal rules, tone, formatting, answer length, or one-step fallback behavior. -- Do not create scenario candidates whose main purpose is to escalate or hand off. "Identify the trigger, avoid - guessing, tell the user support will review, and hand off" is a guardrail/handoff boundary, not a scenario, - even though it contains multiple statements. -- Do create scenario candidates when the instructions define a concrete intake, qualification, troubleshooting, - booking, lead-capture, recommendation, or fulfillment workflow, even when the workflow eventually hands off - to a human. -- Do not create scenario candidates for simple routing triggers such as "user asks for a human", "immediately - hand off this category", or "route sales questions to the sales team" when there is no concrete workflow to run. -- Handoff behavior is a scenario candidate only when part of a larger intake, qualification, or specialized handling workflow. -- Global rules like "if not in docs, escalate", "ask one clarifying question", "do not answer account-specific - questions", or "tell the user support will review" belong in Guardrails or Response Guidelines, not Scenario Candidates. -- Broad buckets like "account-specific issue escalation", "unknown question escalation", "contact support", - "fallback to human", or "documentation unavailable" are not scenario candidates. +- Create a scenario candidate only for a distinct multi-step workflow that would genuinely benefit from a separate named specialist agent, + such as intake, qualification, troubleshooting, booking, recommendation, lead capture, or fulfillment. +- Do not create scenarios for tone, formatting, generic escalation, a simple handoff trigger, missing information, or a one-step factual answer. +- Do not create overlapping scenarios for the same intent, and do not create a scenario for a workflow the root assistant can handle with + one guideline plus FAQ lookup. +- Every scenario candidate must include a response_guideline in the source language. It must preserve the trigger, customer-visible + steps, information to collect, and escalation or completion outcome while omitting tool syntax and internal operations. +- Use a short, complete scenario title well below the schema limit; never truncate a word or phrase to make it fit. +- Scenario candidates remain pending metadata for later scenario creation. Their response_guideline is active immediately after apply. +- Use only tool IDs provided in available_agent_tools. Never invent or substitute a tool. -## Tool Use +## Conversation Messages -- If a scenario candidate requires tools, reference the available tool explicitly inside the scenario instruction - using markdown tool links such as [Handoff to Human](tool://handoff). -- Use only tool IDs listed in available_agent_tools. If a needed tool is unavailable or the workflow depends on - unavailable runtime data such as FAQ relevance scores or business-hours status, place it in Needs Review instead. -- Do not map an unavailable named tool to a different available tool. For example, do not treat FAQ Lookup as - Product Search, Order Status, website browsing, pricing lookup, agent availability, business-hours detection, - ticket creation, or custom-attribute assignment unless the instructions explicitly say that the available - tool provides that behavior. -- If a workflow cannot run without an unavailable tool or runtime signal, do not create a tool-backed scenario - for it. Preserve the instruction in Needs Review with the missing capability named. +- Extract only exact, globally reusable welcome, handoff, or resolution copy found in the custom instructions. +- Leave conditional, scenario-specific, placeholder-based, or merely suggested wording out of conversation_messages. +- Existing config messages remain active and are preserved. If source wording has the same intent, keep the existing config message. +- Migration applies extracted copy only when the corresponding existing config field is blank. -## FAQs/Documents Candidates +## FAQ Candidates -- Convert factual or product-specific knowledge into pending FAQ candidates with a natural customer question and a self-contained answer. -- FAQ candidates are review-stage data only. They are not active assistant knowledge until a human reviews and approves them. -- Use only facts stated in the existing instructions. Do not invent, generalize, update, or fill in missing details. -- Preserve exact prices, limits, dates, time zones, conditions, exceptions, product names, and operational details in the answer. -- Write each question as a standalone question a customer might naturally ask. Make it specific enough to retrieve the corresponding answer. -- Write each answer so it fully answers its question without relying on another FAQ candidate or surrounding context. -- Split unrelated facts into separate candidates. Keep related conditions and exceptions together when separating them would make an answer incomplete. -- Do not create FAQ candidates about what the assistant should say or do, how it should use sources or tools, when it should route or escalate, - or which exact message it should send. Classify those as Response Guidelines, Guardrails, Scenario Candidates, Conversation Messages, - or Needs Review as appropriate. -- FAQ questions must ask about the product or business, not about the assistant. Do not write questions such as "What should the assistant answer?", - "What should I say?", "Which source should the assistant use?", or "Which tool should be called?". -- FAQ answers must contain customer-facing knowledge, not instructions to call tools, inspect internal data, update records, transfer conversations, - or follow internal workflows. -- When factual sources conflict and the instructions do not explicitly establish which fact overrides the others, put the conflict in Needs Review - instead of creating an FAQ candidate. Use an explicitly stated override or superseding fact when one is present. -- Only factual or product-specific knowledge should become FAQs/Documents candidates. -- Generic capability statements such as "answer product questions", "help with billing", - "troubleshoot common issues", or "direct to documentation" are not FAQ/document candidates. - Put them in Business/Product Context or Response Guidelines when useful. -- Product facts, pricing, policies, setup steps, troubleshooting facts, support hours, emergency contacts, - and operational details should become pending FAQ candidates, not Response Guidelines or trusted approved knowledge. -- Do not create FAQ/document candidates for topic labels or unsupported capabilities when the factual content is - missing. Put "pricing details are needed", "same-day delivery schedule details are needed", or similar gaps in - Needs Review instead. +- Convert reusable query-dependent facts into natural customer questions with self-contained answers. +- Use only facts stated in the custom instructions. Preserve exact prices, limits, dates, links, conditions, exceptions, and product names. +- Keep related conditions together; split unrelated facts. Do not duplicate a full FAQ answer in active guidelines or guardrails; + repeat only the minimal condition, threshold, or exception required to enforce mandatory behavior. +- FAQ questions must be about the product or business, not about what the assistant should do. +- FAQ answers must not contain tool use, internal workflows, routing, escalation, or message-copy instructions. +- If facts conflict without a clear specific or later override, omit the unsafe FAQ rather than inventing a resolution. + +## Classification Order + +1. Extract query-dependent knowledge and supporting policy explanations into FAQ candidates first, without removing mandatory behavior. +2. Create guidelines and guardrails from the required behavior, including the minimal condition, threshold, or exception needed to enforce it; + do not repeat the rest of a FAQ answer. +3. Create scenario candidates only from remaining distinct specialist workflows; do not repeat their flattened behavior elsewhere. +4. Check once more that active fields contain no standalone product answers and that every mandatory action and prohibition remains active. + +## Needs Review Notes + +- Use needs_review only for a concrete source conflict or a source-defined behavior or workflow that requires an unavailable capability. +- Do not require mandatory wording before preserving an unavailable customer-facing workflow for review. +- Do not use it for wording cleanup, duplicated instructions, missing fixed message copy, existing config values, or general uncertainty. +- needs_review is informational metadata only; it is not an approval status or apply gate. + +Return data matching the provided schema. diff --git a/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb index 0e2f420ac..b92a1a6a6 100644 --- a/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb +++ b/spec/enterprise/services/captain/assistant_migration/draft_applier_spec.rb @@ -23,7 +23,7 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do let(:faq_document_candidate) do { 'question' => 'When is support available?', - 'answer' => 'Support is available Monday to Friday.' + 'answer' => "Support is available Monday to Friday.\n\nUrgent requests are handled by the on-call team." } end let(:draft) do @@ -46,11 +46,15 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do expect(result.dig(:changes, :response_guidelines, :to)).to include( 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' ) + expect(result.dig(:changes, :faq_responses, :create)).to contain_exactly( + faq_document_candidate.merge('status' => 'approved') + ) expect(assistant.reload.config).not_to have_key('assistant_migration') + expect(assistant.responses.count).to eq(0) expect(assistant.scenarios.count).to eq(0) end - it 'stores scenario candidates in assistant config and flattens them into response guidelines' do + it 'stores scenario and FAQ candidates and creates approved FAQ responses' do described_class.new(assistant: assistant, draft: draft, dry_run: false).perform assistant.reload @@ -62,8 +66,55 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do expect(assistant.response_guidelines).to include( 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' ) - expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer']) + expect(assistant.responses).to contain_exactly( + have_attributes( + question: faq_document_candidate['question'], + answer: faq_document_candidate['answer'], + status: 'approved' + ) + ) expect(assistant.scenarios.count).to eq(0) + + expect do + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + end.not_to(change { assistant.responses.count }) + end + + it 'leaves pending FAQ responses untouched' do + pending_response = assistant.responses.create!( + question: faq_document_candidate['question'], + answer: faq_document_candidate['answer'], + status: :pending + ) + + described_class.new(assistant: assistant, draft: draft, dry_run: false).perform + + expect(pending_response.reload).to be_pending + expect(assistant.responses.approved).to contain_exactly( + have_attributes( + question: faq_document_candidate['question'], + answer: faq_document_candidate['answer'] + ) + ) + end + + it 'rejects conflicting FAQ answers within the same draft' do + conflicting_draft = draft.merge( + faq_document_candidates: [ + faq_document_candidate, + { + 'question' => "When is support\navailable?", + 'answer' => 'Support is available every day.' + } + ] + ) + + expect do + described_class.new(assistant: assistant, draft: conflicting_draft, dry_run: true).perform + end.to raise_error(ArgumentError, 'FAQ candidate conflicts with an existing FAQ: When is support available?') + + expect(assistant.responses.count).to eq(0) + expect(assistant.config).not_to have_key('assistant_migration') end it 'rejects stale drafts whose FAQ candidates use the old string format' do @@ -87,8 +138,12 @@ RSpec.describe Captain::AssistantMigration::DraftApplier do assistant.reload expect(assistant.description).to eq('Support assistant for Test Product.') - expect(assistant.response_guidelines).to include('Be concise.') - expect(assistant.guardrails).to eq(['Do not guess.']) + expect(assistant.response_guidelines).to include( + 'Use plain language.', + 'Be concise.', + 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.' + ) + expect(assistant.guardrails).to contain_exactly('Do not disclose internal notes.', 'Do not guess.') expect(assistant.config.dig('assistant_migration', 'original_values')).to include( 'name' => assistant.name, 'description' => 'Existing assistant description.', diff --git a/spec/enterprise/services/captain/assistant_migration/instruction_classifier_spec.rb b/spec/enterprise/services/captain/assistant_migration/instruction_classifier_spec.rb new file mode 100644 index 000000000..448433f17 --- /dev/null +++ b/spec/enterprise/services/captain/assistant_migration/instruction_classifier_spec.rb @@ -0,0 +1,88 @@ +require 'rails_helper' + +RSpec.describe Captain::AssistantMigration::InstructionClassifier do + describe Captain::AssistantMigration::InstructionClassifierSchema do + it 'does not request classification notes' do + expect(described_class.as_json.to_s).not_to include('classification_notes') + end + end + + describe 'classifier prompt' do + it 'keeps the model focused on active behavior and approved FAQ candidates' do + prompt = Captain::PromptRenderer.render('instruction_classifier') + + expect(prompt).to include( + 'The original custom instructions remain stored unchanged', + 'A FAQ cannot implicitly preserve an action', + 'Scenario candidates remain pending metadata', + 'Convert reusable query-dependent facts into natural customer questions', + 'an error code that requires immediate', + 'actively require specialist-name verification', + 'Mandatory prohibitions are not FAQ-only', + 'never promise refunds after 30 days', + 'never recommend cooking the product', + 'Treat explicit policy boundaries', + 'outside the stated condition, window, or exception', + 'source-defined behavior or workflow that requires an unavailable capability', + 'Do not require mandatory wording', + 'every mandatory action and prohibition remains active' + ) + end + end + + describe Captain::AssistantMigration::InstructionAuditorSchema do + it 'only permits additions that fit in the generated draft' do + schema = described_class.for( + response_guidelines: 0, + guardrails: 2, + scenario_candidates: 1, + faq_document_candidates: 3, + needs_review: 4 + ).new.to_json_schema[:schema] + + expect(schema[:properties]).not_to have_key(:response_guidelines) + expect(schema.dig(:properties, :guardrails, :maxItems)).to eq(2) + expect(schema.dig(:properties, :scenario_candidates, :maxItems)).to eq(1) + expect(schema.dig(:properties, :faq_document_candidates, :maxItems)).to eq(3) + expect(schema.dig(:properties, :needs_review, :maxItems)).to eq(4) + end + end + + describe 'auditor prompt' do + it 'adds missing coverage without replacing the generated draft' do + prompt = Captain::PromptRenderer.render('instruction_auditor') + + expect(prompt).to include( + 'This is a monotonic coverage audit', + 'Never repeat, rewrite, replace, or delete content', + 'If mandatory behavior appears only there, add the missing active guideline or guardrail', + 'available_additions gives the exact remaining capacity', + 'A needs_review item never replaces representable behavior', + 'No mandatory action or prohibition remains FAQ-only' + ) + end + end + + describe 'audited payload' do + it 'appends a review note for an unavailable runtime capability' do + service = described_class.new(assistant: instance_double(Captain::Assistant)) + generated_draft = { + response_guidelines: [], + guardrails: [], + scenario_candidates: [], + faq_document_candidates: [], + needs_review: ['Existing conflict'] + } + + result = service.send( + :audited_payload, + generated_draft, + { needs_review: ['Order-status lookup requires an unavailable account-history tool.'] } + ) + + expect(result[:needs_review]).to eq( + ['Existing conflict', 'Order-status lookup requires an unavailable account-history tool.'] + ) + end + end +end 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 10/23] 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 11/23] 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 12/23] 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 = () => {