From e9540f9b2077ae2243782b866482acf97a8a3b0a Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:47:27 +0100 Subject: [PATCH 01/57] Convert Echo provider layer to multi-turn messages --- .../plugins/echo/provider/http_streaming.rb | 10 ++- .../models/dradis/plugins/echo/provider.rb | 17 +++- .../dradis/plugins/echo/provider/anthropic.rb | 6 +- .../dradis/plugins/echo/provider/gemini.rb | 10 ++- .../dradis/plugins/echo/provider/ollama.rb | 7 +- .../dradis/plugins/echo/provider/open_ai.rb | 4 +- .../plugins/echo/provider/anthropic_spec.rb | 13 +++- .../plugins/echo/provider/gemini_spec.rb | 20 ++++- .../echo/provider/http_streaming_spec.rb | 30 +++++++ .../plugins/echo/provider/ollama_spec.rb | 78 ++++++++++++------- .../plugins/echo/provider/open_ai_spec.rb | 11 ++- 11 files changed, 157 insertions(+), 49 deletions(-) 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 21ac06696..463744e61 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 @@ -16,13 +16,17 @@ module Provider::HttpStreaming # 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: }]) or the + # prompt: sugar for a single user message. + # # 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: nil, prompt: nil, model: nil, &block) resolved_model = model.presence || self.model + resolved_messages = resolve_messages(messages, prompt) uri = build_uri(resolved_model) headers = build_headers - body = build_body(prompt: prompt, model: resolved_model) + body = build_body(messages: resolved_messages, model: resolved_model) buffer = block ? nil : +'' @@ -93,7 +97,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/dradis/plugins/echo/provider.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb index 29bcd7f9c..bf181d5b9 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: nil, prompt: nil, model: nil, &block) raise NotImplementedError, "#{self.class.name} must implement #generate" end @@ -55,5 +55,20 @@ def requires_api_key? def type_name self.class.name.demodulize end + + private + + # Normalizes the two ways to call #generate into a messages array. Callers + # pass either a multi-turn messages: array or the prompt: sugar, which wraps + # a single string into one user message. + def resolve_messages(messages, prompt) + if messages + messages + elsif prompt + [{ role: 'user', content: prompt }] + else + raise ArgumentError, '#generate requires either messages: or prompt:' + end + end end 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 def48e118..f0708a1a2 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 @@ -9,10 +9,10 @@ class Provider::Anthropic < Provider 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,7 +21,7 @@ def build_body(prompt:, model:) def build_headers { 'anthropic-version' => API_VERSION, - 'x-api-key' => api_key + 'x-api-key' => api_key } end 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 dad2990a1..fec20c2a4 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,18 @@ 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 { |message| + { role: ROLE_MAP.fetch(message[:role], message[:role]), + parts: [{ text: message[:content] }] } + } } 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 51879da98..d2333ffb1 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,15 @@ def requires_api_key? false end - def generate(prompt:, model: nil, &block) + def generate(messages: nil, prompt: nil, model: nil, &block) resolved_model = model.presence || self.model + resolved_messages = resolve_messages(messages, prompt) buffer = block_given? ? nil : +'' - client.generate({ model: resolved_model, prompt: prompt }) do |event, _raw| + client.chat({ model: resolved_model, messages: resolved_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 ec462f443..a6a797467 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/spec/models/dradis/plugins/echo/provider/anthropic_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/anthropic_spec.rb index 359f5507b..41f08117e 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/anthropic_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/anthropic_spec.rb @@ -34,10 +34,15 @@ end describe '#build_body' do - it 'builds a streaming messages request' do - body = provider.send(:build_body, prompt: 'Hello', model: 'claude-sonnet-4-6') + it 'passes the multi-turn messages through unchanged' do + messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'How are you?' } + ] + body = provider.send(:build_body, messages: messages, model: 'claude-sonnet-4-6') expect(body[:model]).to eq('claude-sonnet-4-6') - expect(body[:messages]).to eq([{ role: 'user', content: 'Hello' }]) + expect(body[:messages]).to eq(messages) expect(body[:stream]).to be true expect(body[:max_tokens]).to eq(described_class::DEFAULT_MAX_TOKENS) end @@ -46,7 +51,7 @@ describe '#extract_text' do it 'extracts text from content_block_delta events' do payload = { - 'type' => 'content_block_delta', + 'type' => 'content_block_delta', 'delta' => { 'type' => 'text_delta', 'text' => 'Hello' } } expect(provider.send(:extract_text, payload)).to eq('Hello') diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/gemini_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/gemini_spec.rb index 33d599084..67e47bcd6 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/gemini_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/gemini_spec.rb @@ -33,10 +33,26 @@ end describe '#build_body' do - it 'wraps the prompt in the Gemini content structure' do - body = provider.send(:build_body, prompt: 'Hello', model: 'gemini-2.0-flash') + it 'wraps a single user message in the Gemini content structure' do + body = provider.send(:build_body, + messages: [{ role: 'user', content: 'Hello' }], + model: 'gemini-2.0-flash') expect(body[:contents]).to eq([{ role: 'user', parts: [{ text: 'Hello' }] }]) end + + it 'maps the assistant role to model and keeps user turns' do + messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'How are you?' } + ] + body = provider.send(:build_body, messages: messages, model: 'gemini-2.0-flash') + expect(body[:contents]).to eq([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + { role: 'user', parts: [{ text: 'How are you?' }] } + ]) + end end describe '#extract_text' do diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/http_streaming_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/http_streaming_spec.rb index 53d7ea726..b9afa2326 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/http_streaming_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/http_streaming_spec.rb @@ -95,5 +95,35 @@ def stub_http(body:, code: '200') expect(chunks).to eq(['Hello']) expect(result).to be_nil end + + it 'streams a multi-turn messages array' do + delta_event = { 'type' => 'content_block_delta', 'delta' => { 'type' => 'text_delta', 'text' => 'Hello' } } + sse_body = "data: #{JSON.generate(delta_event)}\n\n" + stub_http(body: sse_body) + + messages = [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello' }, + { role: 'user', content: 'Again' } + ] + expect(provider).to receive(:build_body) + .with(messages: messages, model: 'claude-sonnet-4-6').and_call_original + + expect(provider.generate(messages: messages)).to eq('Hello') + end + + it 'wraps the prompt: sugar into a single user message' do + stub_http(body: '') + + expect(provider).to receive(:build_body) + .with(messages: [{ role: 'user', content: 'test' }], model: 'claude-sonnet-4-6') + .and_call_original + + provider.generate(prompt: 'test') + end + + it 'raises when neither messages: nor prompt: is given' do + expect { provider.generate }.to raise_error(ArgumentError) + end end end diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/ollama_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/ollama_spec.rb index 23ab78171..843d326eb 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/ollama_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/ollama_spec.rb @@ -10,61 +10,66 @@ allow(provider).to receive(:client).and_return(client) end - def stub_events(*events) - allow(client).to receive(:generate) do |_payload, &block| + def stub_chat(*events) + allow(client).to receive(:chat) do |_payload, &block| events.each { |event| block.call(event, nil) } end end + # Ollama's chat endpoint nests each chunk under message.content. + def content_event(content, done: false) + { 'message' => { 'content' => content }, 'done' => done } + end + describe '#generate' do it 'concatenates response chunks into the buffer' do - stub_events( - { 'response' => 'Hello ', 'done' => false }, - { 'response' => 'world', 'done' => false }, - { 'response' => '', 'done' => true } + stub_chat( + content_event('Hello '), + content_event('world'), + content_event('', done: true) ) expect(provider.generate(prompt: 'hi')).to eq('Hello world') end it 'preserves whitespace-only chunks, such as standalone line breaks' do - stub_events( - { 'response' => '1. First item.', 'done' => false }, - { 'response' => " \n", 'done' => false }, - { 'response' => '2. Second item.', 'done' => false }, - { 'response' => '', 'done' => true } + stub_chat( + content_event('1. First item.'), + content_event(" \n"), + content_event('2. Second item.'), + content_event('', done: true) ) expect(provider.generate(prompt: 'hi')).to eq("1. First item. \n2. Second item.") end - it 'skips events with an empty response, such as thinking-only chunks' do - stub_events( - { 'response' => '', 'thinking' => 'Reasoning...', 'done' => false }, - { 'response' => 'Answer', 'done' => false }, - { 'response' => '', 'done' => true } + it 'skips events with empty content, such as thinking-only chunks' do + stub_chat( + { 'message' => { 'content' => '', 'thinking' => 'Reasoning...' }, 'done' => false }, + content_event('Answer'), + content_event('', done: true) ) expect(provider.generate(prompt: 'hi')).to eq('Answer') end it 'replaces think tags embedded in the response text' do - stub_events( - { 'response' => '', 'done' => false }, - { 'response' => 'reasoning', 'done' => false }, - { 'response' => '', 'done' => false }, - { 'response' => 'Answer', 'done' => false }, - { 'response' => '', 'done' => true } + stub_chat( + content_event(''), + content_event('reasoning'), + content_event(''), + content_event('Answer'), + content_event('', done: true) ) expect(provider.generate(prompt: 'hi')).to eq('{thinking}reasoning{/thinking}Answer') end it 'yields each chunk to the given block instead of buffering' do - stub_events( - { 'response' => 'Hello ', 'done' => false }, - { 'response' => 'world', 'done' => false }, - { 'response' => '', 'done' => true } + stub_chat( + content_event('Hello '), + content_event('world'), + content_event('', done: true) ) chunks = [] @@ -72,5 +77,26 @@ def stub_events(*events) expect(chunks).to eq(['Hello ', 'world']) end + + it 'sends a multi-turn messages array to the chat endpoint' do + messages = [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello' }, + { role: 'user', content: 'Again' } + ] + expect(client).to receive(:chat) + .with(hash_including(messages: messages)) + .and_yield(content_event('ok'), nil) + + expect(provider.generate(messages: messages)).to eq('ok') + end + + it 'wraps the prompt: sugar into a single user message' do + expect(client).to receive(:chat) + .with(hash_including(messages: [{ role: 'user', content: 'hi' }])) + .and_yield(content_event('ok'), nil) + + provider.generate(prompt: 'hi') + end end end diff --git a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/open_ai_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/open_ai_spec.rb index 8ea0aab33..ff94090ad 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/open_ai_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/provider/open_ai_spec.rb @@ -31,10 +31,15 @@ end describe '#build_body' do - it 'builds a streaming chat completions request' do - body = provider.send(:build_body, prompt: 'Hello', model: 'gpt-4o') + it 'passes the multi-turn messages through unchanged' do + messages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' }, + { role: 'user', content: 'How are you?' } + ] + body = provider.send(:build_body, messages: messages, model: 'gpt-4o') expect(body[:model]).to eq('gpt-4o') - expect(body[:messages]).to eq([{ role: 'user', content: 'Hello' }]) + expect(body[:messages]).to eq(messages) expect(body[:stream]).to be true end end From 4ae1500256c5aecb7175e2d747ffa183501429ee Mon Sep 17 00:00:00 2001 From: Daniel Martin <53006+etdsoft@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:39:27 +0200 Subject: [PATCH 02/57] Apply suggestion from @aapomm Co-authored-by: Aaron Manaloto --- .../app/models/dradis/plugins/echo/provider/gemini.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 fec20c2a4..e97f4658a 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 @@ -12,9 +12,12 @@ class Provider::Gemini < Provider # array, so map each message into its contents structure. def build_body(messages:, model:) { - contents: messages.map { |message| - { role: ROLE_MAP.fetch(message[:role], message[:role]), - parts: [{ text: message[:content] }] } + contents: messages.map do |message| + { + role: ROLE_MAP.fetch(message[:role], message[:role]), + parts: [{ text: message[:content] }] + } + end } } end From 2c59ba73a1d872a38a2326801a94c8a862c4e4b8 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Wed, 22 Jul 2026 14:29:44 +0100 Subject: [PATCH 03/57] Echo: fix dangling brace left by the applied gemini.rb do...end suggestion --- .../app/models/dradis/plugins/echo/provider/gemini.rb | 1 - 1 file changed, 1 deletion(-) 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 e97f4658a..f4a4304fb 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 @@ -18,7 +18,6 @@ def build_body(messages:, model:) parts: [{ text: message[:content] }] } end - } } end From ae0ab4f4d38da553a94641e9165df39a89555620 Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:51:27 +0100 Subject: [PATCH 04/57] Echo: add Session and Message persistence Persist Echo agent conversations. Sessions hang off a polymorphic record (Note/Issue) and an agent; messages record the multi-turn exchange. Deleting a user preserves history by nullifying authorship; deleting a record cleans up its sessions. --- db/schema.rb | 36 ++++++- .../app/models/dradis/plugins/echo/message.rb | 41 +++++++ .../app/models/dradis/plugins/echo/session.rb | 47 ++++++++ .../sessions/messages/_message.html.erb | 22 ++++ ...001_create_dradis_plugins_echo_sessions.rb | 17 +++ ...002_create_dradis_plugins_echo_messages.rb | 22 ++++ .../lib/dradis/plugins/echo/engine.rb | 14 +++ .../dradis-echo/spec/factories/messages.rb | 13 +++ .../dradis-echo/spec/factories/sessions.rb | 7 ++ .../dradis/plugins/echo/message_spec.rb | 83 ++++++++++++++ .../dradis/plugins/echo/session_spec.rb | 102 ++++++++++++++++++ 11 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 engines/dradis-echo/app/models/dradis/plugins/echo/message.rb create mode 100644 engines/dradis-echo/app/models/dradis/plugins/echo/session.rb create mode 100644 engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/messages/_message.html.erb create mode 100644 engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb create mode 100644 engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb create mode 100644 engines/dradis-echo/spec/factories/messages.rb create mode 100644 engines/dradis-echo/spec/factories/sessions.rb create mode 100644 engines/dradis-echo/spec/models/dradis/plugins/echo/message_spec.rb create mode 100644 engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb 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/dradis/plugins/echo/message.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb new file mode 100644 index 000000000..22bfb3b76 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -0,0 +1,41 @@ +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 + + 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? + + # -- 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 if user? + 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..060e7e447 --- /dev/null +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -0,0 +1,47 @@ +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 + + # -- Scopes --------------------------------------------------------------- + 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 project + record.project + end + + def to_provider_messages + messages.order(:created_at, :id).map do |message| + { role: message.role, content: message.content } + end + end + + # 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). + # + # FIXME - ISSUE/NOTE INHERITANCE + def record=(new_record) + super + self.record_type = 'Issue' if new_record.is_a?(Issue) + 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..07404f4af --- /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 — Slice 5 restyles. 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_dradis_plugins_echo_sessions.rb b/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb new file mode 100644 index 000000000..285eeeebe --- /dev/null +++ b/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb @@ -0,0 +1,17 @@ +class CreateDradisPluginsEchoSessions < 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, null: true, + 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_dradis_plugins_echo_messages.rb b/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb new file mode 100644 index 000000000..7547b29ef --- /dev/null +++ b/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb @@ -0,0 +1,22 @@ +class CreateDradisPluginsEchoMessages < 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 until Slice 3 branches conversations. + t.references :parent, null: true, + foreign_key: { to_table: :dradis_plugins_echo_messages } + t.references :user, null: true, + 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..9cfe838aa 100644 --- a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb +++ b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb @@ -32,6 +32,20 @@ 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). Clean them + # up when the record is destroyed. Inherited by Issue < Note. + ::Note.send(:before_destroy) do + Dradis::Plugins::Echo::Session.for_record(self).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..5a06146d0 --- /dev/null +++ b/engines/dradis-echo/spec/factories/messages.rb @@ -0,0 +1,13 @@ +FactoryBot.define do + factory :echo_message, class: 'Dradis::Plugins::Echo::Message' do + association :session, factory: :echo_session + 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..e9ea98ca2 --- /dev/null +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -0,0 +1,102 @@ +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 + end +end From cf09934cb92d4b9b58c36867b8e3dc92880e6483 Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:04:36 +0100 Subject: [PATCH 05/57] Echo: align migration foreign_key hash keys for rubocop Layout/HashAlignment: the foreign_key: continuation must align under the first hash key (null:), or the Lint CI job fails. --- .../20260714000001_create_dradis_plugins_echo_sessions.rb | 4 ++-- .../20260714000002_create_dradis_plugins_echo_messages.rb | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb b/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb index 285eeeebe..54850df83 100644 --- a/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb +++ b/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb @@ -2,9 +2,9 @@ class CreateDradisPluginsEchoSessions < 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 } + foreign_key: { to_table: :dradis_plugins_echo_agents } t.references :user, null: true, - foreign_key: { to_table: :users, on_delete: :nullify } + foreign_key: { to_table: :users, on_delete: :nullify } t.references :record, polymorphic: true, null: false # 0 maps to the :idle enum value diff --git a/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb b/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb index 7547b29ef..3c31ed180 100644 --- a/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb +++ b/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb @@ -2,12 +2,12 @@ class CreateDradisPluginsEchoMessages < 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 } + foreign_key: { to_table: :dradis_plugins_echo_sessions } # Self-referential parent, dormant until Slice 3 branches conversations. t.references :parent, null: true, - foreign_key: { to_table: :dradis_plugins_echo_messages } + foreign_key: { to_table: :dradis_plugins_echo_messages } t.references :user, null: true, - foreign_key: { to_table: :users, on_delete: :nullify } + foreign_key: { to_table: :users, on_delete: :nullify } # 0 maps to the :user role t.integer :role, default: 0, null: false From 4969d63819de655b423226af59e5176beea820f3 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 17 Jul 2026 01:03:36 +0100 Subject: [PATCH 06/57] Echo: cascade session cleanup via Sessionable concern not a Note monkeypatch --- .../dradis/plugins/echo/sessionable.rb | 28 +++++++++++++++++++ .../lib/dradis/plugins/echo/engine.rb | 9 +++--- .../dradis/plugins/echo/session_spec.rb | 12 ++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb 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..14ef80b1a --- /dev/null +++ b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb @@ -0,0 +1,28 @@ +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 + + # 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` above + # 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. + after_destroy do + Dradis::Plugins::Echo::Session.where(record_type: 'Issue', record_id: id).destroy_all + end + 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 9cfe838aa..a951cc306 100644 --- a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb +++ b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb @@ -41,11 +41,10 @@ class Engine < ::Rails::Engine initializer 'echo.extend_note_model' do ActiveSupport.on_load :note_model do - # Sessions hang off a polymorphic record (a Note or Issue). Clean them - # up when the record is destroyed. Inherited by Issue < Note. - ::Note.send(:before_destroy) do - Dradis::Plugins::Echo::Session.for_record(self).destroy_all - end + # Sessions hang off a polymorphic record (a Note or Issue). The + # Sessionable concern owns the association and destroy cleanup; + # Issue < Note inherits it. + ::Note.include Dradis::Plugins::Echo::Sessionable 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 index e9ea98ca2..ba8e3a18a 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -98,5 +98,17 @@ expect { issue.destroy }.to change { described_class.exists?(session.id) }.to(false) end + + # The gap the after_destroy sweep 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 has to catch 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 end end From c972bb447579a3063dad1ba443983262970285bb Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Wed, 22 Jul 2026 15:57:54 +0100 Subject: [PATCH 07/57] Echo: apply PR #1649 Slice-2 review resolutions (aapomm) Move the record_type 'Issue' after_destroy sweep out of the shared Sessionable concern and into the Note-only on_load(:note_model) wiring in engine.rb, right after ::Note.include Sessionable. Hardcoding 'Issue' in the shared concern would let a future host (Evidence/ContentBlock, independent id sequences) delete an unrelated Issue #N's sessions on destroy. - Message: before_validation :complete_user_messages, if: :user? (drop the internal user? guard). - Session#project: delegate :project, to: :record. - Session.for_record: comment why where(record: record) misses forced 'Issue' rows. - Specs: keep the loaded-as-Note sweep coverage (now against the relocated callback) and add a guard proving a non-Note Sessionable host destroy leaves unrelated Issue sessions intact. --- .../dradis/plugins/echo/sessionable.rb | 12 -------- .../app/models/dradis/plugins/echo/message.rb | 4 +-- .../app/models/dradis/plugins/echo/session.rb | 9 +++--- .../lib/dradis/plugins/echo/engine.rb | 18 +++++++++-- .../dradis/plugins/echo/session_spec.rb | 30 ++++++++++++++++--- 5 files changed, 49 insertions(+), 24 deletions(-) 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 index 14ef80b1a..977adaebd 100644 --- a/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb +++ b/engines/dradis-echo/app/models/concerns/dradis/plugins/echo/sessionable.rb @@ -11,18 +11,6 @@ module Sessionable included do has_many :echo_sessions, as: :record, class_name: 'Dradis::Plugins::Echo::Session', dependent: :destroy - - # 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` above - # 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. - after_destroy do - Dradis::Plugins::Echo::Session.where(record_type: 'Issue', record_id: id).destroy_all - end 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 index 22bfb3b76..bfd297a9c 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -12,7 +12,7 @@ class Message < ApplicationRecord # -- Callbacks ------------------------------------------------------------ # User messages are authored in full, so they're never mid-stream. - before_validation :complete_user_messages + before_validation :complete_user_messages, if: :user? after_create_commit :broadcast_created @@ -35,7 +35,7 @@ def broadcast_created end def complete_user_messages - self.status = :complete if user? + 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 index 060e7e447..a8b4102f6 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -8,7 +8,12 @@ class Session < ApplicationRecord 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) } @@ -22,10 +27,6 @@ def self.record_type_for(record) end # -- Instance Methods ----------------------------------------------------- - def project - record.project - end - def to_provider_messages messages.order(:created_at, :id).map do |message| { role: message.role, content: message.content } diff --git a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb index a951cc306..91e45a90d 100644 --- a/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb +++ b/engines/dradis-echo/lib/dradis/plugins/echo/engine.rb @@ -42,9 +42,23 @@ class Engine < ::Rails::Engine 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 and destroy cleanup; - # Issue < Note inherits it. + # 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/models/dradis/plugins/echo/session_spec.rb b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb index ba8e3a18a..97f1b4357 100644 --- a/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb +++ b/engines/dradis-echo/spec/models/dradis/plugins/echo/session_spec.rb @@ -99,10 +99,10 @@ expect { issue.destroy }.to change { described_class.exists?(session.id) }.to(false) end - # The gap the after_destroy sweep 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 has to catch it. + # 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) @@ -110,5 +110,27 @@ 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 From 03498bf1669ebf5bb5fea8c202cc3d6320452b32 Mon Sep 17 00:00:00 2001 From: Product - Coder Date: Fri, 31 Jul 2026 12:37:04 +0100 Subject: [PATCH 08/57] Echo: apply caitmich Slice-2 review (SEC-571 Part A) - Message: require user_id on user messages (symmetric to the assistant absence rule) so a nil user_id means a genuine delete; dependent: :nullify bypasses it via update_all. - Session#record=: reuse record_type_for as the single source of truth, guarded on new_record (record_type_for(nil) raises). - Reorder the record= FIXME above its explanation; trim the _message locals note; migration parent note -> dormant. - Rename migrations to CreateSessions / CreateMessages (files and classes; timestamps unchanged) and drop the redundant null: true on the user references. - Add the user association to the base echo_message factory. --- .../dradis-echo/app/models/dradis/plugins/echo/message.rb | 3 +++ .../dradis-echo/app/models/dradis/plugins/echo/session.rb | 6 +++--- .../echo/projects/sessions/messages/_message.html.erb | 2 +- ..._echo_sessions.rb => 20260714000001_create_sessions.rb} | 5 ++--- ..._echo_messages.rb => 20260714000002_create_messages.rb} | 7 +++---- engines/dradis-echo/spec/factories/messages.rb | 1 + 6 files changed, 13 insertions(+), 11 deletions(-) rename engines/dradis-echo/db/migrate/{20260714000001_create_dradis_plugins_echo_sessions.rb => 20260714000001_create_sessions.rb} (70%) rename engines/dradis-echo/db/migrate/{20260714000002_create_dradis_plugins_echo_messages.rb => 20260714000002_create_messages.rb} (70%) diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb index bfd297a9c..997b75065 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/message.rb @@ -20,6 +20,9 @@ class Message < ApplicationRecord 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 ----------------------------------------------------- diff --git a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb index a8b4102f6..5d1b13251 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -33,15 +33,15 @@ def to_provider_messages 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). - # - # FIXME - ISSUE/NOTE INHERITANCE def record=(new_record) super - self.record_type = 'Issue' if new_record.is_a?(Issue) + self.record_type = self.class.record_type_for(new_record) if new_record new_record 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 index 07404f4af..9554bedcf 100644 --- 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 @@ -1,5 +1,5 @@ <%# locals: (message:) -%> -<%# Minimal render — Slice 5 restyles. Must render from `render partial:` with %> +<%# Minimal render. Must render from `render partial:` with %> <%# only `message:` in locals, so no controller-context helpers here. %>

