feat: reply editor follow-up (#13106)

Co-authored-by: aakashb95 <aakashbakhle@gmail.com>
This commit is contained in:
Shivam Mishra
2026-01-13 14:29:11 +05:30
committed by GitHub
co-authored by aakashb95
parent 7f057127b0
commit 82f5dbe6c1
21 changed files with 507 additions and 186 deletions
@@ -108,6 +108,27 @@ class TasksAPI extends ApiClient {
{ signal }
);
}
/**
* Sends a follow-up message to continue refining a previous 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 {string} [options.conversationId] - The conversation ID for Langfuse session tracking.
* @param {AbortSignal} [signal] - AbortSignal to cancel the request.
* @returns {Promise} A promise that resolves with the follow-up response and updated follow-up context.
*/
followUp({ followUpContext, message, conversationId }, signal) {
return axios.post(
`${this.url}/follow_up`,
{
follow_up_context: followUpContext,
message,
conversation_display_id: conversationId,
},
{ signal }
);
}
}
export default new TasksAPI();
@@ -50,7 +50,8 @@ export default {
async generateAIContent(type = 'improve') {
this.isGenerating = true;
this.generatedContent = await this.processEvent(type);
const { message } = await this.processEvent(type);
this.generatedContent = message;
this.isGenerating = false;
},
applyText() {
@@ -10,7 +10,7 @@ import DropdownBody from 'next/dropdown-menu/base/DropdownBody.vue';
import Icon from 'next/icon/Icon.vue';
const props = defineProps({
defineProps({
hasSelection: {
type: Boolean,
default: false,
@@ -28,7 +28,12 @@ const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
// Selection-based menu items (when text is selected)
const menuItems = computed(() => {
const items = [];
if (props.hasSelection) {
// for now, we don't allow improving just aprt of the selection
// we will add this feature later. Once we do, we can revert the change
const hasSelection = false;
// const hasSelection = props.hasSelection
if (hasSelection) {
items.push({
label: t(
'INTEGRATION_SETTINGS.OPEN_AI.REPLY_OPTIONS.IMPROVE_REPLY_SELECTION'
@@ -1,35 +1,37 @@
<script setup>
import { computed } from 'vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
import { useKbd } from 'dashboard/composables/utils/useKbd';
defineProps({
isGeneratingContent: {
type: Boolean,
default: false,
},
isPrivate: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const handleCancel = () => {
emit('cancel');
};
const shortcutKey = useKbd(['$mod', '+', 'enter']);
const acceptLabel = computed(() => {
return `${t('GENERAL.ACCEPT')} (${shortcutKey.value})`;
});
const handleSubmit = () => {
emit('submit');
};
</script>
<template>
<div
class="flex justify-between items-center p-3 border-t"
:class="{ 'border-n-weak': !isPrivate, 'border-n-amber-12/5': isPrivate }"
>
<div class="flex justify-between items-center p-3 pt-0">
<NextButton
label="Discard"
:label="t('GENERAL.DISCARD')"
slate
link
class="!px-1 hover:!no-underline"
@@ -38,7 +40,7 @@ const handleSubmit = () => {
@click="handleCancel"
/>
<NextButton
label="Accept"
:label="acceptLabel"
class="bg-n-iris-9 text-white"
solid
sm
@@ -56,7 +56,6 @@ import {
getFormattingForEditor,
getSelectionCoords,
calculateMenuPosition,
stripUnsupportedFormatting,
getEffectiveChannelType,
stripUnsupportedFormatting,
} from 'dashboard/helper/editorHelper';
@@ -22,7 +22,13 @@ defineProps({
},
});
const emit = defineEmits(['focus', 'blur', 'clearSelection', 'contentReady']);
const emit = defineEmits([
'focus',
'blur',
'clearSelection',
'contentReady',
'send',
]);
const copilotEditorContent = ref('');
@@ -37,6 +43,11 @@ const onBlur = () => {
const clearEditorSelection = () => {
emit('clearSelection');
};
const onSend = () => {
emit('send', copilotEditorContent.value);
copilotEditorContent.value = '';
};
</script>
<template>
@@ -62,6 +73,7 @@ const clearEditorSelection = () => {
@focus="onFocus"
@blur="onBlur"
@clear-selection="clearEditorSelection"
@send="onSend"
/>
<div
v-else-if="isGeneratingContent"
@@ -48,6 +48,7 @@ import {
getEffectiveChannelType,
} from 'dashboard/helper/editorHelper';
import { useCopilotReply } from 'dashboard/composables/useCopilotReply';
import { useKbd } from 'dashboard/composables/utils/useKbd';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
@@ -94,6 +95,7 @@ export default {
const replyEditor = useTemplateRef('replyEditor');
const copilot = useCopilotReply();
const shortcutKey = useKbd(['$mod', '+', 'enter']);
return {
uiSettings,
@@ -103,6 +105,7 @@ export default {
fetchQuotedReplyFlagFromUISettings,
replyEditor,
copilot,
shortcutKey,
};
},
data() {
@@ -273,7 +276,7 @@ export default {
sendMessageText = this.$t('CONVERSATION.REPLYBOX.CREATE');
}
const keyLabel = this.isEditorHotKeyEnabled('cmd_enter')
? '(⌘ + ↵)'
? `(${this.shortcutKey})`
: '(↵)';
return `${sendMessageText} ${keyLabel}`;
},
@@ -624,7 +627,9 @@ export default {
},
'$mod+Enter': {
action: () => {
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"
/>
<WootMessageEditor
v-else-if="!showAudioRecorderEditor"
@@ -1240,7 +1246,6 @@ export default {
<CopilotReplyBottomPanel
v-if="copilot.isActive.value"
key="copilot-bottom-panel"
:is-private="isOnPrivateNote"
:is-generating-content="copilot.isButtonDisabled.value"
@submit="onSubmitCopilotReply"
@cancel="copilot.toggleEditor"
+38 -5
View File
@@ -154,7 +154,7 @@ export function useAI() {
* @param {string} [content=''] - The content to process (for full message) or selected text (for selection-based).
* @param {Object} [options={}] - Additional options.
* @param {AbortSignal} [options.signal] - AbortSignal to cancel the request.
* @returns {Promise<string>} 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,
};
}
@@ -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,
};
}
@@ -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';
}
@@ -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"
},
@@ -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"
}
}
+4
View File
@@ -14,4 +14,8 @@ class Captain::TasksPolicy < ApplicationPolicy
def label_suggestion?
true
end
def follow_up?
true
end
end
+1
View File
@@ -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]
@@ -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
+29 -1
View File
@@ -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
+98
View File
@@ -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
+4
View File
@@ -86,4 +86,8 @@ class Captain::LabelSuggestionService < Captain::BaseTaskService
def event_name
'label_suggestion'
end
def build_follow_up_context?
false
end
end
@@ -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')
@@ -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
+157
View File
@@ -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