Skip to main content

meerkat_core/
session.rs

1//! Session management for Meerkat
2//!
3//! A session represents a conversation history that can be persisted and resumed.
4//!
5//! # Performance
6//!
7//! Sessions use Arc-based copy-on-write for message storage:
8//! - `fork()` shares the message buffer (O(1), no clone)
9//! - Mutation (push) triggers CoW only when refcount > 1
10//! - `push_batch()` adds multiple messages with a single timestamp update
11
12use crate::Provider;
13use crate::peer_meta::PeerMeta;
14use crate::service::{AppendSystemContextRequest, MobToolAuthorityContext};
15use crate::time_compat::SystemTime;
16use crate::tool_scope::ToolFilter;
17use crate::types::{ContentInput, Message, SessionId, ToolDef, ToolProvenance, ToolResult, Usage};
18use serde::{Deserialize, Deserializer, Serialize, Serializer};
19use std::collections::{BTreeMap, BTreeSet, HashMap};
20use std::sync::Arc;
21
22/// Current session format version
23pub const SESSION_VERSION: u32 = 1;
24
25/// A conversation session with full history
26///
27/// Uses Arc<Vec<Message>> internally for efficient forking (copy-on-write).
28#[derive(Debug, Clone)]
29pub struct Session {
30    /// Format version for migrations
31    version: u32,
32    /// Unique identifier
33    id: SessionId,
34    /// All messages in order (Arc for CoW on fork)
35    pub(crate) messages: Arc<Vec<Message>>,
36    /// When the session was created
37    created_at: SystemTime,
38    /// When the session was last updated
39    updated_at: SystemTime,
40    /// Arbitrary metadata
41    metadata: serde_json::Map<String, serde_json::Value>,
42    /// Cumulative token usage across all LLM calls in this session
43    usage: Usage,
44}
45
46/// Serde helper for Session serialization (flattens Arc)
47#[derive(Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49struct SessionSerde {
50    #[serde(default = "default_version")]
51    version: u32,
52    id: SessionId,
53    messages: Vec<Message>,
54    created_at: SystemTime,
55    updated_at: SystemTime,
56    #[serde(default)]
57    metadata: serde_json::Map<String, serde_json::Value>,
58    #[serde(default)]
59    usage: Usage,
60}
61
62impl Serialize for Session {
63    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
64    where
65        S: Serializer,
66    {
67        let serde_repr = SessionSerde {
68            version: self.version,
69            id: self.id.clone(),
70            messages: (*self.messages).clone(),
71            created_at: self.created_at,
72            updated_at: self.updated_at,
73            metadata: self.metadata.clone(),
74            usage: self.usage.clone(),
75        };
76        serde_repr.serialize(serializer)
77    }
78}
79
80impl<'de> Deserialize<'de> for Session {
81    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
82    where
83        D: Deserializer<'de>,
84    {
85        let serde_repr = SessionSerde::deserialize(deserializer)?;
86        Ok(Session {
87            version: serde_repr.version,
88            id: serde_repr.id,
89            messages: Arc::new(serde_repr.messages),
90            created_at: serde_repr.created_at,
91            updated_at: serde_repr.updated_at,
92            metadata: serde_repr.metadata,
93            usage: serde_repr.usage,
94        })
95    }
96}
97
98fn default_version() -> u32 {
99    SESSION_VERSION
100}
101
102/// Metadata key used to store durable system-context control state.
103pub const SESSION_SYSTEM_CONTEXT_STATE_KEY: &str = "session_system_context_state";
104
105/// Metadata key used to store deferred-turn control state.
106pub const SESSION_DEFERRED_TURN_STATE_KEY: &str = "session_deferred_turn_state";
107
108/// Metadata key used to store recoverable build-only session state.
109pub const SESSION_BUILD_STATE_KEY: &str = "session_build_state";
110
111/// Metadata key used to store durable session-local tool visibility intent.
112pub const SESSION_TOOL_VISIBILITY_STATE_KEY: &str = "session_tool_visibility_state_v1";
113
114/// Canonical separator between appended runtime system-context blocks.
115pub const SYSTEM_CONTEXT_SEPARATOR: &str = "\n\n---\n\n";
116
117/// Durable control state for runtime system-context append requests.
118#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120pub struct SessionSystemContextState {
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub pending: Vec<PendingSystemContextAppend>,
123    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
124    pub seen: std::collections::BTreeMap<String, SeenSystemContextKey>,
125}
126
127/// Pending append request accepted by the control plane but not yet applied at an LLM boundary.
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
129#[serde(rename_all = "snake_case")]
130pub struct PendingSystemContextAppend {
131    pub text: String,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub source: Option<String>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub idempotency_key: Option<String>,
136    pub accepted_at: SystemTime,
137}
138
139/// Durable control state for deferred first-turn prompt and staged callback tool results.
140#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
141#[serde(rename_all = "snake_case")]
142pub struct SessionDeferredTurnState {
143    #[serde(default, skip_serializing_if = "DeferredFirstTurnPhase::is_inactive")]
144    pub first_turn_phase: DeferredFirstTurnPhase,
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub pending_initial_prompt: Option<PendingDeferredPrompt>,
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub pending_tool_results: Vec<PendingToolResultsMessage>,
149}
150
151/// Canonical lifecycle phase for the session's deferred first turn.
152#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
153#[serde(rename_all = "snake_case")]
154pub enum DeferredFirstTurnPhase {
155    /// The session was not created in deferred-first-turn mode.
156    #[default]
157    Inactive,
158    /// The session exists durably but the first turn has not started yet.
159    Pending,
160    /// The first turn has started; build-only overrides are no longer legal.
161    Consumed,
162}
163
164impl DeferredFirstTurnPhase {
165    pub fn is_inactive(&self) -> bool {
166        matches!(self, Self::Inactive)
167    }
168}
169
170fn is_default_hook_run_overrides(value: &crate::HookRunOverrides) -> bool {
171    value == &crate::HookRunOverrides::default()
172}
173
174fn is_default_call_timeout_override(value: &crate::CallTimeoutOverride) -> bool {
175    value == &crate::CallTimeoutOverride::default()
176}
177
178fn is_tool_filter_all(value: &ToolFilter) -> bool {
179    matches!(value, ToolFilter::All)
180}
181
182fn is_zero(value: &u64) -> bool {
183    *value == 0
184}
185
186/// Persisted witness for a durable tool-visibility name.
187#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
188#[serde(rename_all = "snake_case")]
189pub struct ToolVisibilityWitness {
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub stable_owner_key: Option<String>,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub last_seen_provenance: Option<ToolProvenance>,
194}
195
196/// Canonical durable session-local tool visibility intent.
197#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
198#[serde(rename_all = "snake_case")]
199pub struct SessionToolVisibilityState {
200    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
201    pub inherited_base_filter: ToolFilter,
202    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
203    pub active_filter: ToolFilter,
204    #[serde(default, skip_serializing_if = "is_tool_filter_all")]
205    pub staged_filter: ToolFilter,
206    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
207    pub active_requested_deferred_names: BTreeSet<String>,
208    #[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
209    pub staged_requested_deferred_names: BTreeSet<String>,
210    #[serde(default, skip_serializing_if = "is_zero")]
211    pub active_revision: u64,
212    #[serde(default, skip_serializing_if = "is_zero")]
213    pub staged_revision: u64,
214    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
215    pub requested_witnesses: BTreeMap<String, ToolVisibilityWitness>,
216    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
217    pub filter_witnesses: BTreeMap<String, ToolVisibilityWitness>,
218}
219
220/// Durable build-only session state required to faithfully recover and rebuild
221/// a persisted session without surface-local shadow config.
222#[derive(Debug, Clone, Serialize, Deserialize, Default)]
223#[serde(rename_all = "snake_case")]
224pub struct SessionBuildState {
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub system_prompt: Option<String>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub output_schema: Option<crate::OutputSchema>,
229    #[serde(default, skip_serializing_if = "is_default_hook_run_overrides")]
230    pub hooks_override: crate::HookRunOverrides,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub budget_limits: Option<crate::BudgetLimits>,
233    #[serde(default, skip_serializing_if = "Vec::is_empty")]
234    pub recoverable_tool_defs: Vec<ToolDef>,
235    #[serde(default, skip_serializing_if = "Vec::is_empty")]
236    pub silent_comms_intents: Vec<String>,
237    #[serde(default, skip_serializing_if = "Option::is_none")]
238    pub max_inline_peer_notifications: Option<i32>,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub app_context: Option<serde_json::Value>,
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub additional_instructions: Option<Vec<String>>,
243    #[serde(default, skip_serializing_if = "Option::is_none")]
244    pub shell_env: Option<HashMap<String, String>>,
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub mob_tool_authority_context: Option<MobToolAuthorityContext>,
247    #[serde(default, skip_serializing_if = "is_default_call_timeout_override")]
248    pub call_timeout_override: crate::CallTimeoutOverride,
249}
250
251/// Deferred create-time prompt staged for the next turn.
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253#[serde(rename_all = "snake_case")]
254pub struct PendingDeferredPrompt {
255    pub prompt: ContentInput,
256    pub accepted_at: SystemTime,
257}
258
259/// Staged callback tool results waiting to be admitted on the next turn seam.
260#[derive(Debug, Clone, Serialize, Deserialize)]
261#[serde(rename_all = "snake_case")]
262pub struct PendingToolResultsMessage {
263    pub results: Vec<ToolResult>,
264    pub accepted_at: SystemTime,
265}
266
267impl PartialEq for PendingToolResultsMessage {
268    fn eq(&self, other: &Self) -> bool {
269        self.accepted_at == other.accepted_at
270            && serde_json::to_value(&self.results).ok() == serde_json::to_value(&other.results).ok()
271    }
272}
273
274/// Seen idempotency-key entry for system-context append requests.
275#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
276#[serde(rename_all = "snake_case")]
277pub struct SeenSystemContextKey {
278    pub text: String,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub source: Option<String>,
281    pub state: SeenSystemContextState,
282}
283
284/// Lifecycle state for an accepted idempotency key.
285#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
286#[serde(rename_all = "snake_case")]
287pub enum SeenSystemContextState {
288    Pending,
289    Applied,
290}
291
292impl SessionSystemContextState {
293    /// Stage an append request, enforcing per-session idempotency.
294    pub fn stage_append(
295        &mut self,
296        req: &AppendSystemContextRequest,
297        accepted_at: SystemTime,
298    ) -> Result<crate::service::AppendSystemContextStatus, SystemContextStageError> {
299        let text = req.text.trim();
300        if text.is_empty() {
301            return Err(SystemContextStageError::InvalidRequest(
302                "system context text must not be empty".to_string(),
303            ));
304        }
305
306        if let Some(key) = req.idempotency_key.as_ref() {
307            match self.seen.get(key) {
308                Some(existing)
309                    if existing.text == text
310                        && existing.source.as_deref() == req.source.as_deref() =>
311                {
312                    return Ok(crate::service::AppendSystemContextStatus::Duplicate);
313                }
314                Some(existing) => {
315                    return Err(SystemContextStageError::Conflict {
316                        key: key.clone(),
317                        existing_text: existing.text.clone(),
318                        existing_source: existing.source.clone(),
319                    });
320                }
321                None => {}
322            }
323        }
324
325        let append = PendingSystemContextAppend {
326            text: text.to_string(),
327            source: req.source.clone(),
328            idempotency_key: req.idempotency_key.clone(),
329            accepted_at,
330        };
331        if let Some(key) = req.idempotency_key.as_ref() {
332            self.seen.insert(
333                key.clone(),
334                SeenSystemContextKey {
335                    text: append.text.clone(),
336                    source: append.source.clone(),
337                    state: SeenSystemContextState::Pending,
338                },
339            );
340        }
341        self.pending.push(append);
342        Ok(crate::service::AppendSystemContextStatus::Staged)
343    }
344
345    /// Mark all currently-pending appends as applied and clear the pending queue.
346    pub fn mark_pending_applied(&mut self) {
347        for pending in &self.pending {
348            if let Some(key) = pending.idempotency_key.as_ref()
349                && let Some(seen) = self.seen.get_mut(key)
350            {
351                seen.state = SeenSystemContextState::Applied;
352            }
353        }
354        self.pending.clear();
355    }
356}
357
358impl SessionDeferredTurnState {
359    /// Mark that this session has a deferred first turn waiting to start.
360    pub fn mark_initial_turn_pending(&mut self) {
361        self.first_turn_phase = DeferredFirstTurnPhase::Pending;
362    }
363
364    /// Mark the deferred first turn as started.
365    ///
366    /// Returns true when the phase transitioned from `Pending`.
367    pub fn mark_initial_turn_started(&mut self) -> bool {
368        let was_pending = matches!(self.first_turn_phase, DeferredFirstTurnPhase::Pending);
369        if was_pending {
370            self.first_turn_phase = DeferredFirstTurnPhase::Consumed;
371        }
372        was_pending
373    }
374
375    /// Restore the deferred first-turn pending phase after a failed pre-run setup.
376    pub fn restore_initial_turn_pending(&mut self) {
377        self.first_turn_phase = DeferredFirstTurnPhase::Pending;
378    }
379
380    /// Whether build-only first-turn overrides are still legal for this session.
381    pub fn allows_initial_turn_overrides(&self) -> bool {
382        matches!(self.first_turn_phase, DeferredFirstTurnPhase::Pending)
383    }
384
385    /// Stage the create-time prompt for a later first turn.
386    pub fn stage_initial_prompt(&mut self, prompt: ContentInput, accepted_at: SystemTime) {
387        if !prompt.has_images() && prompt.text_content().trim().is_empty() {
388            self.pending_initial_prompt = None;
389            return;
390        }
391
392        self.pending_initial_prompt = Some(PendingDeferredPrompt {
393            prompt,
394            accepted_at,
395        });
396    }
397
398    /// Stage one callback tool-results message for the next turn.
399    pub fn stage_tool_results(
400        &mut self,
401        results: Vec<ToolResult>,
402        accepted_at: SystemTime,
403    ) -> usize {
404        if results.is_empty() {
405            return 0;
406        }
407
408        let accepted = results.len();
409        self.pending_tool_results.push(PendingToolResultsMessage {
410            results,
411            accepted_at,
412        });
413        accepted
414    }
415
416    /// Consume the staged initial prompt, if any.
417    pub fn take_initial_prompt(&mut self) -> Option<ContentInput> {
418        self.pending_initial_prompt
419            .take()
420            .map(|pending| pending.prompt)
421    }
422
423    /// Consume all staged callback tool-results messages.
424    pub fn take_tool_results(&mut self) -> Vec<PendingToolResultsMessage> {
425        std::mem::take(&mut self.pending_tool_results)
426    }
427
428    /// Whether any callback tool results are currently staged.
429    pub fn has_pending_tool_results(&self) -> bool {
430        !self.pending_tool_results.is_empty()
431    }
432}
433
434/// Failure when staging a system-context append request.
435#[derive(Debug, Clone, PartialEq, Eq)]
436pub enum SystemContextStageError {
437    InvalidRequest(String),
438    Conflict {
439        key: String,
440        existing_text: String,
441        existing_source: Option<String>,
442    },
443}
444
445fn render_system_context_block(append: &PendingSystemContextAppend) -> String {
446    let mut rendered = String::from("[Runtime System Context]");
447    if let Some(source) = &append.source {
448        rendered.push_str("\nsource: ");
449        rendered.push_str(source);
450    }
451    rendered.push_str("\n\n");
452    rendered.push_str(&append.text);
453    rendered
454}
455
456impl Session {
457    /// Create a new empty session
458    pub fn new() -> Self {
459        let now = SystemTime::now();
460        Self {
461            version: SESSION_VERSION,
462            id: SessionId::new(),
463            messages: Arc::new(Vec::new()),
464            created_at: now,
465            updated_at: now,
466            metadata: serde_json::Map::new(),
467            usage: Usage::default(),
468        }
469    }
470
471    /// Create a session with a specific ID (for loading)
472    pub fn with_id(id: SessionId) -> Self {
473        let mut session = Self::new();
474        session.id = id;
475        session
476    }
477
478    /// Get the session ID
479    pub fn id(&self) -> &SessionId {
480        &self.id
481    }
482
483    /// Get the session version
484    pub fn version(&self) -> u32 {
485        self.version
486    }
487
488    /// Get all messages
489    pub fn messages(&self) -> &[Message] {
490        &self.messages
491    }
492
493    /// Get mutable access to messages (triggers CoW if Arc is shared)
494    pub fn messages_mut(&mut self) -> &mut Vec<Message> {
495        Arc::make_mut(&mut self.messages)
496    }
497
498    /// Get creation time
499    pub fn created_at(&self) -> SystemTime {
500        self.created_at
501    }
502
503    /// Get last update time
504    pub fn updated_at(&self) -> SystemTime {
505        self.updated_at
506    }
507
508    /// Add a message to the session
509    ///
510    /// Updates the timestamp. For adding multiple messages, prefer `push_batch`.
511    pub fn push(&mut self, message: Message) {
512        Arc::make_mut(&mut self.messages).push(message);
513        self.updated_at = SystemTime::now();
514    }
515
516    /// Add multiple messages in one operation (single timestamp update)
517    ///
518    /// More efficient than multiple `push` calls when adding many messages.
519    pub fn push_batch(&mut self, messages: Vec<Message>) {
520        if messages.is_empty() {
521            return;
522        }
523        let inner = Arc::make_mut(&mut self.messages);
524        inner.extend(messages);
525        self.updated_at = SystemTime::now();
526    }
527
528    /// Explicitly update the timestamp
529    ///
530    /// Call this after bulk operations that don't update timestamps automatically.
531    pub fn touch(&mut self) {
532        self.updated_at = SystemTime::now();
533    }
534
535    /// Whether the conversation has a pending turn boundary.
536    ///
537    /// Returns `true` if the last message is `User` or `ToolResults`, meaning
538    /// the conversation is waiting for an assistant turn and `run_pending` can
539    /// resume without a new user message.
540    pub fn has_pending_boundary(&self) -> bool {
541        self.messages
542            .last()
543            .is_some_and(|m| matches!(m, Message::User(_) | Message::ToolResults { .. }))
544    }
545
546    /// Get the last N messages
547    pub fn last_n(&self, n: usize) -> &[Message] {
548        let start = self.messages.len().saturating_sub(n);
549        &self.messages[start..]
550    }
551
552    /// Count total tokens used.
553    pub fn total_tokens(&self) -> u64 {
554        self.usage.total_tokens()
555    }
556
557    /// Get total usage statistics for the session.
558    pub fn total_usage(&self) -> Usage {
559        self.usage.clone()
560    }
561
562    /// Update cumulative usage after an LLM call.
563    pub fn record_usage(&mut self, turn_usage: Usage) {
564        self.usage.add(&turn_usage);
565        self.updated_at = SystemTime::now();
566    }
567
568    /// Set a system prompt (adds or replaces System message at start)
569    pub fn set_system_prompt(&mut self, prompt: String) {
570        use crate::types::SystemMessage;
571
572        let inner = Arc::make_mut(&mut self.messages);
573        // Check if first message is system
574        if let Some(Message::System(_)) = inner.first() {
575            inner[0] = Message::System(SystemMessage { content: prompt });
576        } else {
577            inner.insert(0, Message::System(SystemMessage { content: prompt }));
578        }
579        self.updated_at = SystemTime::now();
580    }
581
582    /// Append one or more runtime system-context blocks to the canonical system prompt.
583    pub fn append_system_context_blocks(&mut self, appends: &[PendingSystemContextAppend]) {
584        if appends.is_empty() {
585            return;
586        }
587
588        let rendered = appends
589            .iter()
590            .map(render_system_context_block)
591            .collect::<Vec<_>>()
592            .join(SYSTEM_CONTEXT_SEPARATOR);
593
594        let next = match self.messages.first() {
595            Some(Message::System(sys)) if !sys.content.is_empty() => {
596                format!("{}{}{}", sys.content, SYSTEM_CONTEXT_SEPARATOR, rendered)
597            }
598            _ => rendered,
599        };
600        self.set_system_prompt(next);
601    }
602
603    /// Get the last assistant message text content.
604    pub fn last_assistant_text(&self) -> Option<String> {
605        self.messages.iter().rev().find_map(|m| match m {
606            Message::BlockAssistant(a) => {
607                let mut buf = String::new();
608                for block in &a.blocks {
609                    if let crate::types::AssistantBlock::Text { text, .. } = block {
610                        buf.push_str(text);
611                    }
612                }
613                if buf.is_empty() { None } else { Some(buf) }
614            }
615            Message::Assistant(a) if !a.content.is_empty() => Some(a.content.clone()),
616            _ => None,
617        })
618    }
619
620    /// Count tool calls made
621    pub fn tool_call_count(&self) -> usize {
622        self.messages
623            .iter()
624            .filter_map(|m| match m {
625                Message::BlockAssistant(a) => Some(
626                    a.blocks
627                        .iter()
628                        .filter(|b| matches!(b, crate::types::AssistantBlock::ToolUse { .. }))
629                        .count(),
630                ),
631                Message::Assistant(a) => Some(a.tool_calls.len()),
632                _ => None,
633            })
634            .sum()
635    }
636
637    /// Get metadata
638    pub fn metadata(&self) -> &serde_json::Map<String, serde_json::Value> {
639        &self.metadata
640    }
641
642    /// Set a metadata value
643    pub fn set_metadata(&mut self, key: &str, value: serde_json::Value) {
644        self.metadata.insert(key.to_string(), value);
645        self.updated_at = SystemTime::now();
646    }
647
648    /// Remove a metadata value.
649    pub fn remove_metadata(&mut self, key: &str) {
650        self.metadata.remove(key);
651        self.updated_at = SystemTime::now();
652    }
653
654    /// Store SessionMetadata in the session metadata map.
655    pub fn set_session_metadata(
656        &mut self,
657        metadata: SessionMetadata,
658    ) -> Result<(), serde_json::Error> {
659        let value = serde_json::to_value(metadata)?;
660        self.set_metadata(SESSION_METADATA_KEY, value);
661        Ok(())
662    }
663
664    /// Load SessionMetadata from the session metadata map.
665    pub fn session_metadata(&self) -> Option<SessionMetadata> {
666        self.metadata
667            .get(SESSION_METADATA_KEY)
668            .and_then(|value| serde_json::from_value(value.clone()).ok())
669    }
670
671    /// Store durable system-context control state in the session metadata map.
672    pub fn set_system_context_state(
673        &mut self,
674        state: SessionSystemContextState,
675    ) -> Result<(), serde_json::Error> {
676        let value = serde_json::to_value(state)?;
677        self.set_metadata(SESSION_SYSTEM_CONTEXT_STATE_KEY, value);
678        Ok(())
679    }
680
681    /// Load durable system-context control state from the session metadata map.
682    pub fn system_context_state(&self) -> Option<SessionSystemContextState> {
683        self.metadata
684            .get(SESSION_SYSTEM_CONTEXT_STATE_KEY)
685            .and_then(|value| serde_json::from_value(value.clone()).ok())
686    }
687
688    /// Store durable deferred-turn control state in the session metadata map.
689    pub fn set_deferred_turn_state(
690        &mut self,
691        state: SessionDeferredTurnState,
692    ) -> Result<(), serde_json::Error> {
693        let value = serde_json::to_value(state)?;
694        self.set_metadata(SESSION_DEFERRED_TURN_STATE_KEY, value);
695        Ok(())
696    }
697
698    /// Load durable deferred-turn control state from the session metadata map.
699    pub fn deferred_turn_state(&self) -> Option<SessionDeferredTurnState> {
700        self.metadata
701            .get(SESSION_DEFERRED_TURN_STATE_KEY)
702            .and_then(|value| serde_json::from_value(value.clone()).ok())
703    }
704
705    /// Store recoverable build-only session state in the session metadata map.
706    pub fn set_build_state(&mut self, state: SessionBuildState) -> Result<(), serde_json::Error> {
707        let value = serde_json::to_value(state)?;
708        self.set_metadata(SESSION_BUILD_STATE_KEY, value);
709        Ok(())
710    }
711
712    /// Load recoverable build-only session state from the session metadata map.
713    pub fn build_state(&self) -> Option<SessionBuildState> {
714        self.metadata
715            .get(SESSION_BUILD_STATE_KEY)
716            .and_then(|value| serde_json::from_value(value.clone()).ok())
717    }
718
719    /// Store durable tool-visibility control state in the session metadata map.
720    pub fn set_tool_visibility_state(
721        &mut self,
722        state: SessionToolVisibilityState,
723    ) -> Result<(), serde_json::Error> {
724        let value = serde_json::to_value(state)?;
725        self.set_metadata(SESSION_TOOL_VISIBILITY_STATE_KEY, value);
726        Ok(())
727    }
728
729    /// Load durable tool-visibility control state from the session metadata map.
730    pub fn tool_visibility_state(&self) -> Option<SessionToolVisibilityState> {
731        self.metadata
732            .get(SESSION_TOOL_VISIBILITY_STATE_KEY)
733            .and_then(|value| serde_json::from_value(value.clone()).ok())
734    }
735
736    /// Store typed mob operator authority inside canonical build-state metadata.
737    pub fn set_mob_tool_authority_context(
738        &mut self,
739        authority_context: Option<MobToolAuthorityContext>,
740    ) -> Result<(), serde_json::Error> {
741        let mut build_state = self.build_state().unwrap_or_default();
742        build_state.mob_tool_authority_context = authority_context;
743        self.set_build_state(build_state)
744    }
745
746    /// Load typed mob operator authority from canonical build-state metadata.
747    pub fn mob_tool_authority_context(&self) -> Option<MobToolAuthorityContext> {
748        self.build_state()
749            .and_then(|state| state.mob_tool_authority_context)
750    }
751
752    /// Fork the session at a specific message index
753    ///
754    /// Creates a new session with a subset of messages. The messages are copied
755    /// (not shared) since the new session has a different prefix.
756    pub fn fork_at(&self, index: usize) -> Self {
757        let now = SystemTime::now();
758        let truncated = self.messages[..index.min(self.messages.len())].to_vec();
759        Self {
760            version: SESSION_VERSION,
761            id: SessionId::new(),
762            messages: Arc::new(truncated),
763            created_at: now,
764            updated_at: now,
765            metadata: self.metadata.clone(),
766            usage: self.usage.clone(),
767        }
768    }
769
770    /// Fork the entire session (full history)
771    ///
772    /// This is O(1) - the new session shares the message buffer via Arc.
773    /// Copy-on-write occurs when either session mutates its messages.
774    pub fn fork(&self) -> Self {
775        let now = SystemTime::now();
776        Self {
777            version: SESSION_VERSION,
778            id: SessionId::new(),
779            messages: Arc::clone(&self.messages),
780            created_at: now,
781            updated_at: now,
782            metadata: self.metadata.clone(),
783            usage: self.usage.clone(),
784        }
785    }
786}
787
788impl Default for Session {
789    fn default() -> Self {
790        Self::new()
791    }
792}
793
794/// Summary metadata for listing sessions
795#[derive(Debug, Clone, Serialize, Deserialize)]
796#[serde(rename_all = "snake_case")]
797pub struct SessionMeta {
798    pub id: SessionId,
799    pub created_at: SystemTime,
800    pub updated_at: SystemTime,
801    pub message_count: usize,
802    pub total_tokens: u64,
803    #[serde(default)]
804    pub metadata: serde_json::Map<String, serde_json::Value>,
805}
806
807/// Metadata required to reliably resume a session across interfaces.
808#[derive(Debug, Clone, Serialize, Deserialize)]
809#[serde(rename_all = "snake_case")]
810pub struct SessionMetadata {
811    pub model: String,
812    pub max_tokens: u32,
813    #[serde(default = "default_structured_output_retries")]
814    pub structured_output_retries: u32,
815    pub provider: Provider,
816    #[serde(default, skip_serializing_if = "Option::is_none")]
817    pub self_hosted_server_id: Option<String>,
818    #[serde(default, skip_serializing_if = "Option::is_none")]
819    pub provider_params: Option<serde_json::Value>,
820    pub tooling: SessionTooling,
821    #[serde(default)]
822    pub keep_alive: bool,
823    pub comms_name: Option<String>,
824    /// Friendly metadata for peer discovery (populated when comms is enabled).
825    #[serde(default, skip_serializing_if = "Option::is_none")]
826    pub peer_meta: Option<PeerMeta>,
827    /// Realm identity for cross-surface storage sharing/isolation.
828    #[serde(default, skip_serializing_if = "Option::is_none")]
829    pub realm_id: Option<String>,
830    /// Optional process/agent instance identifier within a realm.
831    #[serde(default, skip_serializing_if = "Option::is_none")]
832    pub instance_id: Option<String>,
833    /// Backend pinned by the realm manifest (e.g. "sqlite", "jsonl").
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub backend: Option<String>,
836    /// Config generation used when this session was created/resumed.
837    #[serde(default, skip_serializing_if = "Option::is_none")]
838    pub config_generation: Option<u64>,
839}
840
841fn default_structured_output_retries() -> u32 {
842    2
843}
844
845/// Canonical durable LLM identity for a session.
846#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
847#[serde(rename_all = "snake_case")]
848pub struct SessionLlmIdentity {
849    pub model: String,
850    pub provider: Provider,
851    #[serde(default, skip_serializing_if = "Option::is_none")]
852    pub self_hosted_server_id: Option<String>,
853    #[serde(default, skip_serializing_if = "Option::is_none")]
854    pub provider_params: Option<serde_json::Value>,
855}
856
857impl SessionMetadata {
858    /// Return the current durable LLM identity for this session.
859    pub fn llm_identity(&self) -> SessionLlmIdentity {
860        SessionLlmIdentity {
861            model: self.model.clone(),
862            provider: self.provider,
863            self_hosted_server_id: self.self_hosted_server_id.clone(),
864            provider_params: self.provider_params.clone(),
865        }
866    }
867
868    /// Overwrite the durable LLM identity while preserving unrelated session metadata.
869    pub fn apply_llm_identity(&mut self, identity: &SessionLlmIdentity) {
870        self.model = identity.model.clone();
871        self.provider = identity.provider;
872        self.self_hosted_server_id = identity.self_hosted_server_id.clone();
873        self.provider_params = identity.provider_params.clone();
874    }
875}
876
877/// Key used to store SessionMetadata in Session metadata map.
878pub const SESSION_METADATA_KEY: &str = "session_metadata";
879
880/// Caller intent for a tool category.
881///
882/// Distinguishes "no opinion / didn't exist" (`Inherit`) from explicit
883/// `Enable` / `Disable` so that resumed sessions don't freeze tool
884/// availability at the capabilities of the Meerkat version that created them.
885///
886/// **Dogma §10:** Inherit, disable, and set are different facts.
887#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
888#[serde(rename_all = "snake_case")]
889pub enum ToolCategoryOverride {
890    /// No explicit intent — inherit runtime/factory default.
891    #[default]
892    Inherit,
893    /// Explicitly enabled by caller.
894    Enable,
895    /// Explicitly disabled by caller.
896    Disable,
897}
898
899impl ToolCategoryOverride {
900    /// Resolve this override against a runtime default.
901    ///
902    /// - `Enable` → `true`
903    /// - `Disable` → `false`
904    /// - `Inherit` → `runtime_default`
905    #[must_use]
906    pub fn resolve(self, runtime_default: bool) -> bool {
907        match self {
908            Self::Enable => true,
909            Self::Disable => false,
910            Self::Inherit => runtime_default,
911        }
912    }
913
914    /// Convert to `Option<bool>` for feeding `AgentBuildConfig` override fields.
915    ///
916    /// - `Enable` → `Some(true)`
917    /// - `Disable` → `Some(false)`
918    /// - `Inherit` → `None` (factory default wins)
919    #[must_use]
920    pub fn to_override(self) -> Option<bool> {
921        match self {
922            Self::Enable => Some(true),
923            Self::Disable => Some(false),
924            Self::Inherit => None,
925        }
926    }
927
928    /// Construct from a resolved effective bool.
929    ///
930    /// **Warning:** this collapses `Inherit` into `Enable`/`Disable`. Prefer
931    /// [`from_override`] when persisting session metadata so that `Inherit`
932    /// survives across save/resume cycles. Only use `from_effective` in test
933    /// helpers or when constructing metadata from external sources that only
934    /// provide a resolved bool.
935    #[must_use]
936    pub fn from_effective(enabled: bool) -> Self {
937        if enabled { Self::Enable } else { Self::Disable }
938    }
939
940    /// Construct from an `Option<bool>` override field, preserving `Inherit`.
941    ///
942    /// - `Some(true)` → `Enable`
943    /// - `Some(false)` → `Disable`
944    /// - `None` → `Inherit` (factory default was used, no explicit intent)
945    ///
946    /// This is the inverse of [`to_override`] and should be used when persisting
947    /// session tooling metadata so that `Inherit` survives across save/resume
948    /// cycles.
949    #[must_use]
950    pub fn from_override(value: Option<bool>) -> Self {
951        match value {
952            Some(true) => Self::Enable,
953            Some(false) => Self::Disable,
954            None => Self::Inherit,
955        }
956    }
957}
958
959/// Backward-compatible deserializer: accepts both old `bool` JSON and new
960/// tri-state `"inherit"` / `"enable"` / `"disable"` strings.
961///
962/// Old persisted sessions have `"mob": false` or `"builtins": true`.
963/// - `true`  → `Enable`  (user explicitly had it on)
964/// - `false` → `Inherit` (can't distinguish "disabled" from "didn't exist")
965/// - string  → normal enum deserialization
966fn deserialize_tool_category_compat<'de, D>(
967    deserializer: D,
968) -> Result<ToolCategoryOverride, D::Error>
969where
970    D: serde::Deserializer<'de>,
971{
972    use serde::de;
973
974    struct ToolCategoryVisitor;
975
976    impl de::Visitor<'_> for ToolCategoryVisitor {
977        type Value = ToolCategoryOverride;
978
979        fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
980            formatter.write_str("a boolean or one of \"inherit\", \"enable\", \"disable\"")
981        }
982
983        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
984            Ok(if v {
985                ToolCategoryOverride::Enable
986            } else {
987                ToolCategoryOverride::Inherit
988            })
989        }
990
991        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
992            match v {
993                "inherit" => Ok(ToolCategoryOverride::Inherit),
994                "enable" => Ok(ToolCategoryOverride::Enable),
995                "disable" => Ok(ToolCategoryOverride::Disable),
996                _ => Err(de::Error::unknown_variant(
997                    v,
998                    &["inherit", "enable", "disable"],
999                )),
1000            }
1001        }
1002    }
1003
1004    deserializer.deserialize_any(ToolCategoryVisitor)
1005}
1006
1007/// Tooling intent captured at session creation time.
1008///
1009/// Fields use [`ToolCategoryOverride`] to distinguish "no opinion" from
1010/// explicit enable/disable (Dogma §10). On resume, `Inherit` falls through
1011/// to the factory's current runtime default, allowing new tool categories
1012/// to become available without re-creating the session.
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1014#[serde(rename_all = "snake_case")]
1015pub struct SessionTooling {
1016    #[serde(default, deserialize_with = "deserialize_tool_category_compat")]
1017    pub builtins: ToolCategoryOverride,
1018    #[serde(default, deserialize_with = "deserialize_tool_category_compat")]
1019    pub shell: ToolCategoryOverride,
1020    #[serde(default, deserialize_with = "deserialize_tool_category_compat")]
1021    pub comms: ToolCategoryOverride,
1022    /// Mob (multi-agent orchestration) tools.
1023    #[serde(default, deserialize_with = "deserialize_tool_category_compat")]
1024    pub mob: ToolCategoryOverride,
1025    /// Semantic memory.
1026    #[serde(default, deserialize_with = "deserialize_tool_category_compat")]
1027    pub memory: ToolCategoryOverride,
1028    /// Active skills at session creation time (for deterministic resume).
1029    #[serde(default, skip_serializing_if = "Option::is_none")]
1030    pub active_skills: Option<Vec<crate::skills::SkillId>>,
1031}
1032
1033impl From<&Session> for SessionMeta {
1034    fn from(session: &Session) -> Self {
1035        Self {
1036            id: session.id.clone(),
1037            created_at: session.created_at,
1038            updated_at: session.updated_at,
1039            message_count: session.messages.len(),
1040            total_tokens: session.total_tokens(),
1041            metadata: session.metadata.clone(),
1042        }
1043    }
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::unwrap_used, clippy::expect_used)]
1048mod tests {
1049    use super::*;
1050    use crate::types::{
1051        AssistantMessage, BlockAssistantMessage, StopReason, SystemMessage, UserMessage,
1052    };
1053    use std::sync::Arc;
1054
1055    #[test]
1056    fn test_session_new() {
1057        let session = Session::new();
1058        assert_eq!(session.version(), SESSION_VERSION);
1059        assert!(session.messages().is_empty());
1060        assert!(session.created_at() <= session.updated_at());
1061    }
1062
1063    // Performance tests for Arc-based CoW
1064
1065    #[test]
1066    fn test_fork_shares_arc_no_clone() {
1067        let mut session = Session::new();
1068        for i in 0..100 {
1069            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
1070        }
1071
1072        // Fork should share the same Arc, not clone messages
1073        let forked = session.fork();
1074
1075        // Both should point to the same underlying data (Arc refcount > 1)
1076        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
1077        assert_eq!(forked.messages().len(), 100);
1078    }
1079
1080    #[test]
1081    fn test_fork_at_shares_arc_prefix() {
1082        let mut session = Session::new();
1083        for i in 0..100 {
1084            session.push(Message::User(UserMessage::text(format!("Message {i}"))));
1085        }
1086
1087        // Fork at 50 should create new Arc with copied prefix
1088        let forked = session.fork_at(50);
1089        assert_eq!(forked.messages().len(), 50);
1090
1091        // Original should be unchanged
1092        assert_eq!(session.messages().len(), 100);
1093    }
1094
1095    #[test]
1096    fn test_push_cow_behavior() {
1097        let mut session = Session::new();
1098        session.push(Message::User(UserMessage::text("First".to_string())));
1099
1100        // Fork shares the Arc
1101        let forked = session.fork();
1102        assert!(Arc::ptr_eq(&session.messages, &forked.messages));
1103
1104        // Push on original triggers CoW - original gets new Arc
1105        session.push(Message::User(UserMessage::text("Second".to_string())));
1106
1107        // Now they should have different Arcs
1108        assert!(!Arc::ptr_eq(&session.messages, &forked.messages));
1109        assert_eq!(session.messages().len(), 2);
1110        assert_eq!(forked.messages().len(), 1);
1111    }
1112
1113    // Performance tests for lazy timestamp updates
1114
1115    #[test]
1116    fn test_push_batch_single_timestamp() {
1117        let mut session = Session::new();
1118        let initial_updated = session.updated_at();
1119
1120        // Use push_batch to add multiple messages without repeated syscalls
1121        session.push_batch(vec![
1122            Message::User(UserMessage::text("First".to_string())),
1123            Message::User(UserMessage::text("Second".to_string())),
1124            Message::User(UserMessage::text("Third".to_string())),
1125        ]);
1126
1127        assert_eq!(session.messages().len(), 3);
1128        // Timestamp should have been updated once
1129        assert!(session.updated_at() >= initial_updated);
1130    }
1131
1132    #[test]
1133    fn test_touch_updates_timestamp() {
1134        let mut session = Session::new();
1135        let initial = session.updated_at();
1136
1137        std::thread::sleep(std::time::Duration::from_millis(10));
1138
1139        // Explicit touch to update timestamp
1140        session.touch();
1141
1142        assert!(session.updated_at() > initial);
1143    }
1144
1145    #[test]
1146    fn test_session_push() {
1147        let mut session = Session::new();
1148        let initial_updated = session.updated_at();
1149
1150        // Small delay to ensure time changes
1151        std::thread::sleep(std::time::Duration::from_millis(10));
1152
1153        session.push(Message::User(UserMessage::text("Hello".to_string())));
1154
1155        assert_eq!(session.messages().len(), 1);
1156        assert!(session.updated_at() > initial_updated);
1157    }
1158
1159    #[test]
1160    fn test_session_fork() {
1161        let mut session = Session::new();
1162        session.push(Message::System(SystemMessage {
1163            content: "System prompt".to_string(),
1164        }));
1165        session.push(Message::User(UserMessage::text("Hello".to_string())));
1166        session.push(Message::Assistant(AssistantMessage {
1167            content: "Hi!".to_string(),
1168            tool_calls: vec![],
1169            stop_reason: StopReason::EndTurn,
1170            usage: Usage::default(),
1171        }));
1172
1173        // Fork at index 2 (system + user)
1174        let forked = session.fork_at(2);
1175        assert_eq!(forked.messages().len(), 2);
1176        assert_ne!(forked.id(), session.id());
1177
1178        // Full fork
1179        let full_fork = session.fork();
1180        assert_eq!(full_fork.messages().len(), 3);
1181    }
1182
1183    #[test]
1184    fn test_session_metadata() {
1185        let mut session = Session::new();
1186        session.set_metadata("key", serde_json::json!("value"));
1187
1188        assert_eq!(session.metadata().get("key").unwrap(), "value");
1189    }
1190
1191    #[test]
1192    fn test_session_mob_tool_authority_context_roundtrip() {
1193        let mut session = Session::new();
1194        let authority = MobToolAuthorityContext::new(
1195            crate::service::OpaquePrincipalToken::new("opaque-principal"),
1196            false,
1197        )
1198        .with_managed_mob_scope(["mob-a"])
1199        .with_audit_invocation_id("audit-1");
1200
1201        session
1202            .set_mob_tool_authority_context(Some(authority.clone()))
1203            .expect("authority should serialize");
1204        assert_eq!(session.mob_tool_authority_context(), Some(authority));
1205
1206        session
1207            .set_mob_tool_authority_context(None)
1208            .expect("authority should clear");
1209        assert!(session.mob_tool_authority_context().is_none());
1210    }
1211
1212    #[test]
1213    fn test_session_tool_visibility_state_roundtrip() {
1214        let mut session = Session::new();
1215        let state = SessionToolVisibilityState {
1216            inherited_base_filter: ToolFilter::Allow(["visible".to_string()].into_iter().collect()),
1217            active_filter: ToolFilter::Allow(
1218                ["visible".to_string(), "missing".to_string()]
1219                    .into_iter()
1220                    .collect(),
1221            ),
1222            staged_filter: ToolFilter::Allow(
1223                ["visible".to_string(), "missing".to_string()]
1224                    .into_iter()
1225                    .collect(),
1226            ),
1227            active_revision: 1,
1228            staged_revision: 2,
1229            ..Default::default()
1230        };
1231
1232        session
1233            .set_tool_visibility_state(state.clone())
1234            .expect("tool visibility state should serialize");
1235        assert_eq!(session.tool_visibility_state(), Some(state));
1236    }
1237
1238    #[test]
1239    fn test_session_serialization() {
1240        let mut session = Session::new();
1241        session.push(Message::User(UserMessage::text("Test".to_string())));
1242
1243        let json = serde_json::to_string(&session).unwrap();
1244        let parsed: Session = serde_json::from_str(&json).unwrap();
1245
1246        assert_eq!(parsed.id(), session.id());
1247        assert_eq!(parsed.messages().len(), 1);
1248        assert_eq!(parsed.version(), SESSION_VERSION);
1249    }
1250
1251    #[test]
1252    fn test_session_meta_from_session() {
1253        let mut session = Session::new();
1254        session.push(Message::User(UserMessage::text("Hello".to_string())));
1255        session.push(Message::Assistant(AssistantMessage {
1256            content: "Hi!".to_string(),
1257            tool_calls: vec![],
1258            stop_reason: StopReason::EndTurn,
1259            usage: Usage {
1260                input_tokens: 10,
1261                output_tokens: 5,
1262                cache_creation_tokens: None,
1263                cache_read_tokens: None,
1264            },
1265        }));
1266        session.record_usage(Usage {
1267            input_tokens: 10,
1268            output_tokens: 5,
1269            cache_creation_tokens: None,
1270            cache_read_tokens: None,
1271        });
1272
1273        let meta = SessionMeta::from(&session);
1274        assert_eq!(meta.id, *session.id());
1275        assert_eq!(meta.message_count, 2);
1276        assert_eq!(meta.total_tokens, 15);
1277    }
1278
1279    #[test]
1280    fn has_pending_boundary_empty_session() {
1281        let session = Session::new();
1282        assert!(!session.has_pending_boundary());
1283    }
1284
1285    #[test]
1286    fn has_pending_boundary_after_user_message() {
1287        let mut session = Session::new();
1288        session.push(Message::User(UserMessage::text("hello")));
1289        assert!(session.has_pending_boundary());
1290    }
1291
1292    #[test]
1293    fn has_pending_boundary_after_assistant_message() {
1294        let mut session = Session::new();
1295        session.push(Message::User(UserMessage::text("hello")));
1296        session.push(Message::BlockAssistant(BlockAssistantMessage {
1297            blocks: vec![],
1298            stop_reason: StopReason::EndTurn,
1299        }));
1300        assert!(!session.has_pending_boundary());
1301    }
1302
1303    #[test]
1304    fn has_pending_boundary_after_tool_results() {
1305        let mut session = Session::new();
1306        session.push(Message::User(UserMessage::text("hello")));
1307        session.push(Message::ToolResults { results: vec![] });
1308        assert!(session.has_pending_boundary());
1309    }
1310
1311    #[test]
1312    fn has_pending_boundary_after_system() {
1313        let mut session = Session::new();
1314        session.push(Message::System(SystemMessage {
1315            content: "system".into(),
1316        }));
1317        assert!(!session.has_pending_boundary());
1318    }
1319}