From b4ce59eea8f749190980a98e84e52f7ad96a9ded Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:35:50 +0530 Subject: [PATCH 01/53] feat: reclaim response_bot flag for custom_tools (#13897) Repurpose the deprecated response_bot feature flag slot for custom_tools. Migration disables the flag on any accounts that had response_bot enabled so the repurposed slot starts in its default-off state. Pre-deploy: run the disable script on production using the old flag name (response_bot) before deploying this migration. --- config/features.yml | 6 ++--- ...pose_response_bot_flag_for_custom_tools.rb | 22 +++++++++++++++++++ db/schema.rb | 2 +- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb diff --git a/config/features.yml b/config/features.yml index 41515ff64..00f9321b8 100644 --- a/config/features.yml +++ b/config/features.yml @@ -104,10 +104,10 @@ display_name: Audit Logs enabled: false premium: true -- name: response_bot - display_name: Response Bot +- name: custom_tools + display_name: Custom Tools enabled: false - deprecated: true + premium: true - name: message_reply_to display_name: Message Reply To enabled: false diff --git a/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb new file mode 100644 index 000000000..d6a3199b4 --- /dev/null +++ b/db/migrate/20260324102005_repurpose_response_bot_flag_for_custom_tools.rb @@ -0,0 +1,22 @@ +class RepurposeResponseBotFlagForCustomTools < ActiveRecord::Migration[7.1] + def up + # The response_bot flag (deprecated) has been renamed to custom_tools. + # Disable it on any accounts that had response_bot enabled so the repurposed + # flag starts in its intended default-off state. + Account.feature_custom_tools.find_each(batch_size: 100) do |account| + account.disable_features(:custom_tools) + account.save!(validate: false) + end + + # Remove the stale response_bot entry from ACCOUNT_LEVEL_FEATURE_DEFAULTS. + # ConfigLoader only adds new flags; it never removes renamed ones. + # Leaving it would cause NoMethodError in enable_default_features when + # creating new accounts (feature_response_bot= no longer exists). + config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS') + return if config&.value.blank? + + config.value = config.value.reject { |f| f['name'] == 'response_bot' } + config.save! + GlobalConfig.clear_cache + end +end diff --git a/db/schema.rb b/db/schema.rb index 81f1dfbdd..c8af2be3e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2026_03_20_074636) do +ActiveRecord::Schema[7.1].define(version: 2026_03_24_102005) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" From 0012fa2c3573690390cd1c54f1e6bd770e1db005 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:39:54 +0530 Subject: [PATCH 02/53] fix: align message trimming with configured maxLength (#13947) # Pull Request Template ## Description This PR fixes 1. Messages being trimmed to the default 1024 limit in `trimContent` method, instead of channel-specific limits for drafts and AI tasks. 2. Telegram messages are allowed up to 10,000 characters in config, but the API supports only 4096, causing failures for oversized messages. Fixes https://linear.app/chatwoot/issue/CW-6694/captain-ai-rewrite-tasks-truncate-draft-to-1024-chars-trimcontent https://github.com/chatwoot/chatwoot/issues/13919 ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video **Before** https://www.loom.com/share/00e9d6b4d19247febf35dffa99da3805 **After** https://www.loom.com/share/c4900e9effc345c79bcd8a5aa1ee277b ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- .../components/widgets/conversation/ReplyBox.vue | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index dd2e7a607..ef6fa03d6 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -253,6 +253,9 @@ export default { if (this.isAnInstagramChannel) { return MESSAGE_MAX_LENGTH.INSTAGRAM; } + if (this.isATelegramChannel) { + return MESSAGE_MAX_LENGTH.TELEGRAM; + } if (this.isATiktokChannel) { return MESSAGE_MAX_LENGTH.TIKTOK; } @@ -545,7 +548,10 @@ export default { }, setCopilotAcceptedMessage(message, replyType = this.replyType) { const key = this.getDraftKey(this.conversationIdByRoute, replyType); - this.copilotAcceptedMessages[key] = trimContent(message || ''); + this.copilotAcceptedMessages[key] = trimContent( + message || '', + this.maxLength + ); }, clearCopilotAcceptedMessage(replyType = this.replyType) { const key = this.getDraftKey(this.conversationIdByRoute, replyType); @@ -603,7 +609,7 @@ export default { saveDraft(conversationId, replyType) { if (this.message || this.message === '') { const key = this.getDraftKey(conversationId, replyType); - const draftToSave = trimContent(this.message || ''); + const draftToSave = trimContent(this.message || '', this.maxLength); this.$store.dispatch('draftMessages/set', { key, From 1987ac3d97e4690e94c523f87e4f9f7662a18047 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:56:59 +0530 Subject: [PATCH 03/53] fix: remove bulk_auto_assignment_job cron schedule (#13877) --- app/jobs/inboxes/bulk_auto_assignment_job.rb | 47 ---------- config/initializers/sidekiq.rb | 17 +++- config/schedule.yml | 8 -- .../inboxes/bulk_auto_assignment_job_spec.rb | 93 ------------------- 4 files changed, 14 insertions(+), 151 deletions(-) delete mode 100644 app/jobs/inboxes/bulk_auto_assignment_job.rb delete mode 100644 spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb diff --git a/app/jobs/inboxes/bulk_auto_assignment_job.rb b/app/jobs/inboxes/bulk_auto_assignment_job.rb deleted file mode 100644 index 9e808648b..000000000 --- a/app/jobs/inboxes/bulk_auto_assignment_job.rb +++ /dev/null @@ -1,47 +0,0 @@ -class Inboxes::BulkAutoAssignmentJob < ApplicationJob - queue_as :scheduled_jobs - include BillingHelper - - def perform - Account.feature_assignment_v2.find_each do |account| - if should_skip_auto_assignment?(account) - Rails.logger.info("Skipping auto assignment for account #{account.id}") - next - end - - account.inboxes.where(enable_auto_assignment: true).find_each do |inbox| - process_assignment(inbox) - end - end - end - - private - - def process_assignment(inbox) - allowed_agent_ids = inbox.member_ids_with_assignment_capacity - - if allowed_agent_ids.blank? - Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}") - return - end - - assign_conversations(inbox, allowed_agent_ids) - end - - def assign_conversations(inbox, allowed_agent_ids) - unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT) - unassigned_conversations.find_each do |conversation| - ::AutoAssignment::AgentAssignmentService.new( - conversation: conversation, - allowed_agent_ids: allowed_agent_ids - ).perform - Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}") - end - end - - def should_skip_auto_assignment?(account) - return false unless ChatwootApp.chatwoot_cloud? - - default_plan?(account) - end -end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 9511ae68e..7b78b466a 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -34,7 +34,18 @@ end # https://github.com/ondrejbartas/sidekiq-cron Rails.application.reloader.to_prepare do - # TODO: Switch to `load_from_hash!(..., source: 'schedule')` once we have a - # safe cleanup path for YAML-backed cron jobs already persisted in Redis. - Sidekiq::Cron::Job.load_from_hash YAML.load_file(schedule_file) if File.exist?(schedule_file) && Sidekiq.server? + # load_from_hash! upserts jobs from the YAML and removes any Redis-persisted + # jobs that share the same source tag but are no longer in the file. + # This ensures deleted schedule entries are cleaned up on deploy. + if File.exist?(schedule_file) && Sidekiq.server? + schedule = YAML.load_file(schedule_file) + + # Cron entries removed from schedule.yml but possibly still in Redis + # with source:'dynamic' (predating the source tag). load_from_hash! + # only cleans up source:'schedule' entries, so these need explicit removal. + # Remove names from this list once they've been through a deploy cycle. + %w[bulk_auto_assignment_job].each { |name| Sidekiq::Cron::Job.destroy(name) } + + Sidekiq::Cron::Job.load_from_hash!(schedule, source: 'schedule') + end end diff --git a/config/schedule.yml b/config/schedule.yml index 153724c25..f1054ad68 100644 --- a/config/schedule.yml +++ b/config/schedule.yml @@ -4,7 +4,6 @@ # executed daily at 0000 UTC # schedules daily deferred jobs at stable times for each installation -# keep the existing schedule key while the cron loader still uses load_from_hash internal_check_new_versions_job: cron: '0 0 * * *' class: 'Internal::TriggerDailyScheduledItemsJob' @@ -50,13 +49,6 @@ delete_accounts_job: class: 'Internal::DeleteAccountsJob' queue: scheduled_jobs -# executed every 15 minutes -# to assign unassigned conversations for all inboxes -bulk_auto_assignment_job: - cron: '*/15 * * * *' - class: 'Inboxes::BulkAutoAssignmentJob' - queue: scheduled_jobs - # executed every 30 minutes for assignment_v2 periodic_assignment_job: cron: '*/30 * * * *' diff --git a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb b/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb deleted file mode 100644 index 5e7e3d7cc..000000000 --- a/spec/jobs/inboxes/bulk_auto_assignment_job_spec.rb +++ /dev/null @@ -1,93 +0,0 @@ -require 'rails_helper' - -RSpec.describe Inboxes::BulkAutoAssignmentJob do - let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) } - let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) } - let(:inbox) { create(:inbox, account: account) } - let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) } - let(:assignment_service) { double } - - describe '#perform' do - before do - allow(assignment_service).to receive(:perform) - end - - context 'when inbox has inbox members' do - before do - create(:inbox_member, user: agent, inbox: inbox) - account.enable_features!('assignment_v2') - inbox.update!(enable_auto_assignment: true) - end - - it 'assigns unassigned conversations in enabled inboxes' do - allow(AutoAssignment::AgentAssignmentService).to receive(:new).with( - conversation: conversation, - allowed_agent_ids: [agent.id] - ).and_return(assignment_service) - - described_class.perform_now - expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with( - conversation: conversation, - allowed_agent_ids: [agent.id] - ) - end - - it 'skips inboxes with auto assignment disabled' do - inbox.update!(enable_auto_assignment: false) - allow(AutoAssignment::AgentAssignmentService).to receive(:new) - - described_class.perform_now - - expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with( - conversation: conversation, - allowed_agent_ids: [agent.id] - ) - end - - context 'when account is on default plan in chatwoot cloud' do - before do - account.update!(custom_attributes: {}) - InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }]) - allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true) - end - - it 'skips auto assignment' do - allow(Rails.logger).to receive(:info) - expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}") - - allow(AutoAssignment::AgentAssignmentService).to receive(:new) - expect(AutoAssignment::AgentAssignmentService).not_to receive(:new) - - described_class.perform_now - end - end - end - - context 'when inbox has no members' do - before do - account.enable_features!('assignment_v2') - inbox.update!(enable_auto_assignment: true) - end - - it 'does not assign conversations' do - allow(Rails.logger).to receive(:info) - expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}") - - described_class.perform_now - end - end - - context 'when assignment_v2 feature is disabled' do - before do - account.disable_features!('assignment_v2') - end - - it 'skips auto assignment' do - allow(AutoAssignment::AgentAssignmentService).to receive(:new) - expect(AutoAssignment::AgentAssignmentService).not_to receive(:new) - - described_class.perform_now - end - end - end -end From b4b5de9b46f1ccfbacb35be6bcf5a71572843779 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:10:12 +0530 Subject: [PATCH 04/53] fix: conservative hand_off prompt on auto-resolution (#13953) # Pull Request Template ## Description The initial version of prompt deciding to resolve or hand-off to human agents was too conservative especially in cases where a link or an action was told to customer. If the customer didn't respond, Captain was told to hand it off to the agent, but customer may actually have solved the issue. If not, they can come back and continue the conversation. Removed two lines about the same and now we should not see needless handoffs. ## 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 ## 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 --- enterprise/lib/captain/prompts/conversation_completion.liquid | 2 -- 1 file changed, 2 deletions(-) diff --git a/enterprise/lib/captain/prompts/conversation_completion.liquid b/enterprise/lib/captain/prompts/conversation_completion.liquid index f6f8cd58a..ed81039af 100644 --- a/enterprise/lib/captain/prompts/conversation_completion.liquid +++ b/enterprise/lib/captain/prompts/conversation_completion.liquid @@ -3,8 +3,6 @@ You are evaluating whether a customer support conversation is complete and can b The conversation may be in any language. Apply these criteria based on the intent and meaning of messages, regardless of language. A conversation is INCOMPLETE (keep open) if ANY of these apply: -- The assistant suggested the customer try something or take an action — they may still be attempting it -- The assistant directed the customer to an external resource, link, or contact — they may still be following up - The assistant asked a question or requested information that the customer hasn't provided - The customer asked a question that wasn't fully answered - The customer asked for something the assistant couldn't do — even if the assistant explained why, the customer's need is unmet From 5de7ae492cd7ccac1deea0664da2e2860f403daf Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:55:21 +0530 Subject: [PATCH 05/53] fix: html/body background not applied in appearance mode (#13955) # Pull Request Template ## Description This PR fixes the white background bleed visible in the widget, widget article viewer and help center when dark mode is active. **What was happening** While scrolling, the `` element retained a white background in dark mode. This occurred because dark mode classes were only applied to inner container elements, not the root. **What changed** * **Widget:** Updated the `useDarkMode` composable to sync the `dark` class to `` using `watchEffect`, allowing `` to inherit dark theme variables. Also added background styles to `html`, `body`, and `#app` in `woot.scss`. * **Help center portal:** Moved `bg-white dark:bg-slate-900` from `
` to `` in the portal layout so the entire page background responds correctly to dark mode, including within the widget iframe. * **ArticleViewer:** Replaced hardcoded `bg-white` with `bg-n-solid-1` to ensure better theming. Fixes https://linear.app/chatwoot/issue/CW-6704/widget-body-colour-not-implemented ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Screencasts ### Before **Widget** https://github.com/user-attachments/assets/e0224ad1-81a6-440a-a824-e115fb806728 **Help center** https://github.com/user-attachments/assets/40a8ded5-5360-474d-9ec5-fd23e037c845 ### After **Widget** https://github.com/user-attachments/assets/dd37cc68-99fc-4d60-b2ae-cf41f9d4d38c **Help center** https://github.com/user-attachments/assets/bc998c4e-ef77-46fa-ac7f-4ea16d912ce3 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- app/javascript/widget/assets/scss/woot.scss | 2 +- app/javascript/widget/composables/useDarkMode.js | 6 +++++- app/javascript/widget/views/ArticleViewer.vue | 2 +- app/views/layouts/portal.html.erb | 4 ++-- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/javascript/widget/assets/scss/woot.scss b/app/javascript/widget/assets/scss/woot.scss index 07aa6a0e3..0044ccdfc 100755 --- a/app/javascript/widget/assets/scss/woot.scss +++ b/app/javascript/widget/assets/scss/woot.scss @@ -7,7 +7,7 @@ html, body { - @apply antialiased h-full; + @apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1; } .is-mobile { diff --git a/app/javascript/widget/composables/useDarkMode.js b/app/javascript/widget/composables/useDarkMode.js index bc19c456b..407d90980 100644 --- a/app/javascript/widget/composables/useDarkMode.js +++ b/app/javascript/widget/composables/useDarkMode.js @@ -1,4 +1,4 @@ -import { computed } from 'vue'; +import { computed, watchEffect } from 'vue'; import { useMapGetter } from 'dashboard/composables/store'; const isDarkModeAuto = mode => mode === 'auto'; @@ -23,6 +23,10 @@ export function useDarkMode() { calculatePrefersDarkMode(darkMode.value, systemPreference.value) ); + watchEffect(() => { + document.documentElement.classList.toggle('dark', prefersDarkMode.value); + }); + return { darkMode, prefersDarkMode, diff --git a/app/javascript/widget/views/ArticleViewer.vue b/app/javascript/widget/views/ArticleViewer.vue index 9289d0546..bc4cf775c 100644 --- a/app/javascript/widget/views/ArticleViewer.vue +++ b/app/javascript/widget/views/ArticleViewer.vue @@ -10,7 +10,7 @@ export default { diff --git a/app/views/layouts/portal.html.erb b/app/views/layouts/portal.html.erb index 78418881a..52d8e2789 100644 --- a/app/views/layouts/portal.html.erb +++ b/app/views/layouts/portal.html.erb @@ -58,9 +58,9 @@ By default, it renders: } - +
-
+
<% if !@is_plain_layout_enabled %> <%= render "public/api/v1/portals/header", portal: @portal %> <% end %> From 8824efe0e1767bafb007e5a946df78eab14c8bc7 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:09:02 +0530 Subject: [PATCH 06/53] fix(sentry): syntaxError: No error message (#13954) --- app/javascript/dashboard/App.vue | 4 +++- .../routes/dashboard/settings/account/Index.vue | 13 +++++++------ app/javascript/v3/App.vue | 4 +++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 8912c03d1..a706e2df5 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -98,7 +98,9 @@ export default { mql.onchange = e => setColorTheme(e.matches); }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, async initializeAccount() { await this.$store.dispatch('accounts/get'); diff --git a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue index 5be704c24..0502ebc1b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/account/Index.vue @@ -103,7 +103,10 @@ export default { const { name, locale, id, domain, support_email, features } = this.getAccount(this.accountId); - this.$root.$i18n.locale = this.uiSettings?.locale || locale; + const effectiveLocale = this.uiSettings?.locale || locale; + if (effectiveLocale) { + this.$root.$i18n.locale = effectiveLocale; + } this.name = name; this.locale = locale; this.id = id; @@ -129,11 +132,9 @@ export default { support_email: this.supportEmail, }); // If user locale is set, update the locale with user locale - if (this.uiSettings?.locale) { - this.$root.$i18n.locale = this.uiSettings?.locale; - } else { - // If user locale is not set, update the locale with account locale - this.$root.$i18n.locale = this.locale; + const updatedLocale = this.uiSettings?.locale || this.locale; + if (updatedLocale) { + this.$root.$i18n.locale = updatedLocale; } this.getAccount(this.id).locale = this.locale; useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS')); diff --git a/app/javascript/v3/App.vue b/app/javascript/v3/App.vue index ef7107beb..c3f9b1734 100644 --- a/app/javascript/v3/App.vue +++ b/app/javascript/v3/App.vue @@ -35,7 +35,9 @@ export default { }; }, setLocale(locale) { - this.$root.$i18n.locale = locale; + if (locale) { + this.$root.$i18n.locale = locale; + } }, }, }; From f2cb23d6e90c7ce00f486a02b8c6727a53648057 Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com> Date: Wed, 1 Apr 2026 16:55:49 +0530 Subject: [PATCH 07/53] fix: handle Socket::ResolutionError in browser push notifications (#13957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Linear Ticket https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently https://linear.app/chatwoot/issue/CW-6707/socketresolutionerror-failed-to-open-tcp-connection-to-permanently#comment-14e0f9ff ## Description Browser push notifications fail with Socket::ResolutionError when the push subscription endpoint's domain can't be resolved via DNS (e.g., defunct push service, transient DNS failure). This error wasn't handled in handle_browser_push_error, so it fell through to the catch-all else branch and got reported to Sentry on every notification attempt — 1,637 times in the last 7 days. The dead subscription was never cleaned up or the error suppressed, so every subsequent notification for the affected user triggered the same Sentry alert. Added Socket::ResolutionError to the existing transient network error handler alongside Errno::ECONNRESET, Net::OpenTimeout, and Net::ReadTimeout. The error is logged but not reported to Sentry, and the subscription is kept intact in case it's a temporary DNS blip. ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? - Verified that Socket::ResolutionError is a subclass of StandardError and matches the when clause ## Checklist: - [ ] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules Co-authored-by: Vishnu Narayanan --- app/services/notification/push_notification_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/notification/push_notification_service.rb b/app/services/notification/push_notification_service.rb index 125ad9113..90f835ecb 100644 --- a/app/services/notification/push_notification_service.rb +++ b/app/services/notification/push_notification_service.rb @@ -79,7 +79,7 @@ class Notification::PushNotificationService subscription.destroy! when WebPush::TooManyRequests Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}" - when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout + when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, Socket::ResolutionError Rails.logger.error "WebPush operation error: #{error.message}" else ChatwootExceptionTracker.new(error, account: notification.account).capture_exception From 4cce7f6ad89a6e3e0d967c0e6c7aae34d67fbef0 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Wed, 1 Apr 2026 15:59:12 +0400 Subject: [PATCH 08/53] fix(line): Use non-expiring URLs for image and video messages (#13949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images and videos sent from Chatwoot to LINE inboxes fail to display on the LINE mobile app — users see expired markers, broken thumbnails, or missing images. This happens because LINE mobile lazy-loads images rather than downloading them immediately, and the ActiveStorage signed URLs expire after 5 minutes. Closes https://linear.app/chatwoot/issue/CW-6696/line-messaging-with-image-or-video-may-not-show-when-client-inactive ## How to reproduce 1. Create a LINE inbox and start a chat from the LINE mobile app 2. Close the LINE mobile app 3. Send an image from Chatwoot to that chat 4. Wait 7-8 minutes (past the 5-minute URL expiration) 5. Open the LINE mobile app — the image is broken/expired ## What changed - **`originalContentUrl`**: switched from `download_url` (signed, 5-min expiry) to `file_url` (permanent redirect-based URL) - **`previewImageUrl`**: switched to `thumb_url` (250px resized thumbnail meeting LINE's 1MB/240x240 recommendation), with fallback to `file_url` for non-image attachments like video 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Sojan Jose --- app/services/line/send_on_line_service.rb | 9 +++++++-- spec/services/line/send_on_line_service_spec.rb | 16 ++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/app/services/line/send_on_line_service.rb b/app/services/line/send_on_line_service.rb index b0b6d828d..3c9d6cf17 100644 --- a/app/services/line/send_on_line_service.rb +++ b/app/services/line/send_on_line_service.rb @@ -44,10 +44,15 @@ class Line::SendOnLineService < Base::SendOnChannelService # Support only image and video for now, https://developers.line.biz/en/reference/messaging-api/#image-message next unless attachment.file_type == 'image' || attachment.file_type == 'video' + # Use file_url (permanent redirect-based URL) instead of download_url (signed URL that expires in 5 minutes). + # LINE mobile app lazy-loads images and may fetch them well after the message is sent. + original_url = attachment.file_url + preview_url = attachment.thumb_url.presence || original_url + { type: attachment.file_type, - originalContentUrl: attachment.download_url, - previewImageUrl: attachment.download_url + originalContentUrl: original_url, + previewImageUrl: preview_url } end end diff --git a/spec/services/line/send_on_line_service_spec.rb b/spec/services/line/send_on_line_service_spec.rb index a7520b8d8..4451a53b9 100644 --- a/spec/services/line/send_on_line_service_spec.rb +++ b/spec/services/line/send_on_line_service_spec.rb @@ -161,7 +161,9 @@ describe Line::SendOnLineService do it 'sends the message with text and attachments' do attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') - expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png} + attachment.save! + expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png} expect(line_client).to receive(:push_message).with( message.conversation.contact_inbox.source_id, @@ -169,8 +171,8 @@ describe Line::SendOnLineService do { type: 'text', text: message.content }, { type: 'image', - originalContentUrl: match(expected_url_regex), - previewImageUrl: match(expected_url_regex) + originalContentUrl: match(expected_original_url_regex), + previewImageUrl: match(expected_preview_url_regex) } ] ) @@ -181,16 +183,18 @@ describe Line::SendOnLineService do it 'sends the message with attachments only' do attachment = message.attachments.new(account_id: message.account_id, file_type: :image) attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png') + attachment.save! message.update!(content: nil) - expected_url_regex = %r{rails/active_storage/disk/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_original_url_regex = %r{rails/active_storage/blobs/redirect/[a-zA-Z0-9=_\-+]+/avatar\.png} + expected_preview_url_regex = %r{rails/active_storage/representations/redirect/[a-zA-Z0-9=_\-+]+/[a-zA-Z0-9=_\-+]+/avatar\.png} expect(line_client).to receive(:push_message).with( message.conversation.contact_inbox.source_id, [ { type: 'image', - originalContentUrl: match(expected_url_regex), - previewImageUrl: match(expected_url_regex) + originalContentUrl: match(expected_original_url_regex), + previewImageUrl: match(expected_preview_url_regex) } ] ) From 65867b8b36bdeffa630d8db00e53ebd62d9af10e Mon Sep 17 00:00:00 2001 From: Vishnu Narayanan Date: Wed, 1 Apr 2026 18:02:19 +0530 Subject: [PATCH 09/53] fix: exclude MutexApplicationJob::LockAcquisitionError from Sentry (#13965) ## Summary - Add `MutexApplicationJob::LockAcquisitionError` to Sentry's `excluded_exceptions` - This error is expected control flow (mutex lock contention during webhook processing), not a bug - Generated ~131K Sentry events in March 2026, 100% from `InstagramEventsJob` Fixes https://linear.app/chatwoot/issue/INF-58 --- config/initializers/sentry.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb index ae21d7f61..eff36bfc5 100644 --- a/config/initializers/sentry.rb +++ b/config/initializers/sentry.rb @@ -7,7 +7,7 @@ if ENV['SENTRY_DSN'].present? # We recommend adjusting the value in production: config.traces_sample_rate = 0.1 if ENV['ENABLE_SENTRY_TRANSACTIONS'] - config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException'] + config.excluded_exceptions += ['Rack::Timeout::RequestTimeoutException', 'MutexApplicationJob::LockAcquisitionError'] # to track post data in sentry config.send_default_pii = true unless ENV['DISABLE_SENTRY_PII'] From 7b09b033ef2f82801b633dd1869fd91a3428a4b1 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:02:21 +0530 Subject: [PATCH 10/53] fix: Markdown tables don't render properly in help centre (#13971) # Pull Request Template ## Description This PR fixes an issue where markdown tables were not rendering correctly in the Help Center. The issue was caused by a backslash `(\)` being appended after table row separators `(|)`, which breaks the markdown table parsing. The issue was introduced after recent editor changes made to preserve new lines, which unintentionally affected how table markdown is parsed and displayed. ### https://github.com/chatwoot/prosemirror-schema/pull/44 Fixes https://linear.app/chatwoot/issue/CW-6714/markdown-tables-dont-render-properly-in-help-centre-preview ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? **Before** ``` | Type | What you provide |\ |--------------|-------------------------------|\ | None | No authentication |\ | Bearer Token | A token string |\ | Basic Auth | Username and password |\ | API Key | A custom header name and value| ``` **After** ``` | Type | What you provide | |--------------|-------------------------------| | None | No authentication | | Bearer Token | A token string | | Basic Auth | Username and password | | API Key | A custom header name and value| ``` ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index c8a1b7fdf..ddb6c09cc 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.8", + "@chatwoot/prosemirror-schema": "1.3.9", "@chatwoot/utils": "^0.0.52", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 48edce442..cb4b2b148 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,8 +26,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.8 - version: 1.3.8 + specifier: 1.3.9 + version: 1.3.9 '@chatwoot/utils': specifier: ^0.0.52 version: 0.0.52 @@ -454,8 +454,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.8': - resolution: {integrity: sha512-Vr8eUdydmVr7iRnNky4jXKX3XD4z5HAS4bV7zJXxA4av4ig5qjTldDOg7c/C8rqYNKGR5UEOEu9CQfGcjfKVXg==} + '@chatwoot/prosemirror-schema@1.3.9': + resolution: {integrity: sha512-nbzvW4Rfe7EC+tHF/wWJK5pIxRzfQj/DDAtZI7pwM9uJfv9yQz6bAUCA7kz7Vq1NF29XOisZaT5W0005ygk1pg==} '@chatwoot/utils@0.0.52': resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==} @@ -4966,7 +4966,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.8': + '@chatwoot/prosemirror-schema@1.3.9': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.6.0 From 211fb1102dd208daee414cff1b8d71ea27ac5ebf Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Thu, 2 Apr 2026 11:26:29 +0530 Subject: [PATCH 11/53] chore: rotate oauth password if unconfirmed (#13878) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a user signs up with an email they don't own and sets a password, that password remains valid even after the real owner later signs in via OAuth. This means the original registrant — who never proved ownership of the email — retains working credentials on the account. This change closes that gap by rotating the password to a random value whenever an unconfirmed user completes an OAuth sign-in. The check (`oauth_user_needs_password_reset?`) is evaluated before `skip_confirmation!` runs, since confirmation would flip `confirmed_at` and mask the condition. If the user was unconfirmed, the stored password is replaced with a secure random string that satisfies the password policy. This applies to both the web and mobile OAuth callback paths, as well as the sign-up path where the password is rotated before the reset token is generated. Users who lose access to password-based login as a side effect can recover through the standard "Forgot password" flow at any time. Since they've already proven email ownership via OAuth, this is a low-friction recovery path --- .../omniauth_callbacks_controller.rb | 18 ++++++++++++++++++ .../omniauth_callbacks_controller_spec.rb | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb index af759af54..2c8387142 100644 --- a/app/controllers/devise_overrides/omniauth_callbacks_controller.rb +++ b/app/controllers/devise_overrides/omniauth_callbacks_controller.rb @@ -10,7 +10,12 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa private def sign_in_user + # Capture before skip_confirmation! sets confirmed_at, which would + # make oauth_user_needs_password_reset? return false and skip the + # password reset for persisted unconfirmed users. + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -20,7 +25,10 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa end def sign_in_user_on_mobile + # See comment in sign_in_user for why this is captured before skip_confirmation! + needs_password_reset = oauth_user_needs_password_reset? @resource.skip_confirmation! if confirmable_enabled? + set_random_password_if_oauth_user if needs_password_reset # once the resource is found and verified # we can just send them to the login page again with the SSO params @@ -37,6 +45,7 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa return redirect_to login_page_url(error: 'business-account-only') unless validate_signup_email_is_business_domain? create_account_for_user + set_random_password_if_oauth_user token = @resource.send(:set_reset_password_token) frontend_url = ENV.fetch('FRONTEND_URL', nil) redirect_to "#{frontend_url}/app/auth/password/edit?config=default&reset_password_token=#{token}" @@ -81,6 +90,15 @@ class DeviseOverrides::OmniauthCallbacksController < DeviseTokenAuth::OmniauthCa Avatar::AvatarFromUrlJob.perform_later(@resource, auth_hash['info']['image']) end + def oauth_user_needs_password_reset? + @resource.present? && (@resource.new_record? || !@resource.confirmed?) + end + + def set_random_password_if_oauth_user + # Password must satisfy secure_password requirements (uppercase, lowercase, number, special char) + @resource.update(password: "#{SecureRandom.hex(16)}aA1!") if @resource.persisted? + end + def default_devise_mapping 'user' end diff --git a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb index 603458a01..35bae8e0b 100644 --- a/spec/controllers/devise/omniauth_callbacks_controller_spec.rb +++ b/spec/controllers/devise/omniauth_callbacks_controller_spec.rb @@ -164,5 +164,21 @@ RSpec.describe 'DeviseOverrides::OmniauthCallbacksController', type: :request do expect(response).to have_http_status(:ok) end end + + it 'resets password for an unconfirmed persisted user on OAuth login' do + with_modified_env FRONTEND_URL: 'http://www.example.com' do + user = create(:user, email: 'unconfirmed-oauth@example.com', skip_confirmation: false) + original_password_digest = user.encrypted_password + set_omniauth_config('unconfirmed-oauth@example.com') + + get '/omniauth/google_oauth2/callback' + expect(response).to redirect_to('http://www.example.com/auth/google_oauth2/callback') + follow_redirect! + + user.reload + expect(user).to be_confirmed + expect(user.encrypted_password).not_to eq(original_password_digest) + end + end end end From 8daf6cf6cbba1246f98a59ce474b6bd633646f46 Mon Sep 17 00:00:00 2001 From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:40:11 +0530 Subject: [PATCH 12/53] feat: captain custom tools v1 (#13890) # Pull Request Template ## Description Adds custom tool support to v1 ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. CleanShot 2026-03-24 at 11 37 33@2x CleanShot 2026-03-24 at 11 38 18@2x CleanShot 2026-03-24 at 11 38
32@2x ## Checklist: - [x] My code follows the style guidelines of this project - [ ] I have performed a self-review of my code - [ ] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Shivam Mishra --- .../dashboard/api/captain/customTools.js | 6 ++ .../customTool/CustomToolCard.vue | 9 +- .../customTool/CustomToolForm.vue | 86 +++++++++++++++++-- .../emptyStates/CustomToolsPageEmptyState.vue | 12 +++ .../components-next/sidebar/Sidebar.vue | 30 +++++-- app/javascript/dashboard/featureFlags.js | 1 + .../i18n/locale/en/integrations.json | 10 ++- .../dashboard/captain/captain.routes.js | 2 +- .../routes/dashboard/captain/tools/Index.vue | 22 +++-- config/locales/en.yml | 1 + config/routes.rb | 4 +- .../captain/custom_tools_controller.rb | 24 +++++- enterprise/app/models/captain/custom_tool.rb | 27 ++++-- enterprise/app/models/concerns/toolable.rb | 31 ++++--- .../policies/captain/custom_tool_policy.rb | 4 + .../captain/llm/assistant_chat_service.rb | 22 ++++- .../captain/llm/system_prompts_service.rb | 15 +++- .../captain/tools/custom_http_tool.rb | 47 ++++++++++ .../reconcile_plan_features_service.rb | 2 +- .../models/captain/_custom_tool.json.jbuilder | 2 +- .../captain/custom_tools_controller_spec.rb | 7 +- 21 files changed, 307 insertions(+), 57 deletions(-) create mode 100644 enterprise/app/services/captain/tools/custom_http_tool.rb diff --git a/app/javascript/dashboard/api/captain/customTools.js b/app/javascript/dashboard/api/captain/customTools.js index d0818d941..471c2846b 100644 --- a/app/javascript/dashboard/api/captain/customTools.js +++ b/app/javascript/dashboard/api/captain/customTools.js @@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient { delete(id) { return axios.delete(`${this.url}/${id}`); } + + test(data = {}) { + return axios.post(`${this.url}/test`, { + custom_tool: data, + }); + } } export default new CaptainCustomTools(); diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue index d1d1dd011..d5f1e3e52 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue @@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
-
-
- +
+
+ {{ description }} -import { reactive, computed, useTemplateRef, watch } from 'vue'; +import { reactive, computed, ref, useTemplateRef, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { useVuelidate } from '@vuelidate/core'; -import { required } from '@vuelidate/validators'; +import { required, maxLength } from '@vuelidate/validators'; import { useMapGetter } from 'dashboard/composables/store'; +import CustomToolsAPI from 'dashboard/api/captain/customTools'; import Input from 'dashboard/components-next/input/Input.vue'; import TextArea from 'dashboard/components-next/textarea/TextArea.vue'; @@ -72,8 +73,12 @@ const DEFAULT_PARAM = { required: false, }; +// OpenAI enforces a 64-char limit on function names. The backend slug is +// "custom_" (7 chars) + parameterized title, so cap the title conservatively. +const MAX_TOOL_NAME_LENGTH = 55; + const validationRules = { - title: { required }, + title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) }, endpoint_url: { required }, http_method: { required }, auth_type: { required }, @@ -103,9 +108,15 @@ const isLoading = computed(() => ); const getErrorMessage = (field, errorKey) => { - return v$.value[field].$error - ? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`) - : ''; + if (!v$.value[field].$error) return ''; + + const failedRule = v$.value[field].$errors[0]?.$validator; + if (failedRule === 'maxLength') { + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, { + max: MAX_TOOL_NAME_LENGTH, + }); + } + return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`); }; const formErrors = computed(() => ({ @@ -140,6 +151,30 @@ const handleSubmit = async () => { emit('submit', state); }; + +const isTesting = ref(false); +const testResult = ref(null); +const isTestDisabled = computed( + () => state.endpoint_url.includes('{{') || !!state.request_template +); + +const handleTest = async () => { + if (!state.endpoint_url) return; + + isTesting.value = true; + testResult.value = null; + try { + const { data } = await CustomToolsAPI.test(state); + const isOk = data.status >= 200 && data.status < 300; + testResult.value = { success: isOk, status: data.status }; + } catch (e) { + const message = + e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR'); + testResult.value = { success: false, message }; + } finally { + isTesting.value = false; + } +};