From 1c7e60880d6ff37130a209303f78fb0c9cbb2ab3 Mon Sep 17 00:00:00 2001
From: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:30:14 +0530
Subject: [PATCH] fix: add harness on false promises (#14672)
# Pull Request Template
## Description
https://linear.app/chatwoot/issue/AI-179/false-promise-soft-handoff-guard
## 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.
Against negative cases identified by evals
## 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
---
.../conversation/response_builder_job.rb | 2 +
.../conversation/v1_false_promise_handler.rb | 93 ++++++++++
.../assistant_action_classifier_service.rb | 70 +-------
.../llm/assistant_false_promise_service.rb | 84 +++++++++
.../assistant_response_inspection_helpers.rb | 67 ++++++++
.../captain/llm/system_prompts_service.rb | 60 +++++++
.../captain/assistant_false_promise_schema.rb | 16 ++
.../conversation/response_builder_job_spec.rb | 162 ++++++++++++++++++
8 files changed, 488 insertions(+), 66 deletions(-)
create mode 100644 enterprise/app/jobs/captain/conversation/v1_false_promise_handler.rb
create mode 100644 enterprise/app/services/captain/llm/assistant_false_promise_service.rb
create mode 100644 enterprise/app/services/captain/llm/assistant_response_inspection_helpers.rb
create mode 100644 enterprise/lib/captain/assistant_false_promise_schema.rb
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 5050f11b2..7978ae947 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -1,5 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
include Captain::Conversation::V1ActionClassifier
+ include Captain::Conversation::V1FalsePromiseHandler
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
@@ -38,6 +39,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
message_history: message_history
)
classify_v1_response_action(message_history) if conversation_pending?
+ repair_v1_false_promise_response(message_history) if conversation_pending?
process_response
end
diff --git a/enterprise/app/jobs/captain/conversation/v1_false_promise_handler.rb b/enterprise/app/jobs/captain/conversation/v1_false_promise_handler.rb
new file mode 100644
index 000000000..d3c99a73c
--- /dev/null
+++ b/enterprise/app/jobs/captain/conversation/v1_false_promise_handler.rb
@@ -0,0 +1,93 @@
+module Captain::Conversation::V1FalsePromiseHandler
+ FUTURE_PROMISE_REPAIR_INSTRUCTION = <<~PROMPT.squish.freeze
+ Internal instruction for the assistant, not a customer message: your previous draft promised future work after this
+ message. Regenerate a replacement response now using the same conversation context and available tools. You may use
+ tools now if needed. Do not promise delayed follow-up, later checking, monitoring, notifications, email, callbacks,
+ or background escalation by yourself. Answer with what you can verify now, ask one concrete clarifying question, or
+ offer a human handoff without claiming that it already happened.
+ PROMPT
+
+ private
+
+ def repair_v1_false_promise_response(message_history)
+ false_promise_detected = false
+ return unless v1_false_promise_harness_enabled?
+ return if v1_handoff_requested?
+
+ detection = detect_v1_false_promise(message_history)
+ return unless future_work_promise?(detection)
+
+ false_promise_detected = true
+ mark_v1_false_promise_handoff_fallback
+ regenerate_v1_false_promise_response(message_history)
+ inspect_v1_response_after_false_promise_repair(message_history)
+ rescue StandardError => e
+ mark_v1_false_promise_handoff_fallback if false_promise_detected
+ ChatwootExceptionTracker.new(e, account: account).capture_exception
+ Rails.logger.warn(
+ "[CAPTAIN][ResponseBuilderJob] V1 false promise harness failed for account=#{account.id} " \
+ "conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
+ )
+ end
+
+ def mark_v1_false_promise_handoff_fallback
+ @response.merge!(
+ 'action' => 'handoff',
+ 'action_reason' => 'false_promise_detected',
+ 'action_source' => 'false_promise_harness'
+ )
+ end
+
+ def regenerate_v1_false_promise_response(message_history)
+ repair_message_history = message_history + [{ role: 'assistant', content: @response['response'] }]
+ @response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
+ message_history: repair_message_history,
+ additional_message: FUTURE_PROMISE_REPAIR_INSTRUCTION
+ )
+ end
+
+ def inspect_v1_response_after_false_promise_repair(message_history)
+ classify_v1_response_action(message_history) if conversation_pending?
+ return unless conversation_pending?
+ return if v1_handoff_requested?
+
+ verify_v1_false_promise_repair(message_history)
+ end
+
+ def detect_v1_false_promise(message_history)
+ detection = Captain::Llm::AssistantFalsePromiseService.new(
+ assistant: @assistant,
+ conversation: @conversation
+ ).detect(message_history: message_history, assistant_response: @response['response'])
+
+ log_v1_false_promise_detection(detection)
+ detection
+ end
+
+ def verify_v1_false_promise_repair(message_history)
+ detection = detect_v1_false_promise(message_history)
+ return if safe_response?(detection)
+
+ mark_v1_false_promise_handoff_fallback
+ end
+
+ def future_work_promise?(detection)
+ detection['decision'] == 'future_work_promise'
+ end
+
+ def safe_response?(detection)
+ detection['decision'] == 'safe'
+ end
+
+ def v1_false_promise_harness_enabled?
+ ActiveModel::Type::Boolean.new.cast(@assistant.config['false_promise_harness_enabled'])
+ end
+
+ def log_v1_false_promise_detection(detection)
+ Rails.logger.info(
+ "[CAPTAIN][ResponseBuilderJob] V1 false promise harness account=#{account.id} " \
+ "conversation=#{@conversation.display_id} decision=#{detection['decision']} " \
+ "reason=#{detection['reason']} model=#{detection['model']}"
+ )
+ end
+end
diff --git a/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
index 7c0f1e91e..52b6c3b5a 100644
--- a/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
+++ b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
@@ -1,7 +1,6 @@
class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
include Integrations::LlmInstrumentation
-
- MAX_CONTEXT_MESSAGES = 10
+ include Captain::Llm::AssistantResponseInspectionHelpers
def initialize(assistant:, conversation:)
super()
@@ -11,9 +10,10 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
end
def classify(message_history:, assistant_response:)
- user_prompt = classification_user_prompt(
+ user_prompt = assistant_response_inspection_prompt(
message_history: message_history,
- assistant_response: assistant_response
+ assistant_response: assistant_response,
+ response_tag: 'assistant_response_to_classify'
)
response = instrument_llm_call(instrumentation_params(user_prompt)) do
@@ -35,68 +35,6 @@ class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
private
- def classification_user_prompt(message_history:, assistant_response:)
- <<~PROMPT
-
- #{@assistant.config['instructions']}
-
-
-
- #{format_conversation_context(message_history)}
-
-
-
- #{assistant_response}
-
- PROMPT
- end
-
- def normalize_messages(message_history)
- message_history.filter_map do |message|
- role = message[:role] || message['role']
- next if role.blank?
-
- { role: role.to_s, content: normalize_content(message[:content] || message['content']) }
- end
- end
-
- def normalize_content(content)
- return content if content.is_a?(String)
- return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
-
- content.to_s
- end
-
- def text_part?(part)
- return false unless part.is_a?(Hash)
-
- (part[:type] || part['type']).to_s == 'text'
- end
-
- def format_conversation_context(messages)
- normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
- content = message[:content].to_s.strip
- next if content.blank?
-
- "#{role_label(message[:role])}: #{content}"
- end.join("\n")
- end
-
- def role_label(role)
- return 'User' if role == 'user'
- return 'Assistant' if role == 'assistant'
-
- role.to_s.titleize
- end
-
- def parse_response(content)
- return content if content.is_a?(Hash)
-
- JSON.parse(sanitize_json_response(content))
- rescue JSON::ParserError, TypeError
- {}
- end
-
def normalize_response(parsed, raw_content)
action = parsed['action'].to_s
reason = parsed['action_reason'].to_s
diff --git a/enterprise/app/services/captain/llm/assistant_false_promise_service.rb b/enterprise/app/services/captain/llm/assistant_false_promise_service.rb
new file mode 100644
index 000000000..56d703e77
--- /dev/null
+++ b/enterprise/app/services/captain/llm/assistant_false_promise_service.rb
@@ -0,0 +1,84 @@
+class Captain::Llm::AssistantFalsePromiseService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
+ include Captain::Llm::AssistantResponseInspectionHelpers
+
+ def initialize(assistant:, conversation:)
+ super()
+ @assistant = assistant
+ @conversation = conversation
+ @temperature = 0.0
+ end
+
+ def detect(message_history:, assistant_response:)
+ user_prompt = assistant_response_inspection_prompt(
+ message_history: message_history,
+ assistant_response: assistant_response,
+ response_tag: 'assistant_response_to_check'
+ )
+
+ response = instrument_llm_call(instrumentation_params(user_prompt)) do
+ chat(model: @model, temperature: @temperature)
+ .with_schema(Captain::AssistantFalsePromiseSchema)
+ .with_instructions(system_prompt)
+ .ask(user_prompt)
+ end
+
+ parsed = parse_response(response.content)
+ normalize_response(parsed, response.content)
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
+ Rails.logger.warn(
+ "[CAPTAIN][AssistantFalsePromise] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
+ )
+ { 'decision' => nil, 'reason' => nil, 'error' => e.message, 'model' => @model }
+ end
+
+ private
+
+ def normalize_response(parsed, raw_content)
+ decision = parsed['decision'].to_s
+ reason = parsed['reason'].to_s
+ return invalid_response(raw_content) unless Captain::AssistantFalsePromiseSchema::DECISIONS.include?(decision)
+
+ {
+ 'decision' => decision,
+ 'reason' => reason.presence,
+ 'raw_response' => raw_content,
+ 'model' => @model
+ }
+ end
+
+ def invalid_response(raw_content)
+ {
+ 'decision' => nil,
+ 'reason' => nil,
+ 'raw_response' => raw_content,
+ 'error' => 'invalid_false_promise_response',
+ 'model' => @model
+ }
+ end
+
+ def instrumentation_params(user_prompt)
+ {
+ span_name: 'llm.captain.assistant_false_promise_detector',
+ model: @model,
+ temperature: @temperature,
+ account_id: @conversation.account_id,
+ conversation_id: @conversation.display_id,
+ feature_name: 'assistant_false_promise_detector',
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: user_prompt }
+ ],
+ metadata: {
+ assistant_id: @assistant.id,
+ channel_type: @conversation.inbox&.channel_type,
+ source: 'v1_response_builder'
+ }
+ }
+ end
+
+ def system_prompt
+ Captain::Llm::SystemPromptsService.assistant_false_promise_detector
+ end
+end
diff --git a/enterprise/app/services/captain/llm/assistant_response_inspection_helpers.rb b/enterprise/app/services/captain/llm/assistant_response_inspection_helpers.rb
new file mode 100644
index 000000000..ee4e1a4f8
--- /dev/null
+++ b/enterprise/app/services/captain/llm/assistant_response_inspection_helpers.rb
@@ -0,0 +1,67 @@
+module Captain::Llm::AssistantResponseInspectionHelpers
+ MAX_CONTEXT_MESSAGES = 10
+
+ private
+
+ def assistant_response_inspection_prompt(message_history:, assistant_response:, response_tag:)
+ <<~PROMPT
+
+ #{@assistant.config['instructions']}
+
+
+
+ #{format_conversation_context(message_history)}
+
+
+ <#{response_tag}>
+ #{assistant_response}
+ #{response_tag}>
+ PROMPT
+ end
+
+ def format_conversation_context(messages)
+ normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
+ content = message[:content].to_s.strip
+ next if content.blank?
+
+ "#{role_label(message[:role])}: #{content}"
+ end.join("\n")
+ end
+
+ def normalize_messages(message_history)
+ message_history.filter_map do |message|
+ role = message[:role] || message['role']
+ next if role.blank?
+
+ { role: role.to_s, content: normalize_content(message[:content] || message['content']) }
+ end
+ end
+
+ def normalize_content(content)
+ return content if content.is_a?(String)
+ return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
+
+ content.to_s
+ end
+
+ def text_part?(part)
+ return false unless part.is_a?(Hash)
+
+ (part[:type] || part['type']).to_s == 'text'
+ end
+
+ def role_label(role)
+ return 'User' if role == 'user'
+ return 'Assistant' if role == 'assistant'
+
+ role.to_s.titleize
+ end
+
+ def parse_response(content)
+ return content if content.is_a?(Hash)
+
+ JSON.parse(sanitize_json_response(content))
+ rescue JSON::ParserError, TypeError
+ {}
+ end
+end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index 9520330f6..d56275b87 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -137,6 +137,65 @@ class Captain::Llm::SystemPromptsService
PROMPT
end
+ def assistant_false_promise_detector
+ <<~PROMPT
+ You are checking one failure mode in a customer-support assistant response: unsupported promises of future work.
+
+ Return decision "future_work_promise" when the assistant response says or clearly implies that work has already
+ started, is happening now, or will definitely happen later outside the current reply because of this assistant
+ message. This includes promises that the assistant, bot, Captain, or system will check, verify, investigate,
+ review, monitor, notify, update, email, call back, follow up, get back later, process, refund, cancel, book,
+ order, reserve, file, escalate/forward something in the background, or claim that the current conversation has
+ been or will be transferred, connected, or handed off to a human.
+
+ Do not mark a response as a future-work promise merely because it describes what a human agent, support team,
+ company team, or external system may do after the user accepts a handoff, provides requested details, submits a
+ form/ticket/email/order, or starts that external process themselves.
+
+ Do not mark ordinary in-chat help as a future-work promise. Asking the user for missing information, confirmation,
+ or completion of a step before continuing is safe when the response does not also claim that work has started,
+ is happening now, or will happen in the background.
+
+ Treat transfer claims as future-work promises unless the response is exactly the internal action token
+ `conversation_handoff`. Examples that are future-work promises: "I'm transferring you now", "You've been
+ transferred", "Connecting you now", "Handing off to the team now", "I'll connect you with support",
+ "I'll escalate this", and equivalent phrases in any language.
+
+ Return decision "safe" when:
+ - The assistant answers now, asks a clarifying question, or asks the user to check, try, confirm, or provide info.
+ - The assistant says it can help, check, look up, or guide the user after the user first provides requested
+ information, confirms something, or completes a step.
+ - The assistant asks the user to report back after completing a step and offers to continue helping in chat.
+ - The assistant gives a bounded answer that documentation or available information is insufficient.
+ - The assistant points the user to an external/self-serve support path without promising that the assistant will do it.
+ - The assistant describes what an external support, sales, delivery, finance, or operations team will do after the
+ user submits a form, request, email, application, order, ticket, or in-app chat themselves.
+ - The assistant recommends waiting for an existing external process or support response that was already started
+ outside this assistant message.
+ - The assistant offers future help, monitoring, escalation, or handoff conditionally and waits for the user to
+ accept, without saying the work or transfer has already started.
+ - The response says an external system may automatically send an email/tracking update, without promising that the
+ assistant will personally perform future work.
+ - The response is exactly `conversation_handoff`, which is an internal action token and not a customer-visible promise.
+
+ Be language-independent. The customer and assistant may write in any language.
+ Be conservative: only mark "future_work_promise" when the response promises background/asynchronous work,
+ says work is happening now, or claims a handoff/escalation/notification/action has started or will definitely happen.
+
+ The reason field MUST be one of:
+ - "safe_response"
+ - "asks_user_to_check_or_provide_info"
+ - "external_support_direction"
+ - "unaccepted_handoff_offer"
+ - "future_check_or_investigation"
+ - "future_notification_or_update"
+ - "future_callback_or_email"
+ - "background_escalation_promise"
+
+ Return only the structured fields requested by the response schema.
+ PROMPT
+ end
+
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
@@ -235,6 +294,7 @@ class Captain::Llm::SystemPromptsService
- Do not generate a response more than three sentences.
- Keep the conversation flowing.
- Do not use use your own understanding and training data to provide an answer.
+ - Do not promise work that will happen after this reply. Do not say you will check, investigate, monitor, follow up, notify, email, call, refund, cancel, book, escalate, transfer, or submit anything unless you complete that action now using an available tool or, for human transfer, return `conversation_handoff` as the response. If you lack enough information, ask the user for the missing detail without promising future work.
- Clarify: when there is ambiguity, ask clarifying questions, rather than make assumptions.
- Don't implicitly or explicitly try to end the chat (i.e. do not end a response with "Talk soon!" or "Enjoy!").
- Sometimes the user might just want to chat. Ask them relevant follow-up questions.
diff --git a/enterprise/lib/captain/assistant_false_promise_schema.rb b/enterprise/lib/captain/assistant_false_promise_schema.rb
new file mode 100644
index 000000000..3a9810f7f
--- /dev/null
+++ b/enterprise/lib/captain/assistant_false_promise_schema.rb
@@ -0,0 +1,16 @@
+class Captain::AssistantFalsePromiseSchema < RubyLLM::Schema
+ DECISIONS = %w[safe future_work_promise].freeze
+ REASONS = %w[
+ safe_response
+ asks_user_to_check_or_provide_info
+ external_support_direction
+ unaccepted_handoff_offer
+ future_check_or_investigation
+ future_notification_or_update
+ future_callback_or_email
+ background_escalation_promise
+ ].freeze
+
+ string :decision, enum: DECISIONS, description: 'Whether the response contains an unsupported promise of future work'
+ string :reason, enum: REASONS, description: 'The reason for the selected decision'
+end
diff --git a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
index 8fac81d60..c671edd2a 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -11,6 +11,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
+ let(:mock_false_promise_service) { instance_double(Captain::Llm::AssistantFalsePromiseService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
@@ -22,6 +23,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
+ allow(Captain::Llm::AssistantFalsePromiseService).to receive(:new).and_return(mock_false_promise_service)
+ allow(mock_false_promise_service).to receive(:detect).and_return({ 'decision' => 'safe', 'reason' => 'safe_response' })
end
context 'when captain_v2 is disabled' do
@@ -59,6 +62,165 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
end
+ it 'does not run the false promise harness when the assistant setting is disabled' do
+ expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
+ end
+
+ context 'when false promise harness is enabled in assistant config' do
+ before do
+ assistant.update!(config: assistant.config.merge('false_promise_harness_enabled' => true))
+ end
+
+ it 'sends the original response when the detector marks it safe' do
+ expect(mock_false_promise_service).to receive(:detect).with(
+ message_history: [{ content: 'Hello', role: 'user' }],
+ assistant_response: 'Hey, welcome to Captain Specs'
+ ).and_return({
+ 'decision' => 'safe',
+ 'reason' => 'safe_response',
+ 'model' => 'gpt-4.1'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('pending')
+ expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
+ end
+
+ it 'regenerates future-work promises through the V1 assistant chat service' do
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_return(
+ { 'response' => 'Let me check the documentation and get back to you.' },
+ { 'response' => 'Could you share the exact error message you see?' }
+ )
+ allow(mock_false_promise_service).to receive(:detect)
+ .and_return(
+ {
+ 'decision' => 'future_work_promise',
+ 'reason' => 'future_check_or_investigation',
+ 'model' => 'gpt-4.1'
+ },
+ {
+ 'decision' => 'safe',
+ 'reason' => 'asks_user_to_check_or_provide_info',
+ 'model' => 'gpt-4.1'
+ }
+ )
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('pending')
+ expect(conversation.messages.outgoing.last.content).to eq('Could you share the exact error message you see?')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
+ expect(mock_llm_chat_service).to have_received(:generate_response).with(
+ message_history: [{ content: 'Hello', role: 'user' }]
+ )
+ expect(mock_llm_chat_service).to have_received(:generate_response).with(
+ message_history: [
+ { content: 'Hello', role: 'user' },
+ { role: 'assistant', content: 'Let me check the documentation and get back to you.' }
+ ],
+ additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
+ )
+ end
+
+ it 'hands off instead of sending the unsafe draft when repair generation fails' do
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_return({ 'response' => 'Let me check and get back to you.' })
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .with(
+ message_history: [
+ { content: 'Hello', role: 'user' },
+ { role: 'assistant', content: 'Let me check and get back to you.' }
+ ],
+ additional_message: Captain::Conversation::V1FalsePromiseHandler::FUTURE_PROMISE_REPAIR_INSTRUCTION
+ ).and_raise(StandardError, 'repair timeout')
+ allow(mock_false_promise_service).to receive(:detect).and_return({
+ 'decision' => 'future_work_promise',
+ 'reason' => 'future_check_or_investigation',
+ 'model' => 'gpt-4.1'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ expect(conversation.messages.outgoing.pluck(:content)).not_to include('Let me check and get back to you.')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
+ end
+
+ it 'hands off instead of sending an unverified repair when repair verification is inconclusive' do
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_return(
+ { 'response' => 'Let me check and get back to you.' },
+ { 'response' => 'Could you share the exact error message you see?' }
+ )
+ allow(mock_false_promise_service).to receive(:detect)
+ .and_return(
+ {
+ 'decision' => 'future_work_promise',
+ 'reason' => 'future_check_or_investigation',
+ 'model' => 'gpt-4.1'
+ },
+ {
+ 'decision' => nil,
+ 'reason' => nil,
+ 'error' => 'verification timeout',
+ 'model' => 'gpt-4.1'
+ }
+ )
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ expect(conversation.messages.outgoing.pluck(:content)).not_to include('Could you share the exact error message you see?')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
+ end
+
+ it 'hands off when the regenerated response still contains a future-work promise' do
+ allow(mock_llm_chat_service).to receive(:generate_response)
+ .and_return(
+ { 'response' => 'Let me check and get back to you.' },
+ { 'response' => 'I will monitor this and update you later.' }
+ )
+ allow(mock_false_promise_service).to receive(:detect).and_return({
+ 'decision' => 'future_work_promise',
+ 'reason' => 'future_check_or_investigation',
+ 'model' => 'gpt-4.1'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
+ end
+
+ it 'skips the false promise harness when the action classifier already requested handoff' do
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
+ allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
+ allow(mock_action_classifier_service).to receive(:classify).and_return({
+ 'action' => 'handoff',
+ 'action_reason' => 'explicit_human_request',
+ 'model' => 'gpt-4.1'
+ })
+
+ expect(Captain::Llm::AssistantFalsePromiseService).not_to receive(:new)
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ end
+ end
+
context 'when V1 action classifier is enabled' do
before do
allow(account).to receive(:feature_enabled?).and_return(false)