Skip to main content

orcs_runtime/session/
asset.rs

1//! Session Asset - The accumulating value at the core of ORCS.
2//!
3//! ## Overview
4//!
5//! `SessionAsset` is designed to accumulate value over time:
6//!
7//! - **History**: Conversation history (searchable, referenceable)
8//! - **Preferences**: Learned user preferences
9//! - **ProjectContext**: Project-specific information (grows over time)
10//! - **SkillConfigs**: Skill customization settings
11//! - **ComponentSnapshots**: Saved component states for resume
12
13use chrono::{DateTime, Utc};
14use orcs_auth::CommandGrant;
15use orcs_component::ComponentSnapshot;
16use serde::{Deserialize, Serialize};
17use std::collections::HashMap;
18use std::path::PathBuf;
19use uuid::Uuid;
20
21/// Session version for compatibility checking.
22pub const SESSION_VERSION: u32 = 1;
23
24/// SessionAsset - The accumulating value.
25///
26/// This is the core data structure that persists between sessions.
27/// It contains all the context and history that makes the assistant
28/// more useful over time.
29///
30/// # Example
31///
32/// ```
33/// use orcs_runtime::session::SessionAsset;
34///
35/// let mut asset = SessionAsset::new();
36/// asset.project_context.name = Some("my-project".into());
37///
38/// // Save to JSON
39/// let json = asset.to_json().expect("asset should serialize to JSON");
40/// ```
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct SessionAsset {
43    /// Asset ID (UUID).
44    pub id: String,
45
46    /// Session version for compatibility.
47    pub version: u32,
48
49    /// Creation timestamp (milliseconds since epoch).
50    pub created_at_ms: u64,
51
52    /// Last update timestamp (milliseconds since epoch).
53    pub updated_at_ms: u64,
54
55    /// Conversation history.
56    pub history: ConversationHistory,
57
58    /// User preferences.
59    pub preferences: UserPreferences,
60
61    /// Project context.
62    pub project_context: ProjectContext,
63
64    /// Skill configurations.
65    pub skill_configs: HashMap<String, SkillConfig>,
66
67    /// Component snapshots for resume.
68    #[serde(default)]
69    pub component_snapshots: HashMap<String, ComponentSnapshot>,
70
71    /// Granted command patterns (restored on session resume).
72    ///
73    /// These are the commands that were approved via HIL during previous
74    /// sessions. On resume, they are restored into the `GrantPolicy`
75    /// so the user doesn't need to re-approve the same commands.
76    #[serde(default)]
77    pub granted_commands: Vec<CommandGrant>,
78
79    /// Arbitrary metadata.
80    pub metadata: HashMap<String, serde_json::Value>,
81}
82
83impl SessionAsset {
84    /// Creates a new session asset with a fresh UUID.
85    #[must_use]
86    pub fn new() -> Self {
87        let now = current_time_ms();
88        Self {
89            id: Uuid::new_v4().to_string(),
90            version: SESSION_VERSION,
91            created_at_ms: now,
92            updated_at_ms: now,
93            history: ConversationHistory::new(),
94            preferences: UserPreferences::default(),
95            project_context: ProjectContext::default(),
96            skill_configs: HashMap::new(),
97            component_snapshots: HashMap::new(),
98            granted_commands: Vec::new(),
99            metadata: HashMap::new(),
100        }
101    }
102
103    /// Creates a session asset for a specific project.
104    #[must_use]
105    pub fn for_project(path: impl Into<PathBuf>) -> Self {
106        let mut asset = Self::new();
107        asset.project_context.root_path = Some(path.into());
108        asset
109    }
110
111    /// Updates the modification timestamp.
112    pub fn touch(&mut self) {
113        self.updated_at_ms = current_time_ms();
114    }
115
116    /// Adds a conversation turn.
117    pub fn add_turn(&mut self, turn: ConversationTurn) {
118        self.history.turns.push(turn);
119        self.touch();
120    }
121
122    /// Updates a skill configuration.
123    pub fn set_skill_config(&mut self, skill_id: impl Into<String>, config: SkillConfig) {
124        self.skill_configs.insert(skill_id.into(), config);
125        self.touch();
126    }
127
128    /// Saves a component snapshot.
129    pub fn save_snapshot(&mut self, snapshot: ComponentSnapshot) {
130        self.component_snapshots
131            .insert(snapshot.component_fqn.clone(), snapshot);
132        self.touch();
133    }
134
135    /// Retrieves a component snapshot by FQN.
136    #[must_use]
137    pub fn get_snapshot(&self, fqn: &str) -> Option<&ComponentSnapshot> {
138        self.component_snapshots.get(fqn)
139    }
140
141    /// Removes a component snapshot by FQN.
142    pub fn remove_snapshot(&mut self, fqn: &str) -> Option<ComponentSnapshot> {
143        let snapshot = self.component_snapshots.remove(fqn);
144        if snapshot.is_some() {
145            self.touch();
146        }
147        snapshot
148    }
149
150    /// Clears all component snapshots.
151    pub fn clear_snapshots(&mut self) {
152        if !self.component_snapshots.is_empty() {
153            self.component_snapshots.clear();
154            self.touch();
155        }
156    }
157
158    /// Returns the number of saved snapshots.
159    #[must_use]
160    pub fn snapshot_count(&self) -> usize {
161        self.component_snapshots.len()
162    }
163
164    // === Grant persistence ===
165
166    /// Saves granted commands (replaces existing).
167    ///
168    /// Call this before persisting the session to capture the current
169    /// grant state from a `GrantPolicy`.
170    pub fn save_grants(&mut self, grants: Vec<CommandGrant>) {
171        if self.granted_commands != grants {
172            self.granted_commands = grants;
173            self.touch();
174        }
175    }
176
177    /// Returns the saved granted commands.
178    #[must_use]
179    pub fn granted_commands(&self) -> &[CommandGrant] {
180        &self.granted_commands
181    }
182
183    /// Returns the number of saved grants.
184    #[must_use]
185    pub fn grant_count(&self) -> usize {
186        self.granted_commands.len()
187    }
188
189    /// Returns the creation time as a DateTime.
190    #[must_use]
191    pub fn created_at(&self) -> DateTime<Utc> {
192        DateTime::from_timestamp_millis(self.created_at_ms as i64).unwrap_or_else(Utc::now)
193    }
194
195    /// Returns the update time as a DateTime.
196    #[must_use]
197    pub fn updated_at(&self) -> DateTime<Utc> {
198        DateTime::from_timestamp_millis(self.updated_at_ms as i64).unwrap_or_else(Utc::now)
199    }
200
201    /// Serializes to JSON.
202    pub fn to_json(&self) -> Result<String, serde_json::Error> {
203        serde_json::to_string_pretty(self)
204    }
205
206    /// Deserializes from JSON.
207    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
208        serde_json::from_str(json)
209    }
210
211    /// Calculates a checksum for integrity verification.
212    #[must_use]
213    pub fn checksum(&self) -> String {
214        use std::hash::{Hash, Hasher};
215        let mut hasher = std::collections::hash_map::DefaultHasher::new();
216        self.id.hash(&mut hasher);
217        self.updated_at_ms.hash(&mut hasher);
218        self.history.turns.len().hash(&mut hasher);
219        format!("{:016x}", hasher.finish())
220    }
221}
222
223impl Default for SessionAsset {
224    fn default() -> Self {
225        Self::new()
226    }
227}
228
229/// Conversation history.
230#[derive(Debug, Clone, Serialize, Deserialize, Default)]
231pub struct ConversationHistory {
232    /// Conversation turns.
233    pub turns: Vec<ConversationTurn>,
234
235    /// Compacted (summarized) history.
236    pub compacted: Vec<CompactedTurn>,
237
238    /// Maximum turns to keep before compacting.
239    #[serde(default = "default_max_turns")]
240    pub max_turns: usize,
241}
242
243fn default_max_turns() -> usize {
244    1000
245}
246
247impl ConversationHistory {
248    /// Creates a new empty history.
249    #[must_use]
250    pub fn new() -> Self {
251        Self {
252            turns: Vec::new(),
253            compacted: Vec::new(),
254            max_turns: 1000,
255        }
256    }
257
258    /// Returns the number of turns.
259    #[must_use]
260    pub fn len(&self) -> usize {
261        self.turns.len()
262    }
263
264    /// Returns `true` if empty.
265    #[must_use]
266    pub fn is_empty(&self) -> bool {
267        self.turns.is_empty()
268    }
269
270    /// Returns the most recent N turns.
271    #[must_use]
272    pub fn recent(&self, n: usize) -> &[ConversationTurn] {
273        let start = self.turns.len().saturating_sub(n);
274        &self.turns[start..]
275    }
276
277    /// Searches turns by keyword.
278    #[must_use]
279    pub fn search(&self, keyword: &str) -> Vec<&ConversationTurn> {
280        let keyword_lower = keyword.to_lowercase();
281        self.turns
282            .iter()
283            .filter(|turn| {
284                turn.content.to_lowercase().contains(&keyword_lower)
285                    || turn
286                        .tags
287                        .iter()
288                        .any(|t| t.to_lowercase().contains(&keyword_lower))
289            })
290            .collect()
291    }
292
293    /// Filters turns by time range.
294    #[must_use]
295    pub fn in_range(&self, start_ms: u64, end_ms: u64) -> Vec<&ConversationTurn> {
296        self.turns
297            .iter()
298            .filter(|turn| turn.timestamp_ms >= start_ms && turn.timestamp_ms <= end_ms)
299            .collect()
300    }
301
302    /// Compacts old turns using the provided summarizer.
303    ///
304    /// If the number of turns exceeds `max_turns`, older turns are
305    /// summarized and moved to `compacted`.
306    pub fn compact(&mut self, summarizer: impl Fn(&[ConversationTurn]) -> String) {
307        if self.turns.len() > self.max_turns {
308            let to_compact = self.turns.len() - self.max_turns;
309            let old_turns: Vec<_> = self.turns.drain(..to_compact).collect();
310
311            let summary = summarizer(&old_turns);
312            self.compacted.push(CompactedTurn {
313                summary,
314                turn_count: old_turns.len(),
315                start_time_ms: old_turns.first().map(|t| t.timestamp_ms).unwrap_or(0),
316                end_time_ms: old_turns.last().map(|t| t.timestamp_ms).unwrap_or(0),
317            });
318        }
319    }
320}
321
322/// A single conversation turn.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct ConversationTurn {
325    /// Turn ID (UUID).
326    pub id: String,
327
328    /// Timestamp (milliseconds since epoch).
329    pub timestamp_ms: u64,
330
331    /// Role: "user", "assistant", or "system".
332    pub role: String,
333
334    /// Content text.
335    pub content: String,
336
337    /// Tool calls made in this turn.
338    pub tool_calls: Vec<ToolCallRecord>,
339
340    /// Tags for search.
341    pub tags: Vec<String>,
342
343    /// Arbitrary metadata.
344    pub metadata: HashMap<String, serde_json::Value>,
345}
346
347impl ConversationTurn {
348    /// Creates a user turn.
349    #[must_use]
350    pub fn user(content: impl Into<String>) -> Self {
351        Self {
352            id: Uuid::new_v4().to_string(),
353            timestamp_ms: current_time_ms(),
354            role: "user".into(),
355            content: content.into(),
356            tool_calls: Vec::new(),
357            tags: Vec::new(),
358            metadata: HashMap::new(),
359        }
360    }
361
362    /// Creates an assistant turn.
363    #[must_use]
364    pub fn assistant(content: impl Into<String>) -> Self {
365        Self {
366            id: Uuid::new_v4().to_string(),
367            timestamp_ms: current_time_ms(),
368            role: "assistant".into(),
369            content: content.into(),
370            tool_calls: Vec::new(),
371            tags: Vec::new(),
372            metadata: HashMap::new(),
373        }
374    }
375
376    /// Creates a system turn.
377    #[must_use]
378    pub fn system(content: impl Into<String>) -> Self {
379        Self {
380            id: Uuid::new_v4().to_string(),
381            timestamp_ms: current_time_ms(),
382            role: "system".into(),
383            content: content.into(),
384            tool_calls: Vec::new(),
385            tags: Vec::new(),
386            metadata: HashMap::new(),
387        }
388    }
389
390    /// Adds tags.
391    #[must_use]
392    pub fn with_tags(mut self, tags: Vec<String>) -> Self {
393        self.tags = tags;
394        self
395    }
396
397    /// Adds tool calls.
398    #[must_use]
399    pub fn with_tool_calls(mut self, calls: Vec<ToolCallRecord>) -> Self {
400        self.tool_calls = calls;
401        self
402    }
403}
404
405/// Record of a tool call.
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct ToolCallRecord {
408    /// Tool name.
409    pub tool_name: String,
410
411    /// Arguments passed.
412    pub args: serde_json::Value,
413
414    /// Result (if available).
415    pub result: Option<serde_json::Value>,
416
417    /// Whether the call succeeded.
418    pub success: bool,
419
420    /// Duration in milliseconds.
421    pub duration_ms: u64,
422}
423
424/// Compacted (summarized) turns.
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct CompactedTurn {
427    /// Summary text.
428    pub summary: String,
429
430    /// Number of turns summarized.
431    pub turn_count: usize,
432
433    /// Start time of the summarized range.
434    pub start_time_ms: u64,
435
436    /// End time of the summarized range.
437    pub end_time_ms: u64,
438}
439
440/// User preferences learned over time.
441#[derive(Debug, Clone, Serialize, Deserialize, Default)]
442pub struct UserPreferences {
443    /// Coding style preferences.
444    pub coding_style: CodingStylePrefs,
445
446    /// Communication preferences.
447    pub communication: CommunicationPrefs,
448
449    /// Frequently used tools.
450    pub frequent_tools: Vec<String>,
451
452    /// Custom preferences.
453    pub custom: HashMap<String, serde_json::Value>,
454}
455
456/// Coding style preferences.
457#[derive(Debug, Clone, Serialize, Deserialize, Default)]
458pub struct CodingStylePrefs {
459    /// Preferred languages.
460    pub preferred_languages: Vec<String>,
461
462    /// Indentation type ("spaces" or "tabs").
463    pub indent: Option<String>,
464
465    /// Indentation size.
466    pub indent_size: Option<u8>,
467
468    /// Naming convention.
469    pub naming_convention: Option<String>,
470
471    /// Comment style.
472    pub comment_style: Option<String>,
473}
474
475/// Communication preferences.
476#[derive(Debug, Clone, Serialize, Deserialize, Default)]
477pub struct CommunicationPrefs {
478    /// Verbosity level ("concise", "balanced", "verbose").
479    pub verbosity: Option<String>,
480
481    /// Preferred language.
482    pub language: Option<String>,
483
484    /// Formality level ("casual", "professional").
485    pub formality: Option<String>,
486
487    /// Whether to include code examples.
488    #[serde(default)]
489    pub include_examples: bool,
490}
491
492/// Project-specific context.
493#[derive(Debug, Clone, Serialize, Deserialize, Default)]
494pub struct ProjectContext {
495    /// Project root path.
496    pub root_path: Option<PathBuf>,
497
498    /// Project name.
499    pub name: Option<String>,
500
501    /// Technologies used.
502    pub technologies: Vec<String>,
503
504    /// Key files.
505    pub key_files: Vec<String>,
506
507    /// Architecture notes.
508    pub architecture_notes: Vec<String>,
509
510    /// Learned facts.
511    pub learned_facts: Vec<LearnedFact>,
512
513    /// Persistent context injections.
514    pub persistent_contexts: Vec<ContextInjection>,
515}
516
517impl ProjectContext {
518    /// Adds a learned fact (deduplicates by key).
519    pub fn learn(&mut self, fact: LearnedFact) {
520        if !self.learned_facts.iter().any(|f| f.key == fact.key) {
521            self.learned_facts.push(fact);
522        }
523    }
524
525    /// Adds a persistent context.
526    pub fn add_persistent_context(&mut self, ctx: ContextInjection) {
527        self.persistent_contexts.push(ctx);
528    }
529
530    /// Finds facts matching a keyword.
531    #[must_use]
532    pub fn find_facts(&self, keyword: &str) -> Vec<&LearnedFact> {
533        let keyword_lower = keyword.to_lowercase();
534        self.learned_facts
535            .iter()
536            .filter(|f| {
537                f.key.to_lowercase().contains(&keyword_lower)
538                    || f.value.to_lowercase().contains(&keyword_lower)
539            })
540            .collect()
541    }
542}
543
544/// A fact learned about the project.
545#[derive(Debug, Clone, Serialize, Deserialize)]
546pub struct LearnedFact {
547    /// Key for lookup.
548    pub key: String,
549
550    /// Value.
551    pub value: String,
552
553    /// Source of the fact.
554    pub source: String,
555
556    /// Confidence (0.0-1.0).
557    pub confidence: f32,
558
559    /// When the fact was learned.
560    pub learned_at_ms: u64,
561}
562
563impl LearnedFact {
564    /// Creates a new fact with full confidence.
565    #[must_use]
566    pub fn new(
567        key: impl Into<String>,
568        value: impl Into<String>,
569        source: impl Into<String>,
570    ) -> Self {
571        Self {
572            key: key.into(),
573            value: value.into(),
574            source: source.into(),
575            confidence: 1.0,
576            learned_at_ms: current_time_ms(),
577        }
578    }
579
580    /// Sets the confidence level.
581    #[must_use]
582    pub fn with_confidence(mut self, confidence: f32) -> Self {
583        self.confidence = confidence.clamp(0.0, 1.0);
584        self
585    }
586}
587
588/// Context to inject into conversations.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ContextInjection {
591    /// Injection ID.
592    pub id: String,
593
594    /// When to inject ("always", "on_request", etc.).
595    pub timing: String,
596
597    /// Content to inject.
598    pub content: String,
599}
600
601/// Skill configuration.
602#[derive(Debug, Clone, Serialize, Deserialize)]
603pub struct SkillConfig {
604    /// Skill ID.
605    pub skill_id: String,
606
607    /// Whether the skill is enabled.
608    pub enabled: bool,
609
610    /// Auto-trigger configuration.
611    pub auto_trigger: AutoTriggerConfig,
612
613    /// Custom parameters.
614    pub params: HashMap<String, serde_json::Value>,
615}
616
617impl SkillConfig {
618    /// Creates a new enabled skill config.
619    #[must_use]
620    pub fn new(skill_id: impl Into<String>) -> Self {
621        Self {
622            skill_id: skill_id.into(),
623            enabled: true,
624            auto_trigger: AutoTriggerConfig::default(),
625            params: HashMap::new(),
626        }
627    }
628
629    /// Creates a disabled skill config.
630    #[must_use]
631    pub fn disabled(skill_id: impl Into<String>) -> Self {
632        Self {
633            enabled: false,
634            ..Self::new(skill_id)
635        }
636    }
637}
638
639/// Auto-trigger configuration for skills.
640#[derive(Debug, Clone, Serialize, Deserialize, Default)]
641pub struct AutoTriggerConfig {
642    /// Whether auto-triggering is enabled.
643    pub enabled: bool,
644
645    /// Trigger conditions.
646    pub triggers: Vec<TriggerCondition>,
647}
648
649/// Trigger condition for auto-firing skills.
650#[derive(Debug, Clone, Serialize, Deserialize)]
651pub enum TriggerCondition {
652    /// On file edit matching glob.
653    OnFileEdit { glob: String },
654
655    /// Before LLM request.
656    OnLLMPreRequest,
657
658    /// After LLM response.
659    OnLLMPostResponse,
660
661    /// On custom event.
662    OnEvent { kind: String },
663
664    /// Periodic interval.
665    Interval { seconds: u64 },
666}
667
668// === Helper functions ===
669
670fn current_time_ms() -> u64 {
671    use std::time::{SystemTime, UNIX_EPOCH};
672    SystemTime::now()
673        .duration_since(UNIX_EPOCH)
674        .map(|d| d.as_millis() as u64)
675        .unwrap_or(0)
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    #[test]
683    fn session_asset_new() {
684        let asset = SessionAsset::new();
685        assert!(!asset.id.is_empty());
686        assert_eq!(asset.version, SESSION_VERSION);
687        assert!(asset.history.is_empty());
688    }
689
690    #[test]
691    fn session_asset_for_project() {
692        let asset = SessionAsset::for_project("/path/to/project");
693        assert_eq!(
694            asset.project_context.root_path,
695            Some(PathBuf::from("/path/to/project"))
696        );
697    }
698
699    #[test]
700    fn session_asset_touch() {
701        let mut asset = SessionAsset::new();
702        let original = asset.updated_at_ms;
703        std::thread::sleep(std::time::Duration::from_millis(10));
704        asset.touch();
705        assert!(asset.updated_at_ms >= original);
706    }
707
708    #[test]
709    fn conversation_history_search() {
710        let mut history = ConversationHistory::new();
711        history.turns.push(ConversationTurn::user("Hello Rust"));
712        history
713            .turns
714            .push(ConversationTurn::user("Python is great"));
715        history
716            .turns
717            .push(ConversationTurn::user("rust programming"));
718
719        let results = history.search("rust");
720        assert_eq!(results.len(), 2);
721    }
722
723    #[test]
724    fn conversation_history_recent() {
725        let mut history = ConversationHistory::new();
726        for i in 0..10 {
727            history
728                .turns
729                .push(ConversationTurn::user(format!("Turn {i}")));
730        }
731
732        let recent = history.recent(3);
733        assert_eq!(recent.len(), 3);
734        assert!(recent[0].content.contains('7'));
735    }
736
737    #[test]
738    fn project_context_learn() {
739        let mut ctx = ProjectContext::default();
740        ctx.learn(LearnedFact::new("db", "PostgreSQL", "user"));
741        ctx.learn(LearnedFact::new("framework", "Actix", "codebase"));
742
743        let facts = ctx.find_facts("postgre");
744        assert_eq!(facts.len(), 1);
745        assert_eq!(facts[0].value, "PostgreSQL");
746    }
747
748    #[test]
749    fn session_asset_json_roundtrip() {
750        let mut asset = SessionAsset::new();
751        asset.add_turn(ConversationTurn::user("test"));
752        asset.preferences.coding_style.preferred_languages = vec!["Rust".into()];
753
754        let json = asset.to_json().expect("serialize session asset");
755        let restored = SessionAsset::from_json(&json).expect("deserialize session asset");
756
757        assert_eq!(asset.id, restored.id);
758        assert_eq!(restored.history.len(), 1);
759        assert_eq!(
760            restored.preferences.coding_style.preferred_languages,
761            vec!["Rust".to_string()]
762        );
763    }
764
765    #[test]
766    fn skill_config_new() {
767        let config = SkillConfig::new("lint");
768        assert!(config.enabled);
769        assert_eq!(config.skill_id, "lint");
770    }
771
772    #[test]
773    fn checksum_changes_on_update() {
774        let mut asset = SessionAsset::new();
775        let checksum1 = asset.checksum();
776
777        asset.add_turn(ConversationTurn::user("test"));
778        let checksum2 = asset.checksum();
779
780        assert_ne!(checksum1, checksum2);
781    }
782
783    // === Grant persistence tests ===
784
785    #[test]
786    fn grants_default_empty() {
787        let asset = SessionAsset::new();
788        assert!(asset.granted_commands().is_empty());
789        assert_eq!(asset.grant_count(), 0);
790    }
791
792    #[test]
793    fn save_and_get_grants() {
794        use orcs_auth::CommandGrant;
795
796        let mut asset = SessionAsset::new();
797        let grants = vec![
798            CommandGrant::persistent("ls"),
799            CommandGrant::persistent("cargo"),
800        ];
801        asset.save_grants(grants);
802
803        assert_eq!(asset.grant_count(), 2);
804        assert_eq!(asset.granted_commands()[0].pattern, "ls");
805        assert_eq!(asset.granted_commands()[1].pattern, "cargo");
806    }
807
808    #[test]
809    fn grants_json_roundtrip() {
810        use orcs_auth::{CommandGrant, GrantKind};
811
812        let mut asset = SessionAsset::new();
813        asset.save_grants(vec![
814            CommandGrant::persistent("ls"),
815            CommandGrant::one_time("rm -rf"),
816        ]);
817
818        let json = asset.to_json().expect("serialize grants asset");
819        let restored = SessionAsset::from_json(&json).expect("deserialize grants asset");
820
821        assert_eq!(restored.grant_count(), 2);
822        assert_eq!(restored.granted_commands()[0].pattern, "ls");
823        assert_eq!(restored.granted_commands()[0].kind, GrantKind::Persistent);
824        assert_eq!(restored.granted_commands()[1].pattern, "rm -rf");
825        assert_eq!(restored.granted_commands()[1].kind, GrantKind::OneTime);
826    }
827
828    #[test]
829    fn grants_backward_compat_missing_field() {
830        // Simulate loading a session saved before granted_commands was added
831        let json = r#"{
832            "id": "test-id",
833            "version": 1,
834            "created_at_ms": 0,
835            "updated_at_ms": 0,
836            "history": { "turns": [], "compacted": [], "max_turns": 1000 },
837            "preferences": {
838                "coding_style": { "preferred_languages": [] },
839                "communication": {},
840                "frequent_tools": [],
841                "custom": {}
842            },
843            "project_context": {
844                "technologies": [],
845                "key_files": [],
846                "architecture_notes": [],
847                "learned_facts": [],
848                "persistent_contexts": []
849            },
850            "skill_configs": {},
851            "component_snapshots": {},
852            "metadata": {}
853        }"#;
854
855        let asset = SessionAsset::from_json(json).expect("backward compat deserialization");
856        assert!(asset.granted_commands().is_empty());
857    }
858}