diff --git a/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb b/engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb similarity index 70% rename from engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb rename to engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb index 54850df83..448504391 100644 --- a/engines/dradis-echo/db/migrate/20260714000001_create_dradis_plugins_echo_sessions.rb +++ b/engines/dradis-echo/db/migrate/20260714000001_create_sessions.rb @@ -1,10 +1,9 @@ -class CreateDradisPluginsEchoSessions < ActiveRecord::Migration[8.0] +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, null: true, - foreign_key: { to_table: :users, on_delete: :nullify } + 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 diff --git a/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb b/engines/dradis-echo/db/migrate/20260714000002_create_messages.rb similarity index 70% rename from engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb rename to engines/dradis-echo/db/migrate/20260714000002_create_messages.rb index 3c31ed180..57e335732 100644 --- a/engines/dradis-echo/db/migrate/20260714000002_create_dradis_plugins_echo_messages.rb +++ b/engines/dradis-echo/db/migrate/20260714000002_create_messages.rb @@ -1,13 +1,12 @@ -class CreateDradisPluginsEchoMessages < ActiveRecord::Migration[8.0] +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 until Slice 3 branches conversations. + # Self-referential parent, dormant. t.references :parent, null: true, foreign_key: { to_table: :dradis_plugins_echo_messages } - t.references :user, null: true, - foreign_key: { to_table: :users, on_delete: :nullify } + t.references :user, foreign_key: { to_table: :users, on_delete: :nullify } # 0 maps to the :user role t.integer :role, default: 0, null: false diff --git a/engines/dradis-echo/spec/factories/messages.rb b/engines/dradis-echo/spec/factories/messages.rb index 5a06146d0..9dd84c641 100644 --- a/engines/dradis-echo/spec/factories/messages.rb +++ b/engines/dradis-echo/spec/factories/messages.rb @@ -1,6 +1,7 @@ FactoryBot.define do factory :echo_message, class: 'Dradis::Plugins::Echo::Message' do association :session, factory: :echo_session + user role { :user } content { 'Hello, Echo.' } From cf88f494e505c5860a684c8558e30be5a6817302 Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:21:05 +0100 Subject: [PATCH 09/57] Echo: replace InteractionJob with ReplyJob and generation state machine ReplyJob streams an assistant reply into a persisted message, strips thinking blocks, records model/provider metadata, then serialises: re-enqueue if the user spoke again mid-generation, else flip the session idle and broadcast the composer state. Session#request_reply! is the idle->generating lock gate with stuck-generation recovery. --- .../dradis/plugins/echo/interaction_job.rb | 32 ----- .../app/jobs/dradis/plugins/echo/reply_job.rb | 113 ++++++++++++++++++ .../app/models/dradis/plugins/echo/session.rb | 57 +++++++++ .../sessions/_composer_state.html.erb | 11 ++ .../sessions/messages/_message.html.erb | 3 +- 5 files changed, 183 insertions(+), 33 deletions(-) delete mode 100644 engines/dradis-echo/app/jobs/dradis/plugins/echo/interaction_job.rb create mode 100644 engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb create mode 100644 engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_composer_state.html.erb 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 0a358f50e..000000000 --- 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 000000000..e219e22e6 --- /dev/null +++ b/engines/dradis-echo/app/jobs/dradis/plugins/echo/reply_job.rb @@ -0,0 +1,113 @@ +module Dradis::Plugins::Echo + class ReplyJob < ApplicationJob + queue_as :dradis_project + + # 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 serializes — 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) + serialize(session, cutoff_id) + rescue => e + fail_message(session, message, e) + end + + private + + def stream_reply(agent, session, message, context) + buffer = +'' + started = clock + + agent.provider.generate(messages: context, model: agent.model_override) do |chunk| + buffer << chunk + broadcast_chunk(session, message, chunk) + end + + [buffer, ((clock - started) * 1000).round] + end + + def broadcast_chunk(session, message, chunk) + Turbo::StreamsChannel.broadcast_append_to( + [session, :messages], + target: ActionView::RecordIdentifier.dom_id(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.model_override.presence || agent.provider.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 serialize(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) + if message + message.update!( + status: :failed, + metadata: message.metadata.merge('error' => error.message) + ) + 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/dradis/plugins/echo/session.rb b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb index 5d1b13251..74f4f9f0f 100644 --- a/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb +++ b/engines/dradis-echo/app/models/dradis/plugins/echo/session.rb @@ -1,5 +1,9 @@ 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 -------------------------------------------------------- @@ -27,6 +31,42 @@ def self.record_type_for(record) 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: ActionView::RecordIdentifier.dom_id(self, :composer_state), + partial: 'dradis/plugins/echo/projects/sessions/composer_state', + locals: { session: self } + ) + end + + def project + record.project + 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 + def to_provider_messages messages.order(:created_at, :id).map do |message| { role: message.role, content: message.content } @@ -44,5 +84,22 @@ def record=(new_record) 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` with an + # orphaned streaming message. Once that message hasn't been touched past the + # provider read timeout (plus a margin), fail it and release the session so + # the next request_reply! can start fresh. + def reclaim_stuck_generation! + threshold = Provider::HttpStreaming::READ_TIMEOUT.seconds.ago - STUCK_MARGIN + stuck = messages.where(role: :assistant, status: :streaming).where(updated_at: ..threshold) + return unless stuck.exists? + + stuck.find_each do |message| + message.update!(status: :failed, metadata: message.metadata.merge('error' => 'interrupted')) + end + update!(status: :idle) + end end end 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 000000000..68db682c4 --- /dev/null +++ b/engines/dradis-echo/app/views/dradis/plugins/echo/projects/sessions/_composer_state.html.erb @@ -0,0 +1,11 @@ +<%# locals: (session:) -%> +<%# Broadcast on every idle<->generating transition (Session#broadcast_composer_state). %> +<%# Route-free and locals-only so it renders from ReplyJob. Slice 4 wires the %> +<%# composer form; here we only reflect whether a reply is in flight. %> +
+ <% if session.generating? %> + Generating… + <% else %> + Ready + <% 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 index 9554bedcf..5cbf37b2f 100644 --- 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 @@ -12,7 +12,8 @@ <% end %>

