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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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?
Expand All @@ -17,34 +18,8 @@ def index

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) }

Expand All @@ -63,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

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -23,25 +23,23 @@ class Error < StandardError; end
OpenSSL::SSL::SSLError
].freeze

# Sends prompt to the provider and returns the response.
# 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: }]) or the
# prompt: sugar for a single user message.
# 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(messages: nil, prompt: nil, model: nil, &block)
def generate(messages:, 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(messages: resolved_messages, model: resolved_model)
body = build_body(messages: messages, model: resolved_model)

buffer = block ? nil : +''

Expand Down
17 changes: 1 addition & 16 deletions engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def self.inherited(subclass)

# -- Instance Methods -----------------------------------------------------

def generate(messages: nil, prompt: nil, model: nil, &block)
def generate(messages:, model: nil, &block)
raise NotImplementedError, "#{self.class.name} must implement #generate"
end

Expand All @@ -55,20 +55,5 @@ 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
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,11 @@ def requires_api_key?
false
end

def generate(messages: nil, prompt: nil, model: nil, &block)
def generate(messages:, model: nil, &block)
resolved_model = model.presence || self.model
resolved_messages = resolve_messages(messages, prompt)
buffer = block_given? ? nil : +''

client.chat({ model: resolved_model, messages: resolved_messages }) do |event, _raw|
client.chat({ model: resolved_model, messages: messages }) do |event, _raw|
next if event['done']

chunk = event.dig('message', 'content').to_s
Expand Down
2 changes: 1 addition & 1 deletion engines/dradis-echo/config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

resources :projects, only: [] do
scope module: 'projects' do
resources :interactions, only: [:index, :show, :create] do
resources :interactions, only: [:index] do
get :preview, on: :member
end

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def stub_http(body:, code: '200')
sse_body = "data: #{JSON.generate(delta_event)}\n\n"
stub_http(body: sse_body)

result = provider.generate(prompt: 'test')
result = provider.generate(messages: [{ role: 'user', content: 'test' }])
expect(result).to eq('Hello')
end

Expand All @@ -91,7 +91,7 @@ def stub_http(body:, code: '200')
stub_http(body: sse_body)

chunks = []
result = provider.generate(prompt: 'test') { |chunk| chunks << chunk }
result = provider.generate(messages: [{ role: 'user', content: 'test' }]) { |chunk| chunks << chunk }
expect(chunks).to eq(['Hello'])
expect(result).to be_nil
end
Expand All @@ -112,17 +112,7 @@ def stub_http(body:, code: '200')
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
it 'raises when messages: is not given' do
expect { provider.generate }.to raise_error(ArgumentError)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def content_event(content, done: false)
content_event('', done: true)
)

expect(provider.generate(prompt: 'hi')).to eq('Hello world')
expect(provider.generate(messages: [{ role: 'user', content: 'hi' }])).to eq('Hello world')
end

it 'preserves whitespace-only chunks, such as standalone line breaks' do
Expand All @@ -40,7 +40,7 @@ def content_event(content, done: false)
content_event('', done: true)
)

expect(provider.generate(prompt: 'hi')).to eq("1. First item. \n2. Second item.")
expect(provider.generate(messages: [{ role: 'user', content: 'hi' }])).to eq("1. First item. \n2. Second item.")
end

it 'skips events with empty content, such as thinking-only chunks' do
Expand All @@ -50,7 +50,7 @@ def content_event(content, done: false)
content_event('', done: true)
)

expect(provider.generate(prompt: 'hi')).to eq('Answer')
expect(provider.generate(messages: [{ role: 'user', content: 'hi' }])).to eq('Answer')
end

it 'replaces think tags embedded in the response text' do
Expand All @@ -62,7 +62,7 @@ def content_event(content, done: false)
content_event('', done: true)
)

expect(provider.generate(prompt: 'hi')).to eq('{thinking}reasoning{/thinking}Answer')
expect(provider.generate(messages: [{ role: 'user', content: 'hi' }])).to eq('{thinking}reasoning{/thinking}Answer')
end

it 'yields each chunk to the given block instead of buffering' do
Expand All @@ -73,7 +73,7 @@ def content_event(content, done: false)
)

chunks = []
provider.generate(prompt: 'hi') { |chunk| chunks << chunk }
provider.generate(messages: [{ role: 'user', content: 'hi' }]) { |chunk| chunks << chunk }

expect(chunks).to eq(['Hello ', 'world'])
end
Expand All @@ -90,13 +90,5 @@ def content_event(content, done: false)

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
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
provider = build(:provider)
# Ollama overrides #generate, so test the base class directly
expect do
Dradis::Plugins::Echo::Provider.new.generate(prompt: 'test')
Dradis::Plugins::Echo::Provider.new.generate(messages: [{ role: 'user', content: 'test' }])
end.to raise_error(NotImplementedError)
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
end

describe 'GET /addons/echo/projects/:project_id/interactions' do
it 'renders the sessions panel entry point' do
get "/addons/echo/projects/#{@project.id}/interactions", params: { type: 'issue', record: issue.id }

expect(response).to have_http_status(:ok)
expect(response.body).to include('Start a new conversation')
end

it 'wraps the conversation list in the record Echo turbo-frame' do
get "/addons/echo/projects/#{@project.id}/interactions",
params: { type: 'issue', record: issue.id }
Expand All @@ -48,5 +55,25 @@
# href attributes HTML-escape the query separator (& -> &amp;); match escaped.
expect(response.body).to include(ERB::Util.html_escape(new_conversation_link))
end

it 'raises RecordNotFound (404) rather than a 500 for a missing or unknown type' do
expect do
get "/addons/echo/projects/#{@project.id}/interactions", params: { record: issue.id }
end.to raise_error(ActiveRecord::RecordNotFound)
end
end

describe 'the retired Roslin one-shot path' do
it 'no longer exposes a create route' do
expect do
post "/addons/echo/projects/#{@project.id}/interactions", params: { type: 'issue', record: issue.id }
end.to raise_error(ActionController::RoutingError)
end

it 'no longer exposes a show route' do
expect do
get "/addons/echo/projects/#{@project.id}/interactions/1", params: { type: 'issue', record: issue.id }
end.to raise_error(ActionController::RoutingError)
end
end
end
Loading