Skip to main content

lash_core/
session_graph.rs

1use std::collections::{HashMap, HashSet};
2use std::ops::Deref;
3use std::sync::{Arc, OnceLock};
4
5use crate::session_model::{ConversationRecord, ProtocolEvent, SessionHistoryRecord};
6use crate::{BaseRenderCache, Clock, Message, MessageRole, PromptUsage, TokenUsage};
7
8#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
9pub struct SessionGraphData {
10    #[serde(default)]
11    pub nodes: Vec<SessionNodeRecord>,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub leaf_node_id: Option<String>,
14}
15
16#[derive(Debug)]
17pub struct SessionGraph {
18    inner: Arc<SessionGraphData>,
19    cache: Arc<OnceLock<SessionGraphCache>>,
20}
21
22impl Default for SessionGraph {
23    fn default() -> Self {
24        Self {
25            inner: Arc::new(SessionGraphData::default()),
26            cache: Arc::new(OnceLock::new()),
27        }
28    }
29}
30
31impl Clone for SessionGraph {
32    fn clone(&self) -> Self {
33        Self {
34            inner: Arc::clone(&self.inner),
35            cache: Arc::clone(&self.cache),
36        }
37    }
38}
39
40impl serde::Serialize for SessionGraph {
41    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
42    where
43        S: serde::Serializer,
44    {
45        self.inner.serialize(serializer)
46    }
47}
48
49impl<'de> serde::Deserialize<'de> for SessionGraph {
50    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
51    where
52        D: serde::Deserializer<'de>,
53    {
54        let inner = SessionGraphData::deserialize(deserializer)?;
55        Ok(Self {
56            inner: Arc::new(inner),
57            cache: Arc::new(OnceLock::new()),
58        })
59    }
60}
61
62impl Deref for SessionGraph {
63    type Target = SessionGraphData;
64
65    fn deref(&self) -> &Self::Target {
66        self.inner.as_ref()
67    }
68}
69
70#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
71pub struct SessionNodeRecord {
72    pub node_id: String,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub parent_node_id: Option<String>,
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub caused_by: Option<crate::CausalRef>,
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub agent_frame_id: Option<crate::AgentFrameId>,
79    pub timestamp: String,
80    #[serde(flatten)]
81    pub payload: SessionNodePayload,
82}
83
84#[derive(Clone, Debug)]
85pub(crate) struct SessionNodeDraft {
86    payload: SessionNodeDraftPayload,
87    caused_by: Option<crate::CausalRef>,
88}
89
90#[derive(Clone, Debug)]
91enum SessionNodeDraftPayload {
92    Message(Message),
93    Plugin {
94        plugin_type: String,
95        body: serde_json::Value,
96    },
97    ProtocolEvent(ProtocolEvent),
98}
99
100impl SessionNodeDraft {
101    pub(crate) fn message(message: Message) -> Self {
102        Self {
103            payload: SessionNodeDraftPayload::Message(message),
104            caused_by: None,
105        }
106    }
107
108    pub(crate) fn plugin(plugin_type: impl Into<String>, body: serde_json::Value) -> Self {
109        Self {
110            payload: SessionNodeDraftPayload::Plugin {
111                plugin_type: plugin_type.into(),
112                body,
113            },
114            caused_by: None,
115        }
116    }
117
118    pub(crate) fn protocol_event(event: ProtocolEvent) -> Self {
119        Self {
120            payload: SessionNodeDraftPayload::ProtocolEvent(event),
121            caused_by: None,
122        }
123    }
124
125    pub(crate) fn event(event: SessionHistoryRecord) -> Self {
126        match event {
127            SessionHistoryRecord::Conversation(record) => Self::message(record.to_message()),
128            SessionHistoryRecord::Protocol(event) => Self::protocol_event(event),
129        }
130    }
131
132    pub(crate) fn with_caused_by(mut self, caused_by: Option<crate::CausalRef>) -> Self {
133        self.caused_by = caused_by;
134        self
135    }
136}
137
138#[derive(Clone, Debug, Default, PartialEq)]
139pub struct SharedJsonValue(pub Arc<serde_json::Value>);
140
141impl SharedJsonValue {
142    pub fn new(value: serde_json::Value) -> Self {
143        Self(Arc::new(value))
144    }
145
146    pub fn to_owned(&self) -> serde_json::Value {
147        self.0.as_ref().clone()
148    }
149}
150
151impl AsRef<serde_json::Value> for SharedJsonValue {
152    fn as_ref(&self) -> &serde_json::Value {
153        self.0.as_ref()
154    }
155}
156
157impl serde::Serialize for SharedJsonValue {
158    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
159    where
160        S: serde::Serializer,
161    {
162        self.0.serialize(serializer)
163    }
164}
165
166impl<'de> serde::Deserialize<'de> for SharedJsonValue {
167    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
168    where
169        D: serde::Deserializer<'de>,
170    {
171        let value = serde_json::Value::deserialize(deserializer)?;
172        Ok(Self::new(value))
173    }
174}
175
176#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
177#[serde(tag = "kind", rename_all = "snake_case")]
178// justification: persisted graph nodes retain their public inline payload shape across storage and replay.
179#[allow(clippy::large_enum_variant)]
180pub enum SessionNodePayload {
181    Event {
182        event: SessionHistoryRecord,
183    },
184    Plugin {
185        plugin_type: String,
186        body: SharedJsonValue,
187    },
188}
189
190#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
191pub struct PersistedSessionConfig {
192    pub provider_id: String,
193    pub model: crate::ModelSpec,
194}
195
196#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
197pub struct PersistedTurnState {
198    pub turn_index: usize,
199    #[serde(default)]
200    pub token_usage: TokenUsage,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub last_prompt_usage: Option<PromptUsage>,
203    #[serde(default)]
204    pub protocol_turn_options: crate::ProtocolTurnOptions,
205}
206
207#[derive(Clone, Debug)]
208pub struct SessionMessageTreeNode {
209    pub node_id: String,
210    pub parent_message_node_id: Option<String>,
211    pub message: Message,
212    pub timestamp: String,
213    pub children: Vec<SessionMessageTreeNode>,
214    pub active: bool,
215}
216
217#[derive(Clone, Debug)]
218pub(crate) struct ActiveReadReplacement {
219    pub(crate) leaf_node_id: Option<String>,
220    pub(crate) new_tail_nodes: Vec<SessionNodeRecord>,
221    pub(crate) active_events: Vec<SessionHistoryRecord>,
222    pub(crate) active_messages: Vec<Message>,
223}
224
225#[derive(Clone, Debug)]
226pub(crate) struct SessionReadModel {
227    pub(crate) active_events: Arc<Vec<SessionHistoryRecord>>,
228    pub(crate) messages: Arc<Vec<Message>>,
229    pub(crate) prompt_render_cache: Arc<BaseRenderCache>,
230}
231
232#[derive(Clone, Debug)]
233pub(crate) struct SessionGraphAppendBuilder {
234    existing_ids: HashSet<String>,
235    leaf_node_id: Option<String>,
236    agent_frame_id: Option<crate::AgentFrameId>,
237}
238
239impl SessionGraphAppendBuilder {
240    pub(crate) fn with_agent_frame_id(
241        mut self,
242        agent_frame_id: impl Into<crate::AgentFrameId>,
243    ) -> Self {
244        self.agent_frame_id = Some(agent_frame_id.into());
245        self
246    }
247
248    pub(crate) fn agent_frame_id(&self) -> Option<&str> {
249        self.agent_frame_id.as_deref()
250    }
251
252    pub(crate) fn leaf_node_id(&self) -> Option<&String> {
253        self.leaf_node_id.as_ref()
254    }
255
256    pub(crate) fn set_leaf_node_id(&mut self, leaf_node_id: Option<String>) {
257        self.leaf_node_id = leaf_node_id;
258    }
259
260    pub(crate) fn register_existing_node_ids<'a>(
261        &mut self,
262        node_ids: impl IntoIterator<Item = &'a str>,
263    ) {
264        self.existing_ids
265            .extend(node_ids.into_iter().map(ToOwned::to_owned));
266    }
267
268    pub(crate) fn existing_node_ids(&self) -> &HashSet<String> {
269        &self.existing_ids
270    }
271
272    pub(crate) fn append_messages_at<I>(
273        &mut self,
274        messages: I,
275        timestamp: String,
276    ) -> Vec<SessionNodeRecord>
277    where
278        I: IntoIterator<Item = Message>,
279    {
280        self.append_drafts_at(
281            messages.into_iter().map(SessionNodeDraft::message),
282            timestamp,
283        )
284    }
285
286    pub(crate) fn append_events_at<I>(
287        &mut self,
288        events: I,
289        timestamp: String,
290    ) -> Vec<SessionNodeRecord>
291    where
292        I: IntoIterator<Item = SessionHistoryRecord>,
293    {
294        self.append_drafts_at(events.into_iter().map(SessionNodeDraft::event), timestamp)
295    }
296
297    pub(crate) fn append_drafts_at<I>(
298        &mut self,
299        drafts: I,
300        timestamp: String,
301    ) -> Vec<SessionNodeRecord>
302    where
303        I: IntoIterator<Item = SessionNodeDraft>,
304    {
305        let mut nodes = Vec::new();
306        for draft in drafts {
307            let parent_node_id = self.leaf_node_id.clone();
308            let (node_id, caused_by, payload) = match draft.payload {
309                SessionNodeDraftPayload::Message(mut message) => {
310                    if message.id.is_empty() {
311                        message.id = fresh_node_id("m");
312                    }
313                    let node_id = unique_message_node_id(&message.id, &self.existing_ids);
314                    let caused_by = draft
315                        .caused_by
316                        .or_else(|| causal_ref_from_message_origin(&message.origin));
317                    (
318                        node_id,
319                        caused_by,
320                        SessionNodePayload::Event {
321                            event: SessionHistoryRecord::Conversation(
322                                ConversationRecord::from_message(message),
323                            ),
324                        },
325                    )
326                }
327                SessionNodeDraftPayload::Plugin { plugin_type, body } => {
328                    let node_id = fresh_semantic_node_id("plugin", &self.existing_ids);
329                    (
330                        node_id,
331                        draft.caused_by,
332                        SessionNodePayload::Plugin {
333                            plugin_type,
334                            body: SharedJsonValue::new(body),
335                        },
336                    )
337                }
338                SessionNodeDraftPayload::ProtocolEvent(event) => {
339                    let node_id = fresh_semantic_node_id("protocol", &self.existing_ids);
340                    (
341                        node_id,
342                        draft.caused_by,
343                        SessionNodePayload::Event {
344                            event: SessionHistoryRecord::Protocol(event),
345                        },
346                    )
347                }
348            };
349            self.existing_ids.insert(node_id.clone());
350            self.leaf_node_id = Some(node_id.clone());
351            nodes.push(SessionNodeRecord {
352                node_id,
353                parent_node_id,
354                caused_by,
355                agent_frame_id: self.agent_frame_id.clone(),
356                timestamp: timestamp.clone(),
357                payload,
358            });
359        }
360        nodes
361    }
362}
363
364#[derive(Debug, Clone)]
365struct SessionGraphCache {
366    by_id: HashMap<String, usize>,
367    active_path_indices: Vec<usize>,
368    active_events: Arc<Vec<SessionHistoryRecord>>,
369    active_messages: Arc<Vec<Message>>,
370    /// Index from `Message::id` to its position in `active_messages`,
371    /// kept in sync with the vec so dedup on append is O(1) instead of an
372    /// O(n) linear scan (which made long sessions quadratic in message
373    /// count).
374    active_message_ids: HashMap<String, usize>,
375    /// Memoized render of `active_messages`. Shared with every
376    /// `MessageSequence` built off this read model so the chat projector's
377    /// per-iteration `render_prompt` walk only happens once per turn.
378    /// Replaced (not invalidated in-place) whenever `active_messages`
379    /// changes — the `Arc` identity tracks the cache's validity.
380    prompt_render_cache: Arc<BaseRenderCache>,
381}
382
383impl SessionGraphCache {
384    fn build(graph: &SessionGraph) -> Self {
385        let by_id = graph
386            .nodes
387            .iter()
388            .enumerate()
389            .map(|(idx, node)| (node.node_id.clone(), idx))
390            .collect::<HashMap<_, _>>();
391        let mut active_path_indices = Vec::new();
392        let mut current = graph
393            .leaf_node_id
394            .as_ref()
395            .and_then(|node_id| by_id.get(node_id).copied());
396        while let Some(idx) = current {
397            active_path_indices.push(idx);
398            current = graph.nodes[idx]
399                .parent_node_id
400                .as_ref()
401                .and_then(|node_id| by_id.get(node_id).copied());
402        }
403        active_path_indices.reverse();
404
405        let mut cache = Self {
406            by_id,
407            active_path_indices,
408            active_events: Arc::new(Vec::new()),
409            active_messages: Arc::new(Vec::new()),
410            active_message_ids: HashMap::new(),
411            prompt_render_cache: Arc::new(BaseRenderCache::new()),
412        };
413        cache.rebuild_read_model(graph);
414        cache
415    }
416
417    fn rebuild_read_model(&mut self, graph: &SessionGraph) {
418        let mut active_messages = Vec::with_capacity(self.active_path_indices.len());
419        let mut active_message_ids: HashMap<String, usize> =
420            HashMap::with_capacity(self.active_path_indices.len());
421        let mut active_events = Vec::with_capacity(self.active_path_indices.len());
422        for idx in &self.active_path_indices {
423            let node = &graph.nodes[*idx];
424            if let Some(event) = node.event() {
425                active_events.push(event.clone());
426            }
427            if let Some(message) = node.message() {
428                if !message.is_transient() && !active_message_ids.contains_key(&message.id) {
429                    active_message_ids.insert(message.id.clone(), active_messages.len());
430                    active_messages.push(message);
431                }
432                continue;
433            }
434        }
435        self.active_messages = Arc::new(active_messages);
436        self.active_message_ids = active_message_ids;
437        self.active_events = Arc::new(active_events);
438        self.prompt_render_cache = Arc::new(BaseRenderCache::new());
439    }
440
441    fn read_model_for_agent_frame(
442        &self,
443        graph: &SessionGraph,
444        frame_id: &str,
445        include_unscoped: bool,
446    ) -> SessionReadModel {
447        let mut active_messages = Vec::with_capacity(self.active_path_indices.len());
448        let mut active_message_ids = HashSet::new();
449        let mut active_events = Vec::with_capacity(self.active_path_indices.len());
450        for idx in &self.active_path_indices {
451            let node = &graph.nodes[*idx];
452            if !node_belongs_to_agent_frame(node, frame_id, include_unscoped) {
453                continue;
454            }
455            if let Some(event) = node.event() {
456                active_events.push(event.clone());
457            }
458            if let Some(message) = node.message() {
459                if !message.is_transient() && active_message_ids.insert(message.id.clone()) {
460                    active_messages.push(message);
461                }
462                continue;
463            }
464        }
465        SessionReadModel {
466            active_events: Arc::new(active_events),
467            messages: Arc::new(active_messages),
468            prompt_render_cache: Arc::new(BaseRenderCache::new()),
469        }
470    }
471
472    fn append_node(
473        &mut self,
474        node_index: usize,
475        node: &SessionNodeRecord,
476        previous_leaf_node_id: Option<&str>,
477    ) {
478        self.by_id.insert(node.node_id.clone(), node_index);
479        let parent_matches_leaf = node.parent_node_id.as_deref() == previous_leaf_node_id;
480        if !parent_matches_leaf {
481            return;
482        }
483        self.active_path_indices.push(node_index);
484        if let Some(event) = node.event() {
485            Arc::make_mut(&mut self.active_events).push(event.clone());
486        }
487        if let Some(message) = node.message()
488            && !message.is_transient()
489            && !self.active_message_ids.contains_key(&message.id)
490        {
491            let messages = Arc::make_mut(&mut self.active_messages);
492            self.active_message_ids
493                .insert(message.id.clone(), messages.len());
494            messages.push(message);
495            self.prompt_render_cache = Arc::new(BaseRenderCache::new());
496        }
497    }
498
499    fn reserve_append_capacity(&mut self, additional_nodes: usize, additional_messages: usize) {
500        self.by_id.reserve(additional_nodes);
501        self.active_path_indices.reserve(additional_nodes);
502        if additional_messages > 0 {
503            Arc::make_mut(&mut self.active_messages).reserve(additional_messages);
504        }
505    }
506}
507
508impl SessionNodeRecord {
509    pub fn event(&self) -> Option<&SessionHistoryRecord> {
510        match &self.payload {
511            SessionNodePayload::Event { event } => Some(event),
512            SessionNodePayload::Plugin { .. } => None,
513        }
514    }
515
516    pub fn message(&self) -> Option<Message> {
517        match self.event()? {
518            SessionHistoryRecord::Conversation(record) => Some(record.to_message()),
519            _ => None,
520        }
521    }
522
523    pub fn plugin(&self) -> Option<(&str, &serde_json::Value)> {
524        match &self.payload {
525            SessionNodePayload::Event { .. } => None,
526            SessionNodePayload::Plugin { plugin_type, body } => {
527                Some((plugin_type.as_str(), body.as_ref()))
528            }
529        }
530    }
531
532    pub fn plugin_body<T>(&self) -> Option<T>
533    where
534        T: for<'de> serde::Deserialize<'de>,
535    {
536        let (_, body) = self.plugin()?;
537        T::deserialize(body).ok()
538    }
539}
540
541impl SessionGraph {
542    pub fn append_active_read_delta(&mut self, messages: &[Message]) {
543        self.append_active_read_delta_scoped(None, messages);
544    }
545
546    pub fn append_active_read_delta_for_agent_frame(
547        &mut self,
548        agent_frame_id: &str,
549        messages: &[Message],
550    ) {
551        self.append_active_read_delta_scoped(Some(agent_frame_id), messages);
552    }
553
554    fn append_active_read_delta_scoped(
555        &mut self,
556        agent_frame_id: Option<&str>,
557        messages: &[Message],
558    ) {
559        let appendable_messages = {
560            let read_model = agent_frame_id
561                .map(|frame_id| self.read_model_for_agent_frame(frame_id, false))
562                .unwrap_or_else(|| self.read_model());
563            let mut seen_message_ids = read_model
564                .messages
565                .iter()
566                .map(|message| message.id.as_str())
567                .collect::<HashSet<_>>();
568            messages
569                .iter()
570                .filter(|message| {
571                    !message.is_transient() && seen_message_ids.insert(message.id.as_str())
572                })
573                .cloned()
574                .collect::<Vec<_>>()
575        };
576
577        self.reserve_append_capacity(appendable_messages.len(), appendable_messages.len());
578        self.append_message_batch_scoped(agent_frame_id, appendable_messages);
579    }
580
581    pub(crate) fn append_active_conversation_messages_for_agent_frame(
582        &mut self,
583        agent_frame_id: &str,
584        messages: &[Message],
585    ) {
586        self.append_active_conversation_messages_scoped(Some(agent_frame_id), messages);
587    }
588
589    fn append_active_conversation_messages_scoped(
590        &mut self,
591        agent_frame_id: Option<&str>,
592        messages: &[Message],
593    ) {
594        self.append_active_conversation_messages_scoped_at(
595            agent_frame_id,
596            messages,
597            crate::SystemClock.timestamp_rfc3339(),
598        );
599    }
600
601    pub(crate) fn append_active_conversation_messages_for_agent_frame_at(
602        &mut self,
603        agent_frame_id: &str,
604        messages: &[Message],
605        timestamp: String,
606    ) {
607        self.append_active_conversation_messages_scoped_at(
608            Some(agent_frame_id),
609            messages,
610            timestamp,
611        );
612    }
613
614    fn append_active_conversation_messages_scoped_at(
615        &mut self,
616        agent_frame_id: Option<&str>,
617        messages: &[Message],
618        timestamp: String,
619    ) {
620        let appendable_messages = messages
621            .iter()
622            .filter(|message| !message.is_transient())
623            .cloned()
624            .collect::<Vec<_>>();
625        self.reserve_append_capacity(appendable_messages.len(), appendable_messages.len());
626        self.append_message_batch_scoped_at(agent_frame_id, appendable_messages, timestamp);
627    }
628
629    pub fn from_nodes(nodes: Vec<SessionNodeRecord>, leaf_node_id: Option<String>) -> Self {
630        Self {
631            inner: Arc::new(SessionGraphData {
632                nodes,
633                leaf_node_id,
634            }),
635            cache: Arc::new(OnceLock::new()),
636        }
637    }
638
639    pub(crate) fn append_builder(&self) -> SessionGraphAppendBuilder {
640        SessionGraphAppendBuilder {
641            existing_ids: self.nodes.iter().map(|node| node.node_id.clone()).collect(),
642            leaf_node_id: self.leaf_node_id.clone(),
643            agent_frame_id: None,
644        }
645    }
646
647    fn invalidate_cache(&mut self) {
648        self.cache = Arc::new(OnceLock::new());
649    }
650
651    fn data_mut(&mut self) -> &mut SessionGraphData {
652        self.invalidate_cache();
653        Arc::make_mut(&mut self.inner)
654    }
655
656    fn reserve_append_capacity(&mut self, additional_nodes: usize, additional_messages: usize) {
657        if additional_nodes == 0 {
658            return;
659        }
660        self.detach_initialized_cache_for_append();
661        Arc::make_mut(&mut self.inner)
662            .nodes
663            .reserve(additional_nodes);
664        if let Some(cache_lock) = Arc::get_mut(&mut self.cache)
665            && let Some(cache) = cache_lock.get_mut()
666        {
667            cache.reserve_append_capacity(additional_nodes, additional_messages);
668        }
669    }
670
671    fn detach_initialized_cache_for_append(&mut self) {
672        if Arc::get_mut(&mut self.cache).is_some() {
673            return;
674        }
675        let Some(cache) = self.cache.get().cloned() else {
676            self.invalidate_cache();
677            return;
678        };
679        let lock = OnceLock::new();
680        let _ = lock.set(cache);
681        self.cache = Arc::new(lock);
682    }
683
684    fn cache(&self) -> &SessionGraphCache {
685        self.cache.get_or_init(|| SessionGraphCache::build(self))
686    }
687
688    fn append_message_batch_scoped(
689        &mut self,
690        agent_frame_id: Option<&str>,
691        messages: Vec<Message>,
692    ) {
693        self.append_message_batch_scoped_at(
694            agent_frame_id,
695            messages,
696            crate::SystemClock.timestamp_rfc3339(),
697        );
698    }
699
700    fn append_message_batch_scoped_at(
701        &mut self,
702        agent_frame_id: Option<&str>,
703        messages: Vec<Message>,
704        timestamp: String,
705    ) {
706        if messages.is_empty() {
707            return;
708        }
709        self.append_node_drafts_scoped_at(
710            agent_frame_id,
711            messages.into_iter().map(SessionNodeDraft::message),
712            timestamp,
713        );
714    }
715
716    fn append_prebuilt_nodes(&mut self, nodes: Vec<SessionNodeRecord>) {
717        if nodes.is_empty() {
718            return;
719        }
720
721        self.detach_initialized_cache_for_append();
722        if let Some(cache_lock) = Arc::get_mut(&mut self.cache)
723            && let Some(cache) = cache_lock.get_mut()
724        {
725            let data = Arc::make_mut(&mut self.inner);
726            for node in nodes {
727                let previous_leaf = data.leaf_node_id.clone();
728                let node_id = node.node_id.clone();
729                data.nodes.push(node);
730                cache.append_node(
731                    data.nodes.len() - 1,
732                    data.nodes.last().expect("just appended graph node"),
733                    previous_leaf.as_deref(),
734                );
735                data.leaf_node_id = Some(node_id);
736            }
737            return;
738        }
739
740        let data = self.data_mut();
741        for node in nodes {
742            data.leaf_node_id = Some(node.node_id.clone());
743            data.nodes.push(node);
744        }
745    }
746
747    pub fn append_message(&mut self, message: Message) -> String {
748        self.append_node_draft(SessionNodeDraft::message(message))
749    }
750
751    pub fn append_plugin(
752        &mut self,
753        plugin_type: impl Into<String>,
754        body: serde_json::Value,
755    ) -> String {
756        self.append_node_draft(SessionNodeDraft::plugin(plugin_type, body))
757    }
758
759    pub fn active_path_nodes(&self) -> Vec<&SessionNodeRecord> {
760        self.cache()
761            .active_path_indices
762            .iter()
763            .map(|idx| &self.nodes[*idx])
764            .collect()
765    }
766
767    pub(crate) fn read_model(&self) -> SessionReadModel {
768        let cache = self.cache();
769        SessionReadModel {
770            active_events: Arc::clone(&cache.active_events),
771            messages: Arc::clone(&cache.active_messages),
772            prompt_render_cache: Arc::clone(&cache.prompt_render_cache),
773        }
774    }
775
776    pub(crate) fn read_model_for_agent_frame(
777        &self,
778        frame_id: &str,
779        include_unscoped: bool,
780    ) -> SessionReadModel {
781        if frame_id.is_empty() {
782            return self.read_model();
783        }
784        self.cache()
785            .read_model_for_agent_frame(self, frame_id, include_unscoped)
786    }
787
788    pub fn append_protocol_event(&mut self, event: ProtocolEvent) -> String {
789        self.append_node_draft(SessionNodeDraft::protocol_event(event))
790    }
791
792    pub(crate) fn append_node_draft(&mut self, draft: SessionNodeDraft) -> String {
793        self.append_node_drafts([draft])
794            .into_iter()
795            .next()
796            .expect("single draft append must create one node")
797    }
798
799    pub(crate) fn append_node_drafts<I>(&mut self, drafts: I) -> Vec<String>
800    where
801        I: IntoIterator<Item = SessionNodeDraft>,
802    {
803        self.append_node_drafts_scoped_at(None, drafts, crate::SystemClock.timestamp_rfc3339())
804    }
805
806    pub(crate) fn append_node_drafts_for_agent_frame_at<I>(
807        &mut self,
808        agent_frame_id: &str,
809        drafts: I,
810        timestamp: String,
811    ) -> Vec<String>
812    where
813        I: IntoIterator<Item = SessionNodeDraft>,
814    {
815        self.append_node_drafts_scoped_at(Some(agent_frame_id), drafts, timestamp)
816    }
817
818    fn append_node_drafts_scoped_at<I>(
819        &mut self,
820        agent_frame_id: Option<&str>,
821        drafts: I,
822        timestamp: String,
823    ) -> Vec<String>
824    where
825        I: IntoIterator<Item = SessionNodeDraft>,
826    {
827        let mut builder = self.append_builder();
828        if let Some(agent_frame_id) = agent_frame_id {
829            builder = builder.with_agent_frame_id(agent_frame_id.to_string());
830        }
831        let nodes = builder.append_drafts_at(drafts, timestamp);
832        let node_ids = nodes
833            .iter()
834            .map(|node| node.node_id.clone())
835            .collect::<Vec<_>>();
836        self.append_prebuilt_nodes(nodes);
837        node_ids
838    }
839
840    pub fn user_message_count(&self) -> usize {
841        self.nodes
842            .iter()
843            .filter_map(SessionNodeRecord::message)
844            .filter(|message| matches!(message.role, MessageRole::User))
845            .count()
846    }
847
848    pub fn first_user_message(&self) -> String {
849        self.nodes
850            .iter()
851            .filter_map(SessionNodeRecord::message)
852            .find(|message| matches!(message.role, MessageRole::User))
853            .map(|message| first_message_search_text(&message))
854            .unwrap_or_default()
855    }
856
857    pub fn branch_to(&mut self, node_id: Option<String>) {
858        self.data_mut().leaf_node_id = node_id;
859    }
860
861    pub fn set_leaf_node_id(&mut self, node_id: Option<String>) {
862        self.data_mut().leaf_node_id = node_id;
863    }
864
865    pub fn push_node_record(&mut self, node: SessionNodeRecord) {
866        self.data_mut().nodes.push(node);
867    }
868
869    pub fn extend_node_records<I>(&mut self, nodes: I)
870    where
871        I: IntoIterator<Item = SessionNodeRecord>,
872    {
873        self.data_mut().nodes.extend(nodes);
874    }
875
876    /// Append nodes that extend the current active path, advancing the
877    /// leaf to the last node and updating the cache incrementally
878    /// instead of invalidating it. Use this when the appended nodes are
879    /// genuinely new descendants of the current leaf — e.g. the
880    /// turn-driver merging turn-local graph editor deltas into the base graph.
881    /// Use `extend_node_records` + `set_leaf_node_id` for store-side
882    /// replay paths that don't follow the active-path append shape.
883    pub fn extend_active_path(&mut self, nodes: Vec<SessionNodeRecord>) {
884        self.append_prebuilt_nodes(nodes);
885    }
886
887    pub fn active_path_contains(&self, node_id: &str) -> bool {
888        self.active_path_nodes()
889            .into_iter()
890            .any(|node| node.node_id == node_id)
891    }
892
893    /// If `leaf_node_id` points to a node that no longer exists in
894    /// `self.nodes` (e.g. after compaction rewrote the graph, or a
895    /// stored session referenced a node that was later purged), fall
896    /// back to the most recent message node. Returns `true` if the
897    /// leaf was repaired. Call this on load paths where an orphan
898    /// leaf would project to an empty transcript and silently drop
899    /// the user's history.
900    pub fn heal_orphaned_leaf(&mut self) -> bool {
901        if let Some(leaf) = self.leaf_node_id.as_ref()
902            && self.find_node(leaf).is_none()
903        {
904            let fallback = self
905                .nodes
906                .iter()
907                .rev()
908                .find(|node| node.message().is_some())
909                .map(|node| node.node_id.clone());
910            self.data_mut().leaf_node_id = fallback;
911            return true;
912        }
913        false
914    }
915
916    pub fn fork_current_path(&self) -> SessionGraph {
917        let path = self.active_path_nodes();
918        SessionGraph::from_nodes(
919            path.into_iter().cloned().collect(),
920            self.leaf_node_id.clone(),
921        )
922    }
923
924    pub fn find_node(&self, node_id: &str) -> Option<&SessionNodeRecord> {
925        self.cache()
926            .by_id
927            .get(node_id)
928            .and_then(|idx| self.nodes.get(*idx))
929    }
930
931    pub fn node_index(&self, node_id: &str) -> Option<usize> {
932        self.cache().by_id.get(node_id).copied()
933    }
934
935    pub fn replace_active_read_state(&mut self, messages: &[Message]) {
936        self.replace_active_read_state_scoped(None, messages);
937    }
938
939    pub fn replace_active_read_state_for_agent_frame(
940        &mut self,
941        agent_frame_id: &str,
942        messages: &[Message],
943    ) {
944        self.replace_active_read_state_scoped(Some(agent_frame_id), messages);
945    }
946
947    fn replace_active_read_state_scoped(
948        &mut self,
949        agent_frame_id: Option<&str>,
950        messages: &[Message],
951    ) {
952        let current_nodes = self.active_path_nodes();
953        let existing_ids = self
954            .nodes
955            .iter()
956            .map(|node| node.node_id.clone())
957            .collect::<HashSet<_>>();
958        let replacement = build_active_read_replacement(
959            current_nodes,
960            &existing_ids,
961            agent_frame_id,
962            messages,
963            crate::SystemClock.timestamp_rfc3339(),
964        );
965        let data = self.data_mut();
966        data.leaf_node_id = replacement.leaf_node_id;
967        data.nodes.extend(replacement.new_tail_nodes);
968    }
969
970    pub fn from_active_read_state(messages: &[Message]) -> Self {
971        let mut graph = Self::default();
972        graph.replace_active_read_state(messages);
973        graph
974    }
975
976    pub fn message_tree(&self) -> Vec<SessionMessageTreeNode> {
977        let active_message_ids = self
978            .active_path_nodes()
979            .into_iter()
980            .filter_map(|node| node.message().map(|message| message.id.clone()))
981            .collect::<HashSet<_>>();
982
983        let message_nodes = self
984            .nodes
985            .iter()
986            .filter_map(|node| {
987                let message = node.message()?.clone();
988                let parent_message_node_id =
989                    self.nearest_message_ancestor(node.parent_node_id.as_deref());
990                Some(SessionMessageTreeNode {
991                    node_id: node.node_id.clone(),
992                    parent_message_node_id,
993                    message,
994                    timestamp: node.timestamp.clone(),
995                    children: Vec::new(),
996                    active: active_message_ids.contains(&node.node_id),
997                })
998            })
999            .collect::<Vec<_>>();
1000
1001        build_tree(message_nodes)
1002    }
1003
1004    fn nearest_message_ancestor(&self, node_id: Option<&str>) -> Option<String> {
1005        let by_id = self
1006            .nodes
1007            .iter()
1008            .map(|node| (node.node_id.as_str(), node))
1009            .collect::<HashMap<_, _>>();
1010        let mut current = node_id.and_then(|id| by_id.get(id).copied());
1011        while let Some(node) = current {
1012            if node.message().is_some() {
1013                return Some(node.node_id.clone());
1014            }
1015            current = node
1016                .parent_node_id
1017                .as_deref()
1018                .and_then(|parent| by_id.get(parent).copied());
1019        }
1020        None
1021    }
1022}
1023
1024fn build_tree(mut nodes: Vec<SessionMessageTreeNode>) -> Vec<SessionMessageTreeNode> {
1025    let mut children_by_parent = HashMap::<Option<String>, Vec<SessionMessageTreeNode>>::new();
1026    for node in nodes.drain(..) {
1027        children_by_parent
1028            .entry(node.parent_message_node_id.clone())
1029            .or_default()
1030            .push(node);
1031    }
1032    let mut roots = build_tree_children(None, &mut children_by_parent);
1033    sort_tree(&mut roots);
1034    roots
1035}
1036
1037fn sort_tree(nodes: &mut [SessionMessageTreeNode]) {
1038    nodes.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
1039    for node in nodes {
1040        sort_tree(&mut node.children);
1041    }
1042}
1043
1044fn build_tree_children(
1045    parent_id: Option<String>,
1046    children_by_parent: &mut HashMap<Option<String>, Vec<SessionMessageTreeNode>>,
1047) -> Vec<SessionMessageTreeNode> {
1048    let mut children = children_by_parent.remove(&parent_id).unwrap_or_default();
1049    for child in &mut children {
1050        child.children = build_tree_children(Some(child.node_id.clone()), children_by_parent);
1051    }
1052    children
1053}
1054
1055fn node_belongs_to_agent_frame(
1056    node: &SessionNodeRecord,
1057    frame_id: &str,
1058    include_unscoped: bool,
1059) -> bool {
1060    match node.agent_frame_id.as_deref() {
1061        Some(node_frame_id) => node_frame_id == frame_id,
1062        None => include_unscoped,
1063    }
1064}
1065
1066pub(crate) fn build_active_read_replacement<'a>(
1067    current_nodes: impl IntoIterator<Item = &'a SessionNodeRecord>,
1068    existing_node_ids: &HashSet<String>,
1069    agent_frame_id: Option<&str>,
1070    messages: &[Message],
1071    timestamp: String,
1072) -> ActiveReadReplacement {
1073    let target = messages
1074        .iter()
1075        .filter(|message| !message.is_transient())
1076        .collect::<Vec<_>>();
1077
1078    let mut active_events = Vec::new();
1079    let mut active_messages = Vec::new();
1080    let mut active_message_ids = HashSet::new();
1081    let mut seen_active_read_keys = HashSet::new();
1082    let mut target_idx = 0usize;
1083    let mut leaf_node_id = None;
1084    for node in current_nodes {
1085        if node
1086            .message()
1087            .map(|message| message.is_transient())
1088            .unwrap_or(false)
1089        {
1090            continue;
1091        }
1092        if let Some(key) = recognized_active_read_key(node) {
1093            if !seen_active_read_keys.insert(key.clone()) {
1094                continue;
1095            }
1096            let Some(target_item) = target.get(target_idx) else {
1097                break;
1098            };
1099            if key != format!("message:{}", target_item.id) {
1100                break;
1101            }
1102            push_active_read_node(
1103                node,
1104                &mut active_events,
1105                &mut active_messages,
1106                &mut active_message_ids,
1107            );
1108            leaf_node_id = Some(node.node_id.clone());
1109            target_idx += 1;
1110        } else {
1111            push_active_read_node(
1112                node,
1113                &mut active_events,
1114                &mut active_messages,
1115                &mut active_message_ids,
1116            );
1117            leaf_node_id = Some(node.node_id.clone());
1118        }
1119    }
1120
1121    let mut new_node_ids = HashSet::new();
1122    let mut new_tail_nodes = Vec::new();
1123
1124    for message in target.into_iter().skip(target_idx) {
1125        let parent_node_id = leaf_node_id.clone();
1126        let node_id =
1127            unique_message_node_id_for_replacement(&message.id, existing_node_ids, &new_node_ids);
1128        let node = SessionNodeRecord {
1129            node_id,
1130            parent_node_id,
1131            caused_by: causal_ref_from_message_origin(&message.origin),
1132            agent_frame_id: agent_frame_id.map(ToOwned::to_owned),
1133            timestamp: timestamp.clone(),
1134            payload: SessionNodePayload::Event {
1135                event: SessionHistoryRecord::Conversation(ConversationRecord::from_message(
1136                    message.clone(),
1137                )),
1138            },
1139        };
1140        new_node_ids.insert(node.node_id.clone());
1141        leaf_node_id = Some(node.node_id.clone());
1142        push_active_read_node(
1143            &node,
1144            &mut active_events,
1145            &mut active_messages,
1146            &mut active_message_ids,
1147        );
1148        new_tail_nodes.push(node);
1149    }
1150
1151    ActiveReadReplacement {
1152        leaf_node_id,
1153        new_tail_nodes,
1154        active_events,
1155        active_messages,
1156    }
1157}
1158
1159fn push_active_read_node(
1160    node: &SessionNodeRecord,
1161    active_events: &mut Vec<SessionHistoryRecord>,
1162    active_messages: &mut Vec<Message>,
1163    active_message_ids: &mut HashSet<String>,
1164) {
1165    if let Some(event) = node.event() {
1166        active_events.push(event.clone());
1167    }
1168    if let Some(message) = node.message()
1169        && !message.is_transient()
1170        && active_message_ids.insert(message.id.clone())
1171    {
1172        active_messages.push(message);
1173    }
1174}
1175
1176fn recognized_active_read_key(node: &SessionNodeRecord) -> Option<String> {
1177    match &node.payload {
1178        SessionNodePayload::Event { event } => match event {
1179            SessionHistoryRecord::Conversation(record) => Some(format!("message:{}", record.id)),
1180            _ => None,
1181        },
1182        SessionNodePayload::Plugin { .. } => None,
1183    }
1184}
1185
1186fn causal_ref_from_message_origin(
1187    origin: &Option<crate::MessageOrigin>,
1188) -> Option<crate::CausalRef> {
1189    let Some(crate::MessageOrigin::Process {
1190        process_id,
1191        sequence,
1192        ..
1193    }) = origin
1194    else {
1195        return None;
1196    };
1197    Some(crate::CausalRef::ProcessEvent {
1198        process_id: process_id.clone(),
1199        sequence: *sequence,
1200    })
1201}
1202
1203fn fresh_semantic_node_id(prefix: &str, existing_ids: &HashSet<String>) -> String {
1204    loop {
1205        let candidate = format!("{prefix}:{}", uuid::Uuid::new_v4().simple());
1206        if !existing_ids.contains(&candidate) {
1207            return candidate;
1208        }
1209    }
1210}
1211
1212fn unique_message_node_id(message_id: &str, existing_ids: &HashSet<String>) -> String {
1213    if !existing_ids.contains(message_id) {
1214        return message_id.to_string();
1215    }
1216    let base = format!("message:{message_id}");
1217    if !existing_ids.contains(&base) {
1218        return base;
1219    }
1220    for suffix in 2.. {
1221        let candidate = format!("{base}:{suffix}");
1222        if !existing_ids.contains(&candidate) {
1223            return candidate;
1224        }
1225    }
1226    unreachable!("message node id space exhausted")
1227}
1228
1229fn unique_message_node_id_for_replacement(
1230    message_id: &str,
1231    existing_ids: &HashSet<String>,
1232    new_ids: &HashSet<String>,
1233) -> String {
1234    if !existing_ids.contains(message_id) && !new_ids.contains(message_id) {
1235        return message_id.to_string();
1236    }
1237    let base = format!("message:{message_id}");
1238    if !existing_ids.contains(&base) && !new_ids.contains(&base) {
1239        return base;
1240    }
1241    for suffix in 2.. {
1242        let candidate = format!("{base}:{suffix}");
1243        if !existing_ids.contains(&candidate) && !new_ids.contains(&candidate) {
1244            return candidate;
1245        }
1246    }
1247    unreachable!("message node id space exhausted")
1248}
1249
1250fn fresh_node_id(prefix: &str) -> String {
1251    format!("{prefix}{}", uuid::Uuid::new_v4().simple())
1252}
1253
1254fn first_message_search_text(message: &Message) -> String {
1255    message
1256        .parts
1257        .iter()
1258        .filter_map(|part| match part.kind {
1259            crate::PartKind::ToolCall | crate::PartKind::ToolResult => None,
1260            crate::PartKind::Attachment => Some("[Attachment]".to_string()),
1261            _ => (!part.content.trim().is_empty()).then(|| part.content.clone()),
1262        })
1263        .collect::<Vec<_>>()
1264        .join("\n\n")
1265        .trim()
1266        .to_string()
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272    use crate::{Part, PartKind, PruneState, shared_parts};
1273
1274    fn text_message(id: &str, role: MessageRole, content: &str) -> Message {
1275        Message {
1276            id: id.to_string(),
1277            role,
1278            parts: shared_parts(vec![Part {
1279                id: format!("{id}.p0"),
1280                kind: PartKind::Text,
1281                content: content.to_string(),
1282                attachment: None,
1283                tool_call_id: None,
1284                tool_name: None,
1285                tool_replay: None,
1286                prune_state: PruneState::Intact,
1287                reasoning_meta: None,
1288                response_meta: None,
1289            }]),
1290            origin: None,
1291        }
1292    }
1293
1294    fn protocol_event() -> ProtocolEvent {
1295        ProtocolEvent::typed("test_protocol", serde_json::json!({"step": "started"}))
1296            .expect("protocol event serializes")
1297    }
1298
1299    #[test]
1300    fn typed_append_node_ids_use_semantic_prefixes() {
1301        let mut graph = SessionGraph::default();
1302
1303        let message_id = graph.append_message(text_message("m1", MessageRole::User, "hello"));
1304        let protocol_id = graph.append_protocol_event(protocol_event());
1305        let plugin_id = graph.append_plugin("example", serde_json::json!({"ok": true}));
1306
1307        assert_eq!(message_id, "m1");
1308        assert!(protocol_id.starts_with("protocol:"));
1309        assert!(plugin_id.starts_with("plugin:"));
1310    }
1311
1312    #[test]
1313    fn active_read_replacement_persists_messages_only() {
1314        let message = text_message("m1", MessageRole::User, "hello");
1315        let graph = SessionGraph::from_active_read_state(&[message]);
1316
1317        assert_eq!(graph.nodes.len(), 1);
1318        assert!(matches!(
1319            graph.nodes[0].event(),
1320            Some(SessionHistoryRecord::Conversation(_))
1321        ));
1322    }
1323
1324    #[test]
1325    fn graph_writers_do_not_put_active_read_events_under_plugin_ids() {
1326        let mut graph = SessionGraph::default();
1327        graph.append_message(text_message("m1", MessageRole::User, "hello"));
1328        graph.append_protocol_event(protocol_event());
1329        graph.append_plugin("example", serde_json::json!({"ok": true}));
1330
1331        for node in &graph.nodes {
1332            match node.event() {
1333                Some(SessionHistoryRecord::Conversation(_)) => {
1334                    assert!(!node.node_id.starts_with("plugin:"), "{:?}", node);
1335                }
1336                Some(SessionHistoryRecord::Protocol(_)) => {
1337                    assert!(node.node_id.starts_with("protocol:"), "{:?}", node);
1338                }
1339                None => {
1340                    assert!(node.node_id.starts_with("plugin:"), "{:?}", node);
1341                }
1342            }
1343        }
1344    }
1345}