test(rust): expand coverage for state, threads, and channel prompts (#1969)

This commit is contained in:
Steven Enamakel
2026-05-16 16:43:06 -07:00
committed by GitHub
parent 0354a6c9e7
commit d92cebdbdf
9 changed files with 983 additions and 3 deletions
+150
View File
@@ -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());
}
+104 -1
View File
@@ -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<Vec<SendMessage>>,
}
#[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<ChannelMessage>) -> 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<dyn Channel> = 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<dyn Channel> = 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`"));
}
+3 -2
View File
@@ -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())
};
}
+88
View File
@@ -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);
}
+105
View File
@@ -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<std::path::PathBuf>) -> 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("<name>research-helper</name>"));
assert!(prompt.contains(
&tmp.path()
.join("skills")
.join("research-helper")
.join("SKILL.md")
.display()
.to_string()
));
assert!(prompt.contains("running as a Telegram bot"));
}
}
+116
View File
@@ -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<Option<String>>,
}
#[async_trait]
impl Summarizer for RecordingModelSummarizer {
async fn summarize(
&self,
history: &mut Vec<ConversationMessage>,
model: &str,
) -> anyhow::Result<SummaryStats> {
*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);
}
@@ -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<ConversationMessage> = read_jsonl(&path).expect("read jsonl");
assert_eq!(messages.len(), 2);
assert_eq!(messages[0].id, "m1");
assert_eq!(messages[1].id, "m2");
}
+215
View File
@@ -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);
}
+173
View File
@@ -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();