Skip to content

Echo Sessions: persistent multi-user LLM conversations - #1648

Merged
caitmich merged 58 commits into
developfrom
echo/add-sessions
Aug 4, 2026
Merged

Echo Sessions: persistent multi-user LLM conversations#1648
caitmich merged 58 commits into
developfrom
echo/add-sessions

Conversation

@etdsoft

@etdsoft etdsoft commented Jul 14, 2026

Copy link
Copy Markdown
Member

Milestone: SEC-469 (Echo Sessions). Tracking issue: SEC-582.
This is the single feature PR for Echo Sessions: the 7-slice stacked train
(echo/add-sessions → … → echo/retire-interactions-prototype) was collapsed
onto echo/add-sessions by fast-forward (byte-identical to the verified tip),
superseding #1655 and the merged stack #1649#1654.

Summary

This PR turns Echo's one-off, ephemeral LLM interactions into persistent, multi-user Sessions. Until now, the Issue's Echo tab rendered a saved prompt, streamed a single response into the DOM, and lost everything on reload — no history, no follow-ups.

With this change:

  • Conversations persist. Two new tables (dradis_plugins_echo_sessions, dradis_plugins_echo_messages) store each conversation as an append-only list of user/assistant messages. Reloading the page restores the full transcript, including in-flight (streaming) and failed replies. Messages carry a dormant parent_id so pi-style branching later is a data change, not a schema redesign.
  • You can follow up. Every session has a composer; each human message triggers a new assistant generation with the full conversation as context. Generations are serialized to one in-flight reply per session via an idle/generating status lock on the session (Session#request_reply! + ReplyJob), with stale-lock recovery so a crashed worker can't wedge the composer.
  • Sessions are multiplayer. Any project member can open a session, see the transcript, and post. Messages and streamed chunks fan out over a re-authorizing ActionCable channel (SessionsChannel re-checks project access on every subscribe), so all viewers see every message — human or LLM — live.
  • The provider layer speaks multi-turn. Provider#generate now takes messages: (an array of { role:, content: } hashes) instead of a single prompt, with per-provider role/shape mapping (OpenAI/Anthropic pass-through, Gemini model role + parts, Ollama client.chat). Grammar corrections/suggestions use the same API with a single-message array.
  • The legacy one-shot path is retired. InteractionJob, interactions#show/#create, and the auto-POST prompt_controller.js are gone; InteractionsController slims down to prompt picking (index/preview). The Echo tab keeps the same prompt-picker entry point, now listing the record's existing conversations above it.

Scope notes: v1 is linear (no branching), N humans : 1 LLM, escaped plain-text rendering (no markdown pipeline), and no project-level session index — all per the agreed brief.

Testing steps

  1. Ensure Redis is running, start the app with bin/dev, and start a Resque worker in a separate terminal (QUEUE=* bundle exec rake resque:work) so background jobs are processed.
  2. Login as an admin user.
  3. Navigate to /providers and create/verify an LLM provider (e.g. Ollama pointing at a running local instance).
  4. Navigate to /agents, edit the Roslin agent, assign it the provider, and enable it.
  5. Navigate to /prompts and create a saved prompt (e.g. title "Summarize", prompt text "Summarize this issue: {{ issue.fields['Description'] }}").
  6. Open a project that has at least one Issue, open the Issue, and click the Echo tab.
  7. Assert the tab shows a "Conversations" section with the empty state "No conversations on this Issue yet — start one below." and a "Start a new conversation" prompt picker below it.
  8. Select your saved prompt from the "Saved Prompts" dropdown.
  9. Assert the "Selected Prompt" preview loads with the prompt text and the Liquid variables replaced with the Issue's real content.
  10. Edit the preview text slightly and click Start with this prompt.
  11. Assert the conversation view replaces the picker: your prompt appears as the first message under your name, an assistant bubble appears with a spinner and "Roslin is responding…", and the Send button is disabled with the hint "Roslin is responding — you can send once it finishes."
  12. Assert the reply streams into the assistant bubble live, and when it finishes the spinner disappears and the composer re-enables with the hint "Anyone on this project can join the conversation."
  13. Reload the browser page and re-open the Issue's Echo tab.
  14. Assert the session now appears in the "Conversations" list showing the last-updated time.
  15. Click the conversation and assert the full transcript (your prompt + Roslin's complete reply) was persisted and renders exactly as before the reload.
  16. Type a follow-up question that references Roslin's first answer (e.g. "Shorten your previous answer to one sentence") and click Send.
  17. Assert your message appears immediately, a new streaming assistant bubble appears, and the finished reply shows Roslin retained the conversation context.
  18. In a second browser (or private window), login as a different user with access to the same project, open the same Issue's Echo tab, and click the same conversation.
  19. Assert the second user sees the full existing transcript.
  20. Send a message as the second user.
  21. Assert the message appears in both browsers without reloading, and both browsers show Roslin's reply streaming live.
  22. While Roslin is responding, assert the composer is disabled in both browsers, and re-enables in both when the reply completes.
  23. Click the "Conversations" back link and assert the session list shows the updated message count; start a second session from the prompt picker and assert both sessions are listed independently.
  24. Stop the LLM provider (e.g. stop Ollama) and send another message in a session.
  25. Assert the assistant bubble switches to the error state — "Roslin couldn't finish this response." with an error hint — and the composer re-enables so you can retry.
  26. Reload the page, re-open the session, and assert the failed message state persisted.
  27. Restart the provider, send another message, and assert a normal reply streams in.
  28. As an admin, navigate to /agents and disable Roslin; re-open the Issue's Echo tab and assert the warning "The Roslin agent is not enabled." with an "Enable it" link (as a non-admin the message asks to contact an admin instead). Re-enable Roslin.
  29. Edit the Issue's description and use the Echo grammar suggestions/corrections feature; assert it still works (regression for the provider API change).
  30. Delete the Issue that has sessions and assert the deletion succeeds without errors.

Other Information

  • Streaming is covered in reply_job_spec.rb with mocked providers; the feature spec asserts persisted state only (live-stream assertions under Selenium are a flakiness tar pit).
  • Broadcast partials are locals-only (no controller helpers) so they render from the background job; they tolerate turbo-rails' injected request_id local.
  • Out of scope for v1 (per brief): message branching UI, markdown rendering of replies, notifications/@mentions, project-level session index, editing/deleting/regenerating individual messages.

I assign all rights, including copyright, to any future Dradis
work by myself to Security Roots.

Check List

  • Added a CHANGELOG entry
  • Commit message has a detailed description of what changed and why.

Comment thread engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb Outdated
Comment thread engines/dradis-echo/app/models/dradis/plugins/echo/provider/gemini.rb Outdated
Comment thread engines/dradis-echo/app/models/dradis/plugins/echo/provider.rb Outdated
@etdsoft etdsoft mentioned this pull request Jul 22, 2026
2 tasks
dradis-bot and others added 14 commits July 31, 2026 12:37
Co-authored-by: Aaron Manaloto <aaronpomanaloto@gmail.com>
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.
Layout/HashAlignment: the foreign_key: continuation must align under the
first hash key (null:), or the Lint CI job fails.
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.
- 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.
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.
InteractionsController#create is a live, UI-wired route that still calls InteractionJob, so deleting it here 500s the shipping Roslin panel. Per CTO plan rev 7 (Path C), Slice 3 ships ReplyJob additively; the interactions teardown + provider prompt: sugar removal move to SEC-498, sequenced behind the Slice 5 replacement panel.
Applies PR #1655 review findings on the slice that introduced ReplyJob:

- Poison-pill (#1): Session#to_provider_messages now replays only
  completed turns, so a failed or still-streaming row never sends a
  content: nil turn to a provider.
- Liveness (#2): ReplyJob throttle-touches the streaming message while
  chunks arrive, so a slow-but-live stream isn't reclaimed as stuck.
- Orphaned lock (#3): reclaim_stuck_generation! also releases a
  generating session that has no streaming message once it is stale.
- Exception handling (#5): narrow the rescue to
  Provider::HttpStreaming::Error so genuine bugs and the disabled-agent
  raise propagate to the failed queue; log class/message/backtrace.
- Error leakage (#6): store a generic Message::GENERIC_ERROR string and
  never persist the provider's raw response.
- Enable discard_on ActiveJob::DeserializationError so a job whose
  session was destroyed mid-queue is discarded, not failed-queue noise.

Adds a failed-reply-then-retry regression spec proving the retry sends
only complete turns and the session unlocks.
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.
…sence

Adopt the reviewer's dom_id simplification: pass `[record, :prefix]` as the
Turbo broadcast target so turbo-rails computes the dom_id, instead of calling
ActionView::RecordIdentifier.dom_id ourselves (identical output). Pass
`agent.model_override.presence` at the generate call to mirror the metadata
line; the provider already falls back via `.presence`, so an empty override
resolves to the provider default either way.
Product - Coder and others added 10 commits July 31, 2026 13:55
…ct delegate

Add Agent#resolved_model (model_override.presence || provider.model) and use it
at both ReplyJob call sites so the override-vs-default resolution lives in one
place. Drop Session's unused `delegate :project, to: :record` (the authorize
path uses session.record.project; only its own spec exercised the delegation)
and its spec. Simplify the _composer_state header comment.
Restore `delegate :project, to: :record` (reverting its removal). It reads as
dead code in this slice alone, but it's the seam SessionsChannel#authorize
should use: record is polymorphic, so callers ask the session for its project
rather than reaching through session.record.project. Slice 4 routes the authz
check through it.
A private serialize(session, cutoff_id) overrode ActiveJob::Core#serialize, which every queue adapter calls when enqueuing. perform_later — and so Session#request_reply! — raised NoMethodError at runtime. Rename the private method to finalize and cover the enqueue path.
Purely additive Slice 4. Adds project-scoped Sessions/Messages controllers (Prompt read only at the controller boundary, no FK; Prompt::SCOPES whitelist honoured via set_type), their routes and index/show views, and SessionsChannel — a custom Turbo Streams channel that re-checks :use on the session's project at subscribe time and rejects revoked users, closing the non-expiring signed-stream-name hole. InteractionsController is untouched.
On initial session creation SessionsController#create called request_reply!
before render :show, so ReplyJob's streaming-container broadcast raced ahead of
the browser's SessionsChannel subscription; the container append was missed and
the later chunk/replace broadcasts hit a non-existent node, so the reply only
appeared on reload (SEC-506 Bug 4).

Restore the invariant "the streaming container is broadcast only to a listening
socket": create no longer starts generation. It renders show in the new
Session#reply_pending? state and a dedicated RepliesController#create (POST
sessions/:id/reply) starts generation, to be driven from the session Stimulus
controller once it has connected. reply_pending? (idle + newest message is a
user turn) guards the trigger so reconnects and extra viewers can't spawn an
unsolicited reply. Follow-up messages are unchanged — the client is already
subscribed there.

Refs SEC-506.
Applies PR #1655 review findings on the session controllers slice:

- Blank-prompt 500 (#7): sessions#create now saves without a bang and
  returns 422 on an invalid (blank) prompt, mirroring the messages
  endpoint, instead of raising RecordInvalid.
- Dead index (#9): drop the unused sessions#index action and route;
  nothing links to it (the back-link and lazy frame use the interactions
  path). The dead index view is removed in the views slice.
- check_turbo_config (#10): extract a shared TurboConfigCheck concern
  that pings only the Redis adapter and memoizes, so async/test adapters
  no-op silently — no spurious 'can't contact Redis' alert, no
  per-request round-trip.
- SessionsChannel (#11): tighten the class comment to say re-auth guards
  new subscriptions only (comment-only).
Ask the session for its project instead of reaching through
session.record.project. record is polymorphic, so the delegation is the right
seam for the authorization check and keeps callers decoupled from where a
session's project currently comes from.
Product - Coder and others added 23 commits August 3, 2026 10:52
…index

Applies PR #1655 review findings on the session views slice:

- Error leakage, display side (#6): the _message partial no longer
  renders metadata['error']; it shows a fixed generic summary so a raw
  provider body/host can never reach the browser.
- N+1 (#8): interactions#index preloads authors with includes(:user) and
  loads every conversation's message count in one grouped query, so the
  list no longer fires a user lookup and a COUNT per row.
- Dead view (#9): delete the drifted, unrendered sessions/index.html.erb
  (its route and action were removed in the controllers slice).
Echo's prompt textareas already reference this class to auto-grow
with their content, but the rule itself was never added.
Amends PR #1652 (Echo Sessions, Slice 5a) before merge.

The conversations list and "start a new conversation" picker were
stacked vertically, so a record with many conversations pushed the
composer further down the page. They're now two columns side by side,
each with its own heading ("Conversations" / "Start a new
conversation"), so the list no longer competes with the composer for
vertical room. The left column scrolls independently past a content-
driven height ceiling instead of growing the page.

Conversation rows are extracted into a shared session_item partial,
trimmed to title + last-updated timestamp (message count and author
were noise), and stacked with flexbox utilities instead of a <br> so
the line gap is controllable.

Also replaces the ad-hoc .echo-section-label styling with the app's
existing heading conventions.
The views/stimulus/retire branches were authored on the pre-Slice-4
controllers, so cascading them onto the reviewed controllers left
Slice-4 residue that views had already superseded:

- Restore the reviewed sessions controller/routes/spec: the 'drop dead
  index' cleanup removed the drifted, unrendered index view, action,
  route and its tests. The line-level rebase kept only the template
  deletion, resurrecting the index action/route/test.
- Adopt the parent's session-based scoped_record(session) (Slice-4's
  record-scoping optimisation, a file views never touched) at the one
  call site in set_session, which still passed the record.
Slice 5b of Echo Sessions (SEC-477). New session_controller keeps the
transcript scrolled to the newest message as chunks stream in, posts the
composer over fetch so sending never navigates the Echo frame, and
mirrors the broadcast generating state onto the textarea; it replaces the
now-removed prompt_controller. A feature spec drives the full flow with a
stubbed provider — start a session from a prompt, persist the streamed
first exchange, reload, and prove a follow-up carries the prior context
forward. CHANGELOG notes the multiplayer sessions enhancement.
Read the reply-url / reply-pending Stimulus values and, on connect (after the
<turbo-cable-stream-source> has subscribed), POST to RepliesController to start
generation for a freshly-created session. This closes the SEC-506 Bug 4 race:
the streaming container is broadcast only to an already-listening socket, so the
assistant reply renders live on initial creation instead of only on reload.

Also updates the rack_test session feature specs: generation is now client-
triggered, so they drive the reply-trigger POST explicitly (no JS to do it for
them) and read the first reply back on reload.

Refs SEC-506.
These three specs cover SEC-506 conversation behaviour whose code ships on
the views slice (5a). They were relocated here off #1652 to keep that slice
under the 15-file/600-line cap; this branch stacks on 5a, so they run against
the same code. Covers turbo-frame back-nav, native Echo-tab frame, and
turbo-injected request_id tolerance in broadcast partials.
Applies the PR #1655 review nit on the session Stimulus slice: the send
fetch had no rejection or non-OK handling, so a failed send gave the user
zero feedback. Handle both a non-OK response and a network error by
showing a transient inline alert; the composer text is preserved (reset()
still only runs on OK) so the user can retry.
Slice 5 repurposed InteractionsController#index/#preview into the sessions
landing, leaving #create/#show and InteractionJob as the last remnants of the
Roslin one-shot prototype. Remove them now that ReplyJob-backed sessions are
live, so the record panel keeps only the sessions entry point.
The prompt: sugar and resolve_messages existed solely for the one-shot
InteractionJob path, which is now gone. ReplyJob already calls
generate(messages:), so drop the single-string convenience and require a
multi-turn messages array everywhere.
Applies PR #1655 review findings on the retire slice:

- check_turbo_config (#10): interactions_controller now uses the shared
  TurboConfigCheck concern instead of its own Redis-pinging copy, so
  non-Redis adapters no-op silently.
- Blank type (nit): set_record raises RecordNotFound when @type is blank
  so an unknown/missing type returns 404 instead of a 500 from
  send('').pluralize, mirroring SessionsController#set_record.
- set_prompt (nit): scope the preview prompt lookup through .for(@type)
  like sessions#create, honouring the Prompt::SCOPES whitelist.

The conversations N+1 fix (includes(:user) + grouped counts) is carried
up from the views slice and remains applied to interactions#index here.
Pre-existing Style/BlockDelimiters offenses across Echo spec files that
would break CI once this stack merges onto develop (where the cop is
enabled). Spec-only, parse-equivalent delimiter change.
@etdsoft etdsoft changed the title Convert Echo provider layer to multi-turn messages Echo Sessions: persistent multi-user LLM conversations Aug 3, 2026
@caitmich
caitmich merged commit 41b10c7 into develop Aug 4, 2026
6 checks passed
@caitmich
caitmich deleted the echo/add-sessions branch August 4, 2026 19:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants