Echo Sessions: persistent multi-user LLM conversations - #1648
Merged
Conversation
aapomm
reviewed
Jul 22, 2026
aapomm
reviewed
Jul 22, 2026
aapomm
reviewed
Jul 22, 2026
dradis-bot
force-pushed
the
echo/add-sessions
branch
from
July 22, 2026 15:15
5296b48 to
722d1d4
Compare
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.
dradis-bot
force-pushed
the
echo/add-sessions
branch
from
July 31, 2026 12:10
722d1d4 to
2c59ba7
Compare
…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.
…ed scoping, duck-typed cable check
…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.
…the views->stimulus merge
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.
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
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 dormantparent_idso pi-style branching later is a data change, not a schema redesign.idle/generatingstatus lock on the session (Session#request_reply!+ReplyJob), with stale-lock recovery so a crashed worker can't wedge the composer.SessionsChannelre-checks project access on every subscribe), so all viewers see every message — human or LLM — live.Provider#generatenow takesmessages:(an array of{ role:, content: }hashes) instead of a single prompt, with per-provider role/shape mapping (OpenAI/Anthropic pass-through, Geminimodelrole +parts, Ollamaclient.chat). Grammar corrections/suggestions use the same API with a single-message array.InteractionJob,interactions#show/#create, and the auto-POSTprompt_controller.jsare gone;InteractionsControllerslims 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
bin/dev, and start a Resque worker in a separate terminal (QUEUE=* bundle exec rake resque:work) so background jobs are processed./providersand create/verify an LLM provider (e.g. Ollama pointing at a running local instance)./agents, edit the Roslin agent, assign it the provider, and enable it./promptsand create a saved prompt (e.g. title "Summarize", prompt text "Summarize this issue: {{ issue.fields['Description'] }}")./agentsand 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.Other Information
reply_job_spec.rbwith mocked providers; the feature spec asserts persisted state only (live-stream assertions under Selenium are a flakiness tar pit).request_idlocal.Check List