Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
module Dradis::Plugins::Echo
# Gives a record (a Note, and by inheritance an Issue) an Echo sessions
# association that is cleaned up when the record is destroyed. Included into
# ::Note via the engine's on_load(:note_model) hook so Issue < Note inherits
# it; adding sessions to ContentBlock/Evidence later is a one-line include.
#
# Mirrors app/models/concerns/commentable.rb.
module Sessionable
extend ActiveSupport::Concern

included do
has_many :echo_sessions, as: :record,
class_name: 'Dradis::Plugins::Echo::Session', dependent: :destroy
end
end
end
44 changes: 44 additions & 0 deletions engines/dradis-echo/app/models/dradis/plugins/echo/message.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
module Dradis::Plugins::Echo
class Message < ApplicationRecord
enum :role, %i[user assistant]
enum :status, %i[complete streaming failed], default: :complete
Comment thread
etdsoft marked this conversation as resolved.

store :metadata, coder: JSON

# -- Relationships --------------------------------------------------------
belongs_to :parent, class_name: 'Dradis::Plugins::Echo::Message', optional: true
Comment thread
etdsoft marked this conversation as resolved.
belongs_to :session
belongs_to :user, optional: true

# -- Callbacks ------------------------------------------------------------
# User messages are authored in full, so they're never mid-stream.
before_validation :complete_user_messages, if: :user?

after_create_commit :broadcast_created

# -- Validations ----------------------------------------------------------
validates :content, presence: true, if: :complete?
# Assistant messages are generated by the agent, never a user.
validates :user_id, absence: true, if: :assistant?
# A user message with no user_id was genuinely deleted, so require it on
# create; a later nullify (dependent: :nullify) is what marks the deletion.
validates :user_id, presence: true, if: :user?

Comment thread
etdsoft marked this conversation as resolved.
# -- Instance Methods -----------------------------------------------------

private

def broadcast_created
broadcast_append_to(
[session, :messages],
target: 'echo-messages',
partial: 'dradis/plugins/echo/projects/sessions/messages/message',
locals: { message: self }
)
end

def complete_user_messages
self.status = :complete
end
end
end
48 changes: 48 additions & 0 deletions engines/dradis-echo/app/models/dradis/plugins/echo/session.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
module Dradis::Plugins::Echo
class Session < ApplicationRecord
enum :status, %i[idle generating], default: :idle

# -- Relationships --------------------------------------------------------
belongs_to :agent
belongs_to :record, polymorphic: true
belongs_to :user, optional: true
Comment thread
etdsoft marked this conversation as resolved.
has_many :messages, dependent: :destroy

delegate :project, to: :record

# -- Scopes ---------------------------------------------------------------
# Can't use where(record: record): Rails builds record_type from the
# polymorphic_name ('Note') for an Issue, missing the forced 'Issue' rows.
# See record_type_for / the record= override below.
scope :for_record, ->(record) {
where(record_type: record_type_for(record), record_id: record.id)
Comment thread
etdsoft marked this conversation as resolved.
}

# -- Class Methods --------------------------------------------------------

# Mirrors the record= override below: Issues are stored as 'Issue' even
Comment thread
etdsoft marked this conversation as resolved.
# though they descend from Note, so scopes must resolve the same type.
def self.record_type_for(record)
record.is_a?(Issue) ? 'Issue' : record.class.base_class.name
end

# -- Instance Methods -----------------------------------------------------
def to_provider_messages
Comment thread
etdsoft marked this conversation as resolved.
messages.order(:created_at, :id).map do |message|
{ role: message.role, content: message.content }
end
end

# FIXME - ISSUE/NOTE INHERITANCE
#
# Because Issue descends from Note but doesn't use STI, Rails's default
# polymorphic setter stores 'Note' when you assign an Issue. Force 'Issue'
# here so the record loads back as the right class. Mirrors
# Comment#commentable= (app/models/comment.rb).
def record=(new_record)
super
self.record_type = self.class.record_type_for(new_record) if new_record
new_record
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<%# locals: (message:) -%>
<%# Minimal render. Must render from `render partial:` with %>
<%# only `message:` in locals, so no controller-context helpers here. %>
<div id="<%= dom_id(message) %>" class="echo-message echo-message-<%= message.role %>" data-status="<%= message.status %>">
<p class="echo-message-author">
<% if message.assistant? %>
<%= message.session.agent.name %>
<% elsif message.user %>
<%= message.user.name %>
<% else %>
Deleted user
<% end %>
</p>

<div class="echo-message-content"><%= message.content %></div>

<% if message.streaming? %>
<span class="echo-message-spinner" data-behavior="echo-message-spinner"></span>
<% elsif message.failed? %>
<p class="echo-message-error">Something went wrong generating this response.</p>
<% end %>
</div>
16 changes: 16 additions & 0 deletions engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
class CreateSessions < ActiveRecord::Migration[8.0]
def change
create_table :dradis_plugins_echo_sessions do |t|
t.references :agent, null: false,
foreign_key: { to_table: :dradis_plugins_echo_agents }
t.references :user, foreign_key: { to_table: :users, on_delete: :nullify }
t.references :record, polymorphic: true, null: false

# 0 maps to the :idle enum value
t.integer :status, default: 0, null: false
t.string :title

t.timestamps
end
end
end
21 changes: 21 additions & 0 deletions engines/dradis-echo/db/migrate/20260714000002_create_messages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class CreateMessages < ActiveRecord::Migration[8.0]
def change
create_table :dradis_plugins_echo_messages do |t|
t.references :session, null: false,
foreign_key: { to_table: :dradis_plugins_echo_sessions }
# Self-referential parent, dormant.
t.references :parent, null: true,
foreign_key: { to_table: :dradis_plugins_echo_messages }
t.references :user, foreign_key: { to_table: :users, on_delete: :nullify }

