mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test(rust): expand coverage for state, threads, and channel prompts (#1969)
This commit is contained in:
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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`"));
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user