Skip to content

Per-request session_id override on message send (multi-tab / multi-session safety) #2959

Description

@neo-wanderer

Problem

Sessions are per-agent global server state. The agent registry stores a single entry.session_id (canonical active session), updated by switch_agent_session at librefang-kernel/src/kernel/mod.rs:6779-6781:

self.registry.update_session_id(agent_id, session_id)?;

All send paths (HTTP POST /api/agents/{id}/message, WebSocket /api/agents/{id}/ws, and internal send_message* variants in kernel/mod.rs) read this single field. There is no per-request session_id parameter — the body/frame cannot override it.

This breaks naturally-concurrent usage:

  • Multiple dashboard tabs on the same agent with different session pickers: last "switch" wins globally. Tab A sends → message lands in whichever session Tab B last switched to. Tab A's UI continues showing its old session's history while new messages from the server belong to the other session. Confusing and hard to reason about.
  • Programmatic clients (scripts hitting the REST API) that want to keep multiple sessions warm for the same agent must serialize everything through switch → send → switch back. That's racy against the UI and against other clients.
  • Channel-backed sessions already sidestep this with deterministic SessionId::for_channel(agent, \"<channel>:<chat>\"), proving the kernel can thread a session identity through the send path. The UI/REST path just doesn't expose the hook.

Proposed shape

Add an optional session_id to the public send surface. Resolution order inside send_message_full:

  1. Explicit session_id parameter (new)
  2. Channel-derived session (existing: SessionId::for_channel(...) when sender_context has a non-empty channel and use_canonical_session is false)
  3. Registry's canonical entry.session_id (existing fallback — unchanged behavior when the new param is omitted)

HTTP

POST /api/agents/{agent_id}/message
{
  \"message\": \"...\",
  \"session_id\": \"<optional-sid>\"     // ← new
}

WebSocket

First frame (or per-frame) can carry session_id:

{ \"type\": \"message\", \"text\": \"...\", \"session_id\": \"<sid>\" }

Kernel surface

Either a new variant (send_message_in_session(agent_id, session_id, message, ...)) or add an Option<SessionId> parameter to send_message_full and have existing helpers pass None. The latter is smaller and keeps the helper fan-out manageable.

Safety checks

  • If the caller supplies a session_id that doesn't belong to agent_id, reject with the same error shape switch_agent_session already uses (Session belongs to a different agent).
  • The per-agent agent_msg_locks mutex (kernel/mod.rs:3847) should be scoped to (agent_id, session_id) when the override is set, not just agent_id. Otherwise concurrent sends from two tabs into different sessions of the same agent block each other for no good reason. A single-session caller (no override) keeps the old agent-scoped lock.

What this unblocks

  • Dashboard: tabs can carry ?agentId=...&sessionId=... and pin themselves. No global state mutation on switch — switch_agent_session becomes purely a "set the default for callers who don't specify" operation.
  • Scripting: run parallel conversations against one agent across multiple sessions without switch_agent_session races.
  • Future: lays the foundation for explicit session fan-out (one agent serving N concurrent users through distinct sessions).

Non-goals

  • Not removing switch_agent_session or the canonical session concept — triggers, cron, channel-less callers still need a sensible default. This is additive.
  • Not changing channel routing. Channel-derived session identity stays the way it is (deterministic per channel + chat).
  • Not touching session_mode semantics — persistent vs new still applies to whatever session is eventually selected.

Tests

  • Unit: resolution order (explicit > channel > registry).
  • Unit: wrong-agent session_id rejected.
  • Integration: two concurrent POSTs to the same agent with different session_ids complete without cross-contamination; each session's history contains exactly the messages sent to it.
  • Integration: concurrent sends are serialized within a session but parallelized across sessions of the same agent.

Context

Filed alongside #2941 (per-tool timeout override). These are both "the global-per-agent knob is too coarse" problems — this one on session identity, that one on tool timeouts.

Happy to PR. Flagging for direction first because the kernel surface change touches several send-path helpers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/kernelCore kernel (scheduling, RBAC, workflows)has-prA pull request has been linked to this issue

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions