Files
chatwoot/spec/enterprise/services/captain/assistant/session_capture_service_spec.rb
Aakash BakhleandGitHub ae49af354d fix: serialize multimodal Captain session content (#15096)
Captain now saves agent session records when a user message includes an
image. The saved record keeps the image URL and excludes downloaded
image bytes, so image replies no longer report a JSON serialization
error after delivery.

Fixes:
https://chatwoot-p3.sentry.io/issues/7618423184/?alert_rule_id=13673680&alert_type=issue&notification_uuid=d22a7ab9-95d6-4bba-85e0-733a28466775&project=6382945

## Root cause

RubyLLM downloads image attachments and caches the binary bytes inside
`RubyLLM::Content`. `SessionCaptureService` passed the live object to
the `run_context` JSON column. Rails then tried to encode the cached
JPEG bytes as UTF-8 and raised `JSON::GeneratorError`.

The error did not block replies, handoffs, or credit updates because
session capture rescues its own failures. The failed write meant that
Chatwoot lost the agent session record for the response.

## How to reproduce

1. Send an image to a Captain V2 assistant.
2. Let RubyLLM load the image during the model request.
3. Save the resulting conversation history in an agent session.
4. Observe the JSON encoding error when Rails reaches the cached image
bytes.

## What changed

`SessionCaptureService` now converts `RubyLLM::Content` to its JSON safe
hash before saving the current turn. The hash contains the message text
and attachment URL without the cached bytes. Other message content is
unchanged.

The focused service spec covers a cached JPEG byte payload and passes
with 12 examples. RuboCop reports no offenses in the changed service and
spec.
2026-07-21 13:04:12 +05:30

187 lines
6.7 KiB
Ruby

require 'rails_helper'
RSpec.describe Captain::Assistant::SessionCaptureService do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
let(:conversation) { create(:conversation, account: account) }
let(:result_message) { create(:message, account: account, conversation: conversation) }
let(:usage) do
Agents::RunContext::Usage.new.tap do |u|
u.input_tokens = 120
u.output_tokens = 40
u.total_tokens = 160
end
end
let(:conversation_history) do
[
{ role: :user, content: 'Hi, my internet is not working' },
{ role: :assistant, content: 'Let me check', agent_name: 'Assistant' },
{ role: :user, content: 'CUST001' },
{ role: :assistant, content: '', agent_name: 'Assistant', tool_calls: [{ 'id' => 'call_1', 'name' => 'faq_lookup' }] },
{ role: :tool, content: 'Restart the modem', tool_call_id: 'call_1' },
{ role: :assistant, content: 'Please restart your modem', agent_name: 'Assistant' }
]
end
let(:run_context) do
{
session_id: "#{account.id}_#{conversation.display_id}",
current_agent: 'Assistant',
turn_count: 2,
conversation_history: conversation_history,
state: { cw_metadata: { faq_ids: [11, 12], document_ids: [5] } }
}
end
let(:run_result) { Agents::RunResult.new(output: { 'response' => 'Please restart your modem' }, usage: usage, context: run_context) }
let(:service) do
described_class.new(
assistant: assistant,
conversation: conversation,
run_result: run_result,
result_message: result_message,
credits_consumed: 1.0
)
end
before do
allow(assistant).to receive(:agent_model).and_return('gpt-5.2')
end
describe '#capture' do
it 'creates the session' do
expect { service.capture }.to change(Captain::AgentSession, :count).by(1)
end
it 'does nothing when there is no run result' do
service = described_class.new(
assistant: assistant, conversation: conversation, run_result: nil,
result_message: result_message, credits_consumed: 1.0
)
expect { service.capture }.not_to change(Captain::AgentSession, :count)
end
it 'does nothing when the run failed' do
failed_result = Agents::RunResult.new(output: nil, error: StandardError.new('run failed'), context: run_context, usage: usage)
service = described_class.new(
assistant: assistant, conversation: conversation, run_result: failed_result,
result_message: nil, credits_consumed: 0.0
)
expect { service.capture }.not_to change(Captain::AgentSession, :count)
end
it 'reports failures without raising' do
allow(Captain::AgentSession).to receive(:create!).and_raise(StandardError, 'capture failed')
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
expect { service.capture }.not_to raise_error
expect(ChatwootExceptionTracker).to have_received(:new)
end
end
describe '#capture!' do
it 'creates an assistant session with all attributes' do
session = service.capture!
expect(session).to have_attributes(
account_id: account.id,
assistant_id: assistant.id,
subject_id: conversation.id,
subject_type: 'Conversation',
result_id: result_message.id,
result_type: 'Message',
llm_model: 'openai-gpt-5.2',
credits_consumed: 1.0,
faq_ids: [11, 12],
document_ids: [5],
scenario_ids: [],
user_id: nil
)
expect(session).to be_session_assistant
end
it 'stores the trimmed current turn in run_context' do
history = service.capture!.run_context
expect(history.size).to eq(4)
expect(history.first).to include('role' => 'user', 'content' => 'CUST001')
end
it 'stores multimodal content without cached attachment bytes' do
content = RubyLLM::Content.new('See image', ['https://example.com/image.jpg'])
content.attachments.first.instance_variable_set(:@content, "\xFF\xD8\xFF\xE0JFIF".b)
run_context[:conversation_history] = [
{ role: :user, content: content },
{ role: :assistant, content: 'I can see the image', agent_name: 'Assistant' }
]
history = service.capture!.run_context
expect(history.first).to include(
'role' => 'user',
'content' => {
'text' => 'See image',
'attachments' => [{ 'type' => 'image', 'source' => 'https://example.com/image.jpg' }]
}
)
end
it 'stores the full history when it contains no user message' do
run_context[:conversation_history] = conversation_history.reject { |message| message[:role] == :user }
history = service.capture!.run_context
expect(history.size).to eq(4)
end
it 'handles a successful run result without context or usage' do
run_result = Agents::RunResult.new(output: { 'response' => 'Hello' })
service = described_class.new(
assistant: assistant, conversation: conversation, run_result: run_result,
result_message: result_message, credits_consumed: 1.0
)
session = service.capture!
expect(session.result).to eq(result_message)
expect(session.faq_ids).to eq([])
expect(session.document_ids).to eq([])
expect(session.run_context).to eq([])
end
it 'extracts every scenario that authored a message in the current turn' do
first_scenario = create(:captain_scenario, assistant: assistant, account: account)
second_scenario = create(:captain_scenario, assistant: assistant, account: account)
run_context[:conversation_history] = [
{ role: :user, content: 'Help with my refund' },
{ role: :assistant, content: '', agent_name: first_scenario.handoff_key, tool_calls: [] },
{ role: :assistant, content: 'Checking', agent_name: second_scenario.handoff_key },
{ role: :assistant, content: 'Done', agent_name: first_scenario.handoff_key }
]
run_context[:current_agent] = 'Assistant'
expect(service.capture!.scenario_ids).to eq([first_scenario.id, second_scenario.id])
end
it 'leaves scenario ids empty for the primary assistant agent' do
run_context[:current_agent] = 'Assistant'
expect(service.capture!.scenario_ids).to eq([])
end
it 'does not capture a scenario belonging to another assistant' do
scenario = create(:captain_scenario, assistant: create(:captain_assistant, account: account), account: account)
run_context[:conversation_history] = [
{ role: :user, content: 'Help' },
{ role: :assistant, content: 'No', agent_name: scenario.handoff_key }
]
expect(service.capture!.scenario_ids).to eq([])
end
end
end