//! E2E integration tests for multi-agent personalities. //! //! Covers the full personality feature surface end-to-end without requiring //! the JSON-RPC HTTP stack: //! //! 1. Profile lifecycle — backwards compat, suffix auto-assignment, field roundtrip. //! 2. Memory isolation — two personalities have separate SQLite stores. //! 3. Personality file resolution — soul/memory.md fallback chain. //! 4. Prompt section behaviour — IdentitySection / UserFilesSection / PersonalityRosterSection. //! 5. Integration filtering — allowlist / passthrough / empty. //! 6. Thread-personality binding — persists through ConversationStore. //! //! Run with: `cargo test --test personality_e2e` use std::collections::HashSet; use std::sync::Arc; use serde_json::json; use tempfile::tempdir; use openhuman_core::openhuman::agent::prompts::types::LearnedContextData; use openhuman_core::openhuman::agent::prompts::{ IdentitySection, PersonalityRosterEntry, PersonalityRosterSection, PromptContext, PromptSection, ToolCallFormat, UserFilesSection, }; use openhuman_core::openhuman::embeddings::NoopEmbedding; use openhuman_core::openhuman::memory::{NamespaceDocumentInput, UnifiedMemory}; use openhuman_core::openhuman::memory_conversations::{ ensure_thread, list_threads, update_thread_title, ConversationStore, CreateConversationThread, }; use openhuman_core::openhuman::profiles::{ built_in_profiles, AgentProfile, AgentProfileStore, DEFAULT_PROFILE_ID, }; use openhuman_core::openhuman::profiles::{ filter_integrations, memory_subdir_for_suffix, memory_tree_subdir_for_suffix, resolve_personality_memory_md, resolve_personality_soul, session_raw_subdir_for_suffix, HasToolkit, PersonalityContext, }; // ───────────────────────────────────────────────────────────────────────────── // Test helpers // ───────────────────────────────────────────────────────────────────────────── fn make_profile(id: &str, name: &str) -> AgentProfile { AgentProfile { id: id.to_string(), name: name.to_string(), description: format!("{name} personality"), agent_id: "orchestrator".to_string(), model_override: None, temperature: None, system_prompt_suffix: None, allowed_tools: None, built_in: false, avatar_url: None, voice_id: None, soul_md: None, soul_md_path: None, composio_integrations: None, memory_sources: None, include_agent_conversations: true, allowed_skills: None, allowed_mcp_servers: None, memory_dir_suffix: None, is_master: false, sort_order: None, } } fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext<'a> { let visible_tool_names: &'a HashSet = Box::leak(Box::new(HashSet::new())); PromptContext { workspace_dir, model_name: "test-model", agent_id: "orchestrator", tools: &[], workflows: &[], dispatcher_instructions: "", learned: LearnedContextData::default(), visible_tool_names, tool_call_format: ToolCallFormat::PFormat, connected_integrations: &[], connected_identities_md: String::new(), include_profile: false, include_memory_md: false, curated_snapshot: None, user_identity: None, personality_soul_md: None, personality_memory_md: None, personality_roster: vec![], } } // ───────────────────────────────────────────────────────────────────────────── // 1. Profile lifecycle // ───────────────────────────────────────────────────────────────────────────── #[test] fn backwards_compat_old_profiles_json_deserializes_cleanly() { let tmp = tempdir().expect("tempdir"); let json = r#"{ "activeProfileId": "default", "profiles": [{ "id": "default", "name": "Default", "description": "Old profile pre-personalities.", "agentId": "orchestrator", "builtIn": true }, { "id": "research", "name": "Research", "description": "", "agentId": "researcher", "builtIn": true }] }"#; std::fs::write(tmp.path().join("agent_profiles.json"), json).expect("write"); let store = AgentProfileStore::new(tmp.path().to_path_buf()); let state = store.load().expect("load"); let default = state .profiles .iter() .find(|p| p.id == DEFAULT_PROFILE_ID) .expect("default"); assert!(default.is_master, "default should be master on first read"); assert_eq!(default.memory_dir_suffix.as_deref(), Some("")); assert_eq!(default.avatar_url, None); } #[test] fn three_personalities_get_sequential_memory_suffixes() { let tmp = tempdir().expect("tempdir"); let store = AgentProfileStore::new(tmp.path().to_path_buf()); let state = store.upsert(make_profile("alice", "Alice")).expect("alice"); let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); assert_eq!(alice.memory_dir_suffix.as_deref(), Some("-1")); let state = store.upsert(make_profile("bob", "Bob")).expect("bob"); let bob = state.profiles.iter().find(|p| p.id == "bob").unwrap(); assert_eq!(bob.memory_dir_suffix.as_deref(), Some("-2")); let state = store.upsert(make_profile("carol", "Carol")).expect("carol"); let carol = state.profiles.iter().find(|p| p.id == "carol").unwrap(); assert_eq!(carol.memory_dir_suffix.as_deref(), Some("-3")); } #[test] fn deleted_suffix_is_reused_by_next_personality() { let tmp = tempdir().expect("tempdir"); let store = AgentProfileStore::new(tmp.path().to_path_buf()); store.upsert(make_profile("alice", "Alice")).expect("alice"); store.upsert(make_profile("bob", "Bob")).expect("bob"); store.upsert(make_profile("carol", "Carol")).expect("carol"); store.delete("bob").expect("delete bob"); let state = store.upsert(make_profile("dave", "Dave")).expect("dave"); let dave = state.profiles.iter().find(|p| p.id == "dave").unwrap(); assert_eq!( dave.memory_dir_suffix.as_deref(), Some("-2"), "freed suffix -2 should be reused" ); } #[test] fn personality_fields_roundtrip_through_upsert() { let tmp = tempdir().expect("tempdir"); let store = AgentProfileStore::new(tmp.path().to_path_buf()); let mut alice = make_profile("alice", "Alice"); alice.avatar_url = Some("https://example.com/alice.png".to_string()); alice.voice_id = Some("voice-alice-123".to_string()); alice.soul_md = Some("I am Alice.".to_string()); alice.composio_integrations = Some(vec!["slack".to_string(), "gmail".to_string()]); alice.sort_order = Some(5); store.upsert(alice).expect("upsert"); let state = store.load().expect("reload"); let alice = state.profiles.iter().find(|p| p.id == "alice").unwrap(); assert_eq!( alice.avatar_url.as_deref(), Some("https://example.com/alice.png") ); assert_eq!(alice.voice_id.as_deref(), Some("voice-alice-123")); assert_eq!(alice.soul_md.as_deref(), Some("I am Alice.")); assert_eq!( alice.composio_integrations.as_ref().unwrap(), &["slack", "gmail"] ); assert_eq!(alice.sort_order, Some(5)); } #[test] fn default_profile_memory_suffix_cannot_be_overridden() { let tmp = tempdir().expect("tempdir"); let store = AgentProfileStore::new(tmp.path().to_path_buf()); let mut default_override = built_in_profiles() .into_iter() .find(|p| p.id == DEFAULT_PROFILE_ID) .expect("default profile in built-ins"); default_override.memory_dir_suffix = Some("-99".to_string()); default_override.avatar_url = Some("https://example.com/default.png".to_string()); let state = store.upsert(default_override).expect("upsert"); let default = state .profiles .iter() .find(|p| p.id == DEFAULT_PROFILE_ID) .unwrap(); assert_eq!( default.memory_dir_suffix.as_deref(), Some(""), "default suffix must stay empty regardless of user input" ); assert_eq!( default.avatar_url.as_deref(), Some("https://example.com/default.png") ); assert!(default.is_master); } // ───────────────────────────────────────────────────────────────────────────── // 2. Memory isolation // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] async fn two_personalities_have_isolated_sqlite_stores() { let tmp = tempdir().expect("tempdir"); let mem_default = UnifiedMemory::new_with_memory_dir( tmp.path(), &memory_subdir_for_suffix(""), Arc::new(NoopEmbedding), None, ) .expect("default memory"); let mem_alice = UnifiedMemory::new_with_memory_dir( tmp.path(), &memory_subdir_for_suffix("-1"), Arc::new(NoopEmbedding), None, ) .expect("alice memory"); assert_ne!(mem_default.db_path(), mem_alice.db_path()); assert!(mem_default.db_path().ends_with("memory/memory.db")); assert!(mem_alice.db_path().ends_with("memory-1/memory.db")); assert!(mem_default.db_path().exists()); assert!(mem_alice.db_path().exists()); mem_default .upsert_document(NamespaceDocumentInput { namespace: "shared".to_string(), key: "default-only".to_string(), title: "Default's note".to_string(), content: "Only the default agent knows about this.".to_string(), source_type: "doc".to_string(), priority: "high".to_string(), tags: vec![], metadata: json!({}), category: "core".to_string(), session_id: None, document_id: None, taint: openhuman_core::openhuman::memory::MemoryTaint::Internal, }) .await .expect("write default"); mem_alice .upsert_document(NamespaceDocumentInput { namespace: "shared".to_string(), key: "alice-only".to_string(), title: "Alice's note".to_string(), content: "Only Alice knows about this.".to_string(), source_type: "doc".to_string(), priority: "high".to_string(), tags: vec![], metadata: json!({}), category: "core".to_string(), session_id: None, document_id: None, taint: openhuman_core::openhuman::memory::MemoryTaint::Internal, }) .await .expect("write alice"); let default_hits = mem_default .query_namespace_ranked("shared", "note", 10) .await .expect("default query"); let alice_hits = mem_alice .query_namespace_ranked("shared", "note", 10) .await .expect("alice query"); let default_keys: Vec<_> = default_hits.iter().map(|h| h.key.as_str()).collect(); let alice_keys: Vec<_> = alice_hits.iter().map(|h| h.key.as_str()).collect(); assert!( default_keys.contains(&"default-only"), "default sees its own doc" ); assert!( !default_keys.contains(&"alice-only"), "default must NOT see alice's doc" ); assert!(alice_keys.contains(&"alice-only"), "alice sees her own doc"); assert!( !alice_keys.contains(&"default-only"), "alice must NOT see default's doc" ); } #[tokio::test] async fn personality_memory_persists_across_reopens() { let tmp = tempdir().expect("tempdir"); { let mem = UnifiedMemory::new_with_memory_dir( tmp.path(), &memory_subdir_for_suffix("-1"), Arc::new(NoopEmbedding), None, ) .expect("open 1"); mem.upsert_document(NamespaceDocumentInput { namespace: "alice".to_string(), key: "persistent".to_string(), title: "Persistent note".to_string(), content: "This must survive a reopen.".to_string(), source_type: "doc".to_string(), priority: "high".to_string(), tags: vec![], metadata: json!({}), category: "core".to_string(), session_id: None, document_id: None, taint: openhuman_core::openhuman::memory::MemoryTaint::Internal, }) .await .expect("write"); } let mem2 = UnifiedMemory::new_with_memory_dir( tmp.path(), &memory_subdir_for_suffix("-1"), Arc::new(NoopEmbedding), None, ) .expect("reopen"); let hits = mem2 .query_namespace_ranked("alice", "persistent", 10) .await .expect("query"); assert!(hits.iter().any(|h| h.key == "persistent")); } // ───────────────────────────────────────────────────────────────────────────── // 3. Personality file resolution // ───────────────────────────────────────────────────────────────────────────── #[test] fn resolve_soul_falls_back_through_path_inline_none() { let tmp = tempdir().expect("tempdir"); let mut profile = make_profile("alice", "Alice"); // No overrides → None. assert!(resolve_personality_soul(tmp.path(), &profile).is_none()); // Inline only. profile.soul_md = Some("Inline soul.".to_string()); assert_eq!( resolve_personality_soul(tmp.path(), &profile).as_deref(), Some("Inline soul.") ); // File path takes precedence over inline. let soul_path = tmp.path().join("souls").join("alice.md"); std::fs::create_dir_all(soul_path.parent().unwrap()).unwrap(); std::fs::write(&soul_path, "File-based soul.").unwrap(); profile.soul_md_path = Some("souls/alice.md".to_string()); assert_eq!( resolve_personality_soul(tmp.path(), &profile).as_deref(), Some("File-based soul.") ); // Missing file falls back to inline. profile.soul_md_path = Some("souls/missing.md".to_string()); assert_eq!( resolve_personality_soul(tmp.path(), &profile).as_deref(), Some("Inline soul.") ); } #[test] fn resolve_memory_md_reads_personality_dir() { let tmp = tempdir().expect("tempdir"); let profile = make_profile("alice", "Alice"); assert!(resolve_personality_memory_md(tmp.path(), &profile).is_none()); let mem_path = tmp .path() .join("personalities") .join("alice") .join("MEMORY.md"); std::fs::create_dir_all(mem_path.parent().unwrap()).unwrap(); std::fs::write(&mem_path, "Alice's curated memory.").unwrap(); assert_eq!( resolve_personality_memory_md(tmp.path(), &profile).as_deref(), Some("Alice's curated memory.") ); } #[test] fn personality_context_aggregates_all_overrides() { let tmp = tempdir().expect("tempdir"); let mut alice = make_profile("alice", "Alice"); alice.memory_dir_suffix = Some("-1".to_string()); alice.voice_id = Some("voice-alice".to_string()); alice.soul_md = Some("I am Alice.".to_string()); alice.composio_integrations = Some(vec!["slack".to_string()]); let mem_path = tmp .path() .join("personalities") .join("alice") .join("MEMORY.md"); std::fs::create_dir_all(mem_path.parent().unwrap()).unwrap(); std::fs::write(&mem_path, "Curated.").unwrap(); let ctx = PersonalityContext::from_profile(tmp.path(), alice); assert_eq!(ctx.memory_suffix, "-1"); assert_eq!(ctx.voice_id.as_deref(), Some("voice-alice")); assert_eq!(ctx.soul_md_override.as_deref(), Some("I am Alice.")); assert_eq!(ctx.memory_md_override.as_deref(), Some("Curated.")); assert_eq!(ctx.composio_allowlist.as_ref().unwrap(), &["slack"]); } #[test] fn subdir_helpers_handle_empty_and_numeric_suffixes() { assert_eq!(memory_subdir_for_suffix(""), "memory"); assert_eq!(memory_subdir_for_suffix("-1"), "memory-1"); assert_eq!(memory_tree_subdir_for_suffix(""), "memory_tree"); assert_eq!(memory_tree_subdir_for_suffix("-2"), "memory_tree-2"); assert_eq!(session_raw_subdir_for_suffix(""), "session_raw"); assert_eq!(session_raw_subdir_for_suffix("-3"), "session_raw-3"); } // ───────────────────────────────────────────────────────────────────────────── // 4. Prompt section behaviour // ───────────────────────────────────────────────────────────────────────────── #[test] fn identity_section_uses_personality_soul_when_set() { let tmp = tempdir().expect("tempdir"); let mut ctx = empty_prompt_context(tmp.path()); ctx.personality_soul_md = Some("# I am Alice\n\nI specialise in research.".to_string()); let rendered = IdentitySection.build(&ctx).expect("render"); assert!( rendered.contains("I am Alice"), "personality soul must be in rendered prompt: {rendered}" ); assert!(rendered.contains("specialise in research")); } #[test] fn user_files_section_prefers_personality_memory_md() { let tmp = tempdir().expect("tempdir"); // Write a workspace-level MEMORY.md so we can prove personality override wins. std::fs::write( tmp.path().join("MEMORY.md"), "WORKSPACE memory contents — should not appear.", ) .expect("write workspace memory"); let mut ctx = empty_prompt_context(tmp.path()); ctx.include_memory_md = true; ctx.personality_memory_md = Some("PERSONALITY memory contents.".to_string()); let rendered = UserFilesSection.build(&ctx).expect("render"); assert!( rendered.contains("PERSONALITY memory"), "personality memory should be rendered: {rendered}" ); assert!( !rendered.contains("WORKSPACE memory"), "workspace memory must not be rendered when personality memory is set" ); } #[test] fn user_files_section_falls_back_to_workspace_memory_md() { let tmp = tempdir().expect("tempdir"); std::fs::write(tmp.path().join("MEMORY.md"), "Workspace fallback memory.").expect("write"); let mut ctx = empty_prompt_context(tmp.path()); ctx.include_memory_md = true; // No personality override. let rendered = UserFilesSection.build(&ctx).expect("render"); assert!( rendered.contains("Workspace fallback memory"), "should fall back to workspace MEMORY.md: {rendered}" ); } #[test] fn personality_roster_renders_entries_with_truncated_summaries() { let tmp = tempdir().expect("tempdir"); let mut ctx = empty_prompt_context(tmp.path()); let long_summary = "A".repeat(300); ctx.personality_roster = vec![ PersonalityRosterEntry { id: "alice".to_string(), name: "Alice".to_string(), description: "Research specialist.".to_string(), memory_summary: Some(long_summary.clone()), }, PersonalityRosterEntry { id: "bob".to_string(), name: "Bob".to_string(), description: "Code reviewer.".to_string(), memory_summary: None, }, ]; let rendered = PersonalityRosterSection.build(&ctx).expect("render"); assert!(rendered.contains("Available Personalities")); assert!(rendered.contains("Alice")); assert!(rendered.contains("alice")); assert!(rendered.contains("Research specialist.")); assert!(rendered.contains("Bob")); assert!(rendered.contains("Code reviewer.")); // Summary capped at 200 chars + ellipsis. assert!( !rendered.contains(&"A".repeat(250)), "summary should be truncated below 250 chars" ); assert!(rendered.contains("…")); } #[test] fn personality_roster_renders_empty_when_no_entries() { let tmp = tempdir().expect("tempdir"); let ctx = empty_prompt_context(tmp.path()); let rendered = PersonalityRosterSection.build(&ctx).expect("render"); assert!(rendered.is_empty(), "empty roster should render nothing"); } // ───────────────────────────────────────────────────────────────────────────── // 5. Integration filtering // ───────────────────────────────────────────────────────────────────────────── #[derive(Clone, Debug)] struct FakeIntegration { toolkit: String, } impl HasToolkit for FakeIntegration { fn toolkit_name(&self) -> &str { &self.toolkit } } #[test] fn filter_integrations_none_is_passthrough() { let all = vec![ FakeIntegration { toolkit: "slack".into(), }, FakeIntegration { toolkit: "gmail".into(), }, FakeIntegration { toolkit: "notion".into(), }, ]; let filtered = filter_integrations(&all, None); assert_eq!(filtered.len(), 3); } #[test] fn filter_integrations_allowlist_is_case_insensitive() { let all = vec![ FakeIntegration { toolkit: "slack".into(), }, FakeIntegration { toolkit: "gmail".into(), }, FakeIntegration { toolkit: "notion".into(), }, ]; let allowed = vec!["SLACK".to_string(), "Gmail".to_string()]; let filtered = filter_integrations(&all, Some(&allowed)); let names: Vec<_> = filtered.iter().map(|i| i.toolkit.as_str()).collect(); assert_eq!(names, vec!["slack", "gmail"]); } #[test] fn filter_integrations_empty_allowlist_returns_nothing() { let all = vec![FakeIntegration { toolkit: "slack".into(), }]; let empty: Vec = vec![]; let filtered = filter_integrations(&all, Some(&empty)); assert!(filtered.is_empty()); } // ───────────────────────────────────────────────────────────────────────────── // 6. Thread-personality binding // ───────────────────────────────────────────────────────────────────────────── #[test] fn thread_personality_id_persists_through_store() { let tmp = tempdir().expect("tempdir"); let store = ConversationStore::new(tmp.path().to_path_buf()); let thread = store .ensure_thread(CreateConversationThread { id: "thread-alice".to_string(), title: "Alice chat".to_string(), created_at: "2026-05-27T10:00:00Z".to_string(), parent_thread_id: None, labels: Some(vec!["work".to_string()]), personality_id: Some("alice".to_string()), }) .expect("ensure"); assert_eq!(thread.personality_id.as_deref(), Some("alice")); let listed = store.list_threads().expect("list"); let alice_thread = listed.iter().find(|t| t.id == "thread-alice").unwrap(); assert_eq!(alice_thread.personality_id.as_deref(), Some("alice")); } #[test] fn threads_without_personality_id_remain_none() { let tmp = tempdir().expect("tempdir"); let store = ConversationStore::new(tmp.path().to_path_buf()); store .ensure_thread(CreateConversationThread { id: "legacy-thread".to_string(), title: "Legacy".to_string(), created_at: "2026-05-27T10:00:00Z".to_string(), parent_thread_id: None, labels: None, personality_id: None, }) .expect("ensure"); let listed = store.list_threads().expect("list"); let legacy = listed.iter().find(|t| t.id == "legacy-thread").unwrap(); assert!(legacy.personality_id.is_none()); } #[test] fn updating_thread_title_preserves_personality_id() { let tmp = tempdir().expect("tempdir"); let store = ConversationStore::new(tmp.path().to_path_buf()); store .ensure_thread(CreateConversationThread { id: "thread-bob".to_string(), title: "Original".to_string(), created_at: "2026-05-27T10:00:00Z".to_string(), parent_thread_id: None, labels: None, personality_id: Some("bob".to_string()), }) .expect("ensure"); store .update_thread_title("thread-bob", "Updated title", "2026-05-27T11:00:00Z") .expect("update title"); let listed = store.list_threads().expect("list"); let bob = listed.iter().find(|t| t.id == "thread-bob").unwrap(); assert_eq!(bob.title, "Updated title"); assert_eq!( bob.personality_id.as_deref(), Some("bob"), "personality_id must survive title updates" ); } #[test] fn module_level_helpers_round_trip_personality_id() { // Verify the free-function API (used by some callers) also propagates personality_id. let tmp = tempdir().expect("tempdir"); let workspace = tmp.path().to_path_buf(); let thread = ensure_thread( workspace.clone(), CreateConversationThread { id: "module-thread".to_string(), title: "via module".to_string(), created_at: "2026-05-27T12:00:00Z".to_string(), parent_thread_id: None, labels: None, personality_id: Some("carol".to_string()), }, ) .expect("ensure"); assert_eq!(thread.personality_id.as_deref(), Some("carol")); update_thread_title( workspace.clone(), "module-thread", "Updated", "2026-05-27T13:00:00Z", ) .expect("update title"); let threads = list_threads(workspace).expect("list"); let found = threads.iter().find(|t| t.id == "module-thread").unwrap(); assert_eq!(found.personality_id.as_deref(), Some("carol")); assert_eq!(found.title, "Updated"); }