Skip to content

Session Overview

Session<M, I> is the central orchestrator of the modality library. It owns domain state (via SessionState<M> inside Runtime<Self>), persistence (via Doc), reactivity (via sinks), incremental compilation (via cache), and the feedback channel for async effects. Session implements Reducer with type State = SessionState<M>, meaning reduce functions receive &mut SessionState<M> — a clean boundary that excludes sinks, cache, callbacks, and self_ref.

crates/session/src/session.rs
pub struct Session<M: Modality, I: ReduceIntent<M> = SessionIntent<<M as Reducer>::Intent>> {
rt: Runtime<Self>, // rt.state = SessionState<M>, rt.services = SessionServices
sinks: Vec<Box<dyn Sink<M::Snapshot>>>, // reactivity (typed per-modality snapshots)
on_change_callbacks: Vec<...>, // CRDT sync outbound
cache: CompileCache<...>, // incremental recompilation
agent_rx: Mutex<Receiver<AgentEvent>>, // AI bridge reads tool output here
self_ref: Option<Weak<Mutex<Self>>>, // set by into_arc via post_init
ai_undo_group_active: bool, // whether AI undo group is open (the Session Undo/Redo RFC)
}

The two generic parameters are:

  • M: Modality — the modality type (Whiteboard, Worksheet, LessonPlan).
  • I: ReduceIntent<M> — how intents reduce inside this session. Defaults to SessionIntent<M::Intent>, so leaf sessions can be written as Session<Whiteboard>. Parent sessions override: Session<LessonPlan, ParentSessionIntent<...>>. This is a generic on Session, not an associated type on Modality.

SessionState<M> is extracted from Session so that ReduceIntent::reduce gets a clean interface — no access to sinks, cache, callbacks, or self_ref:

crates/session/src/session_state.rs
pub struct SessionState<M: Modality> {
pub modality: M, // owns State<M::Synced, M::Ephemeral>
pub doc: Doc<M::Synced>, // persistence (LoroDoc for M::Synced only)
pub ephemeral: SessionEphemeral, // session-level: AI + export state
pub version: u64, // monotonic counter
pub agent_tx: Option<Sender<String>>, // forward messages to agent thread
pub agent_config: Option<AgentConfig>, // temperature, model, preamble overrides
pub(crate) invalidated_children: Vec<String>, // deferred cache invalidation
pub undo_timeline: UndoTimeline, // interleaved parent/child undo tracking (the Session Undo/Redo RFC)
}

SessionState provides undo() and redo() methods that operate on the backing LoroDoc and call the modality’s on_undo()/on_redo() hooks to fix up ephemeral state. Session exposes begin_undo_group()/end_undo_group() for merging multiple changes into a single undo step (used by AI streaming).

The undo_timeline field tracks interleaved parent/child changes for parent sessions. Leaf sessions leave it empty and use Doc::can_undo/can_redo directly. Session computes can_undo/can_redo by OR-ing both sources and passes the result to M::snapshot().

Two fields enable deferred operations — reduce sets them, Session’s dispatch() handles them:

  • ephemeral.ai.pending_prompt — reduce sets Some(prompt), dispatch calls I::spawn_generate(...) (needs self_ref, not available in reduce)
  • invalidated_children — reduce pushes placement IDs, dispatch drains and clears cache entries before recompile
graph TD
S["Session&lt;M, I&gt;"]
S --> RT["Runtime&lt;Self&gt;"]
S --> SINKS["Sinks"]
S --> CACHE["CompileCache"]
S --> SELF["self_ref (Weak)"]
RT --> SS["SessionState&lt;M&gt;"]
RT --> SVC["SessionServices"]
RT --> CH["Spawn Channels"]
SS --> MOD["Modality (M)"]
SS --> DOC["Doc&lt;M::Synced&gt;"]
SS --> EPH["SessionEphemeral"]
MOD --> STATE["State&lt;M::Synced, M::Ephemeral&gt;"]
DOC --> LORO["LoroDoc"]
EPH --> AI["AiState"]
EPH --> EXP["ExportState"]
SVC --> BE["Arc&lt;dyn StoreBackend&gt;"]

Each piece has a distinct responsibility:

