diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index 3885c0899..b180457f8 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -1,5 +1,6 @@ use super::*; use serde_json::json; +use tempfile::tempdir; #[test] fn sanitize_snapshot_user_drops_empty_payloads() { @@ -34,3 +35,152 @@ fn cached_entry_is_considered_expired_past_ttl() { let expired = make_cached_entry(CURRENT_USER_REFRESH_TTL + Duration::from_millis(50)); assert!(expired.fetched_at.elapsed() >= CURRENT_USER_REFRESH_TTL); } + +#[test] +fn app_state_path_creates_state_dir_and_points_at_app_state_json() { + let tmp = tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + + let path = app_state_path(&cfg).expect("app_state_path"); + assert!(path.ends_with("state/app-state.json")); + assert!( + cfg.workspace_dir.join("state").is_dir(), + "state dir should be created eagerly" + ); +} + +#[test] +fn resolve_base_normalizes_missing_trailing_slash() { + let mut cfg = Config::default(); + cfg.api_url = Some("https://api.example.test/openhuman".into()); + + let base = resolve_base(&cfg).expect("resolve_base"); + assert_eq!(base.as_str(), "https://api.example.test/"); +} + +#[test] +fn resolve_base_rejects_invalid_urls() { + let mut cfg = Config::default(); + cfg.api_url = Some("://definitely-not-a-url".into()); + + let err = resolve_base(&cfg).expect_err("invalid URL should fail"); + assert!(err.contains("invalid api_url")); +} + +#[test] +fn load_stored_app_state_returns_default_when_missing() { + let tmp = tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + + let state = load_stored_app_state(&cfg).expect("load default app state"); + assert!(state.encryption_key.is_none()); + assert!(state.onboarding_tasks.is_none()); +} + +#[test] +fn load_stored_app_state_quarantines_invalid_json_and_returns_default() { + let tmp = tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + + let path = app_state_path(&cfg).expect("app_state_path"); + std::fs::write(&path, "{ definitely not valid json").unwrap(); + + let state = load_stored_app_state(&cfg).expect("load invalid app state"); + assert!(state.encryption_key.is_none()); + assert!(state.onboarding_tasks.is_none()); + assert!( + !path.exists(), + "invalid source file should be quarantined or removed" + ); + + let state_dir = path.parent().expect("state dir"); + let quarantined: Vec<_> = std::fs::read_dir(state_dir) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.starts_with("app-state.json.corrupted.")) + .collect(); + assert_eq!(quarantined.len(), 1, "expected one quarantined copy"); +} + +#[test] +fn save_and_reload_stored_app_state_round_trips() { + let tmp = tempdir().unwrap(); + let mut cfg = Config::default(); + cfg.workspace_dir = tmp.path().join("workspace"); + + let state = StoredAppState { + encryption_key: Some("enc-key".into()), + onboarding_tasks: Some(StoredOnboardingTasks { + accessibility_permission_granted: true, + local_model_consent_given: true, + local_model_download_started: false, + enabled_tools: vec!["search".into()], + connected_sources: vec!["telegram".into()], + updated_at_ms: Some(42), + }), + }; + + save_stored_app_state(&cfg, &state).expect("save app state"); + let reloaded = load_stored_app_state(&cfg).expect("reload app state"); + assert_eq!(reloaded.encryption_key, Some("enc-key".into())); + let tasks = reloaded.onboarding_tasks.expect("onboarding tasks"); + assert!(tasks.accessibility_permission_granted); + assert!(tasks.local_model_consent_given); + assert_eq!(tasks.enabled_tools, vec!["search".to_string()]); + assert_eq!(tasks.connected_sources, vec!["telegram".to_string()]); + assert_eq!(tasks.updated_at_ms, Some(42)); +} + +#[test] +fn peek_cached_current_user_identity_plucks_known_fields() { + struct CacheResetGuard; + impl Drop for CacheResetGuard { + fn drop(&mut self) { + *CURRENT_USER_CACHE.lock() = None; + } + } + let _reset = CacheResetGuard; + *CURRENT_USER_CACHE.lock() = Some(CachedCurrentUser { + api_base: "https://api.example.test".into(), + token: "tok".into(), + fetched_at: Instant::now(), + user: json!({ + "userId": "user-123", + "display_name": "Alice Example", + "email": "alice@example.test", + "ignored": "x" + }), + }); + + let identity = peek_cached_current_user_identity().expect("identity"); + assert_eq!(identity.id.as_deref(), Some("user-123")); + assert_eq!(identity.name.as_deref(), Some("Alice Example")); + assert_eq!(identity.email.as_deref(), Some("alice@example.test")); +} + +#[test] +fn peek_cached_current_user_identity_returns_none_when_only_empty_fields_exist() { + struct CacheResetGuard; + impl Drop for CacheResetGuard { + fn drop(&mut self) { + *CURRENT_USER_CACHE.lock() = None; + } + } + let _reset = CacheResetGuard; + *CURRENT_USER_CACHE.lock() = Some(CachedCurrentUser { + api_base: "https://api.example.test".into(), + token: "tok".into(), + fetched_at: Instant::now(), + user: json!({ + "id": " ", + "name": "", + "email": " " + }), + }); + + assert!(peek_cached_current_user_identity().is_none()); +} diff --git a/src/openhuman/channels/routes_tests.rs b/src/openhuman/channels/routes_tests.rs index 7a266af7e..d5527767f 100644 --- a/src/openhuman/channels/routes_tests.rs +++ b/src/openhuman/channels/routes_tests.rs @@ -4,7 +4,7 @@ use crate::openhuman::channels::context::{ }; use crate::openhuman::channels::traits::ChannelMessage; use crate::openhuman::memory::{Memory, MemoryCategory, MemoryEntry}; -use crate::openhuman::providers::Provider; +use crate::openhuman::providers::{ChatMessage, Provider}; use crate::openhuman::tools::{Tool, ToolResult}; use async_trait::async_trait; use std::collections::HashMap; @@ -107,6 +107,27 @@ impl Tool for DummyTool { } } +#[derive(Default)] +struct RecordingChannel { + sent: Mutex>, +} + +#[async_trait] +impl Channel for RecordingChannel { + fn name(&self) -> &str { + "recording" + } + + async fn send(&self, message: &SendMessage) -> anyhow::Result<()> { + self.sent.lock().unwrap().push(message.clone()); + Ok(()) + } + + async fn listen(&self, _tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { + Ok(()) + } +} + fn runtime_context(workspace_dir: PathBuf) -> ChannelRuntimeContext { ChannelRuntimeContext { channels_by_name: Arc::new(HashMap::new()), @@ -254,3 +275,85 @@ fn model_command_messages_use_thread_aware_history_keys() { "discord_alice_room_thread:thread-1" ); } + +#[test] +fn load_cached_model_preview_returns_empty_when_cache_json_is_invalid() { + let tempdir = tempfile::tempdir().unwrap(); + let state_dir = tempdir.path().join("state"); + std::fs::create_dir_all(&state_dir).unwrap(); + std::fs::write( + state_dir.join(MODEL_CACHE_FILE), + "{ definitely invalid json", + ) + .unwrap(); + + assert!(load_cached_model_preview(tempdir.path(), "openai").is_empty()); +} + +#[tokio::test] +async fn handle_runtime_command_unknown_provider_sends_helpful_error() { + let ctx = runtime_context(PathBuf::from("/tmp")); + let channel_impl = Arc::new(RecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + let msg = ChannelMessage { + id: "1".into(), + sender: "alice".into(), + reply_target: "room".into(), + content: "/models definitely-not-a-provider".into(), + channel: "telegram".into(), + timestamp: 0, + thread_ts: Some("thread-1".into()), + }; + + let handled = handle_runtime_command_if_needed(&ctx, &msg, Some(&channel)).await; + assert!(handled); + + let sent = channel_impl.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert!(sent[0] + .content + .contains("Unknown provider `definitely-not-a-provider`")); + assert_eq!(sent[0].thread_ts.as_deref(), Some("thread-1")); +} + +#[tokio::test] +async fn handle_runtime_command_set_model_clears_sender_history_and_persists_route_override() { + let ctx = runtime_context(PathBuf::from("/tmp")); + let key = "telegram_alice_room"; + ctx.conversation_histories + .lock() + .unwrap() + .insert(key.to_string(), vec![ChatMessage::user("old history")]); + let channel_impl = Arc::new(RecordingChannel::default()); + let channel: Arc = channel_impl.clone(); + let msg = ChannelMessage { + id: "1".into(), + sender: "alice".into(), + reply_target: "room".into(), + content: "/model gpt-5-mini".into(), + channel: "telegram".into(), + timestamp: 0, + thread_ts: None, + }; + + let handled = handle_runtime_command_if_needed(&ctx, &msg, Some(&channel)).await; + assert!(handled); + + assert!(ctx + .conversation_histories + .lock() + .unwrap() + .get(key) + .is_none()); + assert_eq!( + get_route_selection(&ctx, key), + ChannelRouteSelection { + provider: "openai".into(), + model: "gpt-5-mini".into() + } + ); + + let sent = channel_impl.sent.lock().unwrap(); + assert_eq!(sent.len(), 1); + assert!(sent[0].content.contains("Model switched to `gpt-5-mini`")); +} diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 4e8acf544..2e4cbe39c 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -380,10 +380,11 @@ pub async fn apply_model_settings( config.cloud_providers = providers; } if let Some(primary) = update.primary_cloud { - config.primary_cloud = if primary.trim().is_empty() { + let trimmed = primary.trim(); + config.primary_cloud = if trimmed.is_empty() { None } else { - Some(primary) + Some(trimmed.to_string()) }; } diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 834c76ace..2d4bf62dc 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -820,3 +820,91 @@ async fn workspace_onboarding_flag_set_round_trip() { std::env::remove_var("OPENHUMAN_WORKSPACE"); } } + +#[tokio::test] +async fn apply_model_settings_trims_and_clears_optional_provider_fields() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + let set = ModelSettingsPatch { + inference_url: Some(" https://llm.example.test/v1 ".into()), + primary_cloud: Some(" provider-a ".into()), + reasoning_provider: Some(" provider-reasoning ".into()), + agentic_provider: Some(" provider-agentic ".into()), + coding_provider: Some(" provider-coding ".into()), + memory_provider: Some(" provider-memory ".into()), + embeddings_provider: Some(" provider-embed ".into()), + heartbeat_provider: Some(" provider-heartbeat ".into()), + learning_provider: Some(" provider-learning ".into()), + subconscious_provider: Some(" provider-sub ".into()), + ..Default::default() + }; + apply_model_settings(&mut cfg, set) + .await + .expect("set providers"); + assert_eq!( + cfg.inference_url.as_deref(), + Some("https://llm.example.test/v1") + ); + assert_eq!(cfg.primary_cloud.as_deref(), Some("provider-a")); + assert_eq!( + cfg.reasoning_provider.as_deref(), + Some("provider-reasoning") + ); + assert_eq!(cfg.subconscious_provider.as_deref(), Some("provider-sub")); + + let clear = ModelSettingsPatch { + inference_url: Some(" ".into()), + primary_cloud: Some("".into()), + reasoning_provider: Some(" ".into()), + agentic_provider: Some(" ".into()), + coding_provider: Some(" ".into()), + memory_provider: Some(" ".into()), + embeddings_provider: Some(" ".into()), + heartbeat_provider: Some(" ".into()), + learning_provider: Some(" ".into()), + subconscious_provider: Some(" ".into()), + ..Default::default() + }; + apply_model_settings(&mut cfg, clear) + .await + .expect("clear providers"); + assert!(cfg.inference_url.is_none()); + assert!(cfg.primary_cloud.is_none()); + assert!(cfg.reasoning_provider.is_none()); + assert!(cfg.agentic_provider.is_none()); + assert!(cfg.coding_provider.is_none()); + assert!(cfg.memory_provider.is_none()); + assert!(cfg.embeddings_provider.is_none()); + assert!(cfg.heartbeat_provider.is_none()); + assert!(cfg.learning_provider.is_none()); + assert!(cfg.subconscious_provider.is_none()); +} + +#[tokio::test] +async fn apply_screen_intelligence_settings_clamps_baseline_fps() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + + apply_screen_intelligence_settings( + &mut cfg, + ScreenIntelligenceSettingsPatch { + baseline_fps: Some(99.0), + ..Default::default() + }, + ) + .await + .expect("high clamp"); + assert!((cfg.screen_intelligence.baseline_fps - 30.0).abs() < f32::EPSILON); + + apply_screen_intelligence_settings( + &mut cfg, + ScreenIntelligenceSettingsPatch { + baseline_fps: Some(0.01), + ..Default::default() + }, + ) + .await + .expect("low clamp"); + assert!((cfg.screen_intelligence.baseline_fps - 0.2).abs() < f32::EPSILON); +} diff --git a/src/openhuman/context/channels_prompt.rs b/src/openhuman/context/channels_prompt.rs index afaa39c65..fc6fe91a7 100644 --- a/src/openhuman/context/channels_prompt.rs +++ b/src/openhuman/context/channels_prompt.rs @@ -251,3 +251,108 @@ fn inject_workspace_file( } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::skills::{Skill, SkillFrontmatter, SkillScope}; + use tempfile::tempdir; + + fn skill(name: &str, location: Option) -> Skill { + Skill { + name: name.to_string(), + dir_name: name.to_string(), + description: format!("{name} description"), + version: "1.0.0".into(), + author: None, + tags: vec![], + tools: vec![], + prompts: vec![], + location, + frontmatter: SkillFrontmatter::default(), + resources: vec![], + scope: SkillScope::Project, + legacy: false, + warnings: vec![], + } + } + + #[test] + fn inject_workspace_file_truncates_at_utf8_boundary_and_emits_read_hint() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("SOUL.md"), "é".repeat(10)).unwrap(); + + let mut prompt = String::new(); + inject_workspace_file(&mut prompt, tmp.path(), "SOUL.md", 5); + + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("truncated at 5 chars")); + assert!(prompt.is_char_boundary(prompt.len())); + assert!(prompt.contains("read` for full file") || prompt.contains("read` for full file]")); + } + + #[test] + fn inject_workspace_file_emits_missing_marker_for_bootstrap_files() { + let tmp = tempdir().unwrap(); + let mut prompt = String::new(); + + inject_workspace_file(&mut prompt, tmp.path(), "IDENTITY.md", 100); + + assert!(prompt.contains("[File not found: IDENTITY.md]")); + } + + #[test] + fn load_openclaw_bootstrap_files_omits_missing_optional_files() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("SOUL.md"), "Soul").unwrap(); + std::fs::write(tmp.path().join("IDENTITY.md"), "Identity").unwrap(); + + let mut prompt = String::new(); + load_openclaw_bootstrap_files(&mut prompt, tmp.path(), 100); + + assert!(prompt.contains("### SOUL.md")); + assert!(prompt.contains("### IDENTITY.md")); + assert!(!prompt.contains("PROFILE.md")); + assert!(!prompt.contains("MEMORY.md")); + } + + #[test] + fn build_system_prompt_uses_generic_channel_copy_when_channel_name_absent() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("SOUL.md"), "Soul").unwrap(); + std::fs::write(tmp.path().join("IDENTITY.md"), "Identity").unwrap(); + + let prompt = build_system_prompt(tmp.path(), "test-model", &[], &[], None, None); + + assert!(prompt.contains("You are running as a messaging bot.")); + assert!(prompt.contains("When someone messages you, your response is automatically sent back to the same platform.")); + assert!(!prompt.contains("running as a Discord bot")); + } + + #[test] + fn build_system_prompt_uses_workspace_skill_location_fallback() { + let tmp = tempdir().unwrap(); + std::fs::write(tmp.path().join("SOUL.md"), "Soul").unwrap(); + std::fs::write(tmp.path().join("IDENTITY.md"), "Identity").unwrap(); + + let prompt = build_system_prompt( + tmp.path(), + "test-model", + &[("search", "Find facts")], + &[skill("research-helper", None)], + None, + Some("Telegram"), + ); + + assert!(prompt.contains("research-helper")); + assert!(prompt.contains( + &tmp.path() + .join("skills") + .join("research-helper") + .join("SKILL.md") + .display() + .to_string() + )); + assert!(prompt.contains("running as a Telegram bot")); + } +} diff --git a/src/openhuman/context/manager_tests.rs b/src/openhuman/context/manager_tests.rs index 9f913b88b..c12278a68 100644 --- a/src/openhuman/context/manager_tests.rs +++ b/src/openhuman/context/manager_tests.rs @@ -317,3 +317,119 @@ fn stats_reports_snapshot() { assert_eq!(s.session_memory_current_turn, 1); assert_eq!(s.session_memory_total_tool_calls, 3); } + +struct RecordingModelSummarizer { + model: Mutex>, +} + +#[async_trait] +impl Summarizer for RecordingModelSummarizer { + async fn summarize( + &self, + history: &mut Vec, + model: &str, + ) -> anyhow::Result { + *self.model.lock().unwrap() = Some(model.to_string()); + history.clear(); + Ok(SummaryStats { + messages_removed: 0, + approx_tokens_freed: 0, + summary_chars: 0, + }) + } +} + +#[tokio::test] +async fn new_honors_summarizer_model_override() { + let summarizer = Arc::new(RecordingModelSummarizer { + model: Mutex::new(None), + }); + let mut config = ContextConfig::default(); + config.summarizer_model = Some("compact-model".into()); + let mut manager = ContextManager::new( + &config, + summarizer.clone(), + "main-model".into(), + SystemPromptBuilder::with_defaults(), + ); + manager.record_usage(&UsageInfo { + input_tokens: 92_000, + output_tokens: 4_000, + context_window: 100_000, + ..Default::default() + }); + + let mut history = vec![user("one"), user("two"), user("three")]; + let _ = manager + .reduce_before_call(&mut history) + .await + .expect("reduce"); + + assert_eq!( + summarizer.model.lock().unwrap().as_deref(), + Some("compact-model") + ); +} + +#[test] +fn new_exposes_tool_budget_and_markdown_preference_from_config() { + let summarizer = MockSummarizer::ok(); + let mut config = ContextConfig::default(); + config.tool_result_budget_bytes = 4096; + config.prefer_markdown_tool_output = true; + let manager = ContextManager::new( + &config, + summarizer, + "main-model".into(), + SystemPromptBuilder::with_defaults(), + ); + + assert_eq!(manager.tool_result_budget_bytes(), 4096); + assert!(manager.prefer_markdown_tool_output()); +} + +#[test] +fn session_memory_lifecycle_changes_should_extract_state() { + let summarizer = MockSummarizer::ok(); + let mut manager = manager_with(summarizer); + manager.record_usage(&UsageInfo { + input_tokens: 20_000, + output_tokens: 0, + context_window: 100_000, + ..Default::default() + }); + for _ in 0..5 { + manager.tick_turn(); + } + manager.record_tool_calls(9); + assert!(manager.should_extract_session_memory()); + + manager.mark_session_memory_started(); + assert!(!manager.should_extract_session_memory()); + + manager.mark_session_memory_failed(); + assert!(manager.should_extract_session_memory()); + + manager.mark_session_memory_started(); + manager.mark_session_memory_complete(); + assert!(!manager.should_extract_session_memory()); +} + +#[test] +fn session_memory_handle_mutations_are_reflected_in_manager_stats() { + let summarizer = MockSummarizer::ok(); + let manager = manager_with(summarizer); + let handle = manager.session_memory_handle(); + { + let mut state = handle.lock().unwrap(); + state.current_turn = 7; + state.total_tool_calls = 9; + state.total_tokens = 222; + state.tokens_at_last_extract = 111; + } + + let stats = manager.stats(); + assert_eq!(stats.session_memory_current_turn, 7); + assert_eq!(stats.session_memory_total_tool_calls, 9); + assert_eq!(stats.session_memory_total_tokens, 222); +} diff --git a/src/openhuman/memory/conversations/store_tests.rs b/src/openhuman/memory/conversations/store_tests.rs index 487951a50..721af6351 100644 --- a/src/openhuman/memory/conversations/store_tests.rs +++ b/src/openhuman/memory/conversations/store_tests.rs @@ -560,3 +560,32 @@ fn delete_thread_clears_stats_from_index() { .unwrap(); assert!(store.list_threads().unwrap().is_empty()); } + +#[test] +fn update_thread_labels_missing_thread_returns_error() { + let (_temp, store) = make_store(); + let err = store + .update_thread_labels("missing", vec!["work".into()], "2026-04-10T12:05:00Z") + .unwrap_err(); + assert!(err.contains("thread missing not found")); +} + +#[test] +fn read_jsonl_skips_invalid_lines_but_keeps_valid_ones() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("messages.jsonl"); + std::fs::write( + &path, + concat!( + "{\"id\":\"m1\",\"content\":\"ok\",\"type\":\"text\",\"extraMetadata\":{},\"sender\":\"user\",\"createdAt\":\"2026-04-10T12:00:00Z\"}\n", + "{not valid json}\n", + "{\"id\":\"m2\",\"content\":\"ok2\",\"type\":\"text\",\"extraMetadata\":{},\"sender\":\"agent\",\"createdAt\":\"2026-04-10T12:01:00Z\"}\n" + ), + ) + .unwrap(); + + let messages: Vec = read_jsonl(&path).expect("read jsonl"); + assert_eq!(messages.len(), 2); + assert_eq!(messages[0].id, "m1"); + assert_eq!(messages[1].id, "m2"); +} diff --git a/src/openhuman/threads/ops_tests.rs b/src/openhuman/threads/ops_tests.rs index 17f64ce82..94018cda5 100644 --- a/src/openhuman/threads/ops_tests.rs +++ b/src/openhuman/threads/ops_tests.rs @@ -4,6 +4,9 @@ //! the async `ops::*` entry points rely on. use super::*; use crate::openhuman::threads::title::collapse_whitespace; +use crate::openhuman::threads::turn_state::{ + self, ClearTurnStateRequest, GetTurnStateRequest, TurnState, +}; use crate::openhuman::threads::ThreadsError; use serde_json::{json, Value}; use std::ffi::OsString; @@ -476,3 +479,215 @@ async fn generate_title_returns_typed_not_found_for_stale_thread() { ); assert_eq!(err.to_string(), "thread thread-missing not found"); } + +async fn create_thread_with_title(_workspace: &tempfile::TempDir, thread_id: &str, title: &str) { + let dir = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config") + .workspace_dir; + conversations::ensure_thread( + dir, + CreateConversationThread { + id: thread_id.to_string(), + title: title.to_string(), + created_at: "2026-01-01T00:00:00Z".to_string(), + parent_thread_id: None, + labels: None, + }, + ) + .expect("ensure thread"); +} + +#[tokio::test] +async fn generate_title_leaves_custom_title_unchanged() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + let thread_id = "thread-custom"; + create_thread_with_title(&workspace, thread_id, "Already named").await; + let dir = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config") + .workspace_dir; + conversations::append_message( + dir, + thread_id, + ConversationMessage { + id: "msg-1".into(), + content: "Please summarize my notes".into(), + message_type: "text".into(), + extra_metadata: Value::Null, + sender: "user".into(), + created_at: "2026-01-01T00:01:00Z".into(), + }, + ) + .unwrap(); + + let outcome = thread_generate_title(GenerateConversationThreadTitleRequest { + thread_id: thread_id.to_string(), + assistant_message: None, + }) + .await + .expect("generate title"); + + assert_eq!( + outcome.value.data.as_ref().unwrap().title, + "Already named", + "non-placeholder titles must not be replaced" + ); +} + +#[tokio::test] +async fn generate_title_returns_existing_title_when_no_user_message_exists() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + let thread_id = "thread-no-user"; + create_thread_with_title(&workspace, thread_id, "Chat Jan 1 1:00 AM").await; + + let outcome = thread_generate_title(GenerateConversationThreadTitleRequest { + thread_id: thread_id.to_string(), + assistant_message: Some("assistant reply".into()), + }) + .await + .expect("generate title"); + + assert_eq!( + outcome.value.data.as_ref().unwrap().title, + "Chat Jan 1 1:00 AM" + ); +} + +#[tokio::test] +async fn generate_title_falls_back_to_first_user_message_when_assistant_missing() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + let thread_id = "thread-fallback"; + create_thread_with_title(&workspace, thread_id, "Chat Jan 1 1:00 AM").await; + let dir = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config") + .workspace_dir; + let user_message = "Please summarize the latest five email threads for me."; + conversations::append_message( + dir, + thread_id, + ConversationMessage { + id: "msg-1".into(), + content: user_message.into(), + message_type: "text".into(), + extra_metadata: Value::Null, + sender: "user".into(), + created_at: "2026-01-01T00:01:00Z".into(), + }, + ) + .unwrap(); + + let outcome = thread_generate_title(GenerateConversationThreadTitleRequest { + thread_id: thread_id.to_string(), + assistant_message: None, + }) + .await + .expect("generate title"); + + assert_eq!( + outcome.value.data.as_ref().unwrap().title, + title_from_user_message(user_message).unwrap() + ); +} + +#[tokio::test] +async fn thread_delete_removes_persisted_turn_state_snapshot() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + let thread_id = "thread-delete"; + create_thread_with_title(&workspace, thread_id, "Chat Jan 1 1:00 AM").await; + let dir = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config") + .workspace_dir; + + let snapshot = TurnState::started(thread_id, "req-1", 4, "2026-01-01T00:00:00Z"); + turn_state::store::put(dir.clone(), &snapshot).expect("put snapshot"); + assert!(turn_state::store::get(dir, thread_id).unwrap().is_some()); + + thread_delete(DeleteConversationThreadRequest { + thread_id: thread_id.to_string(), + deleted_at: "2026-01-01T00:02:00Z".into(), + }) + .await + .expect("delete thread"); + + let turn_state = turn_state_get(GetTurnStateRequest { + thread_id: thread_id.to_string(), + }) + .await + .expect("turn_state_get"); + assert!(turn_state.value.data.unwrap().turn_state.is_none()); +} + +#[tokio::test] +async fn threads_purge_removes_valid_and_corrupted_turn_state_files() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + create_thread_with_title(&workspace, "thread-a", "Chat Jan 1 1:00 AM").await; + create_thread_with_title(&workspace, "thread-b", "Chat Jan 1 1:01 AM").await; + let dir = crate::openhuman::config::Config::load_or_init() + .await + .expect("load config") + .workspace_dir; + + let snapshot = TurnState::started("thread-a", "req-1", 4, "2026-01-01T00:00:00Z"); + turn_state::store::put(dir.clone(), &snapshot).expect("put snapshot"); + + let turn_state_dir = dir.join("memory").join("conversations").join("turn_states"); + std::fs::create_dir_all(&turn_state_dir).unwrap(); + std::fs::write( + turn_state_dir.join("corrupted.json"), + "{ definitely not json", + ) + .unwrap(); + + threads_purge(EmptyRequest {}) + .await + .expect("purge threads should also clear snapshots"); + + if turn_state_dir.exists() { + let remaining_json: Vec<_> = std::fs::read_dir(&turn_state_dir) + .unwrap() + .filter_map(Result::ok) + .filter(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("json")) + .collect(); + assert!(remaining_json.is_empty(), "expected no snapshot json files"); + } +} + +#[tokio::test] +async fn turn_state_clear_reports_false_when_snapshot_is_absent() { + let _env_lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + let workspace = tempfile::tempdir().expect("workspace"); + let _workspace_guard = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", workspace.path()); + + let outcome = turn_state_clear(ClearTurnStateRequest { + thread_id: "missing-thread".into(), + }) + .await + .expect("turn_state_clear"); + + assert!(!outcome.value.data.unwrap().cleared); +} diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 40d483b42..e1c3ccde7 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1290,6 +1290,114 @@ async fn json_rpc_thread_not_found_errors_are_structured() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_thread_generate_title_falls_back_when_provider_path_is_unavailable() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_url_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL"); + + with_chat_completion_models(|models| models.clear()); + with_chat_completion_requests(|requests| requests.clear()); + + let (api_addr, api_join) = serve_on_ephemeral(mock_upstream_router()).await; + let api_origin = format!("http://{api_addr}"); + write_min_config(openhuman_home.as_path(), &api_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{rpc_addr}"); + + let create = post_json_rpc(&rpc_base, 9013, "openhuman.threads_create_new", json!({})).await; + let create_outer = assert_no_jsonrpc_error(&create, "threads_create_new"); + let created = create_outer + .get("data") + .expect("data envelope in create response"); + let thread_id = created + .get("id") + .and_then(Value::as_str) + .expect("thread id"); + let original_title = created + .get("title") + .and_then(Value::as_str) + .expect("placeholder title") + .to_string(); + + let user_append = post_json_rpc( + &rpc_base, + 9014, + "openhuman.threads_message_append", + json!({ + "thread_id": thread_id, + "message": { + "id": "msg-user", + "content": "Please summarize the latest five email threads for me.", + "type": "text", + "extraMetadata": {}, + "sender": "user", + "createdAt": "2026-01-01T00:00:00Z" + } + }), + ) + .await; + assert_no_jsonrpc_error(&user_append, "threads_message_append user"); + + let agent_append = post_json_rpc( + &rpc_base, + 9015, + "openhuman.threads_message_append", + json!({ + "thread_id": thread_id, + "message": { + "id": "msg-agent", + "content": "Here is the summary you asked for.", + "type": "text", + "extraMetadata": {}, + "sender": "agent", + "createdAt": "2026-01-01T00:00:02Z" + } + }), + ) + .await; + assert_no_jsonrpc_error(&agent_append, "threads_message_append agent"); + + let title = post_json_rpc( + &rpc_base, + 9016, + "openhuman.threads_generate_title", + json!({ "thread_id": thread_id }), + ) + .await; + let title_outer = assert_no_jsonrpc_error(&title, "threads_generate_title"); + let titled = title_outer + .get("data") + .expect("data envelope in title response"); + let generated_title = titled + .get("title") + .and_then(Value::as_str) + .expect("generated title"); + + assert_ne!(generated_title, original_title); + assert!( + generated_title.contains("Please summarize the latest five email threads for"), + "fallback title should be derived from the first user message: {generated_title}" + ); + + let captured_models = with_chat_completion_models(|models| models.clone()); + assert!( + captured_models.is_empty(), + "the minimal config path currently falls back before hitting mock chat completions" + ); + + api_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_thread_turn_state_lifecycle() { let _env_lock = json_rpc_e2e_env_lock(); @@ -2517,6 +2625,71 @@ async fn json_rpc_app_state_snapshot_returns_runtime_shape() { rpc_join.abort(); } +#[tokio::test] +async fn json_rpc_app_state_update_local_state_round_trips_into_snapshot() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + tokio::time::sleep(Duration::from_millis(100)).await; + + let update = post_json_rpc( + &rpc_base, + 10041, + "openhuman.app_state_update_local_state", + json!({ + "encryptionKey": " secret-key ", + "onboardingTasks": { + "accessibilityPermissionGranted": true, + "enabledTools": ["search"], + "connectedSources": ["telegram"] + } + }), + ) + .await; + let update_result = assert_no_jsonrpc_error(&update, "app_state_update_local_state"); + let updated_state = update_result.get("result").unwrap_or(&update_result); + assert_eq!( + updated_state.get("encryptionKey").and_then(Value::as_str), + Some("secret-key") + ); + + let snapshot = post_json_rpc(&rpc_base, 10042, "openhuman.app_state_snapshot", json!({})).await; + let snapshot_result = assert_no_jsonrpc_error(&snapshot, "app_state_snapshot after update"); + let body = snapshot_result.get("result").unwrap_or(&snapshot_result); + let local_state = body + .get("localState") + .and_then(Value::as_object) + .expect("localState object"); + assert_eq!( + local_state.get("encryptionKey").and_then(Value::as_str), + Some("secret-key") + ); + assert_eq!( + local_state + .get("onboardingTasks") + .and_then(Value::as_object) + .and_then(|tasks| tasks.get("accessibilityPermissionGranted")) + .and_then(Value::as_bool), + Some(true) + ); + + mock_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn json_rpc_wallet_setup_round_trips_status() { let _env_lock = json_rpc_e2e_env_lock();