mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
735 lines
27 KiB
Rust
735 lines
27 KiB
Rust
//! 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<String> = 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<String> = 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");
|
|
}
|