Skip to main content

lash_core/runtime/
state.rs

1//! Runtime session state and persistence helpers.
2//!
3//! `RuntimeSessionState` is the runtime-private mutable state shape. Public
4//! host/plugin reads use `SessionSnapshot` from the plugin API instead.
5
6use lash_sansio::PromptUsage;
7
8use crate::session_model::{Message, SessionPolicy, TokenUsage, plugin_message_to_message};
9use crate::{PersistedTurnState, SessionSnapshot};
10
11use super::usage::TokenLedgerEntry;
12
13/// The runtime's view of a session: the persistable snapshot fields
14/// **plus** scratch fields the runtime tracks but never persists
15/// (head-revision CAS guard, pending dirty-write buffers, replace-graph
16/// flag). Public serialization goes through [`RuntimeSessionState::to_snapshot`],
17/// which drops runtime-only fields by construction.
18#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
19pub struct RuntimeSessionState {
20    pub session_id: String,
21    #[serde(default)]
22    pub policy: SessionPolicy,
23    #[serde(default)]
24    pub agent_frames: Vec<crate::AgentFrameRecord>,
25    #[serde(default, skip_serializing_if = "String::is_empty")]
26    pub current_agent_frame_id: crate::AgentFrameId,
27    #[serde(default)]
28    pub session_graph: crate::SessionGraph,
29    #[serde(default)]
30    pub turn_index: usize,
31    #[serde(default)]
32    pub token_usage: TokenUsage,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub last_prompt_usage: Option<PromptUsage>,
35    #[serde(default)]
36    pub protocol_turn_options: crate::ProtocolTurnOptions,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub tool_state_ref: Option<crate::store::BlobRef>,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub tool_state_generation: Option<u64>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub tool_state_snapshot: Option<crate::ToolState>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub plugin_snapshot_ref: Option<crate::store::BlobRef>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub plugin_snapshot_revision: Option<u64>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub plugin_snapshot: Option<crate::PluginSessionSnapshot>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub execution_state_ref: Option<crate::store::BlobRef>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub execution_state_snapshot: Option<Vec<u8>>,
53    /// Cost-accounting ledger. Every LLM call (parent turns, subagent
54    /// children, compaction, observers, background helpers) contributes an
55    /// entry keyed by `(source, model)`. Separate from `token_usage`
56    /// which tracks context-window accounting only.
57    #[serde(default, skip_serializing_if = "Vec::is_empty")]
58    pub token_ledger: Vec<TokenLedgerEntry>,
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub checkpoint_ref: Option<crate::store::BlobRef>,
61    /// Store head revision observed by the runtime. Lease-fenced commits use it
62    /// as the stale-writer CAS backstop; `None` means the runtime is creating
63    /// the first persisted head.
64    #[serde(skip)]
65    pub head_revision: Option<u64>,
66    /// Signals that the next commit must write the full graph (for example,
67    /// `heal_orphaned_leaf` repaired an invalid leaf). Cleared after the
68    /// next commit.
69    #[serde(skip)]
70    pub graph_replace_required: bool,
71}
72
73impl RuntimeSessionState {
74    pub fn from_snapshot(snapshot: SessionSnapshot) -> Self {
75        let mut state = Self {
76            session_id: snapshot.session_id,
77            policy: snapshot.policy,
78            agent_frames: snapshot.agent_frames,
79            current_agent_frame_id: snapshot.current_agent_frame_id,
80            session_graph: snapshot.session_graph,
81            turn_index: snapshot.turn_index,
82            token_usage: snapshot.token_usage,
83            last_prompt_usage: snapshot.last_prompt_usage,
84            protocol_turn_options: snapshot.protocol_turn_options,
85            tool_state_ref: snapshot.tool_state_ref,
86            tool_state_generation: snapshot.tool_state_generation,
87            tool_state_snapshot: None,
88            plugin_snapshot_ref: snapshot.plugin_snapshot_ref,
89            plugin_snapshot_revision: snapshot.plugin_snapshot_revision,
90            plugin_snapshot: None,
91            execution_state_ref: snapshot.execution_state_ref,
92            execution_state_snapshot: None,
93            token_ledger: snapshot.token_ledger,
94            checkpoint_ref: snapshot.checkpoint_ref,
95            head_revision: None,
96            graph_replace_required: false,
97        };
98        for frame in &mut state.agent_frames {
99            frame.execution_state_snapshot = None;
100        }
101        state.ensure_agent_frame_initialized();
102        state
103    }
104
105    pub fn to_snapshot(&self) -> SessionSnapshot {
106        let mut agent_frames = self.agent_frames.clone();
107        for frame in &mut agent_frames {
108            frame.execution_state_snapshot = None;
109        }
110        SessionSnapshot {
111            session_id: self.session_id.clone(),
112            policy: self.policy.clone(),
113            agent_frames,
114            current_agent_frame_id: self.current_agent_frame_id.clone(),
115            session_graph: self.session_graph.clone(),
116            turn_index: self.turn_index,
117            token_usage: self.token_usage.clone(),
118            last_prompt_usage: self.last_prompt_usage.clone(),
119            protocol_turn_options: self.protocol_turn_options.clone(),
120            tool_state_ref: self.tool_state_ref.clone(),
121            tool_state_generation: self.tool_state_generation,
122            plugin_snapshot_ref: self.plugin_snapshot_ref.clone(),
123            plugin_snapshot_revision: self.plugin_snapshot_revision,
124            execution_state_ref: self.execution_state_ref.clone(),
125            token_ledger: self.token_ledger.clone(),
126            checkpoint_ref: self.checkpoint_ref.clone(),
127        }
128    }
129
130    pub fn apply_snapshot(&mut self, snapshot: &SessionSnapshot) {
131        self.session_id = snapshot.session_id.clone();
132        self.policy = snapshot.policy.clone();
133        self.agent_frames = snapshot.agent_frames.clone();
134        self.current_agent_frame_id = snapshot.current_agent_frame_id.clone();
135        self.ensure_agent_frame_initialized();
136        self.session_graph = snapshot.session_graph.clone();
137        self.turn_index = snapshot.turn_index;
138        self.token_usage = snapshot.token_usage.clone();
139        self.last_prompt_usage = snapshot.last_prompt_usage.clone();
140        self.protocol_turn_options = snapshot.protocol_turn_options.clone();
141        self.tool_state_ref = snapshot.tool_state_ref.clone();
142        self.tool_state_generation = snapshot.tool_state_generation;
143        self.plugin_snapshot_ref = snapshot.plugin_snapshot_ref.clone();
144        self.plugin_snapshot_revision = snapshot.plugin_snapshot_revision;
145        self.execution_state_ref = snapshot.execution_state_ref.clone();
146        self.token_ledger = snapshot.token_ledger.clone();
147        self.checkpoint_ref = snapshot.checkpoint_ref.clone();
148    }
149
150    pub fn stamp_runtime_state(
151        &mut self,
152        tool_state: Option<&crate::ToolState>,
153        plugin_snapshot: Option<&crate::PluginSessionSnapshot>,
154    ) {
155        self.tool_state_snapshot = tool_state.cloned();
156        self.tool_state_generation = tool_state.map(|snapshot| snapshot.generation());
157        self.plugin_snapshot = plugin_snapshot.cloned();
158    }
159
160    pub fn usage_report(&self) -> super::usage::SessionUsageReport {
161        super::usage::SessionUsageReport::from_entries(&self.token_ledger)
162    }
163
164    pub(crate) fn read_model(&self) -> crate::session_graph::SessionReadModel {
165        self.session_graph.read_model_for_agent_frame(
166            &self.current_agent_frame_id,
167            self.current_agent_frame_is_initial(),
168        )
169    }
170
171    pub fn replace_active_read_state(&mut self, messages: &[Message]) {
172        self.session_graph
173            .replace_active_read_state_for_agent_frame(&self.current_agent_frame_id, messages);
174        self.graph_replace_required = false;
175    }
176
177    pub fn append_active_read_delta(&mut self, messages: &[Message]) {
178        self.session_graph
179            .append_active_read_delta_for_agent_frame(&self.current_agent_frame_id, messages);
180    }
181
182    pub fn append_active_conversation_messages(&mut self, messages: &[Message]) {
183        self.session_graph
184            .append_active_conversation_messages_for_agent_frame(
185                &self.current_agent_frame_id,
186                messages,
187            );
188    }
189
190    pub(crate) fn append_active_conversation_messages_with_clock(
191        &mut self,
192        messages: &[Message],
193        clock: &dyn crate::Clock,
194    ) {
195        self.session_graph
196            .append_active_conversation_messages_for_agent_frame_at(
197                &self.current_agent_frame_id,
198                messages,
199                clock.timestamp_rfc3339(),
200            );
201    }
202
203    pub fn read_view(&self) -> crate::SessionReadView {
204        crate::SessionReadView::from_persisted_state(self)
205    }
206
207    pub fn session_graph(&self) -> &crate::SessionGraph {
208        &self.session_graph
209    }
210
211    pub fn policy(&self) -> &SessionPolicy {
212        self.effective_policy()
213    }
214
215    pub fn turn_state(&self) -> PersistedTurnState {
216        PersistedTurnState {
217            turn_index: self.turn_index,
218            token_usage: self.token_usage.clone(),
219            last_prompt_usage: self.last_prompt_usage.clone(),
220            protocol_turn_options: self.protocol_turn_options.clone(),
221        }
222    }
223
224    pub fn token_ledger(&self) -> &[TokenLedgerEntry] {
225        &self.token_ledger
226    }
227
228    pub fn apply_persisted_commit_result(&mut self, result: crate::store::RuntimeCommitResult) {
229        self.head_revision = Some(result.head_revision);
230        self.checkpoint_ref = Some(result.checkpoint_ref);
231        self.tool_state_ref = result.manifest.tool_state_ref;
232        if let Some(snapshot) = self.tool_state_snapshot.as_ref() {
233            self.tool_state_generation = Some(snapshot.generation());
234        } else if self.tool_state_ref.is_none() {
235            self.tool_state_generation = None;
236        }
237        self.plugin_snapshot_ref = result.manifest.plugin_snapshot_ref;
238        self.plugin_snapshot_revision = result.manifest.plugin_snapshot_revision;
239        self.execution_state_ref = result.manifest.execution_state_ref;
240        let execution_state_ref = self.execution_state_ref.clone();
241        if let Some(frame) = self.current_agent_frame_mut() {
242            frame.execution_state_ref = execution_state_ref;
243            frame.execution_state_snapshot = None;
244        }
245        self.graph_replace_required = false;
246        self.tool_state_snapshot = None;
247        self.plugin_snapshot = None;
248        self.execution_state_snapshot = None;
249        if let Some(frame) = self.current_agent_frame_mut() {
250            frame.execution_state_snapshot = None;
251        }
252    }
253
254    pub fn discard_runtime_snapshots(&mut self) {
255        self.tool_state_snapshot = None;
256        self.plugin_snapshot = None;
257        self.execution_state_snapshot = None;
258        if let Some(frame) = self.current_agent_frame_mut() {
259            frame.execution_state_snapshot = None;
260        }
261    }
262
263    pub fn set_execution_state_snapshot(&mut self, execution_state_snapshot: Option<Vec<u8>>) {
264        if execution_state_snapshot.is_none() {
265            self.execution_state_ref = None;
266        }
267        self.execution_state_snapshot = execution_state_snapshot.clone();
268        if let Some(frame) = self.current_agent_frame_mut() {
269            if execution_state_snapshot.is_none() {
270                frame.execution_state_ref = None;
271            }
272            frame.execution_state_snapshot = execution_state_snapshot;
273        }
274    }
275
276    pub fn execution_state_snapshot(&self) -> Option<&[u8]> {
277        self.current_agent_frame()
278            .and_then(|frame| frame.execution_state_snapshot.as_deref())
279            .or(self.execution_state_snapshot.as_deref())
280    }
281
282    pub fn refresh_plugin_snapshots(&mut self, plugins: &crate::PluginSession) {
283        let tool_registry = plugins.tool_registry();
284        let generation = tool_registry.generation();
285        if self.tool_state_ref.is_none() || self.tool_state_generation != Some(generation) {
286            let snapshot = tool_registry.export_state();
287            self.tool_state_generation = Some(snapshot.generation());
288            self.tool_state_snapshot = Some(snapshot);
289        }
290
291        let revision = plugins.snapshot_revision_fingerprint();
292        if self.plugin_snapshot_ref.is_none() || self.plugin_snapshot_revision != Some(revision) {
293            store_plugin_snapshot(&mut self.plugin_snapshot, plugins.snapshot());
294        }
295        self.plugin_snapshot_revision = Some(revision);
296    }
297}
298
299/// Persist a freshly captured plugin snapshot, logging and **retaining the prior
300/// snapshot** when the capture fails.
301///
302/// A failed capture (`Err`) previously collapsed to `None` via `.ok()`, erasing
303/// the last good snapshot — so the next cold rebuild would restore an empty
304/// plugin surface even though a valid snapshot had been captured earlier. Keep
305/// the prior value and surface the error instead.
306pub(crate) fn store_plugin_snapshot(
307    target: &mut Option<crate::PluginSessionSnapshot>,
308    captured: Result<crate::PluginSessionSnapshot, crate::PluginError>,
309) {
310    match captured {
311        Ok(snapshot) => *target = Some(snapshot),
312        Err(err) => tracing::warn!(
313            error = %err,
314            "failed to capture plugin snapshot; retaining the prior snapshot",
315        ),
316    }
317}
318
319impl RuntimeSessionState {
320    pub fn current_agent_frame(&self) -> Option<&crate::AgentFrameRecord> {
321        self.agent_frames
322            .iter()
323            .find(|frame| frame.frame_id == self.current_agent_frame_id)
324    }
325
326    pub fn current_agent_frame_mut(&mut self) -> Option<&mut crate::AgentFrameRecord> {
327        let current_agent_frame_id = self.current_agent_frame_id.clone();
328        self.agent_frames
329            .iter_mut()
330            .find(|frame| frame.frame_id == current_agent_frame_id)
331    }
332
333    pub fn effective_policy(&self) -> &SessionPolicy {
334        self.current_agent_frame()
335            .map(|frame| &frame.assignment.policy)
336            .unwrap_or(&self.policy)
337    }
338
339    pub fn process_execution_env_spec(
340        &self,
341        fallback_policy: &SessionPolicy,
342    ) -> crate::ProcessExecutionEnvSpec {
343        self.current_agent_frame()
344            .map(|frame| {
345                crate::ProcessExecutionEnvSpec::new(
346                    frame.assignment.plugin_options.clone(),
347                    frame.assignment.policy.clone(),
348                )
349            })
350            .unwrap_or_else(|| {
351                crate::ProcessExecutionEnvSpec::new(
352                    crate::PluginOptions::default(),
353                    fallback_policy.clone(),
354                )
355            })
356    }
357
358    pub fn effective_protocol_turn_options(&self) -> &crate::ProtocolTurnOptions {
359        self.current_agent_frame()
360            .map(|frame| &frame.protocol_turn_options)
361            .unwrap_or(&self.protocol_turn_options)
362    }
363
364    pub fn ensure_agent_frame_initialized(&mut self) {
365        self.ensure_agent_frame_initialized_with_clock(&crate::SystemClock);
366    }
367
368    pub fn ensure_agent_frame_initialized_with_clock(&mut self, clock: &dyn crate::Clock) {
369        if self.current_agent_frame_id.is_empty() {
370            self.current_agent_frame_id = default_agent_frame_id(&self.session_id);
371        }
372        if self
373            .agent_frames
374            .iter()
375            .any(|frame| frame.frame_id == self.current_agent_frame_id)
376        {
377            return;
378        }
379        let mut frame = default_agent_frame_with_clock(&self.session_id, &self.policy, clock);
380        frame.frame_id = self.current_agent_frame_id.clone();
381        frame.protocol_turn_options = self.protocol_turn_options.clone();
382        frame.execution_state_ref = self.execution_state_ref.clone();
383        frame.execution_state_snapshot = self.execution_state_snapshot.clone();
384        self.agent_frames.push(frame);
385    }
386
387    pub fn reset_initial_agent_frame(
388        &mut self,
389        assignment: crate::AgentFrameAssignment,
390        protocol_turn_options: crate::ProtocolTurnOptions,
391    ) {
392        self.reset_initial_agent_frame_with_clock(
393            assignment,
394            protocol_turn_options,
395            &crate::SystemClock,
396        );
397    }
398
399    pub fn reset_initial_agent_frame_with_clock(
400        &mut self,
401        assignment: crate::AgentFrameAssignment,
402        protocol_turn_options: crate::ProtocolTurnOptions,
403        clock: &dyn crate::Clock,
404    ) {
405        let frame_id = default_agent_frame_id(&self.session_id);
406        self.policy = assignment.policy.clone();
407        self.protocol_turn_options = protocol_turn_options.clone();
408        self.current_agent_frame_id = frame_id.clone();
409        self.agent_frames = vec![crate::AgentFrameRecord::new_at(
410            frame_id,
411            self.session_id.clone(),
412            None,
413            crate::AgentFrameReason::initial(),
414            None,
415            assignment,
416            protocol_turn_options,
417            clock.timestamp_rfc3339(),
418        )];
419    }
420
421    pub fn append_agent_frame(&mut self, mut frame: crate::AgentFrameRecord) {
422        let previous_frame_id = self.current_agent_frame_id.clone();
423        for existing in &mut self.agent_frames {
424            if existing.frame_id == previous_frame_id {
425                existing.status = crate::AgentFrameStatus::Superseded;
426            }
427        }
428        if frame.previous_frame_id.is_none() && !previous_frame_id.is_empty() {
429            frame.previous_frame_id = Some(previous_frame_id);
430        }
431        frame.status = crate::AgentFrameStatus::Active;
432        self.policy = frame.assignment.policy.clone();
433        self.protocol_turn_options = frame.protocol_turn_options.clone();
434        self.current_agent_frame_id = frame.frame_id.clone();
435        self.execution_state_ref = frame.execution_state_ref.clone();
436        self.execution_state_snapshot = frame.execution_state_snapshot.clone();
437        self.agent_frames.push(frame);
438    }
439
440    fn current_agent_frame_is_initial(&self) -> bool {
441        self.current_agent_frame()
442            .map(|frame| frame.previous_frame_id.is_none())
443            .unwrap_or(true)
444    }
445}
446
447impl Default for RuntimeSessionState {
448    fn default() -> Self {
449        Self {
450            session_id: "root".to_string(),
451            policy: SessionPolicy::default(),
452            agent_frames: default_agent_frames("root", &SessionPolicy::default()),
453            current_agent_frame_id: default_agent_frame_id("root"),
454            session_graph: crate::SessionGraph::default(),
455            turn_index: 0,
456            token_usage: TokenUsage::default(),
457            last_prompt_usage: None,
458            protocol_turn_options: crate::ProtocolTurnOptions::default(),
459            tool_state_ref: None,
460            tool_state_generation: None,
461            tool_state_snapshot: None,
462            plugin_snapshot_ref: None,
463            plugin_snapshot_revision: None,
464            plugin_snapshot: None,
465            execution_state_ref: None,
466            execution_state_snapshot: None,
467            token_ledger: Vec::new(),
468            checkpoint_ref: None,
469            head_revision: None,
470            graph_replace_required: false,
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use std::sync::{Arc, Mutex};
479
480    struct DynamicSnapshotTools {
481        names: Arc<Mutex<Vec<String>>>,
482    }
483
484    #[async_trait::async_trait]
485    impl crate::ToolProvider for DynamicSnapshotTools {
486        fn tool_manifests(&self) -> Vec<crate::ToolManifest> {
487            self.names
488                .lock()
489                .expect("dynamic snapshot names")
490                .iter()
491                .map(|name| {
492                    crate::ToolDefinition::raw(
493                        format!("tool:{name}"),
494                        name,
495                        "dynamic snapshot tool",
496                        crate::ToolDefinition::default_input_schema(),
497                        serde_json::json!({}),
498                    )
499                    .manifest()
500                })
501                .collect()
502        }
503
504        fn resolve_contract(&self, name: &str) -> Option<Arc<crate::ToolContract>> {
505            self.names
506                .lock()
507                .expect("dynamic snapshot names")
508                .iter()
509                .any(|candidate| candidate == name)
510                .then(|| {
511                    Arc::new(
512                        crate::ToolDefinition::raw(
513                            format!("tool:{name}"),
514                            name,
515                            "dynamic snapshot tool",
516                            crate::ToolDefinition::default_input_schema(),
517                            serde_json::json!({}),
518                        )
519                        .contract(),
520                    )
521                })
522        }
523
524        async fn execute(&self, _call: crate::ToolCall<'_>) -> crate::ToolResult {
525            crate::ToolResult::ok(serde_json::json!("ok"))
526        }
527    }
528
529    #[test]
530    fn session_snapshot_serialization_excludes_runtime_only_fields_and_round_trips() {
531        let mut state = RuntimeSessionState {
532            session_id: "snapshot-test".to_string(),
533            policy: SessionPolicy {
534                provider_id: "mock".to_string(),
535                ..SessionPolicy::default()
536            },
537            tool_state_snapshot: Some(crate::ToolState::default()),
538            plugin_snapshot: Some(crate::PluginSessionSnapshot::default()),
539            execution_state_snapshot: Some(vec![1, 2, 3]),
540            head_revision: Some(42),
541            graph_replace_required: true,
542            ..RuntimeSessionState::default()
543        };
544        state.ensure_agent_frame_initialized();
545        if let Some(frame) = state.current_agent_frame_mut() {
546            frame.execution_state_snapshot = Some(vec![4, 5, 6]);
547        }
548
549        let value = serde_json::to_value(state.to_snapshot()).expect("serialize snapshot");
550
551        for runtime_key in [
552            "head_revision",
553            "graph_replace_required",
554            "tool_state_snapshot",
555            "plugin_snapshot",
556            "execution_state_snapshot",
557        ] {
558            assert!(
559                value.get(runtime_key).is_none(),
560                "snapshot unexpectedly exposed {runtime_key}"
561            );
562        }
563        assert!(
564            value["agent_frames"]
565                .as_array()
566                .expect("agent frames")
567                .iter()
568                .all(|frame| frame.get("execution_state_snapshot").is_none())
569        );
570
571        let snapshot: SessionSnapshot = serde_json::from_value(value).expect("round-trip snapshot");
572        let hydrated = RuntimeSessionState::from_snapshot(snapshot);
573
574        assert_eq!(hydrated.session_id, "snapshot-test");
575        assert_eq!(hydrated.policy.recorded_provider_id(), "mock");
576        assert!(hydrated.head_revision.is_none());
577        assert!(!hydrated.graph_replace_required);
578        assert!(hydrated.tool_state_snapshot.is_none());
579        assert!(hydrated.plugin_snapshot.is_none());
580        assert!(hydrated.execution_state_snapshot.is_none());
581        assert!(
582            hydrated
583                .agent_frames
584                .iter()
585                .all(|frame| frame.execution_state_snapshot.is_none())
586        );
587    }
588
589    #[test]
590    fn reconciled_generation_forces_next_plugin_snapshot_export() {
591        let names = Arc::new(Mutex::new(vec!["dynamic_one".to_string()]));
592        let tools: Arc<dyn crate::ToolProvider> = Arc::new(DynamicSnapshotTools {
593            names: Arc::clone(&names),
594        });
595        let plugins = crate::runtime::tests::helpers::plugin_session_with_tools("root", tools);
596        let snapshot = plugins.tool_registry().export_state();
597        let persisted_generation = snapshot.generation();
598        let mut state = RuntimeSessionState {
599            tool_state_ref: Some("persisted-tool-state".to_string().into()),
600            tool_state_generation: Some(persisted_generation),
601            ..RuntimeSessionState::default()
602        };
603
604        names
605            .lock()
606            .expect("dynamic snapshot names")
607            .push("dynamic_two".to_string());
608        let report = plugins
609            .tool_registry()
610            .restore_state(snapshot)
611            .expect("live surface restore");
612        assert_eq!(report.generation, persisted_generation + 1);
613
614        state.refresh_plugin_snapshots(&plugins);
615        let refreshed = state
616            .tool_state_snapshot
617            .as_ref()
618            .expect("generation change re-exports the tool snapshot");
619        assert_eq!(refreshed.generation(), report.generation);
620        assert!(refreshed.contains(&crate::ToolId::from("tool:dynamic_two")));
621    }
622}
623
624pub(super) fn apply_persisted_session_config(
625    policy: &mut SessionPolicy,
626    config: &crate::PersistedSessionConfig,
627) {
628    policy.model = config.model.clone();
629    policy.provider_id = config.provider_id.clone();
630}
631
632pub(super) fn apply_session_checkpoint(
633    state: &mut RuntimeSessionState,
634    checkpoint: Option<crate::store::HydratedSessionCheckpoint>,
635) {
636    let Some(checkpoint) = checkpoint else {
637        state.tool_state_ref = None;
638        state.tool_state_generation = None;
639        state.tool_state_snapshot = None;
640        state.plugin_snapshot_ref = None;
641        state.plugin_snapshot_revision = None;
642        state.plugin_snapshot = None;
643        state.execution_state_ref = None;
644        state.execution_state_snapshot = None;
645        state.ensure_agent_frame_initialized();
646        return;
647    };
648    state.turn_index = checkpoint.turn_state.turn_index;
649    state.token_usage = checkpoint.turn_state.token_usage;
650    state.last_prompt_usage = checkpoint.turn_state.last_prompt_usage;
651    state.protocol_turn_options = checkpoint.turn_state.protocol_turn_options;
652    state.tool_state_ref = checkpoint.tool_state_ref.clone();
653    state.tool_state_generation = checkpoint
654        .tool_state
655        .as_ref()
656        .map(|snapshot| snapshot.generation());
657    state.tool_state_snapshot = checkpoint.tool_state;
658    state.plugin_snapshot_ref = checkpoint.plugin_snapshot_ref.clone();
659    state.plugin_snapshot_revision = checkpoint.plugin_snapshot_revision;
660    state.plugin_snapshot = checkpoint.plugin_snapshot;
661    state.execution_state_ref = checkpoint.execution_state_ref.clone();
662    state.execution_state_snapshot = None;
663    state.ensure_agent_frame_initialized();
664    if let Some(frame) = state.current_agent_frame_mut() {
665        frame.execution_state_ref = checkpoint.execution_state_ref.clone();
666        frame.execution_state_snapshot = checkpoint.execution_state;
667    }
668}
669
670pub(super) fn apply_session_head(
671    state: &mut RuntimeSessionState,
672    head: &crate::store::SessionHead,
673) {
674    state.session_graph = head.graph.clone();
675    state.agent_frames = head.agent_frames.clone();
676    state.current_agent_frame_id = head.current_agent_frame_id.clone();
677    state.checkpoint_ref = head.checkpoint_ref.clone();
678    state.token_ledger = head.token_ledger.clone();
679    state.tool_state_ref = None;
680    state.tool_state_generation = None;
681    state.tool_state_snapshot = None;
682    state.plugin_snapshot_ref = None;
683    state.plugin_snapshot_revision = None;
684    state.plugin_snapshot = None;
685    state.execution_state_ref = None;
686    state.execution_state_snapshot = None;
687    state.ensure_agent_frame_initialized();
688    state.head_revision = Some(head.head_revision);
689    state.graph_replace_required = false;
690    apply_persisted_session_config(&mut state.policy, &head.config);
691}
692
693pub(super) fn append_session_nodes_to_state_with_clock(
694    state: &mut RuntimeSessionState,
695    nodes: &[crate::SessionAppendNode],
696    clock: &dyn crate::Clock,
697) -> Vec<String> {
698    let drafts = nodes
699        .iter()
700        .map(session_append_node_draft)
701        .collect::<Vec<_>>();
702    state.ensure_agent_frame_initialized_with_clock(clock);
703    let node_ids = state.session_graph.append_node_drafts_for_agent_frame_at(
704        &state.current_agent_frame_id,
705        drafts,
706        clock.timestamp_rfc3339(),
707    );
708    normalize_session_graph(state);
709    node_ids
710}
711
712pub(super) fn open_agent_frame_in_state_with_clock(
713    state: &mut RuntimeSessionState,
714    request: crate::OpenAgentFrameRequest,
715    clock: &dyn crate::Clock,
716) -> crate::OpenAgentFrameResult {
717    state.ensure_agent_frame_initialized_with_clock(clock);
718    if request.frame_id.trim().is_empty() || state.current_agent_frame_id == request.frame_id {
719        return crate::OpenAgentFrameResult {
720            frame_id: state.current_agent_frame_id.clone(),
721            opened: false,
722            initial_node_ids: Vec::new(),
723        };
724    }
725
726    let previous = state.current_agent_frame().cloned();
727    let assignment = previous
728        .as_ref()
729        .map(|frame| frame.assignment.clone())
730        .unwrap_or_else(|| crate::AgentFrameAssignment::from_policy(state.policy.clone()));
731    let protocol_turn_options = previous
732        .as_ref()
733        .map(|frame| frame.protocol_turn_options.clone())
734        .unwrap_or_else(|| state.protocol_turn_options.clone());
735    let previous_frame_id = previous.map(|frame| frame.frame_id);
736    state.append_agent_frame(crate::AgentFrameRecord::new_at(
737        request.frame_id.clone(),
738        state.session_id.clone(),
739        previous_frame_id,
740        request.reason,
741        request.caused_by,
742        assignment,
743        protocol_turn_options,
744        clock.timestamp_rfc3339(),
745    ));
746
747    let initial_node_ids =
748        append_session_nodes_to_state_with_clock(state, &request.initial_nodes, clock);
749    if !initial_node_ids.is_empty() {
750        state.graph_replace_required = true;
751    }
752    crate::OpenAgentFrameResult {
753        frame_id: state.current_agent_frame_id.clone(),
754        opened: true,
755        initial_node_ids,
756    }
757}
758
759fn session_append_node_draft(
760    node: &crate::SessionAppendNode,
761) -> crate::session_graph::SessionNodeDraft {
762    match node {
763        crate::SessionAppendNode::Message { message, caused_by } => {
764            crate::session_graph::SessionNodeDraft::message(plugin_message_to_message(message))
765                .with_caused_by(caused_by.clone())
766        }
767        crate::SessionAppendNode::ProtocolEvent { event, caused_by } => {
768            crate::session_graph::SessionNodeDraft::protocol_event(event.clone())
769                .with_caused_by(caused_by.clone())
770        }
771        crate::SessionAppendNode::Plugin {
772            plugin_type,
773            body,
774            caused_by,
775        } => crate::session_graph::SessionNodeDraft::plugin(plugin_type.clone(), body.clone())
776            .with_caused_by(caused_by.clone()),
777    }
778}
779
780fn default_agent_frame_id(session_id: &str) -> crate::AgentFrameId {
781    format!("{session_id}:frame:initial")
782}
783
784fn default_agent_frames(session_id: &str, policy: &SessionPolicy) -> Vec<crate::AgentFrameRecord> {
785    vec![default_agent_frame(session_id, policy)]
786}
787
788fn default_agent_frame(session_id: &str, policy: &SessionPolicy) -> crate::AgentFrameRecord {
789    default_agent_frame_with_clock(session_id, policy, &crate::SystemClock)
790}
791
792fn default_agent_frame_with_clock(
793    session_id: &str,
794    policy: &SessionPolicy,
795    clock: &dyn crate::Clock,
796) -> crate::AgentFrameRecord {
797    crate::AgentFrameRecord::new_at(
798        default_agent_frame_id(session_id),
799        session_id.to_string(),
800        None,
801        crate::AgentFrameReason::initial(),
802        None,
803        crate::AgentFrameAssignment::from_policy(policy.clone()),
804        crate::ProtocolTurnOptions::default(),
805        clock.timestamp_rfc3339(),
806    )
807}
808
809/// Heal any graph corruption (orphaned leaf) on load.
810///
811/// Must run BEFORE any residency-based trim (phase-9 feature) because
812/// healing's fallback search relies on having the full node set in RAM.
813/// Under `Residency::ActivePathOnly`, the runtime loads only the active
814/// path; if the leaf doesn't resolve against that reduced set, the
815/// caller falls back to a full `load_session_graph()` + `normalize` +
816/// trim.
817pub(super) fn normalize_session_graph(state: &mut RuntimeSessionState) {
818    if state.session_graph.heal_orphaned_leaf() {
819        state.graph_replace_required = true;
820    }
821}
822
823/// Trim the resident node set according to `Residency`. Called AFTER
824/// `normalize_session_graph` during `from_environment` load. Under
825/// `KeepAll` this is a no-op; under `ActivePathOnly` it replaces the
826/// resident graph with just the active path. Orphans remain on disk —
827/// the host decides whether/when to tombstone + vacuum them via
828/// `LashRuntime::orphaned_node_ids` + the store primitives.
829///
830pub(super) fn apply_residency_on_load(
831    state: &mut RuntimeSessionState,
832    residency: crate::Residency,
833) {
834    match residency {
835        crate::Residency::KeepAll => {}
836        crate::Residency::ActivePathOnly => {
837            state.session_graph = state.session_graph.fork_current_path();
838        }
839    }
840}
841
842#[cfg(test)]
843mod plugin_snapshot_tests {
844    use super::store_plugin_snapshot;
845    use crate::{PluginError, PluginSessionSnapshot};
846
847    #[test]
848    fn ok_capture_overwrites_target() {
849        let mut target = None;
850        store_plugin_snapshot(&mut target, Ok(PluginSessionSnapshot::default()));
851        assert!(target.is_some(), "a successful capture must be stored");
852    }
853
854    #[test]
855    fn failed_capture_retains_prior_snapshot() {
856        // The regression this guards: a failed snapshot capture used to collapse
857        // to `None` via `.ok()`, erasing the last good snapshot so the next cold
858        // rebuild would restore an empty plugin surface. A failure must leave the
859        // prior snapshot intact.
860        let prior = PluginSessionSnapshot::default();
861        let mut target = Some(prior);
862        store_plugin_snapshot(
863            &mut target,
864            Err(PluginError::Snapshot("capture failed".to_string())),
865        );
866        assert!(
867            target.is_some(),
868            "a failed capture must retain the prior snapshot, not erase it"
869        );
870    }
871}
872
873#[cfg(test)]
874mod residency_tests {
875    use super::apply_residency_on_load;
876    use crate::{
877        Message, MessageRole, Part, PartKind, PruneState, Residency, RuntimeSessionState,
878        shared_parts,
879    };
880
881    fn text_message(id: &str, content: &str) -> Message {
882        Message {
883            id: id.to_string(),
884            role: MessageRole::User,
885            parts: shared_parts(vec![Part {
886                id: format!("{id}.p0"),
887                kind: PartKind::Text,
888                content: content.to_string(),
889                attachment: None,
890                tool_call_id: None,
891                tool_name: None,
892                tool_replay: None,
893                prune_state: PruneState::Intact,
894                reasoning_meta: None,
895                response_meta: None,
896            }]),
897            origin: None,
898        }
899    }
900
901    /// Root, an inactive branch off the root, then an active branch off the root.
902    /// Returns the state plus the inactive and active branch node ids.
903    fn branching_state() -> (RuntimeSessionState, String, String) {
904        let mut state = RuntimeSessionState::default();
905        state.append_active_conversation_messages(&[text_message("root", "root")]);
906        let root = state.session_graph.leaf_node_id.clone();
907        state.append_active_conversation_messages(&[text_message("inactive", "inactive branch")]);
908        let inactive_node = state
909            .session_graph
910            .leaf_node_id
911            .clone()
912            .expect("inactive node");
913        state.session_graph.branch_to(root);
914        state.append_active_conversation_messages(&[text_message("active", "active branch")]);
915        let active_node = state
916            .session_graph
917            .leaf_node_id
918            .clone()
919            .expect("active node");
920        (state, inactive_node, active_node)
921    }
922
923    #[test]
924    fn active_path_only_trims_orphan_branches_on_load() {
925        // The durable worker rebuild (and session resume) call this to match the
926        // live runtime's residency. ActivePathOnly drops nodes off the active
927        // path so a rebuilt session does not silently retain the full graph.
928        let (mut state, inactive_node, active_node) = branching_state();
929        assert!(
930            state.session_graph.find_node(&inactive_node).is_some(),
931            "the inactive branch is resident before trimming"
932        );
933        apply_residency_on_load(&mut state, Residency::ActivePathOnly);
934        assert!(
935            state.session_graph.find_node(&inactive_node).is_none(),
936            "ActivePathOnly must drop the orphaned inactive branch on rebuild"
937        );
938        assert!(
939            state.session_graph.find_node(&active_node).is_some(),
940            "the active path must be retained"
941        );
942    }
943
944    #[test]
945    fn keep_all_retains_orphan_branches_on_load() {
946        let (mut state, inactive_node, _active_node) = branching_state();
947        apply_residency_on_load(&mut state, Residency::KeepAll);
948        assert!(
949            state.session_graph.find_node(&inactive_node).is_some(),
950            "KeepAll must retain the full resident graph"
951        );
952    }
953}