@@ -62,6 +73,7 @@ const clearEditorSelection = () => {
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
+ @send="onSend"
/>
{
- if (this.isAValidEvent('cmd_enter')) {
+ if (this.copilot.isActive.value && this.isFocused) {
+ this.onSubmitCopilotReply();
+ } else if (this.isAValidEvent('cmd_enter')) {
this.onSendReply();
}
},
@@ -1171,6 +1176,7 @@ export default {
@clear-selection="clearEditorSelection"
@close="copilot.showEditor.value = false"
@content-ready="copilot.setContentReady"
+ @send="copilot.sendFollowUp"
/>
} The generated message or an empty string if an error occurs.
+ * @returns {Promise<{message: string, followUpContext?: Object}>} The generated message and optional follow-up context.
*/
const processEvent = async (type = 'improve', content = '', options = {}) => {
try {
@@ -167,20 +167,52 @@ export function useAI() {
options.signal
);
const {
- data: { message: generatedMessage },
+ data: { message: generatedMessage, follow_up_context: followUpContext },
} = result;
- return generatedMessage;
+ return { message: generatedMessage, followUpContext };
} catch (error) {
// Don't show error for aborted requests
if (error.name === 'AbortError' || error.name === 'CanceledError') {
- return '';
+ return { message: '' };
}
const errorData = error.response?.data?.error;
const errorMessage =
errorData?.error?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
useAlert(errorMessage);
- return '';
+ return { message: '' };
+ }
+ };
+
+ /**
+ * Sends a follow-up message to refine a previous AI task result.
+ * @param {Object} options - The follow-up options.
+ * @param {Object} options.followUpContext - The follow-up context from a previous task.
+ * @param {string} options.message - The follow-up message/request from the user.
+ * @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
+ * @returns {Promise<{message: string, followUpContext: Object}>} The follow-up response and updated context.
+ */
+ const followUp = async ({ followUpContext, message, signal }) => {
+ try {
+ const result = await TasksAPI.followUp(
+ { followUpContext, message, conversationId: conversationId.value },
+ signal
+ );
+ const {
+ data: { message: generatedMessage, follow_up_context: updatedContext },
+ } = result;
+ return { message: generatedMessage, followUpContext: updatedContext };
+ } catch (error) {
+ // Don't show error for aborted requests
+ if (error.name === 'AbortError' || error.name === 'CanceledError') {
+ return { message: '', followUpContext };
+ }
+ const errorData = error.response?.data?.error;
+ const errorMessage =
+ errorData?.error?.message ||
+ t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
+ useAlert(errorMessage);
+ return { message: '', followUpContext };
}
};
@@ -201,5 +233,6 @@ export function useAI() {
recordAnalytics,
fetchLabelSuggestions,
processEvent,
+ followUp,
};
}
diff --git a/app/javascript/dashboard/composables/useCopilotReply.js b/app/javascript/dashboard/composables/useCopilotReply.js
index 729fe82fa..428e395a9 100644
--- a/app/javascript/dashboard/composables/useCopilotReply.js
+++ b/app/javascript/dashboard/composables/useCopilotReply.js
@@ -9,13 +9,14 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
* @returns {Object} Copilot reply state and methods
*/
export function useCopilotReply() {
- const { processEvent } = useAI();
+ const { processEvent, followUp } = useAI();
const { updateUISettings } = useUISettings();
const showEditor = ref(false);
const isGenerating = ref(false);
const isContentReady = ref(false);
const generatedContent = ref('');
+ const followUpContext = ref(null);
const abortController = ref(null);
const isActive = computed(() => showEditor.value || isGenerating.value);
@@ -38,6 +39,7 @@ export function useCopilotReply() {
isGenerating.value = false;
isContentReady.value = false;
generatedContent.value = '';
+ followUpContext.value = null;
}
/**
@@ -75,12 +77,14 @@ export function useCopilotReply() {
isContentReady.value = false;
try {
- const content = await processEvent(action, data, {
- signal: abortController.value.signal,
- });
+ const { message: content, followUpContext: newContext } =
+ await processEvent(action, data, {
+ signal: abortController.value.signal,
+ });
if (!abortController.value?.signal.aborted) {
generatedContent.value = content;
+ followUpContext.value = newContext;
if (content) showEditor.value = true;
isGenerating.value = false;
}
@@ -91,6 +95,40 @@ export function useCopilotReply() {
}
}
+ /**
+ * Sends a follow-up message to refine the current generated content.
+ * @param {string} message - The follow-up message from the user
+ */
+ async function sendFollowUp(message) {
+ if (!followUpContext.value || !message.trim()) return;
+
+ abortController.value = new AbortController();
+ isGenerating.value = true;
+ isContentReady.value = false;
+
+ try {
+ const { message: content, followUpContext: updatedContext } =
+ await followUp({
+ followUpContext: followUpContext.value,
+ message,
+ signal: abortController.value.signal,
+ });
+
+ if (!abortController.value?.signal.aborted) {
+ if (content) {
+ generatedContent.value = content;
+ followUpContext.value = updatedContext;
+ showEditor.value = true;
+ }
+ isGenerating.value = false;
+ }
+ } catch {
+ if (!abortController.value?.signal.aborted) {
+ isGenerating.value = false;
+ }
+ }
+ }
+
/**
* Accepts the generated content and returns it.
* Note: Formatting is automatically stripped by the Editor component's
@@ -108,6 +146,7 @@ export function useCopilotReply() {
isGenerating,
isContentReady,
generatedContent,
+ followUpContext,
isActive,
isButtonDisabled,
@@ -117,6 +156,7 @@ export function useCopilotReply() {
toggleEditor,
setContentReady,
execute,
+ sendFollowUp,
accept,
};
}
diff --git a/app/javascript/dashboard/composables/utils/useKbd.js b/app/javascript/dashboard/composables/utils/useKbd.js
index 800c270c4..03bbf19d2 100644
--- a/app/javascript/dashboard/composables/utils/useKbd.js
+++ b/app/javascript/dashboard/composables/utils/useKbd.js
@@ -1,14 +1,25 @@
import { computed } from 'vue';
+function isMacOS() {
+ // Check modern userAgentData API first
+ if (navigator.userAgentData?.platform) {
+ return navigator.userAgentData.platform === 'macOS';
+ }
+ // Fallback to navigator.platform
+ return (
+ navigator.platform.startsWith('Mac') || navigator.platform === 'iPhone'
+ );
+}
+
export function useKbd(keys) {
const keySymbols = {
- $mod: navigator.platform.includes('Mac') ? '⌘' : 'Ctrl',
+ $mod: isMacOS() ? '⌘' : 'Ctrl',
shift: '⇧',
alt: '⌥',
ctrl: 'Ctrl',
cmd: '⌘',
option: '⌥',
- enter: '↩',
+ enter: '↵',
tab: '⇥',
esc: '⎋',
};
@@ -16,7 +27,11 @@ export function useKbd(keys) {
return computed(() => {
return keys
.map(key => keySymbols[key.toLowerCase()] || key)
- .join('')
+ .join(' ')
.toUpperCase();
});
}
+
+export function getModifierKey() {
+ return isMacOS() ? '⌘' : 'Ctrl';
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 44762d0a4..26c96e3ab 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -186,7 +186,7 @@
"MSG_INPUT": "Shift + enter for new line. Start with '/' to select a Canned Response.",
"PRIVATE_MSG_INPUT": "Shift + enter for new line. This will be visible only to Agents",
"MESSAGE_SIGNATURE_NOT_CONFIGURED": "Message signature is not configured, please configure it in profile settings.",
- "COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else...",
+ "COPILOT_MSG_INPUT": "Give copilot additional prompts, or ask anything else... Press enter to send follow-up",
"CLICK_HERE": "Click here to update",
"WHATSAPP_TEMPLATES": "Whatsapp Templates"
},
diff --git a/app/javascript/dashboard/i18n/locale/en/general.json b/app/javascript/dashboard/i18n/locale/en/general.json
index 98f724223..737bb2b33 100644
--- a/app/javascript/dashboard/i18n/locale/en/general.json
+++ b/app/javascript/dashboard/i18n/locale/en/general.json
@@ -8,6 +8,8 @@
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it.",
+ "ACCEPT": "Accept",
+ "DISCARD": "Discard",
"PREFERRED": "Preferred"
}
}
diff --git a/app/policies/captain/tasks_policy.rb b/app/policies/captain/tasks_policy.rb
index d845da5cb..997b8fcda 100644
--- a/app/policies/captain/tasks_policy.rb
+++ b/app/policies/captain/tasks_policy.rb
@@ -14,4 +14,8 @@ class Captain::TasksPolicy < ApplicationPolicy
def label_suggestion?
true
end
+
+ def follow_up?
+ true
+ end
end
diff --git a/config/routes.rb b/config/routes.rb
index 42c131926..d9f0a40aa 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -78,6 +78,7 @@ Rails.application.routes.draw do
post :summarize
post :reply_suggestion
post :label_suggestion
+ post :follow_up
end
end
resource :saml_settings, only: [:show, :create, :update, :destroy]
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
index 9feea7c5f..3e7cd0e38 100644
--- a/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/captain/tasks_controller.rb
@@ -39,6 +39,17 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
render_result(result)
end
+ def follow_up
+ result = Captain::FollowUpService.new(
+ account: Current.account,
+ follow_up_context: params[:follow_up_context]&.to_unsafe_h,
+ user_message: params[:message],
+ conversation_display_id: params[:conversation_display_id]
+ ).perform
+
+ render_result(result)
+ end
+
private
def render_result(result)
@@ -47,7 +58,9 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
else
- render json: { message: result[:message] }
+ response_data = { message: result[:message] }
+ response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
+ render json: response_data
end
end
diff --git a/lib/captain/base_task_service.rb b/lib/captain/base_task_service.rb
index 65b70d749..c60edf686 100644
--- a/lib/captain/base_task_service.rb
+++ b/lib/captain/base_task_service.rb
@@ -29,9 +29,16 @@ class Captain::BaseTaskService
def make_api_call(model:, messages:)
instrumentation_params = build_instrumentation_params(model, messages)
- instrument_llm_call(instrumentation_params) do
+ response = instrument_llm_call(instrumentation_params) do
execute_ruby_llm_request(model: model, messages: messages)
end
+
+ # Build follow-up context for client-side refinement, when applicable
+ if build_follow_up_context? && response[:message].present?
+ response.merge(follow_up_context: build_follow_up_context(messages, response))
+ else
+ response
+ end
end
def execute_ruby_llm_request(model:, messages:)
@@ -118,4 +125,25 @@ class Captain::BaseTaskService
def prompt_from_file(file_name)
Rails.root.join('lib/integrations/openai/openai_prompts', "#{file_name}.liquid").read
end
+
+ # Follow-up context for client-side refinement
+ def build_follow_up_context?
+ # FollowUpService should return its own updated context
+ !is_a?(Captain::FollowUpService)
+ end
+
+ def build_follow_up_context(messages, response)
+ {
+ event_name: event_name,
+ original_context: extract_original_context(messages),
+ last_response: response[:message],
+ conversation_history: []
+ }
+ end
+
+ def extract_original_context(messages)
+ # Get the most recent user message for follow-up context
+ user_msg = messages.reverse.find { |m| m[:role] == 'user' }
+ user_msg ? user_msg[:content] : nil
+ end
end
diff --git a/lib/captain/follow_up_service.rb b/lib/captain/follow_up_service.rb
new file mode 100644
index 000000000..4b925f396
--- /dev/null
+++ b/lib/captain/follow_up_service.rb
@@ -0,0 +1,98 @@
+class Captain::FollowUpService < Captain::BaseTaskService
+ pattr_initialize [:account!, :follow_up_context!, :user_message!, { conversation_display_id: nil }]
+
+ ALLOWED_EVENT_NAMES = %w[
+ professional
+ casual
+ friendly
+ confident
+ straightforward
+ fix_spelling_grammar
+ improve
+ summarize
+ reply_suggestion
+ label_suggestion
+ ].freeze
+
+ def perform
+ return { error: 'Follow-up context missing', error_code: 400 } unless valid_follow_up_context?
+
+ # Build context-aware system prompt
+ system_prompt = build_follow_up_system_prompt(follow_up_context)
+
+ # Build full message array (convert history from string keys to symbol keys)
+ history = follow_up_context['conversation_history'].to_a.map do |msg|
+ { role: msg['role'], content: msg['content'] }
+ end
+
+ messages = [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: follow_up_context['original_context'] },
+ { role: 'assistant', content: follow_up_context['last_response'] },
+ *history,
+ { role: 'user', content: user_message }
+ ]
+
+ response = make_api_call(model: GPT_MODEL, messages: messages)
+ return response if response[:error]
+
+ response.merge(follow_up_context: update_follow_up_context(user_message, response[:message]))
+ end
+
+ private
+
+ def build_follow_up_system_prompt(session_data)
+ action_context = describe_previous_action(session_data['event_name'])
+
+ <<~PROMPT
+ You just performed a #{action_context} action for a customer support agent.
+ Your job now is to help them refine the result based on their feedback.
+ Be concise and focused on their specific request.
+ PROMPT
+ end
+
+ def describe_previous_action(event_name)
+ case event_name
+ when 'professional', 'casual', 'friendly', 'confident', 'straightforward'
+ "tone rewrite (#{event_name})"
+ when 'fix_spelling_grammar'
+ 'spelling and grammar correction'
+ when 'improve'
+ 'message improvement'
+ when 'summarize'
+ 'conversation summary'
+ when 'reply_suggestion'
+ 'reply suggestion'
+ when 'label_suggestion'
+ 'label suggestion'
+ else
+ event_name
+ end
+ end
+
+ def valid_follow_up_context?
+ return false unless follow_up_context.is_a?(Hash)
+ return false unless ALLOWED_EVENT_NAMES.include?(follow_up_context['event_name'])
+
+ required_keys = %w[event_name original_context last_response]
+ required_keys.all? { |key| follow_up_context[key].present? }
+ end
+
+ def update_follow_up_context(user_msg, assistant_msg)
+ updated_history = follow_up_context['conversation_history'].to_a + [
+ { 'role' => 'user', 'content' => user_msg },
+ { 'role' => 'assistant', 'content' => assistant_msg }
+ ]
+
+ {
+ 'event_name' => follow_up_context['event_name'],
+ 'original_context' => follow_up_context['original_context'],
+ 'last_response' => assistant_msg,
+ 'conversation_history' => updated_history
+ }
+ end
+
+ def event_name
+ 'follow_up'
+ end
+end
diff --git a/lib/captain/label_suggestion_service.rb b/lib/captain/label_suggestion_service.rb
index 1ca817fb5..02f8bd89a 100644
--- a/lib/captain/label_suggestion_service.rb
+++ b/lib/captain/label_suggestion_service.rb
@@ -86,4 +86,8 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
def event_name
'label_suggestion'
end
+
+ def build_follow_up_context?
+ false
+ end
end
diff --git a/lib/integrations/openai/processor_service.rb b/lib/integrations/openai/processor_service.rb
deleted file mode 100644
index 796c24be1..000000000
--- a/lib/integrations/openai/processor_service.rb
+++ /dev/null
@@ -1,152 +0,0 @@
-class Integrations::Openai::ProcessorService < Integrations::LlmBaseService
- def reply_suggestion_message
- make_api_call(reply_suggestion_body)
- end
-
- def summarize_message
- make_api_call(summarize_body)
- end
-
- def fix_spelling_grammar_message
- call_llm_with_prompt(fix_spelling_grammar_prompt)
- end
-
- def confident_message
- call_llm_with_prompt(tone_rewrite_prompt('confident'))
- end
-
- def straightforward_message
- call_llm_with_prompt(tone_rewrite_prompt('straightforward'))
- end
-
- def casual_message
- call_llm_with_prompt(tone_rewrite_prompt('casual'))
- end
-
- def friendly_message
- call_llm_with_prompt(tone_rewrite_prompt('friendly'))
- end
-
- def professional_message
- call_llm_with_prompt(tone_rewrite_prompt('professional'))
- end
-
- def improve_message
- template = prompt_from_file('improve')
-
- system_prompt = render_liquid_template(template, {
- 'conversation_context' => conversation.to_llm_text(include_contact_details: true),
- 'draft_message' => event['data']['content']
- })
-
- call_llm_with_prompt(system_prompt, event['data']['content'])
- end
-
- private
-
- def call_llm_with_prompt(system_content, user_content = event['data']['content'])
- body = {
- model: GPT_MODEL,
- messages: [
- { role: 'system', content: system_content },
- { role: 'user', content: user_content }
- ],
- reasoning_effort: 'low' # TODO: make this configurable
- }.to_json
- make_api_call(body)
- end
-
- def prompt_from_file(file_name, enterprise: false)
- path = enterprise ? 'enterprise/lib/enterprise/integrations/openai_prompts' : 'lib/integrations/openai/openai_prompts'
- Rails.root.join(path, "#{file_name}.liquid").read
- end
-
- def render_liquid_template(template_content, variables = {})
- Liquid::Template.parse(template_content).render(variables)
- end
-
- def tone_rewrite_prompt(tone)
- template = prompt_from_file('tone_rewrite')
- render_liquid_template(template, 'tone' => tone)
- end
-
- def fix_spelling_grammar_prompt
- prompt_from_file('fix_spelling_grammar')
- end
-
- # TODO: Replace with LlmFormattable or enterprise/lib/captain/prompts/snippets/conversation.liquid
- def conversation_messages(in_array_format: false)
- messages = init_messages_body(in_array_format)
-
- add_messages_until_token_limit(conversation, messages, in_array_format)
- end
-
- def add_messages_until_token_limit(conversation, messages, in_array_format, start_from = 0)
- character_count = start_from
- conversation.messages.where(message_type: [:incoming, :outgoing]).where(private: false).reorder('id desc').each do |message|
- character_count, message_added = add_message_if_within_limit(character_count, message, messages, in_array_format)
- break unless message_added
- end
- messages
- end
-
- def add_message_if_within_limit(character_count, message, messages, in_array_format)
- content = message.content_for_llm
- if valid_message?(content, character_count)
- add_message_to_list(message, messages, in_array_format, content)
- character_count += content.length
- [character_count, true]
- else
- [character_count, false]
- end
- end
-
- def valid_message?(content, character_count)
- content.present? && character_count + content.length <= TOKEN_LIMIT
- end
-
- def add_message_to_list(message, messages, in_array_format, content)
- formatted_message = format_message(message, in_array_format, content)
- messages.prepend(formatted_message)
- end
-
- def init_messages_body(in_array_format)
- in_array_format ? [] : ''
- end
-
- def format_message(message, in_array_format, content)
- in_array_format ? format_message_in_array(message, content) : format_message_in_string(message, content)
- end
-
- def format_message_in_array(message, content)
- { role: (message.incoming? ? 'user' : 'assistant'), content: content }
- end
-
- def format_message_in_string(message, content)
- sender_type = message.incoming? ? 'Customer' : 'Agent'
- "#{sender_type} #{message.sender&.name} : #{content}\n"
- end
-
- def summarize_body
- {
- model: GPT_MODEL,
- messages: [
- { role: 'system',
- content: prompt_from_file('summary', enterprise: false) },
- { role: 'user', content: conversation_messages }
- ]
- }.to_json
- end
-
- def reply_suggestion_body
- {
- model: GPT_MODEL,
- messages: [
- { role: 'system',
- content: prompt_from_file('reply', enterprise: false) }
- ].concat(conversation_messages(in_array_format: true))
- }.to_json
- end
-end
-
-Integrations::Openai::ProcessorService.prepend_mod_with('Integrations::OpenaiProcessorService')
diff --git a/spec/lib/captain/base_task_service_spec.rb b/spec/lib/captain/base_task_service_spec.rb
index c35f651c5..ff3e97105 100644
--- a/spec/lib/captain/base_task_service_spec.rb
+++ b/spec/lib/captain/base_task_service_spec.rb
@@ -247,4 +247,37 @@ RSpec.describe Captain::BaseTaskService do
expect(service.send(:prompt_from_file, 'test')).to eq('Test prompt content')
end
end
+
+ describe '#extract_original_context' do
+ it 'returns the most recent user message' do
+ messages = [
+ { role: 'user', content: 'First question' },
+ { role: 'assistant', content: 'First response' },
+ { role: 'user', content: 'Follow-up question' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to eq('Follow-up question')
+ end
+
+ it 'returns nil when no user messages exist' do
+ messages = [
+ { role: 'system', content: 'System prompt' },
+ { role: 'assistant', content: 'Response' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to be_nil
+ end
+
+ it 'returns the only user message when there is just one' do
+ messages = [
+ { role: 'system', content: 'System prompt' },
+ { role: 'user', content: 'Single question' }
+ ]
+
+ result = service.send(:extract_original_context, messages)
+ expect(result).to eq('Single question')
+ end
+ end
end
diff --git a/spec/lib/captain/follow_up_service_spec.rb b/spec/lib/captain/follow_up_service_spec.rb
new file mode 100644
index 000000000..77648ab37
--- /dev/null
+++ b/spec/lib/captain/follow_up_service_spec.rb
@@ -0,0 +1,157 @@
+require 'rails_helper'
+
+RSpec.describe Captain::FollowUpService do
+ let(:account) { create(:account) }
+ let(:inbox) { create(:inbox, account: account) }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:user_message) { 'Make it more concise' }
+ let(:follow_up_context) do
+ {
+ 'event_name' => 'professional',
+ 'original_context' => 'Please help me with this issue',
+ 'last_response' => 'I would be happy to assist you with this matter.',
+ 'conversation_history' => [
+ { 'role' => 'user', 'content' => 'Make it shorter' },
+ { 'role' => 'assistant', 'content' => 'Happy to help with this.' }
+ ]
+ }
+ end
+ let(:service) do
+ described_class.new(
+ account: account,
+ follow_up_context: follow_up_context,
+ user_message: user_message,
+ conversation_display_id: conversation.display_id
+ )
+ end
+
+ describe '#perform' do
+ context 'when conversation_display_id is provided' do
+ it 'resolves conversation for instrumentation' do
+ expect(service.send(:conversation)).to eq(conversation)
+ end
+ end
+
+ context 'when follow-up context exists' do
+ it 'constructs messages array with full conversation history' do
+ expect(service).to receive(:make_api_call) do |args|
+ messages = args[:messages]
+
+ expect(messages).to match(
+ [
+ a_hash_including(role: 'system', content: include('tone rewrite (professional)')),
+ { role: 'user', content: 'Please help me with this issue' },
+ { role: 'assistant', content: 'I would be happy to assist you with this matter.' },
+ { role: 'user', content: 'Make it shorter' },
+ { role: 'assistant', content: 'Happy to help with this.' },
+ { role: 'user', content: 'Make it more concise' }
+ ]
+ )
+
+ { message: 'Refined response' }
+ end
+
+ service.perform
+ end
+
+ it 'returns updated follow-up context' do
+ allow(service).to receive(:make_api_call).and_return({ message: 'Refined response' })
+
+ result = service.perform
+
+ expect(result[:message]).to eq('Refined response')
+ expect(result[:follow_up_context]['last_response']).to eq('Refined response')
+ expect(result[:follow_up_context]['conversation_history'].length).to eq(4)
+ expect(result[:follow_up_context]['conversation_history'][-2]['content']).to eq('Make it more concise')
+ expect(result[:follow_up_context]['conversation_history'][-1]['content']).to eq('Refined response')
+ end
+ end
+
+ context 'when follow-up context is missing' do
+ let(:follow_up_context) { nil }
+
+ it 'returns error with 400 code' do
+ result = service.perform
+
+ expect(result[:error]).to eq('Follow-up context missing')
+ expect(result[:error_code]).to eq(400)
+ end
+ end
+ end
+
+ describe '#build_follow_up_system_prompt' do
+ it 'describes tone rewrite actions' do
+ %w[professional casual friendly confident straightforward].each do |tone|
+ session = { 'event_name' => tone }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include("tone rewrite (#{tone})")
+ expect(prompt).to include('help them refine the result')
+ end
+ end
+
+ it 'describes fix_spelling_grammar action' do
+ session = { 'event_name' => 'fix_spelling_grammar' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('spelling and grammar correction')
+ end
+
+ it 'describes improve action' do
+ session = { 'event_name' => 'improve' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('message improvement')
+ end
+
+ it 'describes summarize action' do
+ session = { 'event_name' => 'summarize' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('conversation summary')
+ end
+
+ it 'describes reply_suggestion action' do
+ session = { 'event_name' => 'reply_suggestion' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('reply suggestion')
+ end
+
+ it 'describes label_suggestion action' do
+ session = { 'event_name' => 'label_suggestion' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('label suggestion')
+ end
+
+ it 'uses event_name directly for unknown actions' do
+ session = { 'event_name' => 'custom_action' }
+ prompt = service.send(:build_follow_up_system_prompt, session)
+
+ expect(prompt).to include('custom_action')
+ end
+ end
+
+ describe '#describe_previous_action' do
+ it 'returns tone description for tone operations' do
+ expect(service.send(:describe_previous_action, 'professional')).to eq('tone rewrite (professional)')
+ expect(service.send(:describe_previous_action, 'casual')).to eq('tone rewrite (casual)')
+ expect(service.send(:describe_previous_action, 'friendly')).to eq('tone rewrite (friendly)')
+ expect(service.send(:describe_previous_action, 'confident')).to eq('tone rewrite (confident)')
+ expect(service.send(:describe_previous_action, 'straightforward')).to eq('tone rewrite (straightforward)')
+ end
+
+ it 'returns specific descriptions for other operations' do
+ expect(service.send(:describe_previous_action, 'fix_spelling_grammar')).to eq('spelling and grammar correction')
+ expect(service.send(:describe_previous_action, 'improve')).to eq('message improvement')
+ expect(service.send(:describe_previous_action, 'summarize')).to eq('conversation summary')
+ expect(service.send(:describe_previous_action, 'reply_suggestion')).to eq('reply suggestion')
+ expect(service.send(:describe_previous_action, 'label_suggestion')).to eq('label suggestion')
+ end
+
+ it 'returns event name for unknown operations' do
+ expect(service.send(:describe_previous_action, 'unknown')).to eq('unknown')
+ end
+ end
+end