This introduces a small event layer for the Captain V2 conversation lifecycle. A new `Captain::ConversationEvents` facade dispatches five normalized events (`captain.conversation.engaged`, `captain.conversation.handed_off`, `captain.conversation.resolved`, `captain.response.completed`, `captain.response.failed`) from the points where Captain engages a conversation, replies, fails, hands off, or auto-resolves. Each event carries the conversation, assistant, timestamp, and a `source`/`reason_category` where relevant. ## Why this, why now The Captain V2 flow is about to gain several observers at once: conversation outcome tracking, agent session capture, and analytics all need to know when Captain engages, replies, fails, hands off, or resolves. Wiring each of them directly into `ResponseBuilderJob`, `HookExecutionService`, and the tools would tangle secondary bookkeeping into the paths that deliver customer-facing behavior, and every future consumer would deepen that. Landing the event layer first as its own PR means the flow announces these moments once and stays otherwise untouched: customer-visible behavior (messages, status changes, handoffs, usage enforcement) remains synchronous, while secondary effects subscribe through listeners. The upcoming conversation outcomes PR then reduces to a listener plus a model instead of another round of edits to the core flow, which is why this ships now, before that work merges. The existing inference reporting behavior is folded into this layer: the `conversation.captain_inference_*` events and their dispatch helpers on `Enterprise::Conversation` are removed, and a dedicated `Captain::ReportingEventListener` (registered on the enterprise async dispatcher) maps `source: 'inference'` events to the same stored reporting event names, so recorded analytics and the assistant stats builder are unaffected. ## What changed - New `Captain::ConversationEvents` facade and event type constants - Event emission from `HookExecutionService` (engagement, usage-limit handoff), `ResponseBuilderJob` (response completed/failed, generation-failure handoff), `HandoffTool` (tool handoff), and `InboxPendingConversationsResolutionJob` (inference resolved/handoff) - A dedicated `Captain::ReportingEventListener` preserves inference reporting events through the new event names, removing captain logic from the OSS listener
89 lines
3.3 KiB
Ruby
89 lines
3.3 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe Captain::ConversationEvents do
|
|
let(:account) { create(:account) }
|
|
let(:conversation) { create(:conversation, account: account) }
|
|
let(:assistant) { create(:captain_assistant, account: account) }
|
|
let(:timestamp) { Time.zone.now }
|
|
|
|
describe '.engaged' do
|
|
it 'dispatches the engagement event with normalized context' do
|
|
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
|
Events::Types::CAPTAIN_CONVERSATION_ENGAGED,
|
|
timestamp,
|
|
{ conversation: conversation, assistant: assistant }
|
|
)
|
|
|
|
described_class.engaged(conversation: conversation, assistant: assistant, at: timestamp)
|
|
end
|
|
end
|
|
|
|
describe '.handed_off' do
|
|
it 'dispatches the handoff event with source and reason category' do
|
|
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
|
Events::Types::CAPTAIN_CONVERSATION_HANDED_OFF,
|
|
timestamp,
|
|
{ conversation: conversation, assistant: assistant, source: 'inference', reason_category: :pending_clarification }
|
|
)
|
|
|
|
described_class.handed_off(
|
|
conversation: conversation, assistant: assistant, source: 'inference', reason_category: :pending_clarification, at: timestamp
|
|
)
|
|
end
|
|
end
|
|
|
|
describe '.resolved' do
|
|
it 'dispatches the resolution event with source' do
|
|
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
|
Events::Types::CAPTAIN_CONVERSATION_RESOLVED,
|
|
timestamp,
|
|
{ conversation: conversation, assistant: assistant, source: 'inference' }
|
|
)
|
|
|
|
described_class.resolved(conversation: conversation, assistant: assistant, source: 'inference', at: timestamp)
|
|
end
|
|
end
|
|
|
|
describe '.response_completed' do
|
|
it 'dispatches the response completed event with the result message' do
|
|
message = create(:message, conversation: conversation, account: account)
|
|
|
|
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
|
Events::Types::CAPTAIN_RESPONSE_COMPLETED,
|
|
timestamp,
|
|
{ conversation: conversation, assistant: assistant, message: message }
|
|
)
|
|
|
|
described_class.response_completed(conversation: conversation, assistant: assistant, message: message, at: timestamp)
|
|
end
|
|
end
|
|
|
|
describe 'when dispatch fails' do
|
|
it 'captures the exception instead of raising into the Captain flow' do
|
|
conversation
|
|
assistant
|
|
error = StandardError.new('redis down')
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch).and_raise(error)
|
|
expect(ChatwootExceptionTracker).to receive(:new)
|
|
.with(error, account: account)
|
|
.and_return(instance_double(ChatwootExceptionTracker, capture_exception: true))
|
|
|
|
expect do
|
|
described_class.engaged(conversation: conversation, assistant: assistant, at: timestamp)
|
|
end.not_to raise_error
|
|
end
|
|
end
|
|
|
|
describe '.response_failed' do
|
|
it 'dispatches the response failed event with the failure reason' do
|
|
expect(Rails.configuration.dispatcher).to receive(:dispatch).with(
|
|
Events::Types::CAPTAIN_RESPONSE_FAILED,
|
|
timestamp,
|
|
{ conversation: conversation, assistant: assistant, reason: 'faraday_bad_request_error' }
|
|
)
|
|
|
|
described_class.response_failed(conversation: conversation, assistant: assistant, reason: 'faraday_bad_request_error', at: timestamp)
|
|
end
|
|
end
|
|
end
|