Files
moreminimore-chat/enterprise/app/services/captain/conversation_events.rb
Shivam Mishra 7981e2cc75 refactor: introduce normalized Captain lifecycle events [CW-7792] (#15213)
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
2026-07-31 13:55:57 +05:30

73 lines
2.0 KiB
Ruby

class Captain::ConversationEvents
module Sources
TOOL = 'tool'.freeze
GENERATION_FAILURE = 'generation_failure'.freeze
TIME_BASED = 'time_based'.freeze
INFERENCE = 'inference'.freeze
USAGE_LIMIT = 'usage_limit'.freeze
end
class << self
def engaged(conversation:, assistant:, at:)
dispatch(
Events::Types::CAPTAIN_CONVERSATION_ENGAGED,
at: at,
conversation: conversation,
assistant: assistant
)
end
def handed_off(conversation:, assistant:, source:, at:, reason_category: nil)
dispatch(
Events::Types::CAPTAIN_CONVERSATION_HANDED_OFF,
at: at,
conversation: conversation,
assistant: assistant,
source: source,
reason_category: reason_category
)
end
def resolved(conversation:, assistant:, source:, at:)
dispatch(
Events::Types::CAPTAIN_CONVERSATION_RESOLVED,
at: at,
conversation: conversation,
assistant: assistant,
source: source
)
end
def response_completed(conversation:, assistant:, message:, at:)
dispatch(
Events::Types::CAPTAIN_RESPONSE_COMPLETED,
at: at,
conversation: conversation,
assistant: assistant,
message: message
)
end
def response_failed(conversation:, assistant:, reason:, at:)
dispatch(
Events::Types::CAPTAIN_RESPONSE_FAILED,
at: at,
conversation: conversation,
assistant: assistant,
reason: reason
)
end
private
# Lifecycle events are secondary effects: a dispatch failure must never alter
# the customer-facing Captain flow (blocking a response from being scheduled,
# or turning an already-delivered reply into a spurious handoff).
def dispatch(event_name, at:, **data)
Rails.configuration.dispatcher.dispatch(event_name, at, data)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: data[:conversation]&.account).capture_exception
end
end
end