diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js new file mode 100644 index 000000000..a557b5938 --- /dev/null +++ b/app/javascript/dashboard/api/captain/agentSessions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainAgentSessions extends ApiClient { + constructor() { + super('captain/agent_sessions', { accountScoped: true }); + } +} + +export default new CaptainAgentSessions(); diff --git a/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue new file mode 100644 index 000000000..752616109 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue @@ -0,0 +1,342 @@ + + + + + + + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.GENERATED_BY') }} + + + + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.LOADING') }} + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.EMPTY') }} + + + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.TIMELINE') }} + + + + + + + + + + + + + + {{ step.name }} + + + + + {{ step.detail }} + + + + + + + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.SOURCES') }} + + + {{ + t( + 'CONVERSATION.CAPTAIN_GENERATION.SOURCES_SUMMARY', + citations.length + ) + }} + + + + + + {{ citation.title || citation.link }} + + {{ citation.title }} + + + + + + {{ t('CONVERSATION.CAPTAIN_GENERATION.REASONING') }} + + + {{ reasoning }} + + + + {{ devDetails }} + + + + + + + + diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index 457b583ea..e799bd755 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import MessageMeta from '../MessageMeta.vue'; +import CaptainGenerationDetails from '../CaptainGenerationDetails.vue'; import { emitter } from 'shared/helpers/mitt'; import { useMessageContext } from '../provider.js'; @@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n'; import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import { BUS_EVENTS } from 'shared/constants/busEvents'; -import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants'; const props = defineProps({ hideMeta: { type: Boolean, default: false }, }); -const { variant, orientation, inReplyTo, shouldGroupWithNext } = - useMessageContext(); +const { + variant, + orientation, + inReplyTo, + shouldGroupWithNext, + id, + sender, + senderType, +} = useMessageContext(); const { t } = useI18n(); +const isCaptainMessage = computed( + () => + (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT +); + +const metaColorClass = computed(() => + variant.value === MESSAGE_VARIANTS.PRIVATE + ? 'text-n-amber-12/50' + : 'text-n-slate-11' +); + +const emailMetaClass = computed(() => + variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '' +); + const varaintBaseMap = { [MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12', [MESSAGE_VARIANTS.PRIVATE]: @@ -114,16 +137,21 @@ const replyToPreview = computed(() => { /> - + + + + + + + + diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index fe71b8974..f3c81615b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -72,6 +72,20 @@ "RATING_TITLE": "Rating", "FEEDBACK_TITLE": "Feedback", "REPLY_MESSAGE_NOT_FOUND": "Message not available", + "CAPTAIN_GENERATION": { + "TITLE": "How was this reply generated?", + "GENERATED_BY": "Generated by Captain", + "LOADING": "Loading details…", + "EMPTY": "No generation details available for this message.", + "TIMELINE": "Generation steps", + "STEP_TOOL": "Called {name}", + "STEP_HANDOFF": "Handed off to {name}", + "REASONING": "Reasoning", + "SOURCES": "Knowledge base", + "SOURCES_SUMMARY": "{count} result | {count} results", + "MODEL": "Generated with {model}", + "CREDITS": "Credits: {credits}" + }, "CARD": { "SHOW_LABELS": "Show labels", "HIDE_LABELS": "Hide labels", diff --git a/app/javascript/dashboard/store/captain/agentSessions.js b/app/javascript/dashboard/store/captain/agentSessions.js new file mode 100644 index 000000000..e72d2e58c --- /dev/null +++ b/app/javascript/dashboard/store/captain/agentSessions.js @@ -0,0 +1,62 @@ +import CaptainAgentSessionsAPI from 'dashboard/api/captain/agentSessions'; +import camelcaseKeys from 'camelcase-keys'; + +const SET_SESSION = 'SET_SESSION'; +const SET_FETCHING = 'SET_FETCHING'; + +// Session capture runs right after the message is broadcast (and well after, +// for handoff notes created mid-run), so a 404 on a fresh message may just +// mean the session isn't written yet. Skip caching those so a later +// hover/click retries; older misses are permanent (V1 messages, failed runs). +const RECENT_MESSAGE_WINDOW_SECONDS = 60; + +// Caches Captain agent-session metadata per message id. A missing session +// (404) is cached as null so the UI shows an empty state without refetching. +export default { + namespaced: true, + state: { + sessions: {}, + fetchingIds: [], + }, + getters: { + getSessionByMessageId: state => messageId => state.sessions[messageId], + isFetching: state => messageId => state.fetchingIds.includes(messageId), + hasFetched: state => messageId => messageId in state.sessions, + }, + actions: { + fetch: async ({ state, commit }, { messageId, createdAt }) => { + if (messageId in state.sessions) return; + if (state.fetchingIds.includes(messageId)) return; + + commit(SET_FETCHING, { messageId, isFetching: true }); + try { + const { data } = await CaptainAgentSessionsAPI.show(messageId); + commit(SET_SESSION, { + messageId, + session: camelcaseKeys(data, { deep: true }), + }); + } catch (error) { + const isRecentMessage = + createdAt && + Date.now() / 1000 - createdAt < RECENT_MESSAGE_WINDOW_SECONDS; + // Only a 404 means "no session exists"; transient failures (5xx, + // network errors) stay uncached so a later hover retries. + if (error.response?.status === 404 && !isRecentMessage) { + commit(SET_SESSION, { messageId, session: null }); + } + } finally { + commit(SET_FETCHING, { messageId, isFetching: false }); + } + }, + }, + mutations: { + [SET_SESSION](state, { messageId, session }) { + state.sessions = { ...state.sessions, [messageId]: session }; + }, + [SET_FETCHING](state, { messageId, isFetching }) { + state.fetchingIds = isFetching + ? [...state.fetchingIds, messageId] + : state.fetchingIds.filter(id => id !== messageId); + }, + }, +}; diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js index 23685bfad..d0c8e5002 100755 --- a/app/javascript/dashboard/store/index.js +++ b/app/javascript/dashboard/store/index.js @@ -50,6 +50,7 @@ import teamMembers from './modules/teamMembers'; import teams from './modules/teams'; import userNotificationSettings from './modules/userNotificationSettings'; import webhooks from './modules/webhooks'; +import captainAgentSessions from './captain/agentSessions'; import captainAssistants from './captain/assistant'; import captainDocuments from './captain/document'; import captainResponses from './captain/response'; @@ -115,6 +116,7 @@ export default createStore({ teams, userNotificationSettings, webhooks, + captainAgentSessions, captainAssistants, captainDocuments, captainResponses, diff --git a/config/routes.rb b/config/routes.rb index dfdc23727..0bf6b40c8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -76,6 +76,7 @@ Rails.application.routes.draw do resources :inboxes, only: [:index, :create, :destroy], param: :inbox_id resources :scenarios end + resources :agent_sessions, only: [:show] resources :assistant_responses resources :message_reports, only: [:create] resources :bulk_actions, only: [:create] diff --git a/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb new file mode 100644 index 000000000..f9163de04 --- /dev/null +++ b/enterprise/app/controllers/api/v1/accounts/captain/agent_sessions_controller.rb @@ -0,0 +1,25 @@ +class Api::V1::Accounts::Captain::AgentSessionsController < Api::V1::Accounts::BaseController + before_action :set_message + before_action :authorize_conversation + + def show + @agent_session = Current.account.captain_agent_sessions.find_by(result_type: 'Message', result_id: @message.id) + return head :not_found if @agent_session.blank? + + @citations = Current.account.captain_assistant_responses + .where(id: @agent_session.faq_ids) + .includes(:documentable) + @scenario_titles = Captain::Scenario.where(account_id: Current.account.id, id: @agent_session.scenario_ids) + .pluck(:id, :title).to_h + end + + private + + def set_message + @message = Current.account.messages.find(params[:id]) + end + + def authorize_conversation + authorize @message.conversation, :show? + end +end diff --git a/enterprise/app/services/captain/assistant/session_capture_service.rb b/enterprise/app/services/captain/assistant/session_capture_service.rb index ceeb28210..816fe5355 100644 --- a/enterprise/app/services/captain/assistant/session_capture_service.rb +++ b/enterprise/app/services/captain/assistant/session_capture_service.rb @@ -22,13 +22,12 @@ class Captain::Assistant::SessionCaptureService def capture! model = @assistant.agent_model - metadata = context.dig(:state, :cw_metadata) || {} Captain::AgentSession.create!( assistant: @assistant, session_type: :assistant, subject: @conversation, - result: @result_message, + result: result_message, llm_model: "#{Llm::Models.provider_for(model)}-#{model}", credits_consumed: @credits_consumed, faq_ids: metadata[:faq_ids] || [], @@ -44,6 +43,23 @@ class Captain::Assistant::SessionCaptureService @run_result.context || {} end + def metadata + @metadata ||= context.dig(:state, :cw_metadata) || {} + end + + # On handoff, HandoffTool records the private reason note it created; the session + # attaches there so agents can inspect the generation path on the note itself. + def result_message + handoff_note || @result_message + end + + def handoff_note + note_id = metadata[:handoff_note_id] + return if note_id.blank? + + @conversation.messages.find_by(id: note_id) + end + def scenario_ids ids = current_turn_history.filter_map do |message| next unless message[:role].to_s == 'assistant' diff --git a/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder new file mode 100644 index 000000000..1e1cd1d7d --- /dev/null +++ b/enterprise/app/views/api/v1/accounts/captain/agent_sessions/show.json.jbuilder @@ -0,0 +1,18 @@ +json.id @agent_session.id +json.message_id @agent_session.result_id +json.llm_model @agent_session.llm_model +json.credits_consumed @agent_session.credits_consumed +json.run_context @agent_session.run_context.is_a?(Array) ? @agent_session.run_context : [] +json.citations @citations do |citation| + json.id citation.id + json.title citation.question + # display_url resolves uploaded PDFs to their blob URL; external_link holds a + # "PDF: ..." placeholder for those. Guard on scheme so placeholders render as + # plain text instead of dead anchors. + link = citation.documentable.is_a?(Captain::Document) ? citation.documentable.display_url : nil + json.link link&.match?(%r{\Ahttps?://}) ? link : nil +end +json.scenarios @scenario_titles do |id, title| + json.id id + json.title title +end diff --git a/enterprise/lib/captain/tools/handoff_tool.rb b/enterprise/lib/captain/tools/handoff_tool.rb index d126840be..f3ca8b1fe 100644 --- a/enterprise/lib/captain/tools/handoff_tool.rb +++ b/enterprise/lib/captain/tools/handoff_tool.rb @@ -13,7 +13,7 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool }) # Use existing handoff mechanism from ResponseBuilderJob - trigger_handoff(conversation, reason) + trigger_handoff(tool_context, conversation, reason) "Conversation handed off to human support team#{" (Reason: #{reason})" if reason}" rescue StandardError => e @@ -23,9 +23,9 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool private - def trigger_handoff(conversation, reason) + def trigger_handoff(tool_context, conversation, reason) # post the reason as a private note - conversation.messages.create!( + note = conversation.messages.create!( message_type: :outgoing, private: true, sender: @assistant, @@ -34,6 +34,15 @@ class Captain::Tools::HandoffTool < Captain::Tools::BasePublicTool content: reason ) + # Session capture attributes the run to this note so agents can inspect the + # generation path on the handoff reason instead of the canned follow-up message. + # A reason-less note has no content and never renders in the dashboard, so + # leave it unrecorded and let capture fall back to the follow-up message. + if reason.present? + metadata = tool_context.state[:cw_metadata] ||= {} + metadata[:handoff_note_id] = note.id + end + # Trigger the bot handoff (sets status to open + dispatches events) conversation.bot_handoff! diff --git a/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb new file mode 100644 index 000000000..1444dc0a8 --- /dev/null +++ b/spec/enterprise/controllers/api/v1/accounts/captain/agent_sessions_controller_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe 'Api::V1::Accounts::Captain::AgentSessions', type: :request do + let(:account) { create(:account) } + let(:agent) { create(:user, account: account, role: :agent) } + let(:inbox) { create(:inbox, account: account) } + let(:conversation) { create(:conversation, account: account, inbox: inbox) } + let(:assistant) { create(:captain_assistant, account: account) } + let(:message) do + create(:message, account: account, conversation: conversation, message_type: :outgoing, sender: assistant) + end + + before { create(:inbox_member, user: agent, inbox: inbox) } + + def json_response + JSON.parse(response.body, symbolize_names: true) + end + + describe 'GET /api/v1/accounts/:account_id/captain/agent_sessions/:id' do + context 'when it is an unauthenticated user' do + it 'returns unauthorized' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the message has an agent session' do + let(:document) { create(:captain_document, account: account, assistant: assistant) } + let(:documented_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, + question: 'How do I reset my password?', documentable: document) + end + let(:plain_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, question: 'How do I change my email?') + end + let(:pdf_document) do + create(:captain_document, account: account, assistant: assistant, external_link: nil, + pdf_file: Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/sample.pdf'), 'application/pdf')) + end + let(:pdf_faq) do + create(:captain_assistant_response, account: account, assistant: assistant, + question: 'What are the pricing tiers?', documentable: pdf_document) + end + let(:scenario) { create(:captain_scenario, account: account, assistant: assistant, title: 'Refund flow') } + let(:run_context) do + [ + { 'role' => 'user', 'content' => 'I want a refund' }, + { 'role' => 'assistant', 'content' => '', 'agent_name' => 'Assistant', + 'tool_calls' => [{ 'id' => 'call_1', 'name' => 'faq_lookup', 'arguments' => { 'query' => 'refund' } }] }, + { 'role' => 'tool', 'content' => 'Refunds take 5 days', 'tool_call_id' => 'call_1' }, + { 'role' => 'assistant', 'content' => 'Refunds take 5 days', 'agent_name' => "scenario_#{scenario.id}_refund_flow" } + ] + end + let!(:agent_session) do + create(:captain_agent_session, account: account, assistant: assistant, + subject: conversation, result: message, + llm_model: 'openai-gpt-5.2', credits_consumed: 1.0, + faq_ids: [documented_faq.id, plain_faq.id, pdf_faq.id, documented_faq.id + 100_000], + scenario_ids: [scenario.id], + run_context: run_context) + end + + it 'returns the session with hydrated citations and scenarios' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:success) + aggregate_failures do + expect(json_response[:id]).to eq(agent_session.id) + expect(json_response[:message_id]).to eq(message.id) + expect(json_response[:llm_model]).to eq('openai-gpt-5.2') + expect(json_response[:credits_consumed]).to eq(1.0) + expect(json_response[:run_context].length).to eq(4) + expect(json_response[:run_context].second[:tool_calls].first[:arguments][:query]).to eq('refund') + + citations = json_response[:citations].index_by { |citation| citation[:id] } + expect(citations.keys).to contain_exactly(documented_faq.id, plain_faq.id, pdf_faq.id) + expect(citations[documented_faq.id][:title]).to eq('How do I reset my password?') + expect(citations[documented_faq.id][:link]).to eq(document.external_link) + expect(citations[plain_faq.id][:link]).to be_nil + expect(pdf_document.external_link).to start_with('PDF:') + expect(citations[pdf_faq.id][:link]).to eq(pdf_document.display_url) + expect(citations[pdf_faq.id][:link]).to match(%r{\Ahttps?://}) + + expect(json_response[:scenarios]).to eq([{ id: scenario.id, title: 'Refund flow' }]) + end + end + + it 'does not allow an agent without access to the conversation' do + other_agent = create(:user, account: account, role: :agent) + + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: other_agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when the message has no agent session' do + it 'returns not found' do + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:not_found) + end + end + + context 'when the message does not belong to the account' do + it 'returns not found' do + other_message = create(:message) + + get "/api/v1/accounts/#{account.id}/captain/agent_sessions/#{other_message.id}", + headers: agent.create_new_auth_token, as: :json + + expect(response).to have_http_status(:not_found) + end + end + end +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 eb1cb641c..08bb2a50d 100644 --- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb +++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb @@ -561,6 +561,22 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0) end + it 'attributes the handoff session to the private reason note when the tool recorded one' do + handoff_note = create(:message, conversation: conversation, account: account, message_type: :outgoing, + private: true, sender: assistant, content: 'Needs a human') + run_context[:state][:cw_metadata][:handoff_note_id] = handoff_note.id + allow(mock_agent_runner_service).to receive(:generate_response) do + conversation.update!(status: :open) + { 'response' => 'Let me connect you', 'handoff_tool_called' => true } + end + + described_class.perform_now(conversation, assistant) + + session = Captain::AgentSession.last + expect(session.credits_consumed).to eq(0.0) + expect(session.result_id).to eq(handoff_note.id) + end + it 'creates a zero-credit session when the handoff tool fired but failed to commit' do allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'I tried to hand off', diff --git a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb index 492d24f32..db5ee462c 100644 --- a/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb +++ b/spec/enterprise/lib/captain/tools/handoff_tool_spec.rb @@ -86,6 +86,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do tool.perform(tool_context, reason: reason) end + + it 'records the handoff note id in the run state for session capture' do + tool.perform(tool_context, reason: 'Customer needs specialized support') + + expect(tool_context.state[:cw_metadata][:handoff_note_id]).to eq(Message.last.id) + end end context 'without reason provided' do @@ -107,6 +113,12 @@ RSpec.describe Captain::Tools::HandoffTool, type: :model do tool.perform(tool_context) end + + it 'does not record a handoff note id since the empty note never renders' do + tool.perform(tool_context) + + expect(tool_context.state[:cw_metadata]).to be_nil + end end context 'when handoff fails' do
+ {{ reasoning }} +