#![allow(clippy::unwrap_used)] //! M4 User Acceptance Test: Agent Session Layer. //! //! Proves the full agent session lifecycle: schema-declared policies, //! session start/close, session signals with policy enforcement, audit log, //! session snapshot, FOR SESSION ranking boost, and session isolation. use std::collections::HashMap; use std::time::Duration; use tidaldb::AgentId; use tidaldb::TidalDb; use tidaldb::query::retrieve::{ProfileRef, RetrieveBuilder}; use tidaldb::schema::{ AgentPolicy, DecaySpec, EntityId, EntityKind, SchemaBuilder, Timestamp, Window, }; fn test_schema() -> tidaldb::schema::Schema { let mut builder = SchemaBuilder::new(); // Content signals. for sig in &["view", "like", "share", "reward", "skip"] { let _ = builder .signal( sig, EntityKind::Item, DecaySpec::Exponential { half_life: Duration::from_secs(7 * 24 * 3600), }, ) .windows(&[Window::OneHour, Window::TwentyFourHours]) .velocity(true) .add(); } // Schema-declared session policy: planner_policy. // Allows reward + view signals; denies skip; cap of 100 signals. let _ = builder.session_policy( "planner_policy", AgentPolicy { allowed_signals: vec!["reward".to_string(), "view".to_string()], denied_signals: vec!["skip".to_string()], max_session_duration: Duration::from_secs(3600), // 1 hour max_signals_per_session: 100, }, ); builder.build().unwrap() } fn test_db() -> TidalDb { TidalDb::builder() .ephemeral() .with_schema(test_schema()) .open() .unwrap() } // ── Step 1: Schema with session_policy compiles ───────────────────────────── #[test] fn step1_schema_with_session_policy_builds() { // Schema should build cleanly with a declared policy. let schema = test_schema(); let policy = schema.session_policy("planner_policy"); assert!(policy.is_some(), "planner_policy should be in schema"); let p = policy.unwrap(); assert!(p.allowed_signals.contains(&"reward".to_string())); assert!(p.denied_signals.contains(&"skip".to_string())); assert_eq!(p.max_signals_per_session, 100); } // ── Step 2: Session lifecycle — start and close ───────────────────────────── #[test] fn step2_session_start_and_close() { let db = test_db(); let mut meta = HashMap::new(); meta.insert("context".to_string(), "video-feed".to_string()); let handle = db .start_session(42, "planner-agent", "planner_policy", meta) .unwrap(); assert_eq!(handle.user_id, 42); assert_eq!(handle.agent_id.as_str(), "planner-agent"); assert_eq!(handle.policy_name, "planner_policy"); // Session should appear in active_sessions. let active = db.active_sessions(); assert_eq!(active.len(), 1); assert_eq!(active[0].user_id, 42); let session_id = handle.id; let summary = db.close_session(handle).unwrap(); assert_eq!(summary.id, session_id); assert_eq!(summary.rejections, 0); // Session should no longer be active. assert!(db.active_sessions().is_empty()); } // ── Step 3: session_signal writes and audit ───────────────────────────────── #[test] fn step3_session_signal_and_audit() { let db = test_db(); // Write some content items. for i in 1u64..=5 { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("item-{i}")); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); } let handle = db .start_session(10, "planner-agent", "planner_policy", HashMap::new()) .unwrap(); let session_id = handle.id; let ts = Timestamp::now(); // Write allowed signals. db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None) .unwrap(); db.session_signal(&handle, "view", EntityId::new(2), 0.5, ts, None) .unwrap(); // Check snapshot counts. let snap = db.session_snapshot(session_id).unwrap(); assert_eq!(snap.signals_written, 2); assert_eq!(snap.signals_rejected, 0); // Check audit log. let audit = db.session_audit(session_id).unwrap(); assert_eq!(audit.len(), 2); assert!(audit.iter().all(|e| e.accepted)); db.close_session(handle).unwrap(); } // ── Step 4: Policy rejects denied signals ────────────────────────────────── #[test] fn step4_policy_rejects_denied_signal() { let db = test_db(); let handle = db .start_session(20, "test-agent", "planner_policy", HashMap::new()) .unwrap(); let session_id = handle.id; let ts = Timestamp::now(); // "skip" is explicitly denied in planner_policy. let result = db.session_signal(&handle, "skip", EntityId::new(99), 1.0, ts, None); assert!(result.is_err(), "skip should be rejected by policy"); let err_str = format!("{}", result.unwrap_err()); assert!( err_str.to_lowercase().contains("policy") || err_str.to_lowercase().contains("denied"), "error should mention policy: {err_str}" ); // Rejection should be counted. let snap = db.session_snapshot(session_id).unwrap(); assert_eq!(snap.signals_rejected, 1); assert_eq!(snap.signals_written, 0); // Audit log should show the rejection. let audit = db.session_audit(session_id).unwrap(); assert_eq!(audit.len(), 1); assert!(!audit[0].accepted); assert!(audit[0].reason.is_some()); db.close_session(handle).unwrap(); } // ── Step 5: Policy rejects signals not in allow list ─────────────────────── #[test] fn step5_policy_rejects_non_allowed_signal() { let db = test_db(); let handle = db .start_session(30, "test-agent", "planner_policy", HashMap::new()) .unwrap(); let ts = Timestamp::now(); // "share" is not in the allowed list (which only allows reward + view). let result = db.session_signal(&handle, "share", EntityId::new(1), 1.0, ts, None); assert!( result.is_err(), "share should be rejected (not in allowed_signals)" ); db.close_session(handle).unwrap(); } // ── Step 6: Session annotations and snapshot ────────────────────────────── #[test] fn step6_session_annotations_and_snapshot() { let db = test_db(); let handle = db .start_session(40, "planner-agent", "planner_policy", HashMap::new()) .unwrap(); let session_id = handle.id; let ts = Timestamp::now(); // Write with annotation. db.session_signal( &handle, "reward", EntityId::new(5), 1.0, ts, Some("rust programming language".to_string()), ) .unwrap(); let snap = db.session_snapshot(session_id).unwrap(); assert!(!snap.annotations.is_empty()); assert!(snap.annotations[0].1.contains("rust")); // signaled_entities should include entity 5. assert!(snap.signaled_entities.contains(&5)); db.close_session(handle).unwrap(); } // ── Step 7: Session snapshot is accessible after close ───────────────────── #[test] fn step7_closed_session_snapshot() { let db = test_db(); let handle = db .start_session(50, "planner-agent", "planner_policy", HashMap::new()) .unwrap(); let session_id = handle.id; let ts = Timestamp::now(); db.session_signal(&handle, "reward", EntityId::new(1), 1.0, ts, None) .unwrap(); db.session_signal(&handle, "view", EntityId::new(2), 0.5, ts, None) .unwrap(); let summary = db.close_session(handle).unwrap(); assert_eq!(summary.signals_written, 2); // Snapshot should be retrievable from closed_sessions. let snap = db.session_snapshot(session_id).unwrap(); assert_eq!(snap.signals_written, 2); assert_eq!(snap.signals_rejected, 0); } // ── Step 8: FOR SESSION ranking boost ───────────────────────────────────── #[test] fn step8_for_session_ranking_boost() { let db = test_db(); // Write 10 content items. for i in 1u64..=10 { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("item-{i}")); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); } // Write global signals to entity 1 (so it ranks without any session). let ts = Timestamp::now(); for _ in 0..5 { db.signal("view", EntityId::new(1), 1.0, ts).unwrap(); } let handle = db .start_session(60, "planner-agent", "planner_policy", HashMap::new()) .unwrap(); let session_id = handle.id; // Signal entity 5 heavily in the session (the "personalized" pick). for _ in 0..10 { db.session_signal(&handle, "reward", EntityId::new(5), 2.0, ts, None) .unwrap(); } // Query WITH for_session: entity 5 should get a boost. let query = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("hot")) .limit(10) .for_session(session_id) .build() .unwrap(); let results_with = db.retrieve(&query).unwrap(); // Query WITHOUT for_session: entity 5 has no global signals. let query_no_session = RetrieveBuilder::new(EntityKind::Item, ProfileRef::new("hot")) .limit(10) .build() .unwrap(); let results_without = db.retrieve(&query_no_session).unwrap(); // Both queries should return results. assert!(!results_with.items.is_empty()); assert!(!results_without.items.is_empty()); // WITH session should have a session_snapshot attached. assert!( results_with.session_snapshot.is_some(), "FOR SESSION query should populate session_snapshot in results" ); // Entity 5 should rank higher with the session boost than without. let rank_with = results_with .items .iter() .position(|r| r.entity_id == EntityId::new(5)); let rank_without = results_without .items .iter() .position(|r| r.entity_id == EntityId::new(5)); // With session boost, entity 5 should appear (even if entity 1 still leads // due to global signals). assert!( rank_with.is_some(), "entity 5 should appear in FOR SESSION results" ); // If entity 5 appears in both, its rank should be better (lower index) // with the session boost. if let (Some(rw), Some(rwo)) = (rank_with, rank_without) { assert!( rw <= rwo, "entity 5 rank={rw} with session should be at least as good as rank={rwo} without" ); } db.close_session(handle).unwrap(); } // ── Step 9: Session isolation — two sessions don't cross-contaminate ──────── #[test] fn step9_session_isolation() { let db = test_db(); for i in 1u64..=5 { let mut meta = HashMap::new(); meta.insert("title".to_string(), format!("item-{i}")); db.write_item_with_metadata(EntityId::new(i), &meta) .unwrap(); } // Session A: signals entity 1. let handle_a = db .start_session(70, "agent-a", "planner_policy", HashMap::new()) .unwrap(); let id_a = handle_a.id; // Session B: signals entity 5. let handle_b = db .start_session(71, "agent-b", "planner_policy", HashMap::new()) .unwrap(); let id_b = handle_b.id; let ts = Timestamp::now(); db.session_signal(&handle_a, "reward", EntityId::new(1), 1.0, ts, None) .unwrap(); db.session_signal(&handle_b, "reward", EntityId::new(5), 1.0, ts, None) .unwrap(); // Snapshot A should only see entity 1. let snap_a = db.session_snapshot(id_a).unwrap(); assert!( snap_a.signaled_entities.contains(&1), "session A should have entity 1" ); assert!( !snap_a.signaled_entities.contains(&5), "session A should NOT have entity 5" ); // Snapshot B should only see entity 5. let snap_b = db.session_snapshot(id_b).unwrap(); assert!( snap_b.signaled_entities.contains(&5), "session B should have entity 5" ); assert!( !snap_b.signaled_entities.contains(&1), "session B should NOT have entity 1" ); db.close_session(handle_a).unwrap(); db.close_session(handle_b).unwrap(); } // ── Step 10: Invalid policy name is rejected ──────────────────────────────── #[test] fn step10_invalid_policy_name_rejected() { let db = test_db(); // "nonexistent_policy" is not declared in the schema. let result = db.start_session(80, "test-agent", "nonexistent_policy", HashMap::new()); assert!(result.is_err(), "unknown policy should be rejected"); let err_str = format!("{}", result.unwrap_err()); assert!( err_str.contains("policy") || err_str.contains("not found"), "error should mention policy: {err_str}" ); } // ── Step 11: AgentId validation ───────────────────────────────────────────── #[test] fn step11_agent_id_validation() { // Valid AgentId formats. assert!(AgentId::new("planner-agent").is_ok()); assert!(AgentId::new("agent_01").is_ok()); assert!(AgentId::new("a").is_ok()); // Invalid formats. assert!(AgentId::new("").is_err()); // empty assert!(AgentId::new("Agent-ID").is_err()); // uppercase assert!(AgentId::new("agent id").is_err()); // space assert!(AgentId::new(&"a".repeat(65)).is_err()); // too long } // ── Step 12: active_sessions tracks multiple sessions ────────────────────── #[test] fn step12_active_sessions_tracking() { let db = test_db(); let h1 = db .start_session(90, "agent-1", "planner_policy", HashMap::new()) .unwrap(); let h2 = db .start_session(91, "agent-2", "planner_policy", HashMap::new()) .unwrap(); let active = db.active_sessions(); assert_eq!(active.len(), 2); let id1 = h1.id; db.close_session(h1).unwrap(); let active = db.active_sessions(); assert_eq!(active.len(), 1); assert_eq!(active[0].id, h2.id); db.close_session(h2).unwrap(); assert!(db.active_sessions().is_empty()); // Closed session snapshot still accessible. let snap = db.session_snapshot(id1).unwrap(); assert_eq!(snap.id, id1); }