diff --git a/db/schema.rb b/db/schema.rb index cbce9e5bf..45148c0b7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_05_21_000001) do +ActiveRecord::Schema[8.0].define(version: 2026_07_14_000002) do create_table "active_storage_attachments", force: :cascade do |t| t.string "name", null: false t.string "record_type", null: false @@ -118,6 +118,21 @@ t.index ["provider_id"], name: "index_dradis_plugins_echo_agents_on_provider_id" end + create_table "dradis_plugins_echo_messages", force: :cascade do |t| + t.integer "session_id", null: false + t.integer "parent_id" + t.integer "user_id" + t.integer "role", default: 0, null: false + t.integer "status", default: 0, null: false + t.text "content" + t.text "metadata" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["parent_id"], name: "index_dradis_plugins_echo_messages_on_parent_id" + t.index ["session_id"], name: "index_dradis_plugins_echo_messages_on_session_id" + t.index ["user_id"], name: "index_dradis_plugins_echo_messages_on_user_id" + end + create_table "dradis_plugins_echo_prompts", force: :cascade do |t| t.string "title", null: false t.string "icon", null: false @@ -140,6 +155,20 @@ t.datetime "updated_at", null: false end + create_table "dradis_plugins_echo_sessions", force: :cascade do |t| + t.integer "agent_id", null: false + t.integer "user_id" + t.string "record_type", null: false + t.integer "record_id", null: false + t.integer "status", default: 0, null: false + t.string "title" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["agent_id"], name: "index_dradis_plugins_echo_sessions_on_agent_id" + t.index ["record_type", "record_id"], name: "index_dradis_plugins_echo_sessions_on_record" + t.index ["user_id"], name: "index_dradis_plugins_echo_sessions_on_user_id" + end + create_table "evidence", force: :cascade do |t| t.integer "node_id" t.integer "issue_id" @@ -301,7 +330,12 @@ add_foreign_key "comments", "inline_threads" add_foreign_key "comments", "users", on_delete: :nullify add_foreign_key "dradis_plugins_echo_agents", "dradis_plugins_echo_providers", column: "provider_id" + add_foreign_key "dradis_plugins_echo_messages", "dradis_plugins_echo_messages", column: "parent_id" + add_foreign_key "dradis_plugins_echo_messages", "dradis_plugins_echo_sessions", column: "session_id" + add_foreign_key "dradis_plugins_echo_messages", "users", on_delete: :nullify add_foreign_key "dradis_plugins_echo_prompts", "users" + add_foreign_key "dradis_plugins_echo_sessions", "dradis_plugins_echo_agents", column: "agent_id" + add_foreign_key "dradis_plugins_echo_sessions", "users", on_delete: :nullify add_foreign_key "inline_threads", "users" add_foreign_key "inline_threads", "users", column: "resolved_by_id" add_foreign_key "mapping_fields", "mappings" diff --git a/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb new file mode 100644 index 000000000..977adaebd --- /dev/null +++ b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb @@ -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 diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb new file mode 100644 index 000000000..997b75065 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -0,0 +1,44 @@ +module Dradis::Plugins::Echo + class Message < ApplicationRecord + enum :role, %i[user assistant] + enum :status, %i[complete streaming failed], default: :complete + + store :metadata, coder: JSON + + # -- Relationships -------------------------------------------------------- + belongs_to :parent, class_name: 'Dradis::Plugins::Echo::Message', optional: true + 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? + + # -- 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 diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb new file mode 100644 index 000000000..5d1b13251 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -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 + 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) + } + + # -- Class Methods -------------------------------------------------------- + + # Mirrors the record= override below: Issues are stored as 'Issue' even + # 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 + 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 diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/messages/_message.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/messages/_message.html.erb new file mode 100644 index 000000000..9554bedcf --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/messages/_message.html.erb @@ -0,0 +1,22 @@ +<%# locals: (message:) -%> +<%# Minimal render. Must render from `render partial:` with %> +<%# only `message:` in locals, so no controller-context helpers here. %> +
+

+ <% if message.assistant? %> + <%= message.session.agent.name %> + <% elsif message.user %> + <%= message.user.name %> + <% else %> + Deleted user + <% end %> +

+ +
<%= message.content %>
+ + <% if message.streaming? %> + + <% elsif message.failed? %> +

Something went wrong generating this response.