OwnerHoldsPurpose
Runtime<Self>SessionState<M> + SessionServices + spawn channelsShared Reducer infrastructure.
SessionState<M>modality, doc, ephemeral, version, agent_tx, agent_configEverything a reduce function needs. Clean boundary.
ModalityState<M::Synced, M::Ephemeral>Domain state. Direct &mut access, no locks.
Doc<M::Synced>LoroDocPure persistence. Only M::Synced is persisted. Does not dispatch or emit.
SessionEphemeralAiState, ExportStateSession-level runtime state. Not persisted.
SinksVec<Box<dyn Sink<M::Snapshot>>>Push typed snapshots to subscribers. Emits after reduce + recompile + commit.
SessionServicesArc<dyn StoreBackend>Session-level I/O: child warming, persistence. Separate from M::Services.
CompileCacheHash-based placement cacheIncremental recompilation. Per-placement hash of (placement, child_metadata, resolved_values).
self_refOption<Weak<Mutex<Self>>>Set by into_arc via post_init. Used for agent thread spawning.

Session implements Reducer with type State = SessionState<M>. The static reduce() delegates to the intent type’s own reduce method via the ReduceIntent trait:

crates/session/src/session.rs
impl<M: Modality, I: ReduceIntent<M>> Reducer for Session<M, I> {
type State = SessionState<M>;
type Intent = I;
type Feedback = I::Feedback;
type Error = I::Error;
type Services = SessionServices;
fn runtime(&self) -> &Runtime<Self> { &self.rt }
fn runtime_mut(&mut self) -> &mut Runtime<Self> { &mut self.rt }
fn reduce(
cmd: Command<I, I::Feedback>,
state: &mut SessionState<M>,
) -> Effect<SessionServices, I, I::Feedback> {
match cmd {
Command::Intent(intent) => I::reduce(intent, state),
Command::Feedback(fb) => I::handle_feedback(fb, state),
}
}
fn post_init(&mut self, weak: Weak<Mutex<Self>>) {
self.self_ref = Some(weak);
}
}

Session overrides dispatch() to insert feedback drain, deferred agent spawn, cache invalidation, and a lifecycle phase (recompile, commit, emit) after all reduces complete. See Dispatch Lifecycle for the full flow.

Sessions are constructed via into_arc(session), which wraps in Arc<Mutex<>> and wires the weak self-reference via post_init:

let session = Session::new(synced, services, agent_rx);
let arc = into_arc(session); // post_init sets self_ref
arc.lock().subscribe(Box::new(sink));

Session also implements Component, enabling it to be used inside a Slot for parent-child nesting:

impl<M: Modality, I: ReduceIntent<M>> Component for Session<M, I> {
type Metadata = <M as Component>::Metadata;
type Output = <M as Component>::Output;
fn compile(&self, _meta, _values) -> Result<Output> {
Ok(self.output().clone()) // cached -- Session already compiled during dispatch
}
}

This is what makes Slot<Session<Whiteboard, ...>, Vec<WhiteboardElement>> work on LessonPlan. A Hot child Session returns its already-compiled output without recompilation.

Runtime state that lives on SessionState, not persisted to the LoroDoc:

crates/core/src/state/traits.rs
pub struct AiState {
pub is_streaming: bool,
pub messages: Vec<AiMessage>,
pub current_response: String,
pub thinking_content: String,
pub tool_calls: Vec<AiToolCall>,
pub error: Option<String>,
pub pending_prompt: Option<String>, // deferred generate, consumed by dispatch
}
crates/core/src/state/traits.rs
pub struct ExportState {
pub bytes: Option<Vec<u8>>,
pub ready: bool,
pub error: Option<String>,
}

AI and export state are included in snapshots so Dart can render streaming indicators and export results, but they are never written to the CRDT document.

Session-level I/O, separate from the modality’s domain services:

crates/session/src/intent.rs
pub struct SessionServices {
pub backend: Arc<dyn StoreBackend>,
}

The modality has its own M::Services (e.g., ModalityServices wrapping Arc<AgentService>). SessionServices handles persistence and child warming — concerns that live above any single modality.

ModalitySession TypeShorthand
WhiteboardSession<Whiteboard, SessionIntent<WhiteboardIntent>>Session<Whiteboard>
WorksheetSession<Worksheet, SessionIntent<WorksheetIntent>>Session<Worksheet>
LessonPlanSession<LessonPlan, ParentSessionIntent<LessonPlanIntent, SessionIntent<WhiteboardIntent>>>(no shorthand)

Leaf modalities use the default I = SessionIntent<M::Intent>, so Session<Whiteboard> suffices. Parent modalities explicitly specify ParentSessionIntent<I, CI> which adds child routing and warming on top of the base session intents.

  • ReduceIntent — how intent types reduce inside a Session
  • Dispatch Lifecycle — the full drain-reduce-lifecycle-spawn flow
  • Children — parent sessions and the Hot/Cold lifecycle
  • Snapshots — per-modality snapshot DTOs and sink emission
  • Persistence — Doc<S> and the CRDT persistence layer