')
end
@@ -47,6 +70,61 @@ class CustomMarkdownRenderer < CommonMarker::HtmlRenderer
private
+ def sized_widths?(widths)
+ widths.is_a?(Array) && widths.any? { |w| w.to_i.positive? }
+ end
+
+ def fully_sized?(widths)
+ widths.all? { |w| w.to_i.positive? }
+ end
+
+ # Fully-sized tables hug their exact width so the card doesn't trail empty space;
+ # partial tables stay a plain full-width card so flexible columns can expand.
+ def table_wrapper_open(widths)
+ return '
' unless fully_sized?(widths)
+
+ %(
)
+ end
+
+ # Let the gem render the whole table, then splice a
and sizing style
+ # into the opening
tag. Delegating the row/cell/tbody/alignment markup to
+ # super keeps this working across commonmarker upgrades.
+ # `!important` overrides the portal's `[&_table]:!min-w-full` Tailwind rule.
+ def inject_table_sizing(html, widths)
+ opening = %(
\n#{colgroup_html(widths)})
+ html.sub(/
]*>\n?/, opening)
+ end
+
+ # Capture everything `super` writes by swapping the renderer's output buffer.
+ def capture_html
+ original = @stream
+ @stream = StringIO.new(+'')
+ yield
+ @stream.string
+ ensure
+ @stream = original
+ end
+
+ # Total table width: each column's saved width, or the cell min for unsized ones.
+ def total_width(widths)
+ widths.sum { |w| w.to_i.positive? ? w.to_i : TABLE_CELL_MIN_WIDTH_PX }
+ end
+
+ # Fully sized → lock to the exact total (min-width too, so a narrow saved width
+ # beats the portal's `[&_table]:!min-w-full`). Partial → `max(100%, total)` fills
+ # the container (flexible columns) yet scrolls when the sized columns exceed it.
+ def table_sizing_style(widths)
+ total = total_width(widths)
+ return "table-layout: fixed; min-width: max(100%, #{total}px) !important;" unless fully_sized?(widths)
+
+ "table-layout: fixed; width: #{total}px !important; min-width: #{total}px !important;"
+ end
+
+ def colgroup_html(widths)
+ cols = widths.map { |w| w.to_i.positive? ? %(
) : '
' }
+ "
#{cols.join}
\n"
+ end
+
def extract_image_width(src)
query = URI.parse(src).query
raw = query && CGI.parse(query)['cw_image_width']&.first
diff --git a/lib/filters/filter_keys.yml b/lib/filters/filter_keys.yml
index 25d0e5196..006a862b2 100644
--- a/lib/filters/filter_keys.yml
+++ b/lib/filters/filter_keys.yml
@@ -44,6 +44,12 @@ conversations:
- "not_equal_to"
- "is_present"
- "is_not_present"
+ contact_id:
+ attribute_type: "standard"
+ data_type: "number"
+ filter_operators:
+ - "equal_to"
+ - "not_equal_to"
priority:
attribute_type: "standard"
data_type: "text"
diff --git a/lib/integrations/llm_instrumentation.rb b/lib/integrations/llm_instrumentation.rb
index 326bb901e..0257f5c3a 100644
--- a/lib/integrations/llm_instrumentation.rb
+++ b/lib/integrations/llm_instrumentation.rb
@@ -29,16 +29,18 @@ module Integrations::LlmInstrumentation
result = nil
executed = false
- tracer.in_span(params[:span_name]) do |span|
- set_metadata_attributes(span, params)
+ with_propagated_langfuse_attributes(params) do
+ tracer.in_span(params[:span_name]) do |span|
+ set_metadata_attributes(span, params)
- # By default, the input and output of a trace are set from the root observation
- span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
- result = yield
- executed = true
- span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
- set_error_attributes(span, result) if result.is_a?(Hash)
- result
+ # By default, the input and output of a trace are set from the root observation
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
+ result = yield
+ executed = true
+ span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
+ set_error_attributes(span, result) if result.is_a?(Hash)
+ result
+ end
end
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
@@ -51,6 +53,7 @@ module Integrations::LlmInstrumentation
return yield unless ChatwootApp.otel_enabled?
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
+ apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
result = yield
@@ -96,23 +99,6 @@ module Integrations::LlmInstrumentation
end
end
- def instrument_with_span(span_name, params, &)
- result = nil
- executed = false
- tracer.in_span(span_name) do |span|
- track_result = lambda do |r|
- executed = true
- result = r
- end
- yield(span, track_result)
- end
- rescue StandardError => e
- ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
- raise unless executed
-
- result
- end
-
private
def resolve_account(params)
diff --git a/lib/integrations/llm_instrumentation_completion_helpers.rb b/lib/integrations/llm_instrumentation_completion_helpers.rb
index 551d0780f..26af2aae1 100644
--- a/lib/integrations/llm_instrumentation_completion_helpers.rb
+++ b/lib/integrations/llm_instrumentation_completion_helpers.rb
@@ -10,7 +10,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
span.set_attribute('embedding.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
- set_common_span_metadata(span, params)
end
def set_audio_transcription_span_attributes(span, params)
@@ -18,7 +17,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'whisper-1')
span.set_attribute('audio.duration_seconds', params[:duration]) if params[:duration]
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:file_path].to_s) if params[:file_path]
- set_common_span_metadata(span, params)
end
def set_moderation_span_attributes(span, params)
@@ -26,12 +24,6 @@ module Integrations::LlmInstrumentationCompletionHelpers
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model] || 'text-moderation-latest')
span.set_attribute('moderation.input_length', params[:input]&.length || 0)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:input].to_s)
- set_common_span_metadata(span, params)
- end
-
- def set_common_span_metadata(span, params)
- span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
- span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json) if params[:feature_name]
end
def set_embedding_result_attributes(span, result)
diff --git a/lib/integrations/llm_instrumentation_constants.rb b/lib/integrations/llm_instrumentation_constants.rb
index dfe1e7704..f274d1145 100644
--- a/lib/integrations/llm_instrumentation_constants.rb
+++ b/lib/integrations/llm_instrumentation_constants.rb
@@ -29,4 +29,5 @@ module Integrations::LlmInstrumentationConstants
ATTR_LANGFUSE_OBSERVATION_TYPE = 'langfuse.observation.type'
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
+ ATTR_LANGFUSE_OBSERVATION_METADATA = 'langfuse.observation.metadata.%s'
end
diff --git a/lib/integrations/llm_instrumentation_context.rb b/lib/integrations/llm_instrumentation_context.rb
new file mode 100644
index 000000000..27b1eb2b2
--- /dev/null
+++ b/lib/integrations/llm_instrumentation_context.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module Integrations::LlmInstrumentationContext
+ LANGFUSE_ATTRIBUTES_KEY = :llm_instrumentation_langfuse_attributes
+ LANGFUSE_OBSERVATION_METADATA_KEY = :llm_instrumentation_langfuse_observation_metadata_attributes
+
+ private
+
+ def with_propagated_langfuse_attributes(params)
+ previous_attributes = current_langfuse_attributes
+ previous_observation_metadata_attributes = current_observation_metadata_attributes
+ self.current_langfuse_attributes = previous_attributes.merge(propagated_langfuse_attributes(params))
+ self.current_observation_metadata_attributes = previous_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params))
+
+ yield
+ ensure
+ self.current_langfuse_attributes = previous_attributes
+ self.current_observation_metadata_attributes = previous_observation_metadata_attributes
+ end
+
+ def apply_current_langfuse_attributes(span)
+ set_langfuse_attributes(span, current_langfuse_attributes)
+ set_langfuse_attributes(span, current_observation_metadata_attributes)
+ end
+
+ def current_langfuse_attributes
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] || {}
+ end
+
+ def current_langfuse_attributes=(attrs)
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_ATTRIBUTES_KEY] = attrs
+ end
+
+ def current_observation_metadata_attributes
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] || {}
+ end
+
+ def current_observation_metadata_attributes=(attrs)
+ ActiveSupport::IsolatedExecutionState[LANGFUSE_OBSERVATION_METADATA_KEY] = attrs
+ end
+end
diff --git a/lib/integrations/llm_instrumentation_helpers.rb b/lib/integrations/llm_instrumentation_helpers.rb
index 129092ed4..debbfaeda 100644
--- a/lib/integrations/llm_instrumentation_helpers.rb
+++ b/lib/integrations/llm_instrumentation_helpers.rb
@@ -2,6 +2,7 @@
module Integrations::LlmInstrumentationHelpers
include Integrations::LlmInstrumentationConstants
+ include Integrations::LlmInstrumentationContext
include Integrations::LlmInstrumentationCompletionHelpers
def determine_provider(model_name)
@@ -51,15 +52,55 @@ module Integrations::LlmInstrumentationHelpers
end
def set_metadata_attributes(span, params)
- session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
- span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
- span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
- span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
+ set_langfuse_attributes(span, current_langfuse_attributes.merge(propagated_langfuse_attributes(params)))
+ set_langfuse_attributes(span, current_observation_metadata_attributes.merge(propagated_observation_metadata_attributes(params)))
+ end
- return unless params[:metadata].is_a?(Hash)
+ def propagated_langfuse_attributes(params)
+ attrs = {}
+ session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
+
+ attrs[ATTR_LANGFUSE_USER_ID] = params[:account_id].to_s if params[:account_id]
+ attrs[ATTR_LANGFUSE_SESSION_ID] = session_id if session_id.present?
+ attrs[ATTR_LANGFUSE_TAGS] = [params[:feature_name].to_s] if params[:feature_name].present?
+
+ return attrs unless params[:metadata].is_a?(Hash)
params[:metadata].each do |key, value|
- span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
+ attrs[format(ATTR_LANGFUSE_METADATA, key)] = value.to_s
+ end
+
+ attrs
+ end
+
+ def propagated_observation_metadata_attributes(params)
+ attrs = {}
+ session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
+
+ add_observation_metadata(attrs, 'user_id', params[:account_id])
+ add_observation_metadata(attrs, 'account_id', params[:account_id])
+ add_observation_metadata(attrs, 'session_id', session_id)
+ add_observation_metadata(attrs, 'trace_tags', [params[:feature_name]].to_json)
+ add_observation_metadata(attrs, 'feature_name', params[:feature_name])
+
+ return attrs unless params[:metadata].is_a?(Hash)
+
+ params[:metadata].each do |key, value|
+ add_observation_metadata(attrs, key, value)
+ end
+
+ attrs
+ end
+
+ def add_observation_metadata(attrs, key, value)
+ return if value.blank?
+
+ attrs[format(ATTR_LANGFUSE_OBSERVATION_METADATA, key)] = value.to_s
+ end
+
+ def set_langfuse_attributes(span, attrs)
+ attrs.each do |key, value|
+ span.set_attribute(key, value)
end
end
end
diff --git a/lib/integrations/llm_instrumentation_spans.rb b/lib/integrations/llm_instrumentation_spans.rb
index 85ea599f8..2def9749d 100644
--- a/lib/integrations/llm_instrumentation_spans.rb
+++ b/lib/integrations/llm_instrumentation_spans.rb
@@ -39,6 +39,7 @@ module Integrations::LlmInstrumentationSpans
tool_name = tool_call.name.to_s
span = tracer.start_span(format(TOOL_SPAN_NAME, tool_name))
+ apply_current_langfuse_attributes(span)
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_TYPE, 'tool')
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, tool_call.arguments.to_json)
@@ -61,6 +62,24 @@ module Integrations::LlmInstrumentationSpans
Rails.logger.warn "Failed to end tool span: #{e.message}"
end
+ def instrument_with_span(span_name, params, &)
+ result = nil
+ executed = false
+ tracer.in_span(span_name) do |span|
+ set_metadata_attributes(span, params)
+ track_result = lambda do |r|
+ executed = true
+ result = r
+ end
+ yield(span, track_result)
+ end
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: resolve_account(params)).capture_exception
+ raise unless executed
+
+ result
+ end
+
private
def set_llm_turn_request_attributes(span, params)
diff --git a/lib/opentelemetry_config.rb b/lib/opentelemetry_config.rb
index 5ed17e098..32be413d0 100644
--- a/lib/opentelemetry_config.rb
+++ b/lib/opentelemetry_config.rb
@@ -72,7 +72,10 @@ module OpentelemetryConfig
config = {
endpoint: traces_endpoint,
- headers: { 'Authorization' => "Basic #{auth_header}" }
+ headers: {
+ 'Authorization' => "Basic #{auth_header}",
+ 'x-langfuse-ingestion-version' => '4'
+ }
}
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
diff --git a/package.json b/package.json
index d8527051d..bc51bbd85 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
- "version": "4.14.1",
+ "version": "4.14.2",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -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.17",
+ "@chatwoot/prosemirror-schema": "1.3.19",
"@chatwoot/utils": "^0.0.55",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a4b61061c..68e667953 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,8 +25,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.17
- version: 1.3.17
+ specifier: 1.3.19
+ version: 1.3.19
'@chatwoot/utils':
specifier: ^0.0.55
version: 0.0.55
@@ -458,8 +458,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.17':
- resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==}
+ '@chatwoot/prosemirror-schema@1.3.19':
+ resolution: {integrity: sha512-LbATIAeTzclvbIK6WjtrGUO37AtMWkzCJi+s/KpUIp81TORhc0fHnclGT3353AbxeH6dF51/4hFTTsmF+ziqGA==}
'@chatwoot/utils@0.0.55':
resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==}
@@ -5128,7 +5128,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.17':
+ '@chatwoot/prosemirror-schema@1.3.19':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
diff --git a/public/audio/dashboard/ringtone.mp3 b/public/audio/dashboard/ringtone.mp3
new file mode 100644
index 000000000..c2af2b6d1
Binary files /dev/null and b/public/audio/dashboard/ringtone.mp3 differ
diff --git a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
index 9e03e2587..0fd6ad7bf 100644
--- a/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/inboxes_controller_spec.rb
@@ -100,6 +100,35 @@ RSpec.describe 'Inboxes API', type: :request do
expect(JSON.parse(response.body, symbolize_names: true)[:id]).to eq(inbox.id)
end
+ it 'returns reauthorization_required for embedded signup whatsapp channel when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(true)
+ end
+
+ it 'does not flag reauthorization_required for manual whatsapp channel even when reauth required' do
+ whatsapp_channel = create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', sync_templates: false,
+ validate_provider_config: false)
+ whatsapp_channel.update!(provider_config: whatsapp_channel.provider_config.merge('source' => 'manual'))
+ whatsapp_inbox = create(:inbox, channel: whatsapp_channel, account: account)
+ whatsapp_channel.prompt_reauthorization!
+
+ get "/api/v1/accounts/#{account.id}/inboxes/#{whatsapp_inbox.id}",
+ headers: admin.create_new_auth_token,
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body['reauthorization_required']).to be(false)
+ end
+
it 'returns the inbox if assigned inbox is assigned as agent' do
create(:inbox_member, user: agent, inbox: inbox)
get "/api/v1/accounts/#{account.id}/inboxes/#{inbox.id}",
diff --git a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
index ec2b4dcaa..6f118624b 100644
--- a/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -111,4 +111,40 @@ RSpec.describe 'Onboarding API', type: :request do
end
end
end
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when unauthenticated' do
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation", as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when authenticated as an agent (non-admin)' do
+ let(:agent) { create(:user, account: account, role: :agent) }
+
+ it 'returns unauthorized' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: agent.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+
+ context 'when no help center generation has started' do
+ it 'returns not_started with zero counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => nil,
+ 'state' => nil,
+ 'articles_count' => 0,
+ 'categories_count' => 0
+ )
+ end
+ end
+ end
end
diff --git a/spec/controllers/api/v1/accounts/portals_controller_spec.rb b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
index 860791c0e..ccb5d7449 100644
--- a/spec/controllers/api/v1/accounts/portals_controller_spec.rb
+++ b/spec/controllers/api/v1/accounts/portals_controller_spec.rb
@@ -173,7 +173,8 @@ RSpec.describe 'Api::V1::Accounts::Portals', type: :request do
],
'default_locale' => 'en',
'layout' => 'classic',
- 'social_profiles' => {}
+ 'social_profiles' => {},
+ 'locale_translations' => {}
}
)
end
diff --git a/spec/drops/contact_drop_spec.rb b/spec/drops/contact_drop_spec.rb
index d00a0924d..cd6d1a185 100644
--- a/spec/drops/contact_drop_spec.rb
+++ b/spec/drops/contact_drop_spec.rb
@@ -11,6 +11,11 @@ describe ContactDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word (capitalized) as first name when name has only one word' do
+ contact.update!(name: 'john')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized name') do
contact.update!(name: 'john doe')
expect(subject.name).to eq 'John Doe'
diff --git a/spec/drops/user_drop_spec.rb b/spec/drops/user_drop_spec.rb
index 1093ec4a0..34f8f5eaa 100644
--- a/spec/drops/user_drop_spec.rb
+++ b/spec/drops/user_drop_spec.rb
@@ -11,6 +11,11 @@ describe UserDrop do
expect(subject.first_name).to eq 'John'
end
+ it 'returns the single word as first name when name has only one word' do
+ user.update!(name: 'John')
+ expect(subject.first_name).to eq 'John'
+ end
+
it('return the capitalized first name') do
user.update!(name: 'john doe')
expect(subject.first_name).to eq 'John'
diff --git a/spec/enterprise/builders/saml_user_builder_spec.rb b/spec/enterprise/builders/saml_user_builder_spec.rb
index 9ebaf3a55..d3f3cb601 100644
--- a/spec/enterprise/builders/saml_user_builder_spec.rb
+++ b/spec/enterprise/builders/saml_user_builder_spec.rb
@@ -122,8 +122,8 @@ RSpec.describe SamlUserBuilder do
it 'does not add the user to the target account' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to change(AccountUser, :count)
expect(existing_user.reload.accounts).not_to include(account)
end
@@ -131,8 +131,8 @@ RSpec.describe SamlUserBuilder do
it 'does not convert the user provider to saml' do
expect do
builder.perform
- rescue SamlUserBuilder::AuthenticationFailed
- nil
+ rescue StandardError => e
+ raise unless e.class.name == 'SamlUserBuilder::AuthenticationFailed' # rubocop:disable Style/ClassEqualityComparison
end.not_to(change { existing_user.reload.provider })
end
end
diff --git a/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
new file mode 100644
index 000000000..59d0564fa
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts/onboardings_controller_spec.rb
@@ -0,0 +1,42 @@
+require 'rails_helper'
+
+RSpec.describe 'Enterprise Onboarding API', type: :request do
+ let(:account) { create(:account, domain: 'example.com') }
+ let(:admin) { create(:user, account: account, role: :administrator) }
+
+ describe 'GET /api/v1/accounts/{account.id}/onboarding/help_center_generation' do
+ context 'when help center generation is in progress' do
+ let(:generation_id) { 'generation-123' }
+ let!(:portal) { create(:portal, account_id: account.id) }
+ let!(:category) { create(:category, portal: portal, account_id: account.id) }
+
+ before do
+ account.update!(custom_attributes: { 'help_center_generation_id' => generation_id })
+ create(:article, portal: portal, category: category, account_id: account.id, author_id: admin.id)
+ Onboarding::HelpCenterGenerationState.start(generation_id, total: 3)
+ Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
+ end
+
+ after do
+ Redis::Alfred.delete(Onboarding::HelpCenterGenerationState.key(generation_id))
+ end
+
+ it 'returns Redis state and help center counts' do
+ get "/api/v1/accounts/#{account.id}/onboarding/help_center_generation",
+ headers: admin.create_new_auth_token, as: :json
+
+ expect(response).to have_http_status(:success)
+ expect(response.parsed_body).to include(
+ 'generation_id' => generation_id,
+ 'articles_count' => 1,
+ 'categories_count' => 1
+ )
+ expect(response.parsed_body['state']).to include(
+ 'status' => 'generating',
+ 'finished' => '1',
+ 'total' => '3'
+ )
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
index 62529866d..b281f0c10 100644
--- a/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
+++ b/spec/enterprise/jobs/onboarding/help_center_article_generation_job_spec.rb
@@ -53,11 +53,9 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
admin.id,
generation_id,
hash_including(
- 'article' => hash_including(
- 'title' => 'Hello',
- 'urls' => ['https://x.test/a'],
- 'category_id' => portal.categories.first.id
- )
+ 'title' => 'Hello',
+ 'urls' => ['https://x.test/a'],
+ 'category_id' => portal.categories.first.id
)
)
)
@@ -83,7 +81,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Valid'))
+ hash_including('title' => 'Valid')
)
end
end
@@ -106,7 +104,7 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
expect(writer_jobs.size).to eq(1)
expect(writer_jobs.first['arguments']).to include(
- hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
+ hash_including('title' => 'Approved', 'urls' => ['https://x.test/a'])
)
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
end
@@ -170,19 +168,4 @@ RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
expect(state['skip_reason']).to include('firecrawl exhausted')
end
end
-
- describe 'broadcasts' do
- it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
- curator = instance_double(Onboarding::HelpCenterCurator)
- allow(curator).to receive(:perform).and_raise(
- Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
- )
- allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
-
- payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
- end
end
diff --git a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
index a4db6b1d3..b01ec35e3 100644
--- a/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
+++ b/spec/enterprise/jobs/onboarding/help_center_article_writer_job_spec.rb
@@ -6,8 +6,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
let!(:admin) { create(:user, account: account, role: :administrator) }
let(:generation_id) { 'generation-123' }
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
- let(:article_payload) { { 'article' => article_spec } }
- let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
+ let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_spec] }
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
before do
@@ -68,16 +67,14 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
end
- it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
+ it 'marks generation completed when the final writer fails with ArticleBuildFailed' do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
)
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
- payload = hash_including(generation_id: generation_id, status: 'completed')
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
+ described_class.perform_now(*job_args)
+
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
'status' => 'completed', 'finished' => '2'
)
@@ -105,7 +102,7 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
end
end
- describe 'broadcasts' do
+ describe 'missing state' do
let(:built_article) { instance_double(Article, id: 9876) }
before do
@@ -113,47 +110,10 @@ RSpec.describe Onboarding::HelpCenterArticleWriterJob do
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
end
- it 'broadcasts help_center.article_generated on success' do
- payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.article_generated', payload)
- end
-
- it 'broadcasts help_center.generation_completed when the last writer finishes' do
- described_class.perform_now(*job_args)
- payload = hash_including(generation_id: generation_id, status: 'completed')
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', payload)
- end
-
- it 'does not broadcast article_generated on builder failure' do
- allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
- Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
- )
-
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
- .with(anything, 'help_center.article_generated', anything)
- end
-
- it 'broadcasts generation_completed on late retries past total' do
- described_class.perform_now(*job_args)
- described_class.perform_now(*job_args)
- clear_enqueued_jobs
-
- expect { described_class.perform_now(*job_args) }
- .to have_enqueued_job(ActionCableBroadcastJob)
- .with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
- end
-
- it 'skips progress broadcasts when state is missing' do
+ it 'does not raise when state is missing' do
Redis::Alfred.delete(state_key)
- expect { described_class.perform_now(*job_args) }
- .not_to have_enqueued_job(ActionCableBroadcastJob)
+ expect { described_class.perform_now(*job_args) }.not_to raise_error
end
end
end
diff --git a/spec/enterprise/lib/captain/base_task_service_spec.rb b/spec/enterprise/lib/captain/base_task_service_spec.rb
index b3dc473eb..fb970f726 100644
--- a/spec/enterprise/lib/captain/base_task_service_spec.rb
+++ b/spec/enterprise/lib/captain/base_task_service_spec.rb
@@ -32,6 +32,7 @@ RSpec.describe Captain::BaseTaskService, type: :model do
before do
allow(account).to receive(:feature_enabled?).and_call_original
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(true)
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
end
context 'when usage limit is exceeded' do
@@ -111,6 +112,68 @@ RSpec.describe Captain::BaseTaskService, type: :model do
end.to change { account.custom_attributes['captain_responses_usage'].to_i }.by(1)
end
+ context 'when account has its own OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'still increments usage for services that do not opt into BYOK' do
+ expect(account).to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'returns usage limit exceeded error for services that do not opt into BYOK' do
+ result = service.perform
+ expect(result[:error]).to eq(I18n.t('captain.copilot_limit'))
+ expect(result[:error_code]).to eq(429)
+ end
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ result = perform_result
+ klass = Class.new(described_class) do
+ define_method(:perform) { result }
+ define_method(:event_name) { 'test_event' }
+ define_method(:use_account_openai_hook?) { true }
+ end
+ klass.prepend(Enterprise::Captain::BaseTaskService)
+ klass
+ end
+
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'does not increment usage on a successful result' do
+ expect(account).not_to receive(:increment_response_usage)
+ service.perform
+ end
+
+ context 'when the captain_responses quota is exhausted on Cloud' do
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ allow(account).to receive(:usage_limits).and_return({
+ captain: { responses: { current_available: 0 } }
+ })
+ end
+
+ it 'bypasses the 429 gate and returns the underlying result' do
+ result = service.perform
+ expect(result).to eq(perform_result)
+ end
+ end
+ end
+
context 'when captain is disabled' do
before do
allow(account).to receive(:feature_enabled?).with('captain_tasks').and_return(false)
diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
index 9d233943e..6b2cc55c8 100644
--- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
@@ -39,6 +39,30 @@ RSpec.describe Captain::Llm::AssistantChatService do
allow(mock_chat).to receive(:ask).and_return(mock_response)
service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
end
+
+ it 'marks final response generations for observation-level evaluators' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ message = instance_double(RubyLLM::Message, content: 'Final answer', input_tokens: 10, output_tokens: 20, tool_calls: {})
+
+ attributes = service.send(:generation_attributes, mock_chat, message)
+
+ expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('final_response')
+ end
+
+ it 'marks tool call generations separately from final responses' do
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ message = instance_double(
+ RubyLLM::Message,
+ content: '',
+ input_tokens: 10,
+ output_tokens: 20,
+ tool_calls: { 'call_1' => instance_double(RubyLLM::ToolCall) }
+ )
+
+ attributes = service.send(:generation_attributes, mock_chat, message)
+
+ expect(attributes['langfuse.observation.metadata.generation_stage']).to eq('tool_call')
+ end
end
describe 'image analysis' do
diff --git a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
index 4d4bc7aaf..9a099fc67 100644
--- a/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
+++ b/spec/enterprise/services/captain/tools/firecrawl_service_spec.rb
@@ -53,14 +53,15 @@ RSpec.describe Captain::Tools::FirecrawlService do
let(:expected_payload) do
{
url: url,
- maxDepth: 50,
- ignoreSitemap: false,
+ maxDiscoveryDepth: 50,
+ sitemap: 'include',
limit: crawl_limit,
- webhook: webhook_url,
+ webhook: { url: webhook_url },
scrapeOptions: {
onlyMainContent: true,
formats: ['markdown'],
- excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS
+ excludeTags: Captain::Tools::FirecrawlService::FIRECRAWL_EXCLUDE_TAGS,
+ maxAge: 0
}
}.to_json
end
@@ -74,7 +75,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call is successful' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -85,7 +86,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'makes a POST request with correct parameters' do
service.perform(url, webhook_url, crawl_limit)
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
@@ -95,7 +96,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
it 'uses default crawl limit when not specified' do
default_payload = expected_payload.gsub(crawl_limit.to_s, '10')
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -104,7 +105,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
service.perform(url, webhook_url)
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: default_payload,
headers: expected_headers
@@ -114,7 +115,7 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API call fails' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_raise(StandardError.new('Connection failed'))
end
@@ -126,14 +127,14 @@ RSpec.describe Captain::Tools::FirecrawlService do
context 'when the API returns an error response' do
before do
- stub_request(:post, 'https://api.firecrawl.dev/v1/crawl')
+ stub_request(:post, 'https://api.firecrawl.dev/v2/crawl')
.to_return(status: 422, body: '{"error": "Invalid URL"}')
end
it 'makes the request but does not raise an error' do
expect { service.perform(url, webhook_url, crawl_limit) }.not_to raise_error
- expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v1/crawl')
+ expect(WebMock).to have_requested(:post, 'https://api.firecrawl.dev/v2/crawl')
.with(
body: expected_payload,
headers: expected_headers
diff --git a/spec/jobs/mutex_application_job_spec.rb b/spec/jobs/mutex_application_job_spec.rb
index 91a56407d..e2c03c55a 100644
--- a/spec/jobs/mutex_application_job_spec.rb
+++ b/spec/jobs/mutex_application_job_spec.rb
@@ -55,4 +55,57 @@ RSpec.describe MutexApplicationJob do
end.to raise_error(StandardError)
end
end
+
+ describe '.retry_on_lock_conflict' do
+ let(:job_class) do
+ Class.new(MutexApplicationJob) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
+
+ attr_reader :fallback_args
+
+ def perform(lock_key, _payload)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+
+ def process_without_lock(lock_key, payload)
+ @fallback_args = [lock_key, payload]
+ end
+ end
+ end
+
+ let(:payload) { { 'message' => 'hello' } }
+
+ before do
+ stub_const('LockConflictTestJob', job_class)
+ end
+
+ it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ job = job_class.new(lock_key, payload)
+
+ expect { job.perform_now }.not_to raise_error
+ expect(job.fallback_args).to eq([lock_key, payload])
+ end
+
+ context 'without an exhaustion handler' do
+ let(:job_class) do
+ Class.new(MutexApplicationJob) do
+ retry_on_lock_conflict wait: 1.second, attempts: 1
+
+ def perform(lock_key)
+ with_lock(lock_key) { raise 'lock should not be acquired' }
+ end
+ end
+ end
+
+ it 'raises the lock acquisition error when retries are exhausted' do
+ allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
+
+ expect do
+ job_class.perform_now(lock_key)
+ end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
+ end
+ end
+ end
end
diff --git a/spec/jobs/webhooks/whatsapp_events_job_spec.rb b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
index d82658102..8d1b24b52 100644
--- a/spec/jobs/webhooks/whatsapp_events_job_spec.rb
+++ b/spec/jobs/webhooks/whatsapp_events_job_spec.rb
@@ -62,6 +62,14 @@ RSpec.describe Webhooks::WhatsappEventsJob do
job.perform_now(params)
end
+ it 'still enqueues for manual channels even when reauthorization required' do
+ channel.update!(provider_config: channel.provider_config.merge('source' => 'manual'))
+ channel.prompt_reauthorization!
+ allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
+ expect(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new)
+ job.perform_now(params)
+ end
+
it 'will not enqueue if channel is not present' do
allow(Whatsapp::IncomingMessageWhatsappCloudService).to receive(:new).and_return(process_service)
allow(Whatsapp::IncomingMessageService).to receive(:new).and_return(process_service)
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index 5112e47ed..34c889967 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -260,11 +260,12 @@ RSpec.describe Captain::BaseTaskService do
expect(result[:request_messages]).to eq(messages)
end
- it 'does not track exceptions for account hook failures' do
+ it 'tracks exceptions against the system key when an account hook exists' do
create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'hook-key' })
- expect(Llm::Config).to receive(:with_api_key).with('hook-key', api_base: anything).and_raise(error)
- expect(ChatwootExceptionTracker).not_to receive(:new)
+ expect(Llm::Config).to receive(:with_api_key).with('test-key', api_base: anything).and_raise(error)
+ expect(ChatwootExceptionTracker).to receive(:new).with(error, account: account).and_return(exception_tracker)
+ expect(exception_tracker).to receive(:capture_exception)
result = service.send(:make_api_call, model: model, messages: messages)
@@ -279,11 +280,60 @@ RSpec.describe Captain::BaseTaskService do
before { hook }
+ it 'uses system api key by default' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+
+ context 'when subclass opts into account OpenAI hook usage' do
+ let(:test_service_class) do
+ Class.new(described_class) do
+ def event_name
+ 'test_event'
+ end
+
+ def use_account_openai_hook?
+ true
+ end
+ end
+ end
+
+ before do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ end
+
it 'uses api key from hook' do
expect(service.send(:api_key)).to eq('hook-key')
end
end
+ it 'uses account OpenAI hook for editor task services' do
+ create(:integrations_hook, account: account, app_id: 'openai', status: 'enabled', settings: { 'api_key' => 'hook-key' })
+ user = create(:user, account: account)
+ follow_up_context = {
+ 'event_name' => 'professional',
+ 'original_context' => 'Original text',
+ 'last_response' => 'Last response'
+ }
+
+ editor_services = [
+ Captain::RewriteService.new(account: account, content: 'Text', operation: 'improve', conversation_display_id: conversation.display_id),
+ Captain::SummaryService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::ReplySuggestionService.new(account: account, conversation_display_id: conversation.display_id, user: user),
+ Captain::LabelSuggestionService.new(account: account, conversation_display_id: conversation.display_id),
+ Captain::FollowUpService.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: 'Make it shorter',
+ conversation_display_id: conversation.display_id
+ )
+ ]
+
+ editor_services.each do |editor_service|
+ expect(editor_service.send(:api_key)).to eq('hook-key')
+ end
+ end
+
context 'when openai hook is not configured' do
it 'uses system api key' do
expect(service.send(:api_key)).to eq('test-key')
diff --git a/spec/lib/captain/csat_utility_analysis_service_spec.rb b/spec/lib/captain/csat_utility_analysis_service_spec.rb
index e4e980e01..34e0c9ece 100644
--- a/spec/lib/captain/csat_utility_analysis_service_spec.rb
+++ b/spec/lib/captain/csat_utility_analysis_service_spec.rb
@@ -4,6 +4,11 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
let(:account) { create(:account) }
let(:service) { described_class.new(account: account, message: 'Test message', language: 'en', baseline: {}) }
+ before do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
+ allow(Integrations::Openai::KeyValidator).to receive(:valid?).and_return(true)
+ end
+
describe '#perform' do
before do
allow(account).to receive(:feature_enabled?).and_call_original
@@ -21,4 +26,22 @@ RSpec.describe Captain::CsatUtilityAnalysisService do
expect(result[:message]).to eq('{"classification":"LIKELY_UTILITY","optimized_message":"Utility-safe message"}')
end
end
+
+ describe '#api_key' do
+ context 'when account has an OpenAI hook key' do
+ before do
+ create(:integrations_hook, :openai, account: account, settings: { 'api_key' => 'customer-own-key' })
+ end
+
+ it 'uses the account hook key' do
+ expect(service.send(:api_key)).to eq('customer-own-key')
+ end
+ end
+
+ context 'when account does not have an OpenAI hook key' do
+ it 'uses the system key' do
+ expect(service.send(:api_key)).to eq('test-key')
+ end
+ end
+ end
end
diff --git a/spec/lib/custom_markdown_renderer_spec.rb b/spec/lib/custom_markdown_renderer_spec.rb
index 28c5e069c..6484f2e6c 100644
--- a/spec/lib/custom_markdown_renderer_spec.rb
+++ b/spec/lib/custom_markdown_renderer_spec.rb
@@ -258,6 +258,59 @@ describe CustomMarkdownRenderer do
end
end
+ describe '#table' do
+ def render_table(markdown)
+ doc = CommonMarker.render_doc(markdown, :DEFAULT, [:table])
+ described_class.new.render(doc)
+ end
+
+ let(:plain_table) { "| A | B |\n| --- | --- |\n| 1 | 2 |\n" }
+
+ it 'renders a table without column widths when no marker is present' do
+ output = render_table(plain_table)
+ expect(output).to include('
')
+ expect(output).not_to include('colgroup')
+ expect(output).not_to include('cw-colwidths')
+ end
+
+ context 'when every column has a saved width' do
+ it 'lays the table out at the total width with a sized colgroup' do
+ output = render_table("\n#{plain_table}")
+ # Wrapper hugs the table; min-width is set alongside width so a narrow saved width beats min-w-full.
+ expect(output).to include('
')
+ expect(output).to include('
')
+ expect(output).to include('
')
+ end
+ end
+
+ context 'when only some columns have a saved width' do
+ it 'fills the container so unsized columns stay flexible, floored at the sized total' do
+ output = render_table("\n#{plain_table}")
+ # max(100%, 200px): fills the container (flexible) but scrolls if the sized columns exceed it.
+ expect(output).to include('table-layout: fixed; min-width: max(100%, 200px) !important;')
+ expect(output).to include('
')
+ # No exact-width lock on the wrapper or table — the table must be free to expand.
+ expect(output).to include('