+ <% end %> +
diff --git a/engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb b/engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb new file mode 100644 index 000000000..448504391 --- /dev/null +++ b/engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb @@ -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 diff --git a/engines/dradis-echo/db/migrate/20260714000002_create_messages.rb b/engines/dradis-echo/db/migrate/20260714000002_create_messages.rb new file mode 100644 index 000000000..57e335732 --- /dev/null +++ b/engines/dradis-echo/db/migrate/20260714000002_create_messages.rb @@ -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 diff --git a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb index ce8cb5911..91e45a90d 100644 --- a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb +++ b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb @@ -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 diff --git a/engines/dradis-echo/spec/factories/messages.rb b/engines/dradis-echo/spec/factories/messages.rb new file mode 100644 index 000000000..9dd84c641 --- /dev/null +++ b/engines/dradis-echo/spec/factories/messages.rb @@ -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 diff --git a/engines/dradis-echo/spec/factories/sessions.rb b/engines/dradis-echo/spec/factories/sessions.rb new file mode 100644 index 000000000..47c18ca56 --- /dev/null +++ b/engines/dradis-echo/spec/factories/sessions.rb @@ -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 diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb new file mode 100644 index 000000000..6ee3f2c65 --- /dev/null +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb @@ -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 diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb new file mode 100644 index 000000000..97f1b4357 --- /dev/null +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -0,0 +1,136 @@ +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::Session do + describe 'relationships' do + it { should belong_to(:agent) } + it { should belong_to(:record) } + it { should belong_to(:user).optional } + it { should have_many(:messages).dependent(:destroy) } + end + + describe 'enum' do + it 'defaults to idle' do + expect(build(:echo_session).status).to eq('idle') + end + + it { should define_enum_for(:status).with_values(%i[idle generating]) } + end + + # Issue descends from Note without STI, so the default polymorphic setter + # would store 'Note'. See Session#record= and Comment#commentable=. + describe 'record=' do + it 'stores an Issue as record_type Issue' do + issue = create(:issue) + session = create(:echo_session, record: issue) + + expect(session.reload.record_type).to eq('Issue') + expect(session.record).to eq(issue) + end + + it 'stores a Note as record_type Note' do + note = create(:note) + session = create(:echo_session, record: note) + + expect(session.reload.record_type).to eq('Note') + end + end + + describe '#project' do + it 'delegates to the record' do + note = create(:note) + session = create(:echo_session, record: note) + + expect(session.project).to eq(note.project) + end + end + + describe '#to_provider_messages' do + it 'returns role/content hashes ordered by creation' do + session = create(:echo_session) + create(:echo_message, session: session, role: :user, content: 'First') + create(:assistant_message, session: session, content: 'Second') + + expect(session.to_provider_messages).to eq([ + { role: 'user', content: 'First' }, + { role: 'assistant', content: 'Second' } + ]) + end + end + + describe '.for_record' do + it 'matches an Issue by its forced Issue type' do + issue = create(:issue) + session = create(:echo_session, record: issue) + create(:echo_session, record: create(:note)) + + expect(described_class.for_record(issue)).to eq([session]) + end + end + + describe 'destroying the user' do + it 'nullifies the user on its sessions and messages' do + user = create(:user) + session = create(:echo_session, user: user) + message = create(:echo_message, session: session, user: user) + + user.destroy + + expect(session.reload.user_id).to be_nil + expect(message.reload.user_id).to be_nil + end + end + + describe 'destroying the record' do + it 'destroys sessions attached to a destroyed Note' do + note = create(:note) + session = create(:echo_session, record: note) + + expect { note.destroy }.to change { described_class.exists?(session.id) }.to(false) + end + + it 'destroys sessions attached to a destroyed Issue' do + issue = create(:issue) + session = create(:echo_session, record: issue) + + expect { issue.destroy }.to change { described_class.exists?(session.id) }.to(false) + end + + # The gap the Note-only after_destroy sweep (engine.rb) exists for: an Issue + # row destroyed while loaded as a Note (e.g. a Pro project.notes cascade). + # Loaded as Note, the polymorphic dependent: :destroy queries record_type + # 'Note' and misses the session, so the record_type 'Issue' sweep catches it. + it 'destroys record_type Issue sessions when the Issue row is destroyed loaded as a Note' do + issue = create(:issue) + session = create(:echo_session, record: issue) + note = Note.find(issue.id) + + expect { note.destroy }.to change { described_class.exists?(session.id) }.to(false) + end + + # Guards the bug fix: the Issue sweep lives on Note only (engine.rb), not in + # the shared Sessionable concern. A future host with its own id sequence must + # not delete an unrelated Issue #N's sessions. Model that host on the + # categories table, sharing an id with a real Issue so the old in-concern + # sweep would have wrongly deleted the Issue's session. + it 'leaves unrelated Issue sessions alone when a non-Note Sessionable host is destroyed' do + issue = create(:issue) + issue_session = create(:echo_session, record: issue) + + host_class = Class.new(ApplicationRecord) do + self.table_name = 'categories' + def self.name = 'EchoSessionableTestHost' + include Dradis::Plugins::Echo::Sessionable + end + # Force the host to share the Issue's id: the old in-concern sweep keyed on + # record_id: id, so a same-id host would have wrongly deleted the session. + host_class.where(id: issue.id).delete_all + host = host_class.create!(id: issue.id, name: 'echo-sessionable-host') + + expect { host.destroy }.not_to change { described_class.exists?(issue_session.id) } + end + end +end