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¬ification_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.
187 lines
6.7 KiB
Ruby
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
|