-
<%= message.content %>
+ <%# ReplyJob streams chunks into this container by dom_id(message, :content). %> +
<%= message.content %>
<% if message.streaming? %> From e659993a27a4d50f67f0e7f732b777950528465b Mon Sep 17 00:00:00 2001 From: dradis-bot <286253174+dradis-bot@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:21:05 +0100 Subject: [PATCH 10/57] Echo: spec ReplyJob and Session#request_reply! locking --- .../plugins/echo/interaction_job_spec.rb | 70 --------- .../dradis/plugins/echo/reply_job_spec.rb | 140 ++++++++++++++++++ .../dradis/plugins/echo/session_spec.rb | 51 +++++++ 3 files changed, 191 insertions(+), 70 deletions(-) delete mode 100644 engines/dradis-echo/spec/jobs/dradis/plugins/echo/interaction_job_spec.rb create mode 100644 engines/dradis-echo/spec/jobs/dradis/plugins/echo/reply_job_spec.rb 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 9827e9a81..000000000 --- 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('') + + perform + expect(Turbo::StreamsChannel).to have_received(:broadcast_update_to) do |_, **kwargs| + expect(kwargs[:html]).to include('<script>') + expect(kwargs[:html]).not_to include('') - - perform - expect(Turbo::StreamsChannel).to have_received(:broadcast_update_to) do |_, **kwargs| - expect(kwargs[:html]).to include('<script>') - expect(kwargs[:html]).not_to include('