fix: playground captain v2 scenarios (#13747)

# Pull Request Template

## Description

Playground now uses v2. It was only wired to use v1. Traces get `source:
playground` on langfuse when playground has been used.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

locally and specs
<img width="1806" height="1276" alt="image"
src="https://github.com/user-attachments/assets/41ef4eb3-52b1-4b8e-9a4f-e8510c90cb39"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
Aakash Bakhle
2026-03-11 14:05:16 +05:30
committed by GitHub
parent dbe35252bc
commit 87f5af4caa
6 changed files with 120 additions and 30 deletions

View File

@@ -18,10 +18,18 @@ const newMessage = ref('');
const isLoading = ref(false); const isLoading = ref(false);
const formatMessagesForApi = () => { const formatMessagesForApi = () => {
return messages.value.map(message => ({ return messages.value.map(message => {
role: message.sender, const payload = {
content: message.content, role: message.sender,
})); content: message.content,
};
if (message.sender === 'assistant' && message.agentName) {
payload.agent_name = message.agentName;
}
return payload;
});
}; };
const resetConversation = () => { const resetConversation = () => {
@@ -62,6 +70,7 @@ const sendMessage = async () => {
messages.value.push({ messages.value.push({
content: data.response, content: data.response,
sender: 'assistant', sender: 'assistant',
agentName: data.agent_name,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}); });
} catch (error) { } catch (error) {

View File

@@ -24,10 +24,16 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end end
def playground def playground
response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response( response = if captain_v2_enabled?
additional_message: params[:message_content], Captain::Assistant::AgentRunnerService.new(assistant: @assistant, source: 'playground').generate_response(
message_history: message_history message_history: playground_message_history
) )
else
Captain::Llm::AssistantChatService.new(assistant: @assistant, source: 'playground').generate_response(
additional_message: playground_params[:message_content],
message_history: message_history
)
end
render json: response render json: response
end end
@@ -64,10 +70,31 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end end
def playground_params def playground_params
params.require(:assistant).permit(:message_content, message_history: [:role, :content]) params.require(:assistant).permit(:message_content, message_history: [:role, :content, :agent_name])
end end
def message_history def message_history
(playground_params[:message_history] || []).map { |message| { role: message[:role], content: message[:content] } } (playground_params[:message_history] || []).map do |message|
{
role: message[:role],
content: message[:content],
agent_name: message[:agent_name]
}.compact
end
end
def playground_message_history
history = message_history
current_message = playground_params[:message_content]
return history if current_message.blank?
current_user_message = { role: 'user', content: current_message }
return history if history.last == current_user_message
history + [current_user_message]
end
def captain_v2_enabled?
@assistant.account.feature_enabled?('captain_integration_v2')
end end
end end

View File

@@ -5,7 +5,6 @@ module Captain::ChatHelper
def request_chat_completion def request_chat_completion
log_chat_completion_request log_chat_completion_request
chat = build_chat chat = build_chat
add_messages_to_chat(chat) add_messages_to_chat(chat)
@@ -86,7 +85,8 @@ module Captain::ChatHelper
temperature: temperature, temperature: temperature,
metadata: { metadata: {
assistant_id: @assistant&.id, assistant_id: @assistant&.id,
channel_type: resolved_channel_type channel_type: resolved_channel_type,
source: @source
}.compact }.compact
} }
end end
@@ -130,7 +130,6 @@ module Captain::ChatHelper
end end
def log_chat_completion_request def log_chat_completion_request
Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, Requesting chat completion " \ Rails.logger.info("#{self.class.name} Assistant: #{@assistant.id}, requesting completion for #{@messages} with #{@tools&.length || 0} tools")
"for messages #{@messages} with #{@tools&.length || 0} tools")
end end
end end

View File

@@ -19,11 +19,11 @@ class Captain::Assistant::AgentRunnerService
CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze CONTACT_INBOX_STATE_ATTRIBUTES = %i[id hmac_verified].freeze
CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze CAMPAIGN_STATE_ATTRIBUTES = %i[id title message campaign_type description].freeze
def initialize(assistant:, conversation: nil, callbacks: {}, source: nil)
def initialize(assistant:, conversation: nil, callbacks: {})
@assistant = assistant @assistant = assistant
@conversation = conversation @conversation = conversation
@callbacks = callbacks @callbacks = callbacks
@source = source
end end
def generate_response(message_history: []) def generate_response(message_history: [])
@@ -32,8 +32,7 @@ class Captain::Assistant::AgentRunnerService
process_agent_result(result) process_agent_result(result)
rescue StandardError => e rescue StandardError => e
# when running the agent runner service in a rake task, the conversation might not have an account associated # In rake/local runs, conversation may not be present, so account is optional here.
# for regular production usage, it will run just fine
ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception ChatwootExceptionTracker.new(e, account: @conversation&.account).capture_exception
Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}" Rails.logger.error "[Captain V2] AgentRunnerService error: #{e.message}"
Rails.logger.error e.backtrace.join("\n") Rails.logger.error e.backtrace.join("\n")
@@ -128,6 +127,7 @@ class Captain::Assistant::AgentRunnerService
assistant_id: @assistant.id, assistant_id: @assistant.id,
assistant_config: @assistant.config assistant_config: @assistant.config
} }
state[:source] = @source if @source.present?
build_conversation_state(state) if @conversation build_conversation_state(state) if @conversation
state state
@@ -140,8 +140,7 @@ class Captain::Assistant::AgentRunnerService
state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign state[:campaign] = @conversation.campaign.attributes.symbolize_keys.slice(*CAMPAIGN_STATE_ATTRIBUTES) if @conversation.campaign
return unless @conversation.contact_inbox return unless @conversation.contact_inbox
state[:contact_inbox] = state[:contact_inbox] = @conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
@conversation.contact_inbox.attributes.symbolize_keys.slice(*CONTACT_INBOX_STATE_ATTRIBUTES)
end end
def build_and_wire_agents def build_and_wire_agents
@@ -180,6 +179,7 @@ class Captain::Assistant::AgentRunnerService
format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id], format(ATTR_LANGFUSE_METADATA, 'conversation_id') => conversation[:id],
format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id], format(ATTR_LANGFUSE_METADATA, 'conversation_display_id') => conversation[:display_id],
format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type], format(ATTR_LANGFUSE_METADATA, 'channel_type') => state[:channel_type],
format(ATTR_LANGFUSE_METADATA, 'source') => state[:source],
ATTR_LANGFUSE_TRACE_INPUT => trace_input, ATTR_LANGFUSE_TRACE_INPUT => trace_input,
ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input ATTR_LANGFUSE_OBSERVATION_INPUT => trace_input
}.compact.transform_values(&:to_s) }.compact.transform_values(&:to_s)

