One GenServer per session_id: the ACP turn state machine.
It owns:
the turn state machine (
:idle | {:prompting, Turn} | {:cancelling, Turn}, including the born-cancelled and stale-timer cases),rx_seqlatch consumption (cancel/prompt commute by wire order, not scheduling order),- register-before-respond by construction (the
:vianame is minted instart_link/1beforeinit/1runs), - the prompt turn as a supervised
Taskwith a task-group hold-open gate (the drain fires only when the monitor count hits zero), session/updateemission ordering (updates flow through the Connection on the same FIFO lane as the eventual prompt reply, so no update can serialize after the response),- cancellation (graceful
:acp_cancelinterrupt, then a non-blocking 30sProcess.exit/2backstop; cancel-of-idle is a clean latch-only no-op; the disarm-on-response guard is the state machine itself), - the permission flow (
async_requestwith a per-ask tag; fail-closed deny on every non-{:selected, _}row; a turn cancel aborts open asks), - the prompt reply via the Connection's delegated-reply mechanism (the Session is the adopter and sends the one reply),
- the
mode_stateseam:set_modevalidates against the injected%SessionModeState{}; anilmode state makesset_modeinvalid-params.
Connection seam (dependency injection)
This module reaches the Connection through an injected module (:conn_mod,
default Raxol.AgentClientProtocol.Connection) so the session layer compiles
and tests independently of the sibling Connection module, and so the test
FakeConnection can double-check that the Connection surface the Session
consumes (delegate_reply/3, reply/3, notify/3, async_request/6,
cancel_request/2) is sufficient. The semantics are unchanged — the Session
still mints the reply, still owns the drain gate.
Turn runner seam
begin_prompt/4 has a fixed 4-arity and does not carry the turn
function, so the turn runner is injected at session start (:turn_runner, an
arity-2 fun (session_pid, %PromptRequest{}) -> {:stop, StopReason} | %PromptResponse{}). It runs inside the supervised root Task; its return value
is folded into turn.outcome (write-once) when the task result arrives. A crash
folds to internal-error. The runner MAY receive/peek :acp_cancel between
steps to wind down gracefully (the 30s backstop kills it otherwise).
Summary
Functions
Begin a prompt turn (called by the session/prompt handler task).
Returns a specification to start this module under a supervisor.
Emit a session/update for the live turn (called by a turn task). Forwarded to
the Connection via notify/3 (same FIFO lane as the eventual reply). A post
for a non-live turn returns {:error, :turn_over} and emits nothing (the
straggler-task guard).
The package-level unique Registry name. Keys are {conn_pid, session_id};
session_id stays a binary, never atomized.
Request permission from the client (called by a turn task, blocks :infinity).
The Session stays fully responsive while the task is parked, which is what lets a
cancel abort the ask. Returns {:ok, {:selected, %SelectedPermissionOutcome{}}}
ONLY on a decoded selected outcome; every other terminal (timeout, client error,
decode failure, disconnect, session cancel) returns {:ok, :cancelled} — deny is
the zero value everywhere (fail-closed).
Set the active mode (called by the session/set_mode handler task). Validates
mode_id against the injected %SessionModeState{}; legal mid-turn. A nil
mode state or an unknown mode replies -32602 invalid-params.
Spawn a subagent task inside the current turn group (called by a turn task). The
task is monitored and its ref joins monitors — the hold-open gate: the prompt
reply is withheld until every group member is down. Returns
{:ok, ref}, or {:error, :turn_over} when there is no live prompting turn.
Start a Session. Registration happens via the :via name in start_link/1,
before init/1 runs — this is the structural half of register-before-respond.
A duplicate live {conn, session_id} returns {:error, {:already_started, pid}} (the session/load-of-a-live-session case).
Resolve a steer request against the live turn.
The :via tuple that keys a Session on {conn, session_id}.
Types
@type t() :: %Raxol.AgentClientProtocol.Session{ config: %{ permission_timeout: timeout(), cancel_backstop_ms: pos_integer(), idle_timeout: timeout() }, conn: pid(), conn_mod: module(), emitter: module(), idle_epoch: non_neg_integer(), idle_timer: reference() | nil, journal: {module(), term()} | nil, last_cancel_seq: non_neg_integer(), mode_state: Raxol.AgentClientProtocol.Schema.AgentTypes.SessionModeState.t() | nil, session_id: String.t(), steer_adapter: module(), steer_state: term(), task_sup: pid() | atom(), turn: :idle | {:prompting, Raxol.AgentClientProtocol.Session.Turn.t()} | {:cancelling, Raxol.AgentClientProtocol.Session.Turn.t()}, turn_runner: (pid(), struct() -> {:stop, atom()} | Raxol.AgentClientProtocol.Schema.AgentTypes.PromptResponse.t()), turn_seq: non_neg_integer() }
Functions
@spec begin_prompt(GenServer.server(), struct(), reference(), non_neg_integer()) :: :ok | {:error, Raxol.AgentClientProtocol.Error.t()}
Begin a prompt turn (called by the session/prompt handler task).
Adopts the reply obligation via delegate_reply/3, then either starts the turn
(spawns the supervised root task) or, if the prompt was wire-ordered before a
latched cancel (rx_seq < last_cancel_seq), resolves it born-cancelled
immediately. Returns :ok on the deferred path (the handler must
then return :deferred) or {:error, %Error{}} when the session is busy — a
second prompt on a :prompting/:cancelling session; in the busy case
the handler replies with that error directly (no delegation happened).
Returns a specification to start this module under a supervisor.
See Supervisor.
@spec post_update(GenServer.server(), struct()) :: :ok | {:error, :turn_over}
Emit a session/update for the live turn (called by a turn task). Forwarded to
the Connection via notify/3 (same FIFO lane as the eventual reply). A post
for a non-live turn returns {:error, :turn_over} and emits nothing (the
straggler-task guard).
@spec registry() :: atom()
The package-level unique Registry name. Keys are {conn_pid, session_id};
session_id stays a binary, never atomized.
@spec request_permission(GenServer.server(), struct()) :: {:ok, {:selected, struct()}} | {:ok, :cancelled}
Request permission from the client (called by a turn task, blocks :infinity).
The Session stays fully responsive while the task is parked, which is what lets a
cancel abort the ask. Returns {:ok, {:selected, %SelectedPermissionOutcome{}}}
ONLY on a decoded selected outcome; every other terminal (timeout, client error,
decode failure, disconnect, session cancel) returns {:ok, :cancelled} — deny is
the zero value everywhere (fail-closed).
@spec set_mode(GenServer.server(), String.t()) :: {:ok, Raxol.AgentClientProtocol.Schema.AgentTypes.SetSessionModeResponse.t()} | {:error, Raxol.AgentClientProtocol.Error.t()}
Set the active mode (called by the session/set_mode handler task). Validates
mode_id against the injected %SessionModeState{}; legal mid-turn. A nil
mode state or an unknown mode replies -32602 invalid-params.
@spec spawn_task(GenServer.server(), (-> any())) :: {:ok, reference()} | {:error, :turn_over}
Spawn a subagent task inside the current turn group (called by a turn task). The
task is monitored and its ref joins monitors — the hold-open gate: the prompt
reply is withheld until every group member is down. Returns
{:ok, ref}, or {:error, :turn_over} when there is no live prompting turn.
@spec start_link(keyword()) :: GenServer.on_start()
Start a Session. Registration happens via the :via name in start_link/1,
before init/1 runs — this is the structural half of register-before-respond.
A duplicate live {conn, session_id} returns {:error, {:already_started, pid}} (the session/load-of-a-live-session case).
Options:
:session_id(required, binary):conn(required, Connection pid — also the registry key half):mode_state—%SessionModeState{}ornil:task_sup(required) — the per-connectionTask.Supervisor(ctx.task_sup):turn_runner(required for prompting) — arity-2 fun (see moduledoc):conn_mod— Connection module (defaultRaxol.AgentClientProtocol.Connection):config—%{permission_timeout: _, cancel_backstop_ms: _}:name— override the:vianame (tests)
@spec steer( GenServer.server(), Raxol.AgentClientProtocol.Session.SteerAdapter.request() ) :: Raxol.AgentClientProtocol.Session.SteerAdapter.result()
Resolve a steer request against the live turn.
Redirects the running turn with new user input WITHOUT killing it. Runs the
injected SteerAdapter's expected_turn_id compare-and-swap decision from the
Session's OWN mailbox — the single-writer serialization
a CAS decision requires, satisfied by construction (only this process
reads-modifies-writes the CAS state). Returns the honest CAS outcome
synchronously:
{:ok, {:accepted, ref}}— the steer landed: the steering text was forwarded to the running turn (the{:acp_steer, text}runner seam) and one durable steer record was appended via the emitter.{:ok, {:duplicate, ref}}— idempotent re-delivery of an already-acceptedclient_msg_id;refis the ORIGINAL accept, nothing new happens.{:error, {:stale_turn, expected, actual}}— the CAS lost (the turn ended, or another steer won); nothing journaled, zero model effect.{:error, :no_live_turn}— no turn is running.{:error, :client_msg_id_reuse}— the idempotency key arrived carrying different content (a client bug/attack, never a retry).{:error, :no_steer_channel}— no steer adapter is wired (the default).
request is a map %{text: _, expected_turn_id: _, client_msg_id: _} (see
Raxol.AgentClientProtocol.Session.SteerAdapter.request/0).
The :via tuple that keys a Session on {conn, session_id}.