# 0 maps to the :user role
t.integer :role, default: 0, null: false
# 0 maps to the :complete status
t.integer :status, default: 0, null: false
t.text :content
t.text :metadata

t.timestamps
end
end
end
27 changes: 27 additions & 0 deletions engines/dradis-echo/lib/dradis/plugins/echo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,33 @@ class Engine < ::Rails::Engine
initializer 'echo.extend_user_model' do
ActiveSupport.on_load :user_model do
::User.send(:has_many, :prompts, class_name: 'Dradis::Plugins::Echo::Prompt', dependent: :destroy)
# Keep a deleted user's sessions and messages around (attributed to a
# 'Deleted user') rather than destroying the conversation history.
::User.send(:has_many, :echo_sessions, class_name: 'Dradis::Plugins::Echo::Session', dependent: :nullify)
::User.send(:has_many, :echo_messages, class_name: 'Dradis::Plugins::Echo::Message', dependent: :nullify)
end
end

initializer 'echo.extend_note_model' do
ActiveSupport.on_load :note_model do
# Sessions hang off a polymorphic record (a Note or Issue). The
# Sessionable concern owns the association; Issue < Note inherits it.
::Note.include Dradis::Plugins::Echo::Sessionable

# FIXME - ISSUE/NOTE INHERITANCE
# Mirror Note's Comment/InlineThread/Subscription sweep (note.rb): when an
# Issue row is destroyed while loaded as a Note (e.g. a Pro project.notes
# cascade), it loads as Note so the polymorphic `dependent: :destroy` on the
# Sessionable association misses its record_type: 'Issue' sessions. Do NOT
# guard on is_a?(Issue) -- the loaded-as-Note case is exactly what this
# catches, and it is harmless for a genuine Note (a notes-row id is a
# Note-row or an Issue-row, never both). A genuine Issue is already covered
# by dependent: :destroy. This lives here, on Note only, rather than in the
# shared Sessionable concern: a future host (Evidence/ContentBlock, with its
# own id sequence) must not delete an unrelated Issue #N's sessions.
::Note.after_destroy do
Dradis::Plugins::Echo::Session.where(record_type: 'Issue', record_id: id).destroy_all
end
end
end

Expand Down
14 changes: 14 additions & 0 deletions engines/dradis-echo/spec/factories/messages.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
FactoryBot.define do
factory :echo_message, class: 'Dradis::Plugins::Echo::Message' do
association :session, factory: :echo_session
user
role { :user }
content { 'Hello, Echo.' }

factory :assistant_message do
role { :assistant }
user { nil }
content { 'Hello back.' }
end
end
end
7 changes: 7 additions & 0 deletions engines/dradis-echo/spec/factories/sessions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FactoryBot.define do
factory :echo_session, class: 'Dradis::Plugins::Echo::Session' do
agent
association :record, factory: :note
status { :idle }
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
require 'rails_helper'
require File.expand_path('../../../../factories/providers', __dir__)
require File.expand_path('../../../../factories/agents', __dir__)
require File.expand_path('../../../../factories/sessions', __dir__)
require File.expand_path('../../../../factories/messages', __dir__)

describe Dradis::Plugins::Echo::Message do
describe 'relationships' do
it { should belong_to(:session) }
it { should belong_to(:parent).class_name('Dradis::Plugins::Echo::Message').optional }
it { should belong_to(:user).optional }
end

describe 'enums' do
it { should define_enum_for(:role).with_values(%i[user assistant]) }
it { should define_enum_for(:status).with_values(%i[complete streaming failed]) }

it 'defaults status to complete' do
expect(build(:echo_message).status).to eq('complete')
end
end

describe 'validations' do
it 'requires content when complete' do
message = build(:echo_message, status: :complete, content: nil)
expect(message).not_to be_valid
expect(message.errors[:content]).to be_present
end

it 'allows blank content while streaming' do
message = build(:assistant_message, status: :streaming, content: nil)
expect(message).to be_valid
end

it 'rejects a user on an assistant message' do
message = build(:assistant_message, user: create(:user))
expect(message).not_to be_valid
expect(message.errors[:user_id]).to be_present
end
end

describe 'user messages' do
it 'are forced to complete' do
message = create(:echo_message, role: :user, status: :streaming)
expect(message.status).to eq('complete')
end
end

describe 'metadata' do
it 'is stored as JSON' do
message = create(:echo_message, metadata: { 'model' => 'qwen2.5:14b' })
expect(message.reload.metadata).to eq('model' => 'qwen2.5:14b')
end
end

# Acceptance: the partial must render from `render partial:` with only a
# message: local — no controller context.
describe 'the _message partial' do
def render_message(message)
ApplicationController.render(
partial: 'dradis/plugins/echo/projects/sessions/messages/message',
locals: { message: message }
)
end

it 'names the agent for assistant messages' do
message = create(:assistant_message)
expect(render_message(message)).to include(message.session.agent.name)
end

it 'names the author for user messages' do
user = create(:user)
message = create(:echo_message, user: user)
expect(render_message(message)).to include(user.name)
end

it "falls back to 'Deleted user' when the author is gone" do
message = create(:echo_message, user: create(:user))
message.update_column(:user_id, nil)
expect(render_message(message.reload)).to include('Deleted user')
end
end
end
Loading
Loading