View File

@@ -1,11 +1,12 @@
class Captain::Llm::AssistantChatService < Llm::BaseAiService class Captain::Llm::AssistantChatService < Llm::BaseAiService
include Captain::ChatHelper include Captain::ChatHelper
def initialize(assistant: nil, conversation_id: nil) def initialize(assistant: nil, conversation_id: nil, source: nil)
super() super()
@assistant = assistant @assistant = assistant
@conversation_id = conversation_id @conversation_id = conversation_id
@source = source
@messages = [system_message] @messages = [system_message]
@response = '' @response = ''

View File

@@ -259,10 +259,12 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
message_content: 'Hello assistant', message_content: 'Hello assistant',
message_history: [ message_history: [
{ role: 'user', content: 'Previous message' }, { role: 'user', content: 'Previous message' },
{ role: 'assistant', content: 'Previous response' } { role: 'assistant', content: 'Previous response', agent_name: 'billing_scenario' }
] ]
} }
end end
let(:chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
context 'when it is an un-authenticated user' do context 'when it is an un-authenticated user' do
it 'returns unauthorized' do it 'returns unauthorized' do
@@ -274,11 +276,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
end end
end end
context 'when it is an agent' do context 'when captain v2 is disabled' do
it 'generates a response' do it 'generates a response with the legacy assistant chat service' do
chat_service = instance_double(Captain::Llm::AssistantChatService) allow(Captain::Llm::AssistantChatService).to receive(:new).with(
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params, params: valid_params,
@@ -292,14 +297,15 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
) )
expect(json_response[:content]).to eq('Assistant response') expect(json_response[:content]).to eq('Assistant response')
end end
end
context 'when message_history is not provided' do
it 'uses empty array as default' do it 'uses empty array as default' do
params_without_history = { message_content: 'Hello assistant' } params_without_history = { message_content: 'Hello assistant' }
chat_service = instance_double(Captain::Llm::AssistantChatService) allow(Captain::Llm::AssistantChatService).to receive(:new).with(
allow(Captain::Llm::AssistantChatService).to receive(:new).with(assistant: assistant).and_return(chat_service) assistant: assistant,
source: 'playground'
).and_return(chat_service)
allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' }) allow(chat_service).to receive(:generate_response).and_return({ content: 'Assistant response' })
expect(Captain::Assistant::AgentRunnerService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground", post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_without_history, params: params_without_history,
@@ -313,5 +319,53 @@ RSpec.describe 'Api::V1::Accounts::Captain::Assistants', type: :request do
) )
end end
end end
context 'when captain v2 is enabled' do
before do
account.enable_features('captain_integration_v2')
end
it 'generates a response with the agent runner service' do
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
expect(Captain::Llm::AssistantChatService).not_to receive(:new)
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: valid_params,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent_runner_service).to have_received(:generate_response).with(
message_history: valid_params[:message_history] + [{ role: 'user', content: valid_params[:message_content] }]
)
expect(json_response[:response]).to eq('Assistant response')
end
it 'does not duplicate the latest user message if it is already in history' do
params_with_latest_message = {
message_content: 'Hello assistant',
message_history: [{ role: 'user', content: 'Hello assistant' }]
}
allow(Captain::Assistant::AgentRunnerService).to receive(:new).with(
assistant: assistant,
source: 'playground'
).and_return(agent_runner_service)
allow(agent_runner_service).to receive(:generate_response).and_return({ response: 'Assistant response' })
post "/api/v1/accounts/#{account.id}/captain/assistants/#{assistant.id}/playground",
params: params_with_latest_message,
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(agent_runner_service).to have_received(:generate_response).with(
message_history: params_with_latest_message[:message_history]
)
end
end
end end
end end