diff --git a/CHANGELOG b/CHANGELOG index 7d672c9f87..8e129f9b57 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,17 +1,17 @@ [v#.#.#] ([month] [YYYY]) - - [entity]: - - [future tense verb] [feature] - Cards: add option to move a task to a different list + - Echo: add multiplayer chat sessions on Issues — start a conversation from a saved prompt, follow up with more messages, and watch Roslin's reply stream live to everyone viewing - Upgraded gems: - rails, rails-html-sanitizer, sqlite3, websocket-driver - Bugs fixes: - - [entity]: - - [future tense verb] [bug fix] + - Echo: + - Preserve line breaks in streamed Ollama responses for models that emit them as standalone tokens + - Remove a stray leading blank line from streamed responses + - Send only completed conversation turns to the AI provider, so a failed or in-progress reply can no longer break the next one + - Show a generic message when a reply fails instead of surfacing the provider's raw error + - Return a graceful error instead of a server error when a chat session is started with a blank prompt - Bug tracker items: - [item] - - Echo enhancements: - - [Echo entity]: - - [future tense verb] [Echo enhancement] - New integrations: - [integration] - Integration enhancements: @@ -34,10 +34,6 @@ v5.2.0 (July 2026) - UI: add dark mode support to the login and setup wizard pages - Upgraded gems: - concurrent-ruby, crass, faraday, msgpack, net-imap, nokogiri, puma - - Bugs fixes: - - Echo: - - Preserve line breaks in streamed Ollama responses for models that emit them as standalone tokens - - Remove a stray leading blank line from streamed responses v5.1.0 (May 2026) - DataTables: add sticky table toolbar that tracks below the navigation bar when scrolling diff --git a/app/assets/javascripts/hera/behaviors.js b/app/assets/javascripts/hera/behaviors.js index 3fa9179268..f996f950c4 100644 --- a/app/assets/javascripts/hera/behaviors.js +++ b/app/assets/javascripts/hera/behaviors.js @@ -205,6 +205,12 @@ document.addEventListener('turbo:load', function () { initBehaviors(document.querySelector('body')); }); + // Native navigations (including loading="lazy") don't fire + // turbo:load + document.addEventListener('turbo:frame-load', function (event) { + initBehaviors(event.target); + }); + // Because this is an event and not a data-driven behavior, we can leave it // out of initBehaviors and attach the listener to document directly. // diff --git a/app/assets/stylesheets/hera/themes.scss b/app/assets/stylesheets/hera/themes.scss index bffc9e4a7c..748cb32a46 100644 --- a/app/assets/stylesheets/hera/themes.scss +++ b/app/assets/stylesheets/hera/themes.scss @@ -7,6 +7,7 @@ @mixin dark-theme { // Backgrounds --brand-bg: #{$brand-500}; + --brand-fg: #{$white}; --nav-active-bg: #{$brand-600}; --primary-bg: #{$grey-900}; --primary-bg-subtle: #{$grey-800}; @@ -105,6 +106,7 @@ :root { // Backgrounds --brand-bg: #{$brand-500}; + --brand-fg: #{$white}; --nav-active-bg: #{$brand-500}; --primary-bg: #{$white}; --primary-bg-subtle: #{darken($white, 1%)}; // darken($primary-bg, 1%) diff --git a/app/assets/stylesheets/hera/utilities.scss b/app/assets/stylesheets/hera/utilities.scss index 7b95bf5dc8..9449a82750 100644 --- a/app/assets/stylesheets/hera/utilities.scss +++ b/app/assets/stylesheets/hera/utilities.scss @@ -1,3 +1,7 @@ +.field-sizing-content { + field-sizing: content; +} + .font-light { font-family: 'ProximaNovaLight', 'system-ui'; } diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index f4d1ec5b97..a44b0c36c1 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -3,7 +3,7 @@ class ApplicationJob < ActiveJob::Base # retry_on ActiveRecord::Deadlocked # Most jobs are safe to ignore if the underlying records are no longer available - # discard_on ActiveJob::DeserializationError + discard_on ActiveJob::DeserializationError before_perform do |job| ActiveRecord::Base.connection_handler.clear_active_connections! diff --git a/config/environments/test.rb b/config/environments/test.rb index 380306bbee..07977b3af0 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -62,6 +62,12 @@ config.assets.precompile += %w( hera/test.css hera/test.js ) + # Turbo Stream broadcasts render partials outside a request (e.g. Echo's live + # session transcript), where the Sprockets middleware environment isn't in + # scope. With assets compiled on demand in test, resolve them lazily instead + # of failing the strict "precompiled?" check that only makes sense in prod. + config.assets.check_precompiled_asset = false + # Raises error for missing translations. # config.i18n.raise_on_missing_translations = true diff --git a/db/schema.rb b/db/schema.rb index cbce9e5bf5..45148c0b77 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/assets/config/dradis/plugins/echo/manifests/hera.js b/engines/dradis-echo/app/assets/config/dradis/plugins/echo/manifests/hera.js index 30cb65c68c..f446ee8b82 100644 --- a/engines/dradis-echo/app/assets/config/dradis/plugins/echo/manifests/hera.js +++ b/engines/dradis-echo/app/assets/config/dradis/plugins/echo/manifests/hera.js @@ -2,5 +2,5 @@ //= link controllers/dradis/plugins/echo/agent_controller.js //= link controllers/dradis/plugins/echo/grammar_controller.js -//= link controllers/dradis/plugins/echo/prompt_controller.js //= link controllers/dradis/plugins/echo/prompt_selector_controller.js +//= link controllers/dradis/plugins/echo/session_controller.js diff --git a/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/manifests/hera.scss b/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/manifests/hera.scss index a2f13d24aa..a5ef8223e7 100644 --- a/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/manifests/hera.scss +++ b/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/manifests/hera.scss @@ -1,3 +1,4 @@ //= require dradis/plugins/echo/grammar //= require dradis/plugins/echo/prompts //= require dradis/plugins/echo/providers +//= require dradis/plugins/echo/sessions diff --git a/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/sessions.scss b/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/sessions.scss new file mode 100644 index 0000000000..e91745da43 --- /dev/null +++ b/engines/dradis-echo/app/assets/stylesheets/dradis/plugins/echo/sessions.scss @@ -0,0 +1,305 @@ +// Echo Sessions — session list, message list, composer. +// Ships in Slice 5. Add `//= require dradis/plugins/echo/sessions` to the engine +// hera.scss manifest. Replaces the inline `style="height: 50vh"` in the old +// interactions/show.html.erb. Tokens only (var(--*)); no hardcoded colors, +// no ID selectors, no !important, no inline styles. px only for borders. + +// ---- Interactions layout (conversations and prompt picker side-by-side) ---- +.echo-interactions-layout { + display: flex; + gap: 1.5rem; + + @media (max-width: 767px) { + flex-direction: column; + } +} + +.echo-interactions-conversations { + flex-shrink: 0; + width: 14rem; + + @media (max-width: 767px) { + width: 100%; + } +} + +.echo-conversations-column-header { + align-items: center; + display: flex; + justify-content: space-between; + margin-bottom: 0.75rem; + + .echo-new-conversation-link { + color: var(--text-link); + font-size: 0.85rem; + text-decoration: none; + white-space: nowrap; + + &:hover { + color: var(--text-link-hover); + } + } +} + +.echo-interactions-panel { + flex: 1; +} + +// ---- Session list (interactions#index, left column) ---- +.echo-session-list { + margin-bottom: 1.5rem; + max-height: clamp(20rem, 70vh, 40rem); + overflow-y: auto; +} + +.echo-session-item { + align-items: center; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + color: var(--text-default); + display: flex; + gap: 0.75rem; + margin-bottom: 0.5rem; + padding: 0.6rem 0.85rem; + text-decoration: none; + transition: background-color 0.12s ease-in-out; + + &:hover { + background-color: var(--secondary-bg); + } + + .echo-session-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; + } + + .echo-session-chevron { + color: var(--text-muted); + flex-shrink: 0; + } + + .echo-session-icon { + color: var(--text-muted); + text-align: center; + width: 1.1rem; + } + + .echo-session-meta { + color: var(--text-muted); + font-size: 0.75rem; + } + + .echo-session-row { + align-items: center; + display: flex; + gap: 0.5rem; + + .spinner-container { + flex-shrink: 0; + } + } + + .echo-session-title { + flex: 1 1 auto; + font-weight: 600; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.echo-session-item.active { + background-color: var(--secondary-bg-subtle); + border-color: var(--brand-bg); +} + +.echo-session-empty { + color: var(--text-muted); + font-size: 0.9rem; + padding: 0.5rem 0.25rem 1rem; +} + +// ---- Conversation surface (sessions#show) ---- +// Flex column: scrolling message list + pinned composer. Content-driven height +// with a sensible ceiling — no viewport-locked 50vh. +.echo-conversation { + display: flex; + flex-direction: column; + // A content-driven ceiling: track 70vh but never below 20rem or above 40rem. + // clamp() passes through SassC untouched, whereas a bare min()/max() is a Sass + // built-in that errors comparing vh and rem. With the min-height floor below, + // this is equivalent to `max-height: min(70vh, 40rem)`. + max-height: clamp(20rem, 70vh, 40rem); + min-height: 20rem; +} + +.echo-conversation-header { + align-items: center; + border-bottom: 1px solid var(--border-color); + display: flex; + flex: 0 0 auto; + gap: 0.5rem; + margin-bottom: 0.25rem; + padding-bottom: 0.6rem; + + .echo-conversation-agent { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 400; + } + + .echo-conversation-title { + color: var(--text-default); + font-weight: 600; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.echo-messages { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0.25rem 0.5rem 0.25rem 0.25rem; +} + +// ---- Message row ---- +.echo-message { + border-bottom: 1px solid var(--border-color); + display: flex; + gap: 0.6rem; + padding: 0.85rem 0.25rem; + + &:last-child { + border-bottom: none; + } + + .echo-message-body { + min-width: 0; + width: 100%; + } + + // v1: plain text with preserved whitespace. No markdown/Textile pipeline. + // Content is HTML-escaped server-side before rendering/broadcasting. + .echo-message-content { + color: var(--text-default); + margin-top: 0.25rem; + white-space: break-spaces; + word-break: break-word; + } + + .time { + color: var(--text-muted); + font-size: 0.75rem; + margin-left: 0.4rem; + } + + .user { + color: var(--text-default); + font-weight: 600; + } +} + +// Assistant (Roslin) — brand accent + tint so the single LLM reads distinctly +// from N human participants. Keyed off role, not user presence. +.echo-message-assistant { + background-color: var(--secondary-bg-subtle); + border-left: 3px solid var(--brand-bg); + border-radius: 0 0.35rem 0.35rem 0; + padding-left: 0.75rem; +} + +.echo-avatar-agent { + align-items: center; + background-color: var(--brand-bg); + border-radius: 50%; + color: var(--brand-fg); + display: flex; + flex: 0 0 auto; + height: 2.1875rem; + justify-content: center; + width: 2.1875rem; +} + +// Streaming state — spinner + caption under the (possibly partial) content. +.echo-message-streaming { + .echo-streaming-caption { + align-items: center; + color: var(--text-muted); + display: inline-flex; + font-size: 0.8rem; + font-style: italic; + gap: 0.4rem; + margin-top: 0.25rem; + } +} + +.spinner-border-xs { + border-width: 0.15em; + height: 0.85rem; + width: 0.85rem; +} + +// Failed state — persisted; a reload still shows what went wrong. +.echo-message-failed { + border-left-color: var(--text-error); + + .echo-avatar-agent { + background-color: var(--text-error); + } + + .echo-error-hint { + color: var(--text-muted); + font-size: 0.8rem; + margin-top: 0.15rem; + } + + .echo-error-line { + align-items: baseline; + color: var(--text-error); + display: flex; + gap: 0.4rem; + margin-top: 0.25rem; + } +} + +// Deleted (nullified) user — author line falls back to "Deleted user". +.echo-message-deleted { + .user { + color: var(--text-muted); + font-style: italic; + } +} + +// ---- Composer ---- +.echo-composer { + border-top: 1px solid var(--border-color); + flex: 0 0 auto; + margin-top: 0.25rem; + padding-top: 0.75rem; + + textarea.form-control { + resize: none; + } + + .echo-composer-actions { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; + margin-top: 0.5rem; + } + + .echo-composer-hint { + align-items: center; + color: var(--text-muted); + display: inline-flex; + font-size: 0.75rem; + gap: 0.4rem; + } +} diff --git a/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb new file mode 100644 index 0000000000..faa9d7c84a --- /dev/null +++ b/engines/dradis-echo/app/channels/dradis/plugins/echo/sessions_channel.rb @@ -0,0 +1,48 @@ +module Dradis::Plugins::Echo + # Custom Turbo Streams channel for Echo sessions. Turbo's default channel + # trusts any client holding a validly-signed stream name for the lifetime of + # that name — which never expires — so a user who loses project access could + # subscribe and keep receiving the transcript. We re-check authorization when + # a subscription is established: resolve the session behind the signed name + # and only stream if the subscriber may still :use its project, otherwise + # reject. Note this guards *new* subscriptions only; a connection opened while + # still authorized keeps streaming until it is torn down. + # + # Follows the documented turbo-rails custom-channel pattern + # (Turbo::StreamsChannel). + class SessionsChannel < ApplicationCable::Channel + extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName + include Turbo::Streams::StreamName::ClassMethods + + def subscribed + session = session_from_stream_name + + if session && authorized?(session) + stream_from verified_stream_name_from_params + else + reject + end + end + + private + + def authorized?(session) + Ability.new(current_user).can?(:use, session.project) + end + + # The signed name encodes `[session, :messages]`, i.e. + # ":messages". A tampered name fails verification and + # yields nil (never reaching locate), so the only raise path left is a + # session deleted between signing and subscribe — RecordNotFound, which we + # treat as unauthorized. Narrower than a blanket rescue so genuine bugs + # (NoMethodError etc.) still surface. + def session_from_stream_name + name = verified_stream_name_from_params + return unless name + + GlobalID::Locator.locate(name.split(':').first, only: Session) + rescue ActiveRecord::RecordNotFound + nil + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb new file mode 100644 index 0000000000..98d97b3580 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/has_session.rb @@ -0,0 +1,21 @@ +module Dradis::Plugins::Echo + # Loads the URL-nested session for the message/reply controllers and re-scopes + # it through the current project, so an out-of-scope session_id raises + # ActiveRecord::RecordNotFound rather than leaking another project's session. + # Pulls in RecordScoping for the scoped lookup; consumers only include this. + module HasSession + extend ActiveSupport::Concern + include RecordScoping + + included do + before_action :set_session + end + + private + + def set_session + @session = Session.find(params[:session_id]) + scoped_record(@session) + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb new file mode 100644 index 0000000000..e877127661 --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/record_scoping.rb @@ -0,0 +1,22 @@ +module Dradis::Plugins::Echo + # Shared record scoping for the session controllers. A session is always + # anchored to a record (an Issue or a Note) that must live inside the current + # project; resolving it through the project's collections gives us + # cross-project/record isolation for free — an out-of-scope id raises + # ActiveRecord::RecordNotFound, mirroring Projects::GrammarController. + # + # We read the session's stored record_type/record_id directly rather than + # loading session.record: the association would fire a polymorphic query just + # to re-scope through the project. Issues are persisted as 'Issue' even though + # they descend from Note (see Session#record=), so record_type maps straight + # onto the matching project collection (issues, notes, ...). + module RecordScoping + extend ActiveSupport::Concern + + private + + def scoped_record(session) + current_project.public_send(session.record_type.underscore.pluralize).find(session.record_id) + end + end +end diff --git a/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb new file mode 100644 index 0000000000..92982ff97d --- /dev/null +++ b/engines/dradis-echo/app/controllers/concerns/dradis/plugins/echo/turbo_config_check.rb @@ -0,0 +1,32 @@ +module Dradis::Plugins::Echo + # Shared `check_turbo_config` before_action for the streaming controllers. + # Turbo Streams only work if Action Cable can reach its backend, so the view + # can warn the user when it can't. + # + # Only a Redis-backed adapter needs a reachable external server, so we ping + # just those (duck-typed on the subscription connection) and memoize the + # result. Every other adapter (async in development, test in specs) is always + # treated as healthy — no spurious "can't contact Redis" alert and no + # per-request Redis round-trip. + module TurboConfigCheck + extend ActiveSupport::Concern + + private + + def check_turbo_config + return @turbo_status if defined?(@turbo_status) + + @turbo_status = turbo_backend_reachable? + end + + def turbo_backend_reachable? + adapter = ActionCable.server.pubsub + return true unless adapter.respond_to?(:redis_connection_for_subscriptions) + + adapter.redis_connection_for_subscriptions.ping + true + rescue StandardError + false + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/interactions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/interactions_controller.rb index 141c9b7b7c..9078e04c14 100644 --- a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/interactions_controller.rb +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/interactions_controller.rb @@ -1,49 +1,25 @@ module Dradis::Plugins::Echo class Projects::InteractionsController < AuthenticatedController include ProjectScoped + include TurboConfigCheck layout false before_action :check_turbo_config, only: [:index] before_action :set_type - before_action :set_prompt, only: [:preview, :show] - before_action :set_record, except: [:create] + before_action :set_prompt, only: [:preview] + before_action :set_record def index Prompt.seed_default_prompts(current_user) if current_user.prompts.empty? @prompts = current_user.prompts.for(@type) + @sessions = Session.for_record(@record).order(updated_at: :desc) end def preview; end - def show - @prompt_content = params[:prompt] - @interaction_id = SecureRandom.hex(20) - @response_id = SecureRandom.hex(10) - end - - def create - InteractionJob.perform_later( - agent_id: Agents::Roslin.id, - prompt: params[:prompt], - interaction_id: params[:interaction_id], - response_id: params[:response_id] - ) - - head :ok - end - private - def check_turbo_config - @turbo_status = begin - ActionCable.server.pubsub.redis_connection_for_subscriptions.ping - true - rescue - false - end - end - def liquid_parse(template) assigns = { 'issue' => IssueDrop.new(@record) } @@ -62,10 +38,12 @@ def record_params end def set_prompt - @prompt = current_user.prompts.find(params[:id]) + @prompt = current_user.prompts.for(@type).find(params[:id]) end def set_record + raise ActiveRecord::RecordNotFound if @type.blank? + @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb new file mode 100644 index 0000000000..8c5f31ccbe --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/messages_controller.rb @@ -0,0 +1,20 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::MessagesController < AuthenticatedController + include HasSession + include ProjectScoped + layout false + + # Appends a user turn and re-opens the reply gate. The new message + # broadcasts itself into the transcript, so there's nothing to render back. + def create + message = @session.messages.build(content: params.expect(:content), user: current_user) + + if message.save + @session.request_reply! + head :ok + else + head :unprocessable_entity + end + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb new file mode 100644 index 0000000000..210cc4cea6 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions/replies_controller.rb @@ -0,0 +1,17 @@ +module Dradis::Plugins::Echo + class Projects::Sessions::RepliesController < AuthenticatedController + include HasSession + include ProjectScoped + layout false + + # Starts generation for a freshly-created session. The session Stimulus + # controller POSTs here from `connect` — after subscribing to the + # SessionsChannel — so ReplyJob's streaming container lands on a listening + # socket. reply_pending? guards against a reconnect or stray POST spawning an + # unsolicited reply on an already-answered session. + def create + @session.request_reply! if @session.reply_pending? + head :ok + end + end +end diff --git a/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb new file mode 100644 index 0000000000..12e5f2f395 --- /dev/null +++ b/engines/dradis-echo/app/controllers/dradis/plugins/echo/projects/sessions_controller.rb @@ -0,0 +1,72 @@ +module Dradis::Plugins::Echo + class Projects::SessionsController < AuthenticatedController + include EventPublisher + include ProjectScoped + include RecordScoping + include TurboConfigCheck + layout false + + before_action :set_type + before_action :set_record, only: [:create] + before_action :check_turbo_config, only: [:show, :create] + before_action :set_session, only: [:show] + + def show; end + + # Starts a conversation from a saved prompt. The Prompt is read only here, at + # the controller boundary — we copy its title and never store a FK, so a + # later edit or deletion of the template can't rewrite history. Scoping the + # lookup through `for(@type)` honours the Prompt::SCOPES whitelist. The first + # user Message carries the (Liquid-rendered, possibly edited) prompt text. + # + # We deliberately do NOT call request_reply! here: the browser hasn't + # subscribed to the SessionsChannel yet, so generating now would broadcast the + # streaming container before the socket is listening and never render live. + # Instead we render `show` in the reply_pending? state and let the session + # Stimulus controller POST to RepliesController once connected. + def create + return head :unprocessable_entity if params[:prompt].blank? + + prompt = current_user.prompts.for(@type).find(params[:prompt_id]) + + @session = Session.new( + agent: Agents::Roslin.instance, + record: @record, + title: prompt.title, + user: current_user + ) + @session.messages.build(content: params[:prompt], user: current_user) + + if @session.save + @sessions = Session.for_record(@record).order(updated_at: :desc) + publish_event('echo_session.created', session: { id: @session.id }) + render :show + else + head :unprocessable_entity + end + end + + private + + def record_params + params.permit(:id, :prompt, :prompt_id, :project_id, :record, :type) + end + + def set_record + raise ActiveRecord::RecordNotFound if @type.blank? + + @record = current_project.send(@type.to_s.pluralize).find(record_params[:record]) + end + + def set_session + @session = Session.find(record_params[:id]) + @record = scoped_record(@session) + @sessions = Session.for_record(@record).order(updated_at: :desc) + end + + def set_type + allowed = Prompt::SCOPES.map(&:to_s) + @type = allowed.include?(record_params[:type]) ? record_params[:type].to_sym : nil + end + end +end diff --git a/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/prompt_controller.js b/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/prompt_controller.js deleted file mode 100644 index 802c062a8d..0000000000 --- a/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/prompt_controller.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Controller } from "@hotwired/stimulus" - -export default class extends Controller { - static values = { - interactionId: String, - prompt: String, - responseId: String, - url: String, - } - - connect() { - fetch(this.urlValue, { - method: 'POST', - headers: { - 'Accept': 'text/vnd.turbo-stream.html', - 'Content-Type': 'application/json', - 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content, - }, - body: JSON.stringify({ - interaction_id: this.interactionIdValue, - response_id: this.responseIdValue, - prompt: this.promptValue - }) - }) - } -} diff --git a/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/session_controller.js b/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/session_controller.js new file mode 100644 index 0000000000..69a04fdd51 --- /dev/null +++ b/engines/dradis-echo/app/javascript/controllers/dradis/plugins/echo/session_controller.js @@ -0,0 +1,115 @@ +import { Controller } from "@hotwired/stimulus" + +// Drives the Echo conversation surface (sessions#show): +// * keeps the transcript pinned to the newest message as chunks stream in, +// * clears the composer after a successful send, +// * mirrors the server's generating state onto the textarea. +// The Send button's disabled state is server-rendered by the broadcast +// _composer_state partial; here we only keep the textarea in step with it. +export default class extends Controller { + static targets = ["messages", "input"] + static values = { replyUrl: String, replyPending: Boolean } + + connect() { + this.#scrollToBottom() + + // Streaming appends arrive as childList/characterData mutations under + // .echo-messages; keep the view pinned to the bottom as they land. + this.messagesObserver = new MutationObserver(() => this.#scrollToBottom()) + this.messagesObserver.observe(this.messagesTarget, { + childList: true, characterData: true, subtree: true + }) + + // The _composer_state partial is broadcast-replaced on every + // idle<->generating flip; re-sync the textarea whenever it changes. + this.#syncGenerating() + this.stateObserver = new MutationObserver(() => this.#syncGenerating()) + this.stateObserver.observe(this.element, { + attributeFilter: ["data-generating"], attributes: true, childList: true, subtree: true + }) + + this.#triggerPendingReply() + } + + disconnect() { + this.messagesObserver?.disconnect() + this.stateObserver?.disconnect() + clearTimeout(this.sendErrorTimeout) + } + + // Post over fetch so sending never navigates the Echo frame — the new user + // message and Roslin's reply both arrive over the socket. Clear the box on OK; + // on a non-OK response or a network error, keep the text and warn the user so + // a failed send isn't silently swallowed. + send(event) { + event.preventDefault() + if (this.#generating()) return + + const form = event.target + this.#clearSendError() + + fetch(form.action, { + body: new FormData(form), + headers: { "Accept": "text/vnd.turbo-stream.html", "X-CSRF-Token": this.#csrfToken() }, + method: "POST" + }).then((response) => { + if (response.ok) { + form.reset() + } else { + this.#showSendError() + } + }).catch(() => this.#showSendError()) + } + + // Surfaces a send failure inline without clearing the composer, so the user + // can retry the same text. Auto-dismisses; a fresh failure replaces it. + #showSendError() { + this.#clearSendError() + + const alert = document.createElement("div") + alert.className = "alert alert-danger echo-send-error" + alert.setAttribute("role", "alert") + alert.dataset.behavior = "echo-send-error" + alert.textContent = "Your message couldn't be sent. Check your connection and try again." + + this.element.insertBefore(alert, this.element.firstChild) + this.sendErrorTimeout = setTimeout(() => this.#clearSendError(), 8000) + } + + #clearSendError() { + clearTimeout(this.sendErrorTimeout) + this.element.querySelector("[data-behavior~=echo-send-error]")?.remove() + } + + // On a freshly-created session the server did NOT start generation: it would + // have raced ReplyJob's streaming-container broadcast ahead of this element's + // subscription and dropped it. Now that we're + // subscribed, POST to start the reply so every chunk lands on a listening + // socket. The server re-checks reply_pending? so a reconnect can't spawn a + // second reply. + #triggerPendingReply() { + if (!this.replyPendingValue || !this.hasReplyUrlValue) return + + fetch(this.replyUrlValue, { + headers: { "X-CSRF-Token": this.#csrfToken() }, + method: "POST" + }) + } + + #csrfToken() { + return document.querySelector("meta[name=csrf-token]")?.content || "" + } + + #generating() { + const state = this.element.querySelector("[data-behavior~=echo-composer-state]") + return state?.dataset.generating === "true" + } + + #scrollToBottom() { + if (this.hasMessagesTarget) this.messagesTarget.scrollTop = this.messagesTarget.scrollHeight + } + + #syncGenerating() { + if (this.hasInputTarget) this.inputTarget.disabled = this.#generating() + } +} diff --git a/engines/dradis-echo/app/jobs/dradis/plugins/echo/interaction_job.rb b/engines/dradis-echo/app/jobs/dradis/plugins/echo/interaction_job.rb deleted file mode 100644 index 0a358f50e7..0000000000 --- a/engines/dradis-echo/app/jobs/dradis/plugins/echo/interaction_job.rb +++ /dev/null @@ -1,32 +0,0 @@ -module Dradis::Plugins::Echo - class InteractionJob < ApplicationJob - queue_as :dradis_project - - def perform(agent_id:, prompt:, interaction_id:, response_id:) - agent = Agent.find(agent_id) - raise "Agent '#{agent.name}' is not enabled" unless agent.enabled? - - spinner_shown = true - - agent.provider.generate(prompt: prompt, model: agent.model_override) do |chunk| - if spinner_shown - Turbo::StreamsChannel.broadcast_remove_to [interaction_id, 'prompts'], target: "#{response_id}_spinner" - spinner_shown = false - end - - Turbo::StreamsChannel.broadcast_append_to( - [interaction_id, 'prompts'], - target: response_id, - content: ERB::Util.html_escape(chunk) - ) - end - - Turbo::StreamsChannel.broadcast_append_to [interaction_id, 'prompts'], target: 'messages', html: '

Done.

' - rescue => e - msg = '
' - msg << ERB::Util.html_escape(e.message) - msg << '
' - Turbo::StreamsChannel.broadcast_update_to [interaction_id, 'prompts'], target: response_id, html: msg - end - end -end diff --git a/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb new file mode 100644 index 0000000000..a0fc45e241 --- /dev/null +++ b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb @@ -0,0 +1,133 @@ +module Dradis::Plugins::Echo + class ReplyJob < ApplicationJob + queue_as :dradis_project + + # How often, at most, to touch the streaming message so Session's stuck- + # generation reclaim can tell a live-but-slow stream from a dead one without + # a database write per chunk. + TOUCH_INTERVAL = 5 + + # Generates one assistant reply for a session: it streams the provider + # response into a `streaming` message, persists the final text as + # `complete` with model/provider metadata, then finalizes — re-enqueueing + # itself if the user spoke again mid-generation, or flipping the session + # back to `idle`. Session#request_reply! owns the idle->generating gate. + def perform(session) + agent = session.agent + raise "Agent '#{agent.name}' is not enabled" unless agent.enabled? + + cutoff_id = session.messages.maximum(:id).to_i + context = session.to_provider_messages + message = session.messages.create!(role: :assistant, status: :streaming) + + text, duration_ms = stream_reply(agent, session, message, context) + + complete(agent, message, text, duration_ms) + finalize(session, cutoff_id) + rescue Provider::HttpStreaming::Error => e + # A genuine provider/transport failure: surface it to the user as a failed + # message. Anything else (a disabled agent, a bug in our code) is left to + # propagate so it reaches Resque's failed queue instead of being hidden. + fail_message(session, message, e) + end + + private + + def stream_reply(agent, session, message, context) + buffer = +'' + started = clock + last_touch = started + + agent.provider.generate(messages: context, model: agent.resolved_model) do |chunk| + buffer << chunk + broadcast_chunk(session, message, chunk) + + # Throttled liveness signal for Session#reclaim_stuck_generation!. + if clock - last_touch >= TOUCH_INTERVAL + message.touch + last_touch = clock + end + end + + [buffer, ((clock - started) * 1000).round] + end + + def broadcast_chunk(session, message, chunk) + Turbo::StreamsChannel.broadcast_append_to( + [session, :messages], + target: [message, :content], + content: ERB::Util.html_escape(chunk) + ) + end + + def complete(agent, message, text, duration_ms) + message.update!( + content: strip_thinking(text), + status: :complete, + metadata: message.metadata.merge( + 'duration_ms' => duration_ms, + 'model' => agent.resolved_model, + 'provider' => agent.provider.type_name + ) + ) + broadcast_message(message) + end + + # Providers surface their reasoning either as raw tags or, + # for Ollama, as the {thinking}{/thinking} markers Provider::Ollama swaps + # them for. Neither belongs in the persisted answer, so drop the blocks + # and any stray markers before saving. + def strip_thinking(text) + text + .gsub(/\{thinking\}.*?\{\/thinking\}/m, '') + .gsub(/.*?<\/think>/m, '') + .gsub(/\{\/?thinking\}/, '') + .gsub(/<\/?think>/, '') + .strip + end + + # Under a lock so it can't race the controller flipping idle<->generating: + # if a user message landed after the reply started (id past the cutoff), + # answer it too by re-enqueueing; otherwise release the session to idle. + def finalize(session, cutoff_id) + session.with_lock do + if session.messages.where(role: :user).where('id > ?', cutoff_id).exists? + self.class.perform_later(session) + else + session.update!(status: :idle) + session.broadcast_composer_state + end + end + end + + def fail_message(session, message, error) + Rails.logger.error( + "#{self.class.name} failed: #{error.class}: #{error.message}\n" \ + "#{Array(error.backtrace).join("\n")}" + ) + + if message + message.update!( + status: :failed, + metadata: message.metadata.merge('error' => Message::GENERIC_ERROR) + ) + broadcast_message(message) + end + + session.with_lock { session.update!(status: :idle) } + session.broadcast_composer_state + end + + def broadcast_message(message) + message.broadcast_replace_to( + [message.session, :messages], + partial: 'dradis/plugins/echo/projects/sessions/messages/message', + locals: { message: message } + ) + end + + def clock + Process.clock_gettime(Process::CLOCK_MONOTONIC) + end + end +end diff --git a/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/provider/http_streaming.rb b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/provider/http_streaming.rb index 21ac066967..0f98005a09 100644 --- a/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/provider/http_streaming.rb +++ b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/provider/http_streaming.rb @@ -1,5 +1,6 @@ require 'net/http' require 'json' +require 'openssl' require 'uri' module Dradis::Plugins::Echo @@ -8,21 +9,37 @@ module Provider::HttpStreaming READ_TIMEOUT = 120 - # Sends prompt to the provider and returns the response. + # Raised for any provider/transport failure (non-2xx responses, timeouts, + # dropped connections). Callers rescue this to tell a genuine provider + # problem apart from a bug in our own code, which must not be swallowed. + class Error < StandardError; end + + # Low-level transport failures we translate into Error so callers only ever + # have to rescue Provider::HttpStreaming::Error. + NETWORK_ERRORS = [ + Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ETIMEDOUT, + EOFError, IOError, SocketError, + Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Net::HTTPBadResponse, + OpenSSL::SSL::SSLError + ].freeze + + # Sends the conversation to the provider and returns the response. # # With a block: yields each text chunk as it arrives, enabling streaming UX - # (e.g. InteractionJob broadcasts each chunk to the browser via Turbo). + # (e.g. ReplyJob broadcasts each chunk to the browser via Turbo). # # Without a block: accumulates all chunks and returns the complete response # as a string once the API finishes, for use outside a streaming context. # + # Accepts a multi-turn messages: array ([{ role:, content: }]). + # # Subclasses must implement: #build_uri, #build_headers, #build_body, # #extract_text. Optionally override #end_of_stream_marker. - def generate(prompt:, model: nil, &block) + def generate(messages:, model: nil, &block) resolved_model = model.presence || self.model uri = build_uri(resolved_model) headers = build_headers - body = build_body(prompt: prompt, model: resolved_model) + body = build_body(messages: messages, model: resolved_model) buffer = block ? nil : +'' @@ -61,7 +78,7 @@ def parse_sse_response(uri, headers:, body:, &block) unless response.is_a?(Net::HTTPSuccess) error_body = +'' response.read_body { |chunk| error_body << chunk } - raise "#{self.class.name} API error (#{response.code}): #{error_body}" + raise Error, "#{self.class.name} API error (#{response.code}): #{error_body}" end response.read_body do |chunk| @@ -79,6 +96,8 @@ def parse_sse_response(uri, headers:, body:, &block) end end end + rescue *NETWORK_ERRORS => e + raise Error, e.message end # Returns the URI for the provider's API endpoint. @@ -93,7 +112,7 @@ def build_headers end # Returns the request body hash for the provider's API. - def build_body(prompt:, model:) + def build_body(messages:, model:) raise NotImplementedError, "#{self.class.name} must implement #build_body" end 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 0000000000..977adaebd9 --- /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/agent.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/agent.rb index 5edab25959..0b2390992c 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/agent.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/agent.rb @@ -19,6 +19,14 @@ class Agent < ApplicationRecord # -- Instance Methods ----------------------------------------------------- + # The model actually sent to the provider: the agent's override when set, + # otherwise the provider's default. Keeps the override-vs-default resolution + # in one place instead of repeating `model_override.presence || ...` at every + # call site. + def resolved_model + model_override.presence || provider.model + end + private # The engine provisions system agents (e.g. Roslin), and they must not be deleted. 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 0000000000..4b3660e4b0 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -0,0 +1,59 @@ +module Dradis::Plugins::Echo + class Message < ApplicationRecord + # User-facing text stored on a failed message. Deliberately generic so we + # never persist a provider's raw response body, hostname, or status line; + # the real error is logged server-side instead. + GENERIC_ERROR = "Roslin couldn't finish this response.".freeze + + 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 ------------------------------------------------------------ + # The author is the source of truth for a user turn, so controllers don't + # repeat `role: :user` on every build. ReplyJob still sets `role: :assistant` + # explicitly (assistant messages carry no user). Runs before the predicate + # callbacks below so `user?` sees the derived role. + before_validation :set_role_from_user + + # 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 + + def set_role_from_user + self.role ||= :user if user.present? + end + end +end diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb index 29bcd7f9c5..2942b3859a 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb @@ -40,7 +40,7 @@ def self.inherited(subclass) # -- Instance Methods ----------------------------------------------------- - def generate(prompt:, model: nil, &block) + def generate(messages:, model: nil, &block) raise NotImplementedError, "#{self.class.name} must implement #generate" end diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/anthropic.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/anthropic.rb index def48e1187..c714299f3c 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/anthropic.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/anthropic.rb @@ -3,16 +3,16 @@ class Provider::Anthropic < Provider include Provider::HttpStreaming API_VERSION = '2023-06-01'.freeze - DEFAULT_ADDRESS = 'https://api.anthropic.com/v1/messages'.freeze + DEFAULT_ADDRESS = 'https://api.anthropic.com/v1'.freeze DEFAULT_MAX_TOKENS = 4096 DEFAULT_MODEL = 'claude-sonnet-4-6'.freeze private - def build_body(prompt:, model:) + def build_body(messages:, model:) { max_tokens: DEFAULT_MAX_TOKENS, - messages: [{ role: 'user', content: prompt }], + messages: messages, model: model, stream: true } @@ -21,12 +21,12 @@ def build_body(prompt:, model:) def build_headers { 'anthropic-version' => API_VERSION, - 'x-api-key' => api_key + 'x-api-key' => api_key } end def build_uri(_model) - URI(address) + URI("#{address}/messages") end # Anthropic sends several SSE event types; only content_block_delta carries text: diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/gemini.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/gemini.rb index dad2990a1c..f4a4304fb9 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/gemini.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/gemini.rb @@ -4,12 +4,20 @@ class Provider::Gemini < Provider DEFAULT_ADDRESS = 'https://generativelanguage.googleapis.com/v1beta/models'.freeze DEFAULT_MODEL = 'gemini-2.5-flash'.freeze + ROLE_MAP = { 'assistant' => 'model' }.freeze private - def build_body(prompt:, model:) + # Gemini names the assistant role "model" and wraps content in a parts + # array, so map each message into its contents structure. + def build_body(messages:, model:) { - contents: [{ role: 'user', parts: [{ text: prompt }] }] + contents: messages.map do |message| + { + role: ROLE_MAP.fetch(message[:role], message[:role]), + parts: [{ text: message[:content] }] + } + end } end diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/ollama.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/ollama.rb index 51879da98e..2d174225c2 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/ollama.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/ollama.rb @@ -7,14 +7,14 @@ def requires_api_key? false end - def generate(prompt:, model: nil, &block) + def generate(messages:, model: nil, &block) resolved_model = model.presence || self.model buffer = block_given? ? nil : +'' - client.generate({ model: resolved_model, prompt: prompt }) do |event, _raw| + client.chat({ model: resolved_model, messages: messages }) do |event, _raw| next if event['done'] - chunk = event['response'].to_s + chunk = event.dig('message', 'content').to_s next if chunk.empty? chunk = chunk.sub('', '{thinking}').sub('', '{/thinking}') diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/open_ai.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/open_ai.rb index ec462f4439..a6a7974677 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/provider/open_ai.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/provider/open_ai.rb @@ -8,10 +8,10 @@ class Provider::OpenAI < Provider private - def build_body(prompt:, model:) + def build_body(messages:, model:) { model: model, - messages: [{ role: 'user', content: prompt }], + messages: messages, stream: true } 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 0000000000..5d72c2e2a8 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -0,0 +1,126 @@ +module Dradis::Plugins::Echo + class Session < ApplicationRecord + # Beyond the provider read timeout a still-streaming message can only be the + # debris of a crashed job, so we allow this much slack before reclaiming it. + STUCK_MARGIN = 30.seconds + + 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 + + # The session's project comes via its record today, but record is polymorphic + # (other scopes may join later), so callers ask the session — not the record — + # for its project. This is the authorization seam (SessionsChannel#authorized?). + 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 ----------------------------------------------------- + # Broadcasts the composer partial so the browser reflects the current + # idle/generating state. Called on every idle<->generating transition. + def broadcast_composer_state + broadcast_replace_to( + [self, :composer_state], + target: [self, :composer_state], + partial: 'dradis/plugins/echo/projects/sessions/composer_state', + locals: { session: self } + ) + end + + # The gate in front of ReplyJob: flips idle->generating and enqueues exactly + # one job. A no-op while already generating, so repeated calls (a user + # sending several messages) never double-enqueue — the running job re-checks + # for newer messages when it finishes. A generation whose streaming message + # has gone stale is treated as dead and reclaimed first. + def request_reply! + enqueue = false + + with_lock do + reclaim_stuck_generation! if generating? + + if idle? + update!(status: :generating) + broadcast_composer_state + enqueue = true + end + end + + ReplyJob.perform_later(self) if enqueue + end + + # True when a reply is owed but generation hasn't started: the session is idle + # and the newest message is a user turn. Lets the freshly-subscribed client + # trigger request_reply! so the streaming container is only broadcast once the + # socket is listening. Flips to false once ReplyJob sets `generating` or a + # reply lands, keeping the client trigger idempotent across reconnects. + def reply_pending? + idle? && messages.order(:created_at, :id).last&.user? + end + + # Only completed turns are safe to replay to a provider: a streaming row has + # no content yet, and a failed one carries a nil/partial body. Sending either + # would poison the next request with a `content: nil` turn. + def to_provider_messages + messages.where(status: :complete).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 + + private + + # A crashed ReplyJob leaves the session locked in `generating`. Two shapes: + # + # 1. An orphaned streaming message. ReplyJob touches the streaming row as + # chunks arrive (a throttled liveness signal), so a message untouched + # past the read timeout plus a margin is genuinely dead — not a slow but + # live stream. Fail it and release the session. + # 2. No streaming message at all: enqueue raised after the status commit, + # the worker was hard-killed before messages.create!, or the job was + # lost from the queue. Here the session's own updated_at is the liveness + # signal — once it's past the threshold, release the orphaned lock. + def reclaim_stuck_generation! + threshold = Provider::HttpStreaming::READ_TIMEOUT.seconds.ago - STUCK_MARGIN + streaming = messages.where(role: :assistant, status: :streaming) + stuck = streaming.where(updated_at: ..threshold) + + if stuck.exists? + stuck.find_each do |message| + message.update!(status: :failed, metadata: message.metadata.merge('error' => Message::GENERIC_ERROR)) + end + update!(status: :idle) + elsif streaming.none? && updated_at <= threshold + update!(status: :idle) + end + end + end +end diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/issues/_show-content.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/issues/_show-content.html.erb index 1b8eeb6110..bf23f8580f 100644 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/issues/_show-content.html.erb +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/issues/_show-content.html.erb @@ -9,10 +9,14 @@
-

Dradis Echo

+

Dradis Echo

- -
+ <%# Native lazy frame: loads interactions#index when the Echo tab becomes %> + <%# visible. index.html.erb answers with a matching , so initial %> + <%# load, prompt->show and back->index all Turbo-frame-match the same id. %> +
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_prompt.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_prompt.html.erb index 7264bdf265..ca7b39722e 100644 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_prompt.html.erb +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_prompt.html.erb @@ -1,7 +1,10 @@ -<%= form_with url: echo.project_interaction_path(current_project, prompt.id), method: :get, +<%# Starts a new session from the selected prompt. The response renders sessions#show %> +<%# into the Echo record frame, replacing the picker with the live conversation. %> +<%= form_with url: echo.project_sessions_path(current_project), method: :post, data: { turbo_frame: dom_id(@record, :echo) } do |f| %> <%= f.hidden_field :type, value: @type %> <%= f.hidden_field :record, value: @record.id %> + <%= f.hidden_field :prompt_id, value: prompt.id %>
<%= @@ -9,7 +12,7 @@ value: liquid_parse(prompt.prompt), class: 'form-control field-sizing-content', placeholder: 'Enter your prompt here...', - rows: 30 + rows: 6 %>
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_response.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_response.html.erb deleted file mode 100644 index 36f2857be7..0000000000 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/_response.html.erb +++ /dev/null @@ -1,30 +0,0 @@ -
-
-
-

- -

-
-
-
<%= @prompt_content %>
-
-
-
- -
-

- -

-
- <%# Keep the spinner div on the same line as its container: .echo-content uses - white-space: break-spaces, so a whitespace text node left behind after the - spinner is removed would render as a visible blank line before the response. %> -
"><%= spinner_tag %>
-
-
-
-
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/index.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/index.html.erb index bd1d0e4594..d6a3698030 100644 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/index.html.erb +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/index.html.erb @@ -1,3 +1,7 @@ +<%# Wrapped in the record's Echo frame so this response Turbo-frame-matches the %> +<%# native lazy frame in issues/_show-content.html.erb and the back-link in %> +<%# sessions/show.html.erb (both navigate dom_id(@record, :echo)). %> + <% unless @turbo_status %>
There was an error contacting Redis: please make sure the Redis server is running. @@ -6,33 +10,41 @@ <% if Dradis::Plugins::Echo::Agents::Roslin.enabled? %> <% if @prompts.any? %> -

- Interact with Roslin by selecting a - <%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to use with this Issue. -

-

- You can edit the prompt before sending it, any changes are one-off and - won't affect the saved prompt template. -

+
+ <%= render 'dradis/plugins/echo/projects/sessions/conversations_column', + record: @record, type: @type, sessions: @sessions %> -
-
-

Saved Prompts

- -
+
+
Start a new conversation
+

+ Interact with Roslin by selecting a + <%= link_to 'saved prompt', echo.prompts_path, data: { turbo_frame: '_top' } %> to use with this Issue. +

+

+ You can edit the prompt before sending it, any changes are one-off and + won't affect the saved prompt template. +

+ +
+
+

Saved Prompts

+ +
-
-

Selected Prompt

- - +
+

Selected Prompt

+ + +
+
<% else %> @@ -60,3 +72,4 @@ or adapt them to your audience.

<% end %> + diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/show.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/show.html.erb deleted file mode 100644 index d2eb3952b3..0000000000 --- a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/interactions/show.html.erb +++ /dev/null @@ -1,20 +0,0 @@ - -
- -

Dradis Echo interaction #<%= @interaction_id.truncate(12) %>

- - <%# Subscribe to main channel for real-time updates via ActionCable %> - <%= turbo_stream_from @interaction_id, 'prompts' %> - - <%# Here is where we will stream the responses from the LLM %> -
- <%= render partial: 'dradis/plugins/echo/projects/interactions/response' %> -
-
-
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_composer_state.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_composer_state.html.erb new file mode 100644 index 0000000000..e0f71fedbd --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_composer_state.html.erb @@ -0,0 +1,24 @@ +<%# locals: (session:, request_id: nil) -%> +<%# request_id is accepted-and-ignored: turbo-rails injects it into every broadcast %> +<%# partial's locals (non-nil in-request), so strict locals must tolerate it or %> +<%# broadcast_composer_state 500s. Route-free and locals-only so it renders from %> +<%# ReplyJob on every idle<->generating transition; session_controller reads %> +<%# data-generating to keep the textarea in step with the server-rendered button. %> +<%# Lock on generating? OR reply_pending?: the composer_state replace that flips a %> +<%# freshly-created session to generating can race the client's in-flight subscribe %> +<%# and be dropped; folding reply_pending? in server-renders the initial lock without %> +<%# depending on that broadcast. Later transitions are subscribed and broadcast-driven. %> +<% locked = session.generating? || session.reply_pending? %> +<%= tag.div id: dom_id(session, :composer_state), + data: { behavior: 'echo-composer-state', generating: locked } do %> +
+ <% if locked %> + Roslin is responding — you can send once it finishes. + <% else %> + Anyone on this project can join the conversation. + <% end %> + +
+<% end %> diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_conversations_column.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_conversations_column.html.erb new file mode 100644 index 0000000000..ab2d2a543e --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_conversations_column.html.erb @@ -0,0 +1,23 @@ +<%# locals: (record:, type:, sessions:, active_session: nil, new_conversation_path: nil) -%> +
+
+
Conversations
+ <% if new_conversation_path %> + <%= link_to new_conversation_path, class: 'echo-new-conversation-link', + data: { turbo_frame: dom_id(record, :echo) } do %> + New + <% end %> + <% end %> +
+
+ <% if sessions.any? %> + <%= render partial: 'dradis/plugins/echo/projects/sessions/session_item', + collection: sessions, as: :session, + locals: { record: record, type: type, active_session: active_session } %> + <% else %> +
+ No conversations on this Issue yet — start one below. +
+ <% end %> +
+
diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_session_item.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_session_item.html.erb new file mode 100644 index 0000000000..e01dc0ee8d --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_session_item.html.erb @@ -0,0 +1,19 @@ +<%# locals: (session:, record:, type:, active_session: nil) -%> +<% is_active = active_session && session.id == active_session.id %> +<%= link_to echo.project_session_path(current_project, session, type: type, record: record.id), + id: dom_id(session), + class: class_names('echo-session-item', active: is_active), + aria: { current: is_active ? 'page' : nil }, + data: { turbo_frame: dom_id(record, :echo) } do %> + + + + <%= session.title.presence || "Session ##{session.id}" %> + <% if session.generating? %> + <%= spinner_tag spinner_class: 'spinner-border-sm text-primary', inline: true %> + <% end %> + + + Updated <%= local_time_ago(session.updated_at) %> + +<% 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 0000000000..6dc3113038 --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/messages/_message.html.erb @@ -0,0 +1,43 @@ +<%# locals: (message:, request_id: nil) -%> +<%# request_id is accepted-and-ignored: turbo-rails injects it into every broadcast %> +<%# partial's locals (non-nil in a real request), so strict locals must tolerate it %> +<%# or the in-request broadcast_created 500s with StrictLocalsError. Renders from %> +<%# `render partial:` with only `message:` in locals, so no controller-context %> +<%# helpers here. Author line is keyed off `role`, never off user presence. %> +<% assistant = message.assistant? %> +<%= tag.div id: dom_id(message), + class: ['echo-message', assistant ? 'echo-message-assistant' : 'echo-message-user', + ('echo-message-streaming' if assistant && message.streaming?), + ('echo-message-failed' if assistant && message.failed?), + ('echo-message-deleted' if !assistant && message.user.nil?)].compact, + data: { status: message.status } do %> + <% if assistant %> + + + + <% else %> + <%= avatar_image(message.user, size: 35) %> + <% end %> + +
+
+ <%= assistant ? message.session.agent.name : (message.user&.name || 'Deleted user') %> + <%= local_time_ago(message.created_at) %> +
+ + <% if assistant && message.failed? %> + <%# Never render metadata['error'] here: the raw provider body/host is logged + server-side only. The view shows a fixed, generic summary. %> +
Roslin couldn’t finish this response.
+
Send another message to try again.
+ <% else %> + <%# ReplyJob streams chunks into this container by dom_id(message, :content). Keep the + spinner caption OUTSIDE it so appended chunks never overwrite the caption, and keep + the container on one line — .echo-message-content uses white-space: break-spaces. %> +
<%= message.content %>
+ <% if assistant && message.streaming? %> +
Roslin is responding…
+ <% end %> + <% end %> +
+<% end %> diff --git a/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb new file mode 100644 index 0000000000..4afb8c0d9c --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/show.html.erb @@ -0,0 +1,57 @@ + + <% unless @turbo_status %> +
+ There was an error contacting Redis: please make sure the Redis server is running. +
+ <% end %> + +
+ <%= render 'dradis/plugins/echo/projects/sessions/conversations_column', + record: @record, type: @type, sessions: @sessions, active_session: @session, + new_conversation_path: echo.project_interactions_path(current_project, type: @type, record: @record.id) %> + +
+ <%# Live transcript + composer state, both over the re-authorizing SessionsChannel %> + <%# (Slice 4): the socket re-checks project access on every subscribe. %> + <%= turbo_stream_from [@session, :messages], channel: Dradis::Plugins::Echo::SessionsChannel %> + <%= turbo_stream_from [@session, :composer_state], channel: Dradis::Plugins::Echo::SessionsChannel %> + + <%# reply-pending / reply-url drive the client-side generation trigger: on a %> + <%# freshly-created session the controller POSTs this reply URL from `connect`, %> + <%# after subscribing to the SessionsChannel, so the streaming container isn't %> + <%# broadcast before the socket is listening. An answered session renders %> + <%# reply-pending=false, so no spurious generation fires. %> +
+
+
+ <%= @session.title.presence || "Session ##{@session.id}" %> + · with <%= @session.agent.name %> +
+
+ + <%# Broadcasts append to the literal `echo-messages` target (Message#broadcast_created), %> + <%# so this id must stay verbatim; only one session view is mounted per Echo frame. %> +
+ <%= render partial: 'dradis/plugins/echo/projects/sessions/messages/message', + collection: @session.messages.order(:created_at, :id), as: :message %> +
+ + <%= form_with url: echo.project_session_messages_path(current_project, @session), method: :post, + class: 'echo-composer', + data: { action: 'submit->dradis--plugins--echo--session#send' } do |f| %> + <%= + f.text_area :content, + class: 'form-control field-sizing-content', + placeholder: 'Message Roslin…', + rows: 2, + data: { 'dradis--plugins--echo--session-target': 'input' } + %> + <%= render 'dradis/plugins/echo/projects/sessions/composer_state', session: @session %> + <% end %> +
+
+
+
diff --git a/engines/dradis-echo/config/routes.rb b/engines/dradis-echo/config/routes.rb index 7d72c30fc8..55c88d4b03 100644 --- a/engines/dradis-echo/config/routes.rb +++ b/engines/dradis-echo/config/routes.rb @@ -4,12 +4,21 @@ resources :providers, except: [:show] resources :projects, only: [] do - resources :interactions, only: [:index, :show, :create], controller: 'projects/interactions' do - get :preview, on: :member - end + scope module: 'projects' do + resources :interactions, only: [:index] do + get :preview, on: :member + end + + resources :grammar_corrections, only: [:create] + resources :grammar_suggestions, only: [:create] - resources :grammar_corrections, only: [:create], controller: 'projects/grammar_corrections' - resources :grammar_suggestions, only: [:create], controller: 'projects/grammar_suggestions' + resources :sessions, only: [:show, :create] do + scope module: 'sessions' do + resource :reply, only: [:create] + resources :messages, only: [:create] + end + end + end end resources :prompts, except: [:show] 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 0000000000..4485043910 --- /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 0000000000..57e335732e --- /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 ce8cb59110..91e45a90d8 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/channels/dradis/plugins/echo/sessions_channel_spec.rb b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb new file mode 100644 index 0000000000..116e415c7f --- /dev/null +++ b/engines/dradis-echo/spec/channels/dradis/plugins/echo/sessions_channel_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +describe Dradis::Plugins::Echo::SessionsChannel, type: :channel do + let(:user) { create(:user) } + let(:agent) { create(:agent) } + let(:session) { create(:echo_session, agent: agent) } + + let(:signed_name) { Turbo::StreamsChannel.signed_stream_name([session, :messages]) } + let(:stream_name) { Turbo::StreamsChannel.verified_stream_name(signed_name) } + + before do + session.project.assign_owner(user) + stub_connection(current_user: user) + end + + it 'accepts the subscription and streams the transcript for an authorized user' do + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_confirmed + expect(subscription).to have_stream_from(stream_name) + end + + it "rejects a user whose :use on the session's project is denied" do + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + + expect(subscription).to be_rejected + end + + # Re-authorizes at subscribe time: the same signed name is accepted while the + # user may :use the project and rejected once that permission is revoked. + it 'flips between accept and reject as authorization changes' do + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_confirmed + + allow_any_instance_of(Ability).to receive(:can?).and_return(false) + + subscribe(signed_stream_name: signed_name) + expect(subscription).to be_rejected + end + + it 'rejects a tampered stream name that fails verification' do + subscribe(signed_stream_name: 'not-a-valid-signed-stream-name') + + expect(subscription).to be_rejected + end +end diff --git a/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb new file mode 100644 index 0000000000..1a51730836 --- /dev/null +++ b/engines/dradis-echo/spec/controllers/concerns/dradis/plugins/echo/turbo_config_check_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +describe Dradis::Plugins::Echo::TurboConfigCheck do + subject(:controller) do + Class.new { include Dradis::Plugins::Echo::TurboConfigCheck }.new + end + + describe '#check_turbo_config' do + it 'reports healthy without pinging when the adapter is not Redis' do + # A plain double doesn't respond_to :redis_connection_for_subscriptions, + # so the duck-typed guard returns healthy without a ping. (Stubbing the + # method here would make respond_to? true and defeat the check — an + # unexpected call would instead raise, still failing the example.) + adapter = double('non-redis adapter') + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'pings and reports healthy for a reachable Redis adapter' do + redis = double('redis', ping: 'PONG') + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_return(redis) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(true) + end + + it 'reports unhealthy when the Redis ping fails' do + adapter = ActionCable::SubscriptionAdapter::Redis.allocate + allow(adapter).to receive(:redis_connection_for_subscriptions).and_raise(StandardError) + allow(ActionCable.server).to receive(:pubsub).and_return(adapter) + + expect(controller.send(:check_turbo_config)).to be(false) + 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 0000000000..9dd84c6419 --- /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/providers.rb b/engines/dradis-echo/spec/factories/providers.rb index ffa57ab9e5..abc77e0696 100644 --- a/engines/dradis-echo/spec/factories/providers.rb +++ b/engines/dradis-echo/spec/factories/providers.rb @@ -5,7 +5,7 @@ model { 'qwen2.5:14b' } factory :anthropic_provider, class: 'Dradis::Plugins::Echo::Provider::Anthropic' do - address { 'https://api.anthropic.com/v1/messages' } + address { 'https://api.anthropic.com/v1' } api_key { 'sk-ant-test' } model { 'claude-sonnet-4-6' } end diff --git a/engines/dradis-echo/spec/factories/sessions.rb b/engines/dradis-echo/spec/factories/sessions.rb new file mode 100644 index 0000000000..47c18ca56e --- /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/features/dradis/plugins/echo/sessions_conversation_spec.rb b/engines/dradis-echo/spec/features/dradis/plugins/echo/sessions_conversation_spec.rb new file mode 100644 index 0000000000..cd3bffe9f1 --- /dev/null +++ b/engines/dradis-echo/spec/features/dradis/plugins/echo/sessions_conversation_spec.rb @@ -0,0 +1,66 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +# Live-browser acceptance for the Echo Sessions conversation UI: the flows rack_test +# request specs can't exercise — the Bootstrap tab-show that triggers the native lazy +# frame, and Turbo-frame back-navigation between sessions#show and interactions#index. +describe 'Echo Sessions conversation UI', js: true do + before { login_to_project_as_user } + + let!(:roslin) do + Dradis::Plugins::Echo::Agents::Roslin.provision!.tap { |a| a.update!(enabled: true) } + end + + let(:issue) { create(:issue, node: current_project.issue_library, text: "#[Title]#\nSQLi") } + + let!(:prompt) do + @logged_in_as.prompts.create!( + title: 'Summarise the finding', + prompt: 'Summarise the issue', + scope: 'issue', + visibility: :user + ) + end + + # Bug 3: the frame-mechanism swap (data-behavior=fetch -> native lazy turbo-frame). + it 'lazy-loads the conversation list when the Echo tab is shown and returns to it via the "+ New" link' do + session = create(:echo_session, agent: roslin, record: issue, title: 'Earlier conversation') + create(:echo_message, session: session, role: :user, content: 'my earlier question', user: @logged_in_as) + + visit project_issue_path(current_project, issue) + + # The Echo tab-pane is hidden until the Bootstrap tab is shown; showing it must + # trigger the native to fetch interactions#index. + click_link 'Echo' + expect(page).to have_css("turbo-frame#echo_issue_#{issue.id}") + expect(page).to have_content('Earlier conversation') # lazy frame loaded on tab-show + + # Into the session (sessions#show renders into the same native frame). + find('.echo-session-item', text: 'Earlier conversation').click + expect(page).to have_content('my earlier question') + + # Back to the list: Turbo navigates the frame to interactions#index. Before the + # fix this reported "Content missing" (index answered frameless). + find('.echo-new-conversation-link').click + expect(page).to have_content('Earlier conversation') + expect(page).to have_no_content('Content missing') + end + + # Bug 1: sessions#create -> render :show must be 200, not a StrictLocalsError 500. + it 'starts a conversation from a saved prompt without a StrictLocalsError' do + visit project_issue_path(current_project, issue) + click_link 'Echo' + + # The prompt-selector stimulus controller auto-loads the selected prompt's preview + # form (the "Start with this prompt" submit) into the echo-prompt-preview frame. + expect(page).to have_button('Start with this prompt') + click_button 'Start with this prompt' + + # The create response renders sessions#show into the echo frame: the first user + # message (the prompt body) appears and no error page / strict-locals crash. + expect(page).to have_content('Summarise the issue') + expect(page).to have_no_content('unknown local') + expect(page).to have_css('.echo-conversation') + end +end diff --git a/engines/dradis-echo/spec/features/dradis/plugins/echo/sessions_spec.rb b/engines/dradis-echo/spec/features/dradis/plugins/echo/sessions_spec.rb new file mode 100644 index 0000000000..b9127b9acd --- /dev/null +++ b/engines/dradis-echo/spec/features/dradis/plugins/echo/sessions_spec.rb @@ -0,0 +1,98 @@ +require 'rails_helper' + +Dir[Dradis::Plugins::Echo::Engine.root.join('spec/factories/*.rb')].sort.each { |f| require f } + +# End-to-end coverage of the Echo session flow through the real views, controllers +# and models: start a session from a saved prompt, persist the first exchange, and +# prove a follow-up retains the prior transcript. The provider is stubbed so the +# reply is deterministic, and ReplyJob runs inline (perform_enqueued_jobs) so the +# assistant turn is persisted within the request — no ActionCable needed (the test +# cable adapter never delivers to a browser anyway). +describe 'Echo sessions' do + include ActiveJob::TestHelper + + let(:user) { @logged_in_as } + + before do + login_to_project_as_user + + # Capture the context handed to the provider each turn so we can assert the + # follow-up carried the full history forward. + @contexts = [] + allow_any_instance_of(Dradis::Plugins::Echo::Provider::Ollama) + .to receive(:generate) do |_provider, messages:, model: nil, &block| + @contexts << messages + block.call("Roslin reply #{@contexts.size}") + end + end + + let!(:roslin) do + Dradis::Plugins::Echo::Agents::Roslin.provision!.tap { |agent| agent.update!(enabled: true) } + end + + let(:issue) { create(:issue, node: @project.issue_library, text: "#[Title]#\nSQLi") } + + let!(:prompt) do + user.prompts.create!( + title: 'Summarise the finding', + prompt: 'Summarise {{ issue.title }}', + scope: 'issue', + visibility: :user + ) + end + + # Generation is started by the session Stimulus controller once subscribed, not + # by sessions#create. rack_test has no JS, so drive that reply-trigger POST + # explicitly and run the job inline. + def start_session + visit echo.preview_project_interaction_path(@project.id, prompt.id, type: 'issue', record: issue.id) + click_button 'Start with this prompt' + + session = Dradis::Plugins::Echo::Session.last + perform_enqueued_jobs { page.driver.post(echo.project_session_reply_path(@project.id, session)) } + session + end + + it 'starts a session from a prompt and persists the streamed first exchange' do + session = start_session + + expect(session.title).to eq('Summarise the finding') + expect(session.user).to eq(user) + expect(session.record).to eq(issue) + + expect(session.messages.order(:id).pluck(:role, :content)).to eq([ + %w[user Summarise\ SQLi], + %w[assistant Roslin\ reply\ 1] + ]) + expect(session.reload).to be_idle + + # The reply is generated after create renders show, so it lands on reload. + visit echo.project_session_path(@project.id, session, type: 'issue', record: issue.id) + expect(page).to have_content('Roslin reply 1') + end + + it 'retains the conversation context on a follow-up message after reload' do + session = start_session + + # Reload the conversation: the first reply has finished, so the transcript + # survives (acceptance: survives reload) and the composer re-enables. + visit echo.project_session_path(@project.id, session, type: 'issue', record: issue.id) + expect(page).to have_content('Roslin reply 1') + + fill_in 'content', with: 'What is the impact?' + perform_enqueued_jobs { click_button 'Send' } + + expect(session.messages.reload.order(:id).pluck(:role, :content)).to eq([ + %w[user Summarise\ SQLi], + %w[assistant Roslin\ reply\ 1], + ['user', 'What is the impact?'], + %w[assistant Roslin\ reply\ 2] + ]) + + # The provider's second turn saw the whole prior transcript — context retained. + expect(@contexts.last.map { |message| message[:content] }).to eq( + ['Summarise SQLi', 'Roslin reply 1', 'What is the impact?'] + ) + expect(session.reload).to be_idle + end +end diff --git a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/interaction_job_spec.rb b/engines/dradis-echo/spec/jobs/dradis/plugins/echo/interaction_job_spec.rb deleted file mode 100644 index 9827e9a815..0000000000 --- a/engines/dradis-echo/spec/jobs/dradis/plugins/echo/interaction_job_spec.rb +++ /dev/null @@ -1,70 +0,0 @@ -require 'rails_helper' -require File.expand_path('../../../../factories/agents', __dir__) -require File.expand_path('../../../../factories/providers', __dir__) - -describe Dradis::Plugins::Echo::InteractionJob do - let(:interaction_id) { 'project-1' } - let(:response_id) { 'response-1' } - let(:prompt) { 'Summarise this issue.' } - let(:agent) { create(:system_agent) } - - def perform - described_class.perform_now( - agent_id: agent.id, - prompt: prompt, - interaction_id: interaction_id, - response_id: response_id - ) - end - - before do - allow(Turbo::StreamsChannel).to receive(:broadcast_append_to) - allow(Turbo::StreamsChannel).to receive(:broadcast_remove_to) - allow(Turbo::StreamsChannel).to receive(:broadcast_update_to) - end - - describe 'when agent is not enabled' do - before { agent.update!(enabled: false) } - - it 'broadcasts a user-friendly error' do - perform - expect(Turbo::StreamsChannel).to have_received(:broadcast_update_to) do |_, **kwargs| - expect(kwargs[:html]).to include('is not enabled') - end - end - end - - describe 'error message sanitisation' do - it 'HTML-escapes the error message before broadcasting' do - allow_any_instance_of(Dradis::Plugins::Echo::Provider::Ollama) - .to receive(:generate).and_raise('') - - perform - expect(Turbo::StreamsChannel).to have_received(:broadcast_update_to) do |_, **kwargs| - expect(kwargs[:html]).to include('<script>') - expect(kwargs[:html]).not_to include('