+
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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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.
## 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;
+ }
+};
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
class="[&_textarea]:font-mono"
/>
+
+
+
+
+ {{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }} +
+
+
+ {{
+ testResult.status
+ ? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
+ status: testResult.status,
+ })
+ : testResult.message
+ }}
+
+