From 7a5385cc32061c95ff28ec1d0af4fa2ddc269fcf Mon Sep 17 00:00:00 2001
From: Shivam Mishra
Date: Tue, 21 Jul 2026 15:14:18 +0530
Subject: [PATCH 09/15] feat: improve captain overview loading and reuse stats
for summary [CW-7610] (#15105)
---
.../dashboard/api/captain/assistant.js | 13 +++---
.../pageComponents/overview/MetricCard.vue | 7 ++-
.../pageComponents/overview/WelcomeCard.vue | 33 +++++++++++---
.../captain/assistants/overview/Index.vue | 45 ++++++++++++++++---
.../accounts/captain/assistants_controller.rb | 22 ++++++---
lib/captain/overview_summary_service.rb | 1 +
.../captain_overview_summary.liquid | 1 +
.../captain/assistants_controller_spec.rb | 14 +++++-
8 files changed, 112 insertions(+), 24 deletions(-)
diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js
index 1fc17798d..5af6110ab 100644
--- a/app/javascript/dashboard/api/captain/assistant.js
+++ b/app/javascript/dashboard/api/captain/assistant.js
@@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient {
});
}
- getStats({ assistantId, range }) {
- return axios.get(`${this.url}/${assistantId}/stats`, {
+ getStats({ assistantId, range, signal }) {
+ const requestConfig = {
params: { range, timezone_offset: getTimezoneOffset() },
- });
+ };
+ if (signal) requestConfig.signal = signal;
+
+ return axios.get(`${this.url}/${assistantId}/stats`, requestConfig);
}
- getSummary({ assistantId, range }) {
+ getSummary({ assistantId, range, stats }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
- params: { range, timezone_offset: getTimezoneOffset() },
+ params: { range, timezone_offset: getTimezoneOffset(), stats },
});
}
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
index cf66a0a2f..9a68b71ce 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue
@@ -9,6 +9,7 @@ const props = defineProps({
// null = neutral, true = good direction, false = bad direction
trendGood: { type: Boolean, default: null },
clickable: { type: Boolean, default: false },
+ loading: { type: Boolean, default: false },
});
const emit = defineEmits(['click']);
@@ -45,7 +46,11 @@ const onActivate = () => {
class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100"
/>
-
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
index df336a7e4..5e868956f 100644
--- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
+++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue
@@ -9,6 +9,10 @@ const props = defineProps({
type: String,
default: '30',
},
+ stats: {
+ type: Object,
+ default: null,
+ },
});
const route = useRoute();
@@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId);
const welcomeMarkdown = ref('');
const isLoading = ref(false);
+// Increments on every fetch so a slow response for a superseded
+// range/stats/assistant can't overwrite the latest request's state.
+let fetchToken = 0;
+
const fetchSummary = async () => {
+ fetchToken += 1;
+ const token = fetchToken;
+
+ if (!props.stats) {
+ welcomeMarkdown.value = '';
+ isLoading.value = false;
+ return;
+ }
+
isLoading.value = true;
+ let message = '';
try {
const { data } = await CaptainAssistant.getSummary({
assistantId: assistantId.value,
range: props.range,
+ stats: props.stats,
});
- welcomeMarkdown.value = data.message ?? '';
+ message = data.message ?? '';
} catch {
- welcomeMarkdown.value = '';
- } finally {
- isLoading.value = false;
+ message = '';
}
+
+ if (token !== fetchToken) return;
+ welcomeMarkdown.value = message;
+ isLoading.value = false;
};
-watch([() => props.range, assistantId], fetchSummary, { immediate: true });
+watch([() => props.range, () => props.stats, assistantId], fetchSummary, {
+ immediate: true,
+});
// Render through the shared markdown formatter (html disabled, so it is safe)
// used everywhere else for Captain output, instead of a bespoke parser. It
diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
index 29411e56d..ba31bc05d 100644
--- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
+++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+
+
+
+ {{ 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
+ )
+ }}
+
+
+
+
+
+
+ {{ 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
From 0e376f4fe238ec5011c1ab63d81774d3ac186fc5 Mon Sep 17 00:00:00 2001
From: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
Date: Tue, 21 Jul 2026 16:19:53 +0530
Subject: [PATCH 11/15] feat(whatsapp-call): support BSUID callers for inbound
voice calls (#14743)
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-7276/bsuid-support-to-whatsapp-voice-calling
## Description
Keeps WhatsApp voice calls in the same thread as the chat when a caller
has adopted a **WhatsApp username** and hidden their phone number.
This makes the inbound-call path BSUID-aware, reusing the same
identifier the messaging pipeline keys on so calls land on the existing
`ContactInbox`/conversation.
## Type of change
- [ ] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Locally via UI
## 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
---------
Co-authored-by: Muhsin Keloth