mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: zahica1234 <i.milev001@gmail.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
4843 lines
169 KiB
Rust
4843 lines
169 KiB
Rust
//! Raw-line oriented E2E coverage for memory, memory_tree, memory_sync,
|
|
//! memory_sources, and threads.
|
|
//!
|
|
//! The tests call public Rust APIs and localhost-only readers so they stay
|
|
//! hermetic while still exercising production code paths that are awkward to
|
|
//! reach through full JSON-RPC flows.
|
|
|
|
use axum::http::{HeaderMap, StatusCode};
|
|
use axum::response::{Html, IntoResponse, Response};
|
|
use axum::routing::get;
|
|
use axum::Router;
|
|
use chrono::{TimeZone, Utc};
|
|
use serde_json::json;
|
|
use serde_json::{Map, Value};
|
|
use std::ffi::OsString;
|
|
use std::path::Path;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
use tempfile::TempDir;
|
|
|
|
use openhuman_core::openhuman::agent::progress::AgentProgress;
|
|
use openhuman_core::openhuman::agent::task_board::{TaskBoard, TaskBoardCard, TaskCardStatus};
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::embeddings::NoopEmbedding;
|
|
use openhuman_core::openhuman::memory::query::{
|
|
MemoryQueryTool, MemoryTreeDrillDownTool, MemoryTreeFetchLeavesTool,
|
|
MemoryTreeIngestDocumentTool, MemoryTreeQuerySourceTool, MemoryTreeSearchEntitiesTool,
|
|
MemoryTreeWalkTool,
|
|
};
|
|
use openhuman_core::openhuman::memory::tools::{
|
|
MemoryForgetTool, MemoryRecallTool, MemoryStoreTool,
|
|
};
|
|
use openhuman_core::openhuman::memory::tree_policy::TreePolicy;
|
|
use openhuman_core::openhuman::memory::tree_source;
|
|
use openhuman_core::openhuman::memory::{
|
|
all_memory_controller_schemas, all_memory_registered_controllers,
|
|
preferences::{
|
|
load_general_preferences, recall_related_preferences, recall_situational_preferences,
|
|
USER_PREF_GENERAL_NAMESPACE, USER_PREF_SITUATIONAL_NAMESPACE,
|
|
},
|
|
read_rpc as memory_read_rpc,
|
|
remember::RememberSourceKind,
|
|
rpc_models::{
|
|
ApiEnvelope, ApiError, ApiMeta, AppendConversationMessageRequest,
|
|
ConversationMessageRecord, ConversationMessagesRequest, CreateConversationThreadRequest,
|
|
DeleteConversationThreadRequest, DeleteDocumentRequest, EmptyRequest,
|
|
GenerateConversationThreadTitleRequest, ListDocumentsRequest, ListMemoryFilesRequest,
|
|
MemoryInitRequest, PaginationMeta, QueryNamespaceRequest, ReadMemoryFileRequest,
|
|
RecallContextRequest, RecallMemoriesRequest, UpdateConversationMessageRequest,
|
|
UpdateConversationThreadLabelsRequest, UpdateConversationThreadTitleRequest,
|
|
UpsertConversationThreadRequest, WriteMemoryFileRequest,
|
|
},
|
|
traits::{Memory, MemoryCategory, MemoryEntry, NamespaceSummary, RecallOpts},
|
|
util::redact::{redact, redact_endpoint},
|
|
MemoryIngestionConfig, MemoryIngestionRequest,
|
|
};
|
|
use openhuman_core::openhuman::memory_queue::types::ReembedBackfillPayload;
|
|
use openhuman_core::openhuman::memory_queue::{
|
|
self, AppendBufferPayload, AppendTarget, ExtractChunkPayload, FlushStalePayload, JobKind,
|
|
JobStatus, NewJob, NodeRef, SealPayload, DEFAULT_LOCK_DURATION_MS,
|
|
};
|
|
use openhuman_core::openhuman::memory_sources::readers::reader_for;
|
|
use openhuman_core::openhuman::memory_sources::registry;
|
|
use openhuman_core::openhuman::memory_sources::rpc as memory_sources_rpc;
|
|
use openhuman_core::openhuman::memory_sources::status::{source_status, FreshnessLabel};
|
|
use openhuman_core::openhuman::memory_sources::sync::sync_source;
|
|
use openhuman_core::openhuman::memory_sources::types::{
|
|
ContentType, MemorySourceEntry, SourceContent, SourceItem, SourceKind,
|
|
};
|
|
use openhuman_core::openhuman::memory_sources::{
|
|
all_memory_sources_controller_schemas, all_memory_sources_registered_controllers,
|
|
};
|
|
use openhuman_core::openhuman::memory_store::chunks::store::{upsert_chunks, with_connection};
|
|
use openhuman_core::openhuman::memory_store::chunks::types::{
|
|
approx_token_count, chunk_id, Chunk, DataSource, Metadata, SourceKind as ChunkSourceKind,
|
|
SourceRef,
|
|
};
|
|
use openhuman_core::openhuman::memory_store::trees::types::{
|
|
SummaryNode, Tree, TreeKind, TreeStatus as StoredTreeStatus,
|
|
};
|
|
use openhuman_core::openhuman::memory_store::{
|
|
MemoryClient, NamespaceDocumentInput, UnifiedMemory,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{
|
|
canonicalise as canonicalise_chat, ChatBatch, ChatMessage,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::canonicalize::document::{
|
|
canonicalise as canonicalise_document, DocumentInput,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::canonicalize::email::{
|
|
canonicalise as canonicalise_email, EmailMessage, EmailThread,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::canonicalize::email_clean;
|
|
use openhuman_core::openhuman::memory_sync::composio;
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::profile::{
|
|
canonicalize, delete_connected_identity_facets, is_self_identity, is_self_identity_any_toolkit,
|
|
load_connected_identities, render_connected_identities_section, ConnectedIdentity,
|
|
IdentityKind,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::profile_md::{
|
|
block_end, block_start, merge_provider_into_profile_md, remove_provider_from_profile_md,
|
|
replace_managed_block,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::slack::{
|
|
post_process as slack_post_process, schemas as slack_memory_schemas,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::sync_state::{
|
|
extract_item_id, DailyBudget, SyncState, DEFAULT_DAILY_REQUEST_LIMIT,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::user_scopes;
|
|
use openhuman_core::openhuman::memory_sync::composio::providers::{
|
|
agent_ready_toolkits, all_providers as all_composio_providers, capability_matrix,
|
|
catalog_for_toolkit, classify_unknown, curated_scope_for, find_curated, get_provider,
|
|
init_default_providers as init_default_composio_providers, is_action_visible_with_pref,
|
|
register_provider, toolkit_from_slug, toolkit_has_scope, ComposioProvider, CuratedTool,
|
|
NormalizedTask, ProviderContext, ProviderUserProfile, SyncOutcome as ComposioSyncOutcome,
|
|
SyncReason, TaskFetchFilter, ToolScope, UserScopePref,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::sync_status::{
|
|
rpc as memory_sync_status_rpc, schemas as memory_sync_status_schemas,
|
|
};
|
|
use openhuman_core::openhuman::memory_sync::traits::{
|
|
SyncOutcome as PipelineSyncOutcome, SyncPipeline, SyncPipelineKind,
|
|
};
|
|
use openhuman_core::openhuman::memory_tools::tools::{MemoryToolsListTool, MemoryToolsPutTool};
|
|
use openhuman_core::openhuman::memory_tools::{
|
|
render_tool_memory_rules, tool_memory_namespace, ToolMemoryPriority, ToolMemoryRule,
|
|
ToolMemoryRulesSection, ToolMemorySource, ToolMemoryStore, TOOL_MEMORY_HEADING,
|
|
TOOL_MEMORY_PROMPT_CAP,
|
|
};
|
|
use openhuman_core::openhuman::memory_tree::score::embed::Embedder;
|
|
use openhuman_core::openhuman::memory_tree::score::extract::{
|
|
CompositeExtractor, EntityExtractor, EntityKind, ExtractedEntities, ExtractedEntity,
|
|
ExtractedTopic,
|
|
};
|
|
use openhuman_core::openhuman::memory_tree::score::resolver::CanonicalEntity;
|
|
use openhuman_core::openhuman::memory_tree::score::signals::{
|
|
combine, combine_cheap_only, compute as compute_score_signals, entity_density_score,
|
|
interaction, metadata_weight, source_weight, token_count, unique_words, ScoreSignals,
|
|
SignalWeights,
|
|
};
|
|
use openhuman_core::openhuman::memory_tree::score::store as score_store;
|
|
use openhuman_core::openhuman::memory_tree::score::{resolver, ScoringConfig};
|
|
use openhuman_core::openhuman::memory_tree::summarise::{
|
|
fallback_summary, SummaryContext, SummaryInput,
|
|
};
|
|
use openhuman_core::openhuman::memory_tree::tree::bucket_seal::LeafRef;
|
|
use openhuman_core::openhuman::memory_tree::tree_runtime::store as tree_runtime_store;
|
|
use openhuman_core::openhuman::memory_tree::tree_runtime::{
|
|
all_tree_summarizer_controller_schemas, all_tree_summarizer_registered_controllers,
|
|
derive_node_ids, derive_parent_id, estimate_tokens, level_from_node_id, node_id_to_path,
|
|
NodeLevel, TreeNode,
|
|
};
|
|
use openhuman_core::openhuman::memory_tree::{retrieval, score::embed};
|
|
use openhuman_core::openhuman::security::{AutonomyLevel, SecurityPolicy};
|
|
use openhuman_core::openhuman::threads::ops as thread_ops;
|
|
use openhuman_core::openhuman::threads::title::{
|
|
build_title_prompt, collapse_whitespace, is_auto_generated_thread_title,
|
|
sanitize_generated_title, title_from_user_message, title_log_fingerprint,
|
|
};
|
|
use openhuman_core::openhuman::threads::turn_state::{
|
|
self, ClearTurnStateRequest, GetTurnStateRequest, GetTurnStateResponse, ListTurnStatesResponse,
|
|
SubagentActivity, SubagentToolCall, ToolTimelineEntry, ToolTimelineStatus, TurnLifecycle,
|
|
TurnPhase, TurnState, TurnStateMirror, TurnStateStore,
|
|
};
|
|
use openhuman_core::openhuman::threads::ThreadsError;
|
|
use openhuman_core::openhuman::threads::{
|
|
all_threads_controller_schemas, all_threads_registered_controllers,
|
|
};
|
|
use openhuman_core::openhuman::tools::traits::{PermissionLevel, Tool, ToolCategory};
|
|
|
|
struct EnvVarGuard {
|
|
key: &'static str,
|
|
old: Option<OsString>,
|
|
}
|
|
|
|
impl EnvVarGuard {
|
|
fn set_to_path(key: &'static str, value: &Path) -> Self {
|
|
let old = std::env::var_os(key);
|
|
unsafe {
|
|
std::env::set_var(key, value.as_os_str());
|
|
}
|
|
Self { key, old }
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvVarGuard {
|
|
fn drop(&mut self) {
|
|
unsafe {
|
|
match &self.old {
|
|
Some(value) => std::env::set_var(self.key, value),
|
|
None => std::env::remove_var(self.key),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
|
|
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
|
ENV_LOCK
|
|
.get_or_init(|| Mutex::new(()))
|
|
.lock()
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
}
|
|
|
|
fn config_in(tmp: &TempDir) -> Config {
|
|
let mut config = Config::default();
|
|
config.workspace_dir = tmp.path().to_path_buf();
|
|
config
|
|
}
|
|
|
|
fn source(kind: SourceKind, id: &str) -> MemorySourceEntry {
|
|
MemorySourceEntry {
|
|
id: id.to_string(),
|
|
kind,
|
|
label: format!("{id} label"),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: None,
|
|
glob: None,
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: None,
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
}
|
|
}
|
|
|
|
fn chunk(source_id: &str, seq: u32, timestamp_ms: i64) -> Chunk {
|
|
let content = format!("chunk {source_id} {seq}");
|
|
let ts = Utc.timestamp_millis_opt(timestamp_ms).unwrap();
|
|
Chunk {
|
|
id: chunk_id(ChunkSourceKind::Document, source_id, seq, &content),
|
|
content,
|
|
metadata: Metadata::point_in_time(ChunkSourceKind::Document, source_id, "owner", ts),
|
|
token_count: approx_token_count(source_id),
|
|
seq_in_source: seq,
|
|
created_at: ts,
|
|
partial_message: false,
|
|
}
|
|
}
|
|
|
|
fn tree_node(namespace: &str, node_id: &str, summary: &str) -> TreeNode {
|
|
let created_at = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
|
|
TreeNode {
|
|
node_id: node_id.to_string(),
|
|
namespace: namespace.to_string(),
|
|
level: level_from_node_id(node_id),
|
|
parent_id: derive_parent_id(node_id),
|
|
summary: summary.to_string(),
|
|
token_count: estimate_tokens(summary),
|
|
child_count: 0,
|
|
created_at,
|
|
updated_at: created_at,
|
|
metadata: Some(json!({ "kind": "coverage", "node": node_id }).to_string()),
|
|
}
|
|
}
|
|
|
|
async fn serve_routes(router: Router) -> String {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
|
.await
|
|
.expect("bind test server");
|
|
let addr = listener.local_addr().expect("local addr");
|
|
tokio::spawn(async move {
|
|
let _ = axum::serve(listener, router).await;
|
|
});
|
|
format!("http://{addr}")
|
|
}
|
|
|
|
async fn html_page() -> Html<&'static str> {
|
|
Html(
|
|
"<html><head><title>Raw Page</title></head><body><main><h1>Hello</h1><p>Selected body</p></main><aside>Skip me</aside></body></html>",
|
|
)
|
|
}
|
|
|
|
async fn large_header() -> Response {
|
|
vec![b'x'; 10 * 1024 * 1024 + 1].into_response()
|
|
}
|
|
|
|
async fn rss_feed(headers: HeaderMap) -> Response {
|
|
let mode = headers
|
|
.get("x-feed-mode")
|
|
.and_then(|value| value.to_str().ok())
|
|
.unwrap_or("rss");
|
|
if mode == "atom" {
|
|
return Html(
|
|
r#"<feed><entry><id>atom-1</id><title>Atom One</title><content>Atom body</content><link href="https://example.test/atom-1"/><updated>2026-05-29T12:00:00Z</updated></entry></feed>"#,
|
|
)
|
|
.into_response();
|
|
}
|
|
Html(
|
|
r#"<rss><channel><item><guid>rss-1</guid><title>RSS One</title><link>https://example.test/rss-1</link><description><![CDATA[<p>RSS body</p>]]></description><pubDate>Fri, 29 May 2026 12:00:00 +0000</pubDate></item><item><guid>rss-2</guid><title>RSS Two</title><description>Second</description></item></channel></rss>"#,
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn canonicalizers_clean_sort_and_preserve_metadata() {
|
|
let doc_json = json!({
|
|
"title": "Doc",
|
|
"body": " Document body ",
|
|
"modified_at": "2026-05-29T12:00:00Z",
|
|
"source_ref": " file://doc "
|
|
});
|
|
let doc: DocumentInput = serde_json::from_value(doc_json).expect("document input");
|
|
let doc_out = canonicalise_document("doc:1", "alice", &["plans".into()], doc, None)
|
|
.expect("document canonicalise")
|
|
.expect("document output");
|
|
assert_eq!(doc_out.markdown, "Document body\n");
|
|
assert_eq!(doc_out.metadata.source_id, "doc:1");
|
|
assert_eq!(doc_out.metadata.source_ref.unwrap().value, "file://doc");
|
|
|
|
let empty_doc = DocumentInput {
|
|
provider: "drive".into(),
|
|
title: " ".into(),
|
|
body: "\n ".into(),
|
|
modified_at: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
|
|
source_ref: Some(" ".into()),
|
|
};
|
|
assert!(
|
|
canonicalise_document("doc:empty", "alice", &[], empty_doc, None)
|
|
.unwrap()
|
|
.is_none()
|
|
);
|
|
|
|
let older = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
|
|
let newer = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
|
|
let email_out = canonicalise_email(
|
|
"gmail:thread-1",
|
|
"alice@example.com",
|
|
&["inbox".into()],
|
|
EmailThread {
|
|
provider: "gmail".into(),
|
|
thread_subject: "Launch".into(),
|
|
messages: vec![
|
|
EmailMessage {
|
|
from: "Bob <bob@example.com>".into(),
|
|
to: vec!["alice@example.com".into()],
|
|
cc: vec!["team@example.com".into()],
|
|
subject: "Re: Launch".into(),
|
|
sent_at: newer,
|
|
body: "Reply\n\nUnsubscribe here".into(),
|
|
source_ref: Some("<msg-new@example.com>".into()),
|
|
list_unsubscribe: Some("<mailto:unsubscribe@example.com>".into()),
|
|
},
|
|
EmailMessage {
|
|
from: "alice@example.com".into(),
|
|
to: vec!["bob@example.com".into()],
|
|
cc: Vec::new(),
|
|
subject: "Launch".into(),
|
|
sent_at: older,
|
|
body: "First\n\n> quoted one\n> quoted two\n> quoted three".into(),
|
|
source_ref: Some("<msg-old@example.com>".into()),
|
|
list_unsubscribe: None,
|
|
},
|
|
],
|
|
},
|
|
)
|
|
.expect("email canonicalise")
|
|
.expect("email output");
|
|
assert!(email_out.markdown.find("Subject: Launch") < email_out.markdown.find("Re: Launch"));
|
|
assert!(email_out.markdown.contains("List-Unsubscribe:"));
|
|
assert!(!email_out.markdown.contains("quoted three"));
|
|
assert_eq!(email_out.metadata.time_range, (older, newer));
|
|
assert_eq!(
|
|
email_out.metadata.source_ref.as_ref().unwrap().value,
|
|
"<msg-old@example.com>"
|
|
);
|
|
|
|
let chat_out = canonicalise_chat(
|
|
"slack:#eng",
|
|
"alice",
|
|
&["eng".into()],
|
|
ChatBatch {
|
|
platform: "slack".into(),
|
|
channel_label: "#eng".into(),
|
|
messages: vec![
|
|
ChatMessage {
|
|
author: "Bob".into(),
|
|
timestamp: newer,
|
|
text: " second ".into(),
|
|
source_ref: Some("slack://2".into()),
|
|
},
|
|
ChatMessage {
|
|
author: "Alice".into(),
|
|
timestamp: older,
|
|
text: "first".into(),
|
|
source_ref: Some("slack://1".into()),
|
|
},
|
|
],
|
|
},
|
|
)
|
|
.expect("chat canonicalise")
|
|
.expect("chat output");
|
|
assert!(chat_out.markdown.find("Alice") < chat_out.markdown.find("Bob"));
|
|
assert_eq!(chat_out.metadata.source_ref.unwrap().value, "slack://1");
|
|
|
|
assert_eq!(
|
|
email_clean::drop_footer_noise("Real\n\nView in browser\nFooter"),
|
|
"Real"
|
|
);
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "2026-05-29" }))
|
|
.unwrap()
|
|
.date_naive()
|
|
.to_string(),
|
|
"2026-05-29"
|
|
);
|
|
assert_eq!(
|
|
email_clean::extract_email("Name <n@example.com>").as_deref(),
|
|
Some("n@example.com")
|
|
);
|
|
assert_eq!(email_clean::md_escape("a*b_c|d"), "a\\*b\\_c\\|d");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_ingestion_pipeline_extracts_graph_preferences_and_recall_hits() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).expect("memory");
|
|
let content = r#"
|
|
From: Alice Morgan <alice@example.test>
|
|
To: Bob Stone <bob@example.test>, Cara Park <cara@example.test>
|
|
Cc: OpenHuman Core <core@example.test>
|
|
Subject: OpenHuman coverage memory plan
|
|
Date: 2026-05-29
|
|
|
|
# Project Alpha
|
|
Project name: OpenHuman
|
|
Subproject: memory-coverage
|
|
Owner: Alice Morgan
|
|
Name: Thread raw coverage
|
|
Due date: 2026-06-01
|
|
Target milestone: 2026-06-15
|
|
Preferred embedding model for local experiments: text-embedding-3-small
|
|
Preferred extraction mode to try first: sentence
|
|
|
|
Alice Morgan owns memory-coverage.
|
|
Bob Stone works_on memory-coverage.
|
|
OpenHuman uses JSON-RPC.
|
|
Cara Park prefers deterministic fixtures.
|
|
Alice Morgan will review the ingestion assertions.
|
|
Bob Stone sent draft notes to Cara Park.
|
|
Kitchen is north of Garden.
|
|
"#;
|
|
|
|
let result = memory
|
|
.ingest_document(MemoryIngestionRequest {
|
|
document: NamespaceDocumentInput {
|
|
namespace: "memory-raw-ingestion".into(),
|
|
key: "plan-1".into(),
|
|
title: "OpenHuman coverage memory plan".into(),
|
|
content: content.into(),
|
|
source_type: "test".into(),
|
|
priority: "high".into(),
|
|
tags: vec!["seed".into()],
|
|
metadata: json!({ "fixture": "raw-memory-e2e" }),
|
|
category: "core".into(),
|
|
session_id: Some("session-coverage".into()),
|
|
document_id: Some("doc-memory-raw-ingestion".into()),
|
|
},
|
|
config: MemoryIngestionConfig::default(),
|
|
})
|
|
.await
|
|
.expect("ingest document");
|
|
|
|
assert_eq!(result.document_id, "doc-memory-raw-ingestion");
|
|
assert_eq!(result.namespace, "memory-raw-ingestion");
|
|
assert!(result.tags.contains(&"deadline".to_string()));
|
|
assert!(result.tags.contains(&"decision".to_string()));
|
|
assert!(result.tags.contains(&"preference".to_string()));
|
|
assert!(result.entity_count >= 5);
|
|
assert!(result.relation_count >= 8);
|
|
assert!(result.preference_count >= 1);
|
|
assert!(result.decision_count >= 2);
|
|
assert!(result
|
|
.entities
|
|
.iter()
|
|
.any(|entity| entity.name == "ALICE MORGAN"));
|
|
assert!(result
|
|
.relations
|
|
.iter()
|
|
.any(|relation| relation.subject.contains("OPENHUMAN")
|
|
&& relation.predicate == "USES"
|
|
&& relation.object.contains("TEXT-EMBEDDING")));
|
|
|
|
let rows = memory
|
|
.graph_query_namespace("memory-raw-ingestion", Some("ALICE MORGAN"), Some("OWNS"))
|
|
.await
|
|
.expect("query graph");
|
|
assert!(rows.iter().any(|row| row["object"] == "MEMORY-COVERAGE"));
|
|
|
|
let context = memory
|
|
.query_namespace_context_data(
|
|
"memory-raw-ingestion",
|
|
"who owns memory coverage and what uses text embedding",
|
|
5,
|
|
)
|
|
.await
|
|
.expect("query context");
|
|
assert!(context
|
|
.hits
|
|
.iter()
|
|
.flat_map(|hit| hit.supporting_relations.iter())
|
|
.any(|relation| relation.predicate == "OWNS" || relation.predicate == "USES"));
|
|
|
|
let recall = memory
|
|
.recall_namespace_memories("memory-raw-ingestion", 5)
|
|
.await
|
|
.expect("recall memories");
|
|
assert!(recall
|
|
.iter()
|
|
.any(|hit| hit.document_id.as_deref() == Some("doc-memory-raw-ingestion")));
|
|
|
|
let extract_again = memory
|
|
.extract_graph(
|
|
"doc-memory-raw-ingestion",
|
|
&NamespaceDocumentInput {
|
|
namespace: "memory-raw-ingestion".into(),
|
|
key: "plan-1".into(),
|
|
title: "OpenHuman coverage memory plan".into(),
|
|
content: "OpenHuman uses JSON-RPC.\nAlice Morgan prefers small tests.".into(),
|
|
source_type: "test".into(),
|
|
priority: "high".into(),
|
|
tags: Vec::new(),
|
|
metadata: Value::Null,
|
|
category: "core".into(),
|
|
session_id: None,
|
|
document_id: Some("doc-memory-raw-ingestion".into()),
|
|
},
|
|
&MemoryIngestionConfig {
|
|
extraction_mode: openhuman_core::openhuman::memory::ExtractionMode::Chunk,
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("extract graph again");
|
|
assert_eq!(extract_again.extraction_mode, "chunk");
|
|
assert!(extract_again.preference_count >= 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_source_readers_validate_and_use_local_inputs_only() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
|
|
let mut folder = source(SourceKind::Folder, "src_folder");
|
|
folder.path = Some(tmp.path().to_string_lossy().to_string());
|
|
folder.glob = Some("**/*".into());
|
|
std::fs::write(tmp.path().join("note.md"), "# Note").expect("write note");
|
|
std::fs::write(tmp.path().join("page.html"), "<p>Body</p>").expect("write html");
|
|
std::fs::write(tmp.path().join("plain.txt"), "Plain").expect("write txt");
|
|
std::fs::create_dir_all(tmp.path().join("nested")).expect("nested dir");
|
|
|
|
let folder_reader = reader_for(&SourceKind::Folder);
|
|
assert_eq!(folder_reader.kind(), SourceKind::Folder);
|
|
let items = folder_reader
|
|
.list_items(&folder, &config)
|
|
.await
|
|
.expect("folder list");
|
|
assert!(items.iter().any(|item| item.id == "note.md"));
|
|
assert!(items.iter().any(|item| item.id == "page.html"));
|
|
let html = folder_reader
|
|
.read_item(&folder, "page.html", &config)
|
|
.await
|
|
.expect("folder read html");
|
|
assert_eq!(html.content_type, ContentType::Html);
|
|
let traversal = folder_reader
|
|
.read_item(&folder, "../outside.md", &config)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(traversal.contains("not found") || traversal.contains("traversal"));
|
|
|
|
let web_base = serve_routes(
|
|
Router::new()
|
|
.route("/page", get(html_page))
|
|
.route("/too-large", get(large_header))
|
|
.route("/missing", get(|| async { StatusCode::NOT_FOUND })),
|
|
)
|
|
.await;
|
|
let mut page = source(SourceKind::WebPage, "src_web");
|
|
page.url = Some(format!("{web_base}/page"));
|
|
page.selector = Some("main.content".into());
|
|
let web_reader = reader_for(&SourceKind::WebPage);
|
|
let page_items = web_reader
|
|
.list_items(&page, &config)
|
|
.await
|
|
.expect("web list");
|
|
assert_eq!(page_items[0].id, format!("{web_base}/page"));
|
|
let page_content = web_reader
|
|
.read_item(&page, &page_items[0].id, &config)
|
|
.await
|
|
.expect("web read");
|
|
assert_eq!(page_content.title, "Raw Page");
|
|
assert!(page_content.body.contains("Selected body"));
|
|
assert!(!page_content.body.contains("Skip me"));
|
|
page.url = Some("file:///etc/passwd".into());
|
|
let bad_scheme = web_reader
|
|
.read_item(&page, "relative-id", &config)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(bad_scheme.contains("http(s)"));
|
|
page.url = Some(format!("{web_base}/too-large"));
|
|
assert!(web_reader
|
|
.read_item(&page, "relative-id", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("exceeds"));
|
|
page.url = Some(format!("{web_base}/missing"));
|
|
assert!(web_reader
|
|
.read_item(&page, "relative-id", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("404"));
|
|
|
|
let rss_base = serve_routes(Router::new().route("/feed", get(rss_feed))).await;
|
|
let mut rss = source(SourceKind::RssFeed, "src_rss");
|
|
rss.url = Some(format!("{rss_base}/feed"));
|
|
rss.max_items = Some(1);
|
|
let rss_reader = reader_for(&SourceKind::RssFeed);
|
|
let feed_items = rss_reader
|
|
.list_items(&rss, &config)
|
|
.await
|
|
.expect("rss list");
|
|
assert_eq!(feed_items.len(), 1);
|
|
assert_eq!(feed_items[0].id, "rss-1");
|
|
let feed_content = rss_reader
|
|
.read_item(&rss, "rss-1", &config)
|
|
.await
|
|
.expect("rss read");
|
|
assert_eq!(feed_content.content_type, ContentType::Html);
|
|
assert!(feed_content.metadata["link"]
|
|
.as_str()
|
|
.unwrap()
|
|
.contains("rss-1"));
|
|
assert!(rss_reader
|
|
.read_item(&rss, "missing", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("not found"));
|
|
|
|
let mut twitter = source(SourceKind::TwitterQuery, "src_tw");
|
|
twitter.query = Some("AI safety".into());
|
|
assert!(reader_for(&SourceKind::TwitterQuery)
|
|
.list_items(&twitter, &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("not yet configured"));
|
|
|
|
let mut composio = source(SourceKind::Composio, "src_cmp");
|
|
composio.toolkit = Some("gmail".into());
|
|
composio.connection_id = Some("conn-1".into());
|
|
let composio_reader = reader_for(&SourceKind::Composio);
|
|
assert_eq!(
|
|
composio_reader
|
|
.list_items(&composio, &config)
|
|
.await
|
|
.expect("composio list")[0]
|
|
.title,
|
|
"gmail connection"
|
|
);
|
|
assert!(composio_reader
|
|
.read_item(&composio, "conn-1", &config)
|
|
.await
|
|
.expect("composio read")
|
|
.body
|
|
.contains("provider sync pipeline"));
|
|
|
|
for (kind, expected) in [
|
|
(SourceKind::Composio, "composio"),
|
|
(SourceKind::Folder, "folder"),
|
|
(SourceKind::GithubRepo, "github_repo"),
|
|
(SourceKind::TwitterQuery, "twitter_query"),
|
|
(SourceKind::RssFeed, "rss_feed"),
|
|
(SourceKind::WebPage, "web_page"),
|
|
] {
|
|
assert_eq!(kind.as_str(), expected);
|
|
}
|
|
assert!(source(SourceKind::GithubRepo, "bad")
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("url"));
|
|
assert!(source(SourceKind::TwitterQuery, "bad")
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("query"));
|
|
|
|
let mut github = source(SourceKind::GithubRepo, "src_github");
|
|
github.url = Some("https://github.com/tinyhumansai/openhuman".into());
|
|
let github_reader = reader_for(&SourceKind::GithubRepo);
|
|
assert_eq!(github_reader.kind(), SourceKind::GithubRepo);
|
|
assert!(github_reader
|
|
.read_item(&github, "unknown:123", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid item id"));
|
|
assert!(github_reader
|
|
.read_item(&github, "issue:not-a-number", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid issue number"));
|
|
assert!(github_reader
|
|
.read_item(&github, "pr:not-a-number", &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid PR number"));
|
|
github.url = Some("https://github.com/tinyhumansai/openhuman/tree/main".into());
|
|
assert!(github_reader
|
|
.list_items(&github, &config)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("expected https://github.com/<owner>/<repo>"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_source_status_counts_reader_and_composio_prefixes() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let now = Utc::now().timestamp_millis();
|
|
let chunks = vec![
|
|
chunk("mem_src:src_folder:note-1", 0, now - 1_000),
|
|
chunk("mem_src:src_folder:note-2", 1, now - 60_000),
|
|
chunk("gmail:acct:msg-1", 0, now - 600_000),
|
|
];
|
|
upsert_chunks(&config, &chunks).expect("upsert chunks");
|
|
with_connection(&config, |conn| {
|
|
conn.execute(
|
|
"UPDATE mem_tree_chunks SET embedding = X'00010203' WHERE source_id = ?1",
|
|
["mem_src:src_folder:note-1"],
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.expect("mark one embedded");
|
|
|
|
let mut folder = source(SourceKind::Folder, "src_folder");
|
|
folder.path = Some(tmp.path().to_string_lossy().to_string());
|
|
let folder_status = source_status(&config, &folder)
|
|
.await
|
|
.expect("folder status");
|
|
assert_eq!(folder_status.source_id, "src_folder");
|
|
assert_eq!(folder_status.chunks_synced, 2);
|
|
assert_eq!(folder_status.chunks_pending, 1);
|
|
assert_eq!(folder_status.freshness, FreshnessLabel::Active);
|
|
|
|
let mut composio = source(SourceKind::Composio, "src_cmp");
|
|
composio.toolkit = Some("gmail".into());
|
|
composio.connection_id = Some("acct".into());
|
|
let composio_status = source_status(&config, &composio)
|
|
.await
|
|
.expect("composio status");
|
|
assert_eq!(composio_status.chunks_synced, 1);
|
|
assert_eq!(composio_status.chunks_pending, 1);
|
|
assert_eq!(composio_status.freshness, FreshnessLabel::Idle);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_thread_tree_and_sync_controller_schemas_execute_public_handlers() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let config = Config::load_or_init().await.expect("init isolated config");
|
|
|
|
let thread_schemas = all_threads_controller_schemas();
|
|
let thread_controllers = all_threads_registered_controllers();
|
|
assert_eq!(thread_schemas.len(), 16);
|
|
assert_eq!(thread_schemas.len(), thread_controllers.len());
|
|
assert_eq!(
|
|
openhuman_core::openhuman::threads::schemas::schemas("missing").function,
|
|
"unknown"
|
|
);
|
|
for function in [
|
|
"list",
|
|
"upsert",
|
|
"create_new",
|
|
"messages_list",
|
|
"message_append",
|
|
"generate_title",
|
|
"update_labels",
|
|
"update_title",
|
|
"message_update",
|
|
"delete",
|
|
"purge",
|
|
"turn_state_get",
|
|
"turn_state_list",
|
|
"turn_state_clear",
|
|
"task_board_get",
|
|
"task_board_put",
|
|
] {
|
|
assert!(thread_schemas
|
|
.iter()
|
|
.any(|schema| schema.namespace == "threads" && schema.function == function));
|
|
}
|
|
|
|
let thread_upsert = thread_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "upsert")
|
|
.expect("threads upsert controller");
|
|
assert!((thread_upsert.handler)(Map::new())
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid params"));
|
|
|
|
let task_board_put = thread_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "task_board_put")
|
|
.expect("task board put controller");
|
|
let task_board_get = thread_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "task_board_get")
|
|
.expect("task board get controller");
|
|
let mut put_params = Map::new();
|
|
put_params.insert("thread_id".into(), json!("thread/schema-handlers"));
|
|
put_params.insert(
|
|
"cards".into(),
|
|
json!([
|
|
{
|
|
"id": "card-1",
|
|
"title": "Cover controller schemas",
|
|
"status": "todo",
|
|
"plan": ["inspect", "assert"],
|
|
"order": 1,
|
|
"updatedAt": "2026-05-29T12:00:00Z"
|
|
}
|
|
]),
|
|
);
|
|
let put_json = (task_board_put.handler)(put_params)
|
|
.await
|
|
.expect("put task board");
|
|
assert_eq!(put_json["taskBoard"]["cards"][0]["id"], "card-1");
|
|
|
|
let mut get_params = Map::new();
|
|
get_params.insert("thread_id".into(), json!("thread/schema-handlers"));
|
|
let get_json = (task_board_get.handler)(get_params)
|
|
.await
|
|
.expect("get task board");
|
|
assert_eq!(
|
|
get_json["taskBoard"]["cards"][0]["title"],
|
|
"Cover controller schemas"
|
|
);
|
|
|
|
let tree_schemas = all_tree_summarizer_controller_schemas();
|
|
let tree_controllers = all_tree_summarizer_registered_controllers();
|
|
assert_eq!(tree_schemas.len(), 5);
|
|
assert_eq!(tree_schemas.len(), tree_controllers.len());
|
|
let ingest_schema = tree_schemas
|
|
.iter()
|
|
.find(|schema| schema.function == "ingest")
|
|
.expect("ingest schema");
|
|
assert!(ingest_schema
|
|
.inputs
|
|
.iter()
|
|
.any(|field| field.name == "metadata" && !field.required));
|
|
|
|
let tree_status = tree_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "status")
|
|
.expect("tree status controller");
|
|
let mut tree_params = Map::new();
|
|
tree_params.insert("namespace".into(), json!("schema_handlers"));
|
|
let status_json = (tree_status.handler)(tree_params)
|
|
.await
|
|
.expect("tree status");
|
|
assert_eq!(status_json["result"]["total_nodes"], 0);
|
|
let tree_ingest = tree_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "ingest")
|
|
.expect("tree ingest controller");
|
|
let mut bad_ingest = Map::new();
|
|
bad_ingest.insert("namespace".into(), json!("schema_handlers"));
|
|
bad_ingest.insert("content".into(), json!("content"));
|
|
bad_ingest.insert("timestamp".into(), json!(123));
|
|
assert!((tree_ingest.handler)(bad_ingest)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("expected string"));
|
|
|
|
let sync_schemas = memory_sync_status_schemas::all_controller_schemas();
|
|
let sync_controllers = memory_sync_status_schemas::all_registered_controllers();
|
|
assert_eq!(sync_schemas.len(), 1);
|
|
assert_eq!(sync_controllers.len(), 1);
|
|
assert_eq!(sync_schemas[0].function, "status_list");
|
|
|
|
let now = Utc::now().timestamp_millis();
|
|
let mut first = chunk("slack:team:message-1", 0, now - 1_000);
|
|
first.id = "sync-status-covered-1".into();
|
|
let mut second = chunk("slack:team:message-2", 1, now - 2_000);
|
|
second.id = "sync-status-covered-2".into();
|
|
upsert_chunks(&config, &[first, second]).expect("upsert sync status chunks");
|
|
with_connection(&config, |conn| {
|
|
conn.execute(
|
|
"INSERT INTO mem_tree_chunk_embeddings \
|
|
(chunk_id, model_signature, vector, dim, created_at) \
|
|
VALUES ('sync-status-covered-1', 'test-sig', X'00000000', 1, 0.0)",
|
|
[],
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.expect("insert embedding sidecar");
|
|
|
|
let status = memory_sync_status_rpc::status_list_rpc(&config)
|
|
.await
|
|
.expect("sync status rpc")
|
|
.value;
|
|
let slack = status
|
|
.statuses
|
|
.iter()
|
|
.find(|status| status.provider == "slack")
|
|
.expect("slack sync status");
|
|
assert_eq!(slack.chunks_synced, 2);
|
|
assert_eq!(slack.chunks_pending, 1);
|
|
assert_eq!(slack.batch_total, 2);
|
|
assert_eq!(slack.batch_processed, 1);
|
|
|
|
let status_json = (sync_controllers[0].handler)(Map::new())
|
|
.await
|
|
.expect("sync status controller");
|
|
assert!(status_json["statuses"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|row| row["provider"] == "slack"));
|
|
|
|
let slack_schemas = slack_memory_schemas::all_slack_memory_controller_schemas();
|
|
let slack_controllers = slack_memory_schemas::all_slack_memory_registered_controllers();
|
|
assert_eq!(slack_schemas.len(), 2);
|
|
assert_eq!(slack_schemas.len(), slack_controllers.len());
|
|
assert_eq!(slack_memory_schemas::schemas("unknown").function, "unknown");
|
|
let trigger = slack_controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "sync_trigger")
|
|
.expect("slack sync trigger controller");
|
|
let mut bad_trigger = Map::new();
|
|
bad_trigger.insert("connection_id".into(), json!(123));
|
|
assert!((trigger.handler)(bad_trigger)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid params"));
|
|
}
|
|
|
|
#[test]
|
|
fn memory_schema_registries_and_query_tool_metadata_cover_public_surfaces() {
|
|
let memory_schemas = all_memory_controller_schemas();
|
|
let memory_controllers = all_memory_registered_controllers();
|
|
assert_eq!(memory_schemas.len(), 34);
|
|
assert_eq!(memory_schemas.len(), memory_controllers.len());
|
|
for function in [
|
|
"init",
|
|
"list_documents",
|
|
"list_namespaces",
|
|
"delete_document",
|
|
"query_namespace",
|
|
"recall_context",
|
|
"recall_memories",
|
|
"namespace_list",
|
|
"doc_put",
|
|
"doc_ingest",
|
|
"doc_list",
|
|
"doc_delete",
|
|
"context_query",
|
|
"context_recall",
|
|
"clear_namespace",
|
|
"list_files",
|
|
"read_file",
|
|
"write_file",
|
|
"kv_set",
|
|
"kv_get",
|
|
"kv_delete",
|
|
"kv_list_namespace",
|
|
"graph_upsert",
|
|
"graph_query",
|
|
"sync_channel",
|
|
"sync_all",
|
|
"ingestion_status",
|
|
"learn_all",
|
|
"tool_rule_put",
|
|
"tool_rule_get",
|
|
"tool_rule_list",
|
|
"tool_rule_delete",
|
|
"tool_rules_for_prompt",
|
|
"tool_rules_json",
|
|
] {
|
|
let schema = openhuman_core::openhuman::memory::schemas::schemas(function);
|
|
assert_eq!(schema.namespace, "memory");
|
|
assert_eq!(schema.function, function);
|
|
assert!(memory_schemas
|
|
.iter()
|
|
.any(|candidate| candidate.function == function));
|
|
}
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory::schemas::schemas("missing").function,
|
|
"unknown"
|
|
);
|
|
|
|
let legacy_tree_schemas = openhuman_core::openhuman::memory::schema::all_controller_schemas();
|
|
let legacy_tree_controllers =
|
|
openhuman_core::openhuman::memory::schema::all_registered_controllers();
|
|
assert!(
|
|
legacy_tree_schemas.len() >= 19,
|
|
"expected at least 19 memory controller schemas, got {}",
|
|
legacy_tree_schemas.len()
|
|
);
|
|
assert_eq!(legacy_tree_schemas.len(), legacy_tree_controllers.len());
|
|
for function in [
|
|
"ingest",
|
|
"list_chunks",
|
|
"get_chunk",
|
|
"memory_backfill_status",
|
|
"list_sources",
|
|
"search",
|
|
"recall",
|
|
"entity_index_for",
|
|
"chunks_for_entity",
|
|
"top_entities",
|
|
"chunk_score",
|
|
"delete_chunk",
|
|
"graph_export",
|
|
"obsidian_vault_status",
|
|
"flush_now",
|
|
"wipe_all",
|
|
"reset_tree",
|
|
"pipeline_status",
|
|
"set_enabled",
|
|
] {
|
|
let schema = openhuman_core::openhuman::memory::schema::schemas(function);
|
|
assert_eq!(schema.namespace, "memory_tree");
|
|
assert_eq!(schema.function, function);
|
|
assert!(legacy_tree_schemas
|
|
.iter()
|
|
.any(|candidate| candidate.function == function));
|
|
}
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory::schema::schemas("missing").function,
|
|
"unknown"
|
|
);
|
|
|
|
let consolidated = MemoryQueryTool;
|
|
let schema = consolidated.parameters_schema();
|
|
assert_eq!(consolidated.name(), "memory_tree");
|
|
assert_eq!(consolidated.category(), ToolCategory::System);
|
|
assert_eq!(consolidated.permission_level(), PermissionLevel::ReadOnly);
|
|
assert!(schema["properties"]["mode"]["enum"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|mode| mode == "walk"));
|
|
|
|
for tool in [
|
|
&MemoryTreeSearchEntitiesTool as &dyn Tool,
|
|
&MemoryTreeQuerySourceTool,
|
|
&MemoryTreeDrillDownTool,
|
|
&MemoryTreeFetchLeavesTool,
|
|
&MemoryTreeIngestDocumentTool,
|
|
&MemoryTreeWalkTool,
|
|
] {
|
|
assert!(!tool.name().is_empty());
|
|
assert!(!tool.description().is_empty());
|
|
assert_eq!(tool.category(), ToolCategory::System);
|
|
assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly);
|
|
assert_eq!(tool.parameters_schema()["type"], "object");
|
|
let _ = tool.is_concurrency_safe(&json!({}));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn memory_tree_policy_and_source_registry_write_metadata_mirror() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let policy = TreePolicy::topic();
|
|
let now = 1_700_000_000_000_i64;
|
|
assert_eq!(TreePolicy::global(), TreePolicy::Global);
|
|
assert_eq!(TreePolicy::source(), TreePolicy::Source);
|
|
assert!(policy.topic_creation_threshold() > policy.topic_archive_threshold());
|
|
assert_eq!(policy.topic_recency_decay(None, now), 0.0);
|
|
assert_eq!(policy.topic_recency_decay(Some(now + 60_000), now), 1.0);
|
|
assert_eq!(
|
|
policy.topic_recency_decay(Some(now - 60 * 86_400_000), now),
|
|
0.0
|
|
);
|
|
|
|
let stats = openhuman_core::openhuman::memory_store::trees::types::EntityIndexStats {
|
|
mention_count_30d: 9,
|
|
distinct_sources: 4,
|
|
last_seen_ms: Some(now - 4 * 86_400_000),
|
|
query_hits_30d: 2,
|
|
graph_centrality: Some(0.75),
|
|
};
|
|
assert!(policy.topic_hotness("user@example.com", &stats, now) > 0.0);
|
|
|
|
let first = tree_source::get_or_create_source_tree(&config, "gmail:user@example.com")
|
|
.expect("create source tree");
|
|
let second = tree_source::get_or_create_source_tree(&config, "gmail:user@example.com")
|
|
.expect("reuse source tree");
|
|
assert_eq!(first.id, second.id);
|
|
|
|
let mirror = tree_source::file::source_file_path(&config, &first.scope);
|
|
let body = std::fs::read_to_string(&mirror)
|
|
.unwrap_or_else(|err| panic!("read {}: {err}", mirror.display()));
|
|
assert!(body.starts_with("---\n"));
|
|
assert!(body.contains("kind: source"));
|
|
assert!(body.contains("scope: \"gmail:user@example.com\""));
|
|
assert!(body.contains("last_sealed_at: null"));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_title_error_and_turn_state_helpers_cover_wire_shapes() {
|
|
assert!(is_auto_generated_thread_title("Chat Jan 1 1:23 AM"));
|
|
assert!(!is_auto_generated_thread_title("Chat Jan 1 1:2 AM"));
|
|
assert!(!is_auto_generated_thread_title("Planning the launch"));
|
|
assert_eq!(collapse_whitespace(" a\tb\n c "), "a b c");
|
|
assert_eq!(
|
|
sanitize_generated_title("\n`Deploy review!`\nsecond").as_deref(),
|
|
Some("Deploy review")
|
|
);
|
|
assert!(sanitize_generated_title("\"\"").is_none());
|
|
assert_eq!(
|
|
title_from_user_message("/briefing Morning update. Then email").as_deref(),
|
|
Some("briefing Morning update")
|
|
);
|
|
assert_ne!(
|
|
title_log_fingerprint("alpha"),
|
|
title_log_fingerprint("beta")
|
|
);
|
|
let prompt = build_title_prompt("hello", "hi");
|
|
assert!(prompt.contains("First user message:\nhello"));
|
|
assert!(prompt.contains("Assistant reply:\nhi"));
|
|
|
|
let not_found: String = ThreadsError::not_found("thread-1").into();
|
|
assert!(not_found.contains("ThreadNotFound"));
|
|
let scoped = ThreadsError::from_thread_scoped_store_error(
|
|
"thread-1",
|
|
"thread thread-2 not found".to_string(),
|
|
);
|
|
assert!(matches!(scoped, ThreadsError::Message(_)));
|
|
|
|
let mut state = TurnState::started("thread-1", "request-1", 6, "2026-05-29T12:00:00Z");
|
|
state.lifecycle = TurnLifecycle::Streaming;
|
|
state.phase = Some(TurnPhase::ToolUse);
|
|
state.active_tool = Some("memory.search".into());
|
|
state.tool_timeline.push(ToolTimelineEntry {
|
|
id: "tool-1".into(),
|
|
name: "memory.search".into(),
|
|
round: 1,
|
|
status: ToolTimelineStatus::Success,
|
|
args_buffer: Some("{\"q\":\"coverage\"}".into()),
|
|
display_name: Some("Memory Search".into()),
|
|
detail: Some("2 results".into()),
|
|
source_tool_name: Some("memory.search".into()),
|
|
subagent: None,
|
|
});
|
|
let wire = serde_json::to_value(GetTurnStateResponse {
|
|
turn_state: Some(state.clone()),
|
|
})
|
|
.expect("turn state json");
|
|
assert_eq!(wire["turnState"]["threadId"], "thread-1");
|
|
assert_eq!(wire["turnState"]["phase"], "tool_use");
|
|
let decoded: GetTurnStateResponse = serde_json::from_value(wire).expect("decode turn state");
|
|
assert_eq!(decoded.turn_state.unwrap(), state);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_sync_composio_catalog_scope_and_state_helpers_cover_edge_cases() {
|
|
assert_eq!(SyncReason::ConnectionCreated.as_str(), "connection_created");
|
|
assert_eq!(SyncReason::Periodic.as_str(), "periodic");
|
|
assert_eq!(SyncReason::Manual.as_str(), "manual");
|
|
|
|
let mut outcome = ComposioSyncOutcome {
|
|
toolkit: "gmail".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
reason: SyncReason::Manual.as_str().into(),
|
|
items_ingested: 3,
|
|
started_at_ms: 200,
|
|
finished_at_ms: 150,
|
|
summary: "done".into(),
|
|
details: json!({ "pages": 1 }),
|
|
};
|
|
assert_eq!(outcome.elapsed_ms(), 0);
|
|
outcome.finished_at_ms = 275;
|
|
assert_eq!(outcome.elapsed_ms(), 75);
|
|
|
|
assert_eq!(TaskFetchFilter::default().effective_max(), 25);
|
|
assert_eq!(
|
|
TaskFetchFilter {
|
|
max: 7,
|
|
..Default::default()
|
|
}
|
|
.effective_max(),
|
|
7
|
|
);
|
|
let task_json = json!({
|
|
"externalId": "issue-1",
|
|
"provider": "github",
|
|
"title": "Fix coverage",
|
|
"labels": ["test"],
|
|
"raw": { "number": 1 }
|
|
});
|
|
let task: NormalizedTask = serde_json::from_value(task_json).expect("task");
|
|
assert_eq!(task.external_id, "issue-1");
|
|
assert_eq!(task.source_id, "");
|
|
assert_eq!(task.labels, vec!["test"]);
|
|
|
|
let profile = ProviderUserProfile {
|
|
toolkit: "github".into(),
|
|
email: Some("dev@example.com".into()),
|
|
extras: json!({ "login": "dev" }),
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(profile).unwrap()["extras"]["login"],
|
|
"dev"
|
|
);
|
|
|
|
assert_eq!(ToolScope::Read.as_str(), "read");
|
|
assert_eq!(ToolScope::Write.as_str(), "write");
|
|
assert_eq!(ToolScope::Admin.as_str(), "admin");
|
|
assert_eq!(classify_unknown("GMAIL_DELETE_DRAFT"), ToolScope::Admin);
|
|
assert_eq!(classify_unknown("NOTION_CREATE_PAGE"), ToolScope::Write);
|
|
assert_eq!(classify_unknown("GMAIL_FETCH_EMAILS"), ToolScope::Read);
|
|
assert_eq!(
|
|
toolkit_from_slug(" MICROSOFT_TEAMS_SEND_MESSAGE "),
|
|
Some("microsoft_teams".into())
|
|
);
|
|
assert_eq!(toolkit_from_slug(""), None);
|
|
let catalog = &[CuratedTool {
|
|
slug: "GMAIL_SEND_EMAIL",
|
|
scope: ToolScope::Write,
|
|
}];
|
|
assert_eq!(
|
|
find_curated(catalog, "gmail_send_email").map(|tool| tool.scope),
|
|
Some(ToolScope::Write)
|
|
);
|
|
assert!(find_curated(catalog, "GMAIL_DELETE_EMAIL").is_none());
|
|
|
|
let read_only = UserScopePref {
|
|
read: true,
|
|
write: false,
|
|
admin: false,
|
|
};
|
|
assert!(is_action_visible_with_pref(
|
|
"GMAIL_FETCH_EMAILS",
|
|
&read_only
|
|
));
|
|
assert!(!is_action_visible_with_pref("GMAIL_SEND_EMAIL", &read_only));
|
|
assert_eq!(
|
|
curated_scope_for("GMAIL_DELETE_MESSAGE"),
|
|
Some(ToolScope::Admin)
|
|
);
|
|
assert!(toolkit_has_scope("gmail", ToolScope::Admin));
|
|
assert!(catalog_for_toolkit("google_calendar").is_some());
|
|
assert!(agent_ready_toolkits()
|
|
.windows(2)
|
|
.all(|pair| pair[0] <= pair[1]));
|
|
|
|
let matrix = capability_matrix();
|
|
let gmail = matrix.iter().find(|cap| cap.toolkit == "gmail").unwrap();
|
|
assert!(gmail.native_provider);
|
|
assert!(gmail.curated_tools);
|
|
assert!(gmail.curated_tool_count > 0);
|
|
let spotify = matrix.iter().find(|cap| cap.toolkit == "spotify").unwrap();
|
|
assert!(!spotify.native_provider);
|
|
assert!(spotify.curated_tools);
|
|
|
|
let sync_target = composio::SyncTarget {
|
|
toolkit: "gmail".into(),
|
|
connection_id: "conn-1".into(),
|
|
};
|
|
assert_eq!(sync_target.toolkit, "gmail");
|
|
|
|
let mut budget = DailyBudget {
|
|
date: "2000-01-01".into(),
|
|
requests_used: DEFAULT_DAILY_REQUEST_LIMIT,
|
|
limit: DEFAULT_DAILY_REQUEST_LIMIT,
|
|
};
|
|
assert_eq!(budget.remaining(), DEFAULT_DAILY_REQUEST_LIMIT);
|
|
budget.record_request();
|
|
assert_eq!(budget.requests_used, 1);
|
|
budget.record_requests(DEFAULT_DAILY_REQUEST_LIMIT + 10);
|
|
assert!(budget.is_exhausted());
|
|
|
|
let mut state = SyncState::new("gmail", "conn-1");
|
|
assert_eq!(state.budget_remaining(), DEFAULT_DAILY_REQUEST_LIMIT);
|
|
assert!(!state.budget_exhausted());
|
|
state.record_requests(2);
|
|
state.mark_synced("msg-1");
|
|
state.advance_cursor("cursor-1");
|
|
state.set_last_seen_id("msg-2");
|
|
state.set_last_sync_at_ms(123);
|
|
assert!(state.is_synced("msg-1"));
|
|
assert!(!state.is_synced("msg-2"));
|
|
assert_eq!(state.cursor.as_deref(), Some("cursor-1"));
|
|
assert_eq!(state.last_seen_id.as_deref(), Some("msg-2"));
|
|
assert_eq!(state.last_sync_at_ms, Some(123));
|
|
|
|
let item = json!({
|
|
"id": " ",
|
|
"message": { "id": " msg-99 " },
|
|
"nested": { "empty": "" }
|
|
});
|
|
assert_eq!(
|
|
extract_item_id(&item, &["missing", "nested.empty", "message.id"]),
|
|
Some("msg-99".into())
|
|
);
|
|
assert_eq!(extract_item_id(&item, &["missing"]), None);
|
|
}
|
|
|
|
#[test]
|
|
fn slack_memory_schemas_and_post_processors_normalize_composio_shapes() {
|
|
let schemas = slack_memory_schemas::all_slack_memory_controller_schemas();
|
|
assert_eq!(schemas.len(), 2);
|
|
assert_eq!(schemas[0].namespace, "slack_memory");
|
|
assert!(schemas
|
|
.iter()
|
|
.any(|schema| schema.function == "sync_status" && schema.inputs.is_empty()));
|
|
|
|
let mut history = json!({
|
|
"data": {
|
|
"messages": [
|
|
{
|
|
"ts": "1717000000.000100",
|
|
"user": "U001",
|
|
"text": " hello ",
|
|
"thread_ts": "1717000000.000100",
|
|
"permalink": "https://slack.test/archives/C1/p1"
|
|
},
|
|
{ "ts": "1717000000.000200", "text": " " },
|
|
{ "user": "U002", "text": "missing timestamp" }
|
|
]
|
|
}
|
|
});
|
|
slack_post_process::post_process("SLACK_FETCH_CONVERSATION_HISTORY", None, &mut history);
|
|
assert_eq!(history["messages"].as_array().unwrap().len(), 1);
|
|
assert_eq!(history["messages"][0]["text"], "hello");
|
|
assert_eq!(history["messages"][0]["user"], "U001");
|
|
|
|
let mut channels = json!({
|
|
"data": {
|
|
"conversations": [
|
|
{ "id": "C001", "name": "engineering", "is_private": true },
|
|
{ "id": " ", "name": "skip" },
|
|
{ "id": "C002" }
|
|
]
|
|
}
|
|
});
|
|
slack_post_process::post_process("SLACK_LIST_CONVERSATIONS", None, &mut channels);
|
|
assert_eq!(channels["channels"].as_array().unwrap().len(), 2);
|
|
assert_eq!(channels["channels"][0]["is_private"], true);
|
|
assert_eq!(channels["channels"][1]["name"], "C002");
|
|
|
|
let mut search = json!({
|
|
"messages": {
|
|
"matches": [
|
|
{
|
|
"ts": "1717000000.000300",
|
|
"bot_id": "B001",
|
|
"text": "bot update",
|
|
"channel": { "id": "C001" },
|
|
"permalink": "https://slack.test/search/result"
|
|
},
|
|
{ "ts": "1717000000.000400", "text": "" }
|
|
],
|
|
"paging": { "pages": 3 }
|
|
}
|
|
});
|
|
slack_post_process::post_process("SLACK_SEARCH_MESSAGES", None, &mut search);
|
|
assert_eq!(search["pages"], 3);
|
|
assert_eq!(search["messages"].as_array().unwrap().len(), 1);
|
|
assert_eq!(search["messages"][0]["channel_id"], "C001");
|
|
assert_eq!(search["messages"][0]["user"], "B001");
|
|
|
|
let mut non_object = json!("replace me");
|
|
slack_post_process::post_process("SLACK_LIST_CONVERSATIONS", None, &mut non_object);
|
|
assert!(non_object["channels"].as_array().unwrap().is_empty());
|
|
let mut passthrough = json!({ "ok": true });
|
|
slack_post_process::post_process("SLACK_UNKNOWN", None, &mut passthrough);
|
|
assert_eq!(passthrough, json!({ "ok": true }));
|
|
}
|
|
|
|
#[test]
|
|
fn memory_tree_scoring_signal_helpers_cover_boundaries_and_serialization() {
|
|
assert_eq!(EntityKind::parse("email").unwrap(), EntityKind::Email);
|
|
assert!(EntityKind::Email.is_mechanical());
|
|
assert!(!EntityKind::Person.is_mechanical());
|
|
assert!(EntityKind::parse("unknown").is_err());
|
|
|
|
let regex_entities = openhuman_core::openhuman::memory_tree::score::extract::regex::extract(
|
|
"Alice emailed bob@example.com from https://example.test and mentioned #coverage.",
|
|
);
|
|
assert!(regex_entities
|
|
.entities
|
|
.iter()
|
|
.any(|entity| entity.kind == EntityKind::Email && entity.text == "bob@example.com"));
|
|
let canonical = resolver::canonicalise(®ex_entities);
|
|
assert!(canonical
|
|
.iter()
|
|
.any(|entity| entity.canonical_id == "email:bob@example.com"));
|
|
assert_eq!(
|
|
resolver::canonical_id_for(EntityKind::Url, "https://Example.test/path/"),
|
|
"url:https://Example.test/path/"
|
|
);
|
|
assert_eq!(
|
|
resolver::canonical_id_for(EntityKind::Hashtag, "#Coverage"),
|
|
"hashtag:coverage"
|
|
);
|
|
let scoring_config = ScoringConfig::default_regex_only();
|
|
assert!(scoring_config.definite_keep_threshold > scoring_config.definite_drop_threshold);
|
|
assert!(scoring_config.llm_extractor.is_none());
|
|
let regex_only = CompositeExtractor::regex_only();
|
|
assert_eq!(regex_only.name(), "composite");
|
|
|
|
let mut extracted = ExtractedEntities {
|
|
entities: vec![ExtractedEntity {
|
|
kind: EntityKind::Person,
|
|
text: "Alice".into(),
|
|
span_start: 0,
|
|
span_end: 5,
|
|
score: 0.9,
|
|
}],
|
|
topics: vec![ExtractedTopic {
|
|
label: "phoenix".into(),
|
|
score: 0.8,
|
|
}],
|
|
llm_importance: Some(0.3),
|
|
llm_importance_reason: Some("initial".into()),
|
|
};
|
|
assert!(!extracted.is_empty());
|
|
extracted.merge(ExtractedEntities {
|
|
entities: vec![
|
|
ExtractedEntity {
|
|
kind: EntityKind::Person,
|
|
text: "alice".into(),
|
|
span_start: 0,
|
|
span_end: 5,
|
|
score: 1.0,
|
|
},
|
|
ExtractedEntity {
|
|
kind: EntityKind::Organization,
|
|
text: "OpenHuman".into(),
|
|
span_start: 10,
|
|
span_end: 19,
|
|
score: 0.7,
|
|
},
|
|
],
|
|
topics: vec![
|
|
ExtractedTopic {
|
|
label: "phoenix".into(),
|
|
score: 0.9,
|
|
},
|
|
ExtractedTopic {
|
|
label: "coverage".into(),
|
|
score: 0.6,
|
|
},
|
|
],
|
|
llm_importance: Some(0.8),
|
|
llm_importance_reason: Some("higher".into()),
|
|
});
|
|
assert_eq!(extracted.entities.len(), 2);
|
|
assert_eq!(extracted.topics.len(), 2);
|
|
assert_eq!(extracted.unique_entity_count(), 2);
|
|
assert_eq!(extracted.llm_importance, Some(0.8));
|
|
assert_eq!(extracted.llm_importance_reason.as_deref(), Some("higher"));
|
|
|
|
assert_eq!(token_count::score(0), 0.0);
|
|
assert_eq!(token_count::score(30), 1.0);
|
|
assert_eq!(token_count::score(9_000), 0.5);
|
|
assert_eq!(unique_words::score("hi bob"), 0.5);
|
|
assert!(unique_words::score("repeat repeat repeat repeat repeat repeat") < 0.1);
|
|
assert!(unique_words::score("alpha beta gamma delta epsilon zeta eta") > 0.9);
|
|
|
|
let mut meta = Metadata::point_in_time(ChunkSourceKind::Chat, "thread-1", "owner", Utc::now());
|
|
assert_eq!(interaction::score(&meta), 0.5);
|
|
meta.tags = vec![
|
|
"sent".into(),
|
|
"reply".into(),
|
|
"mention".into(),
|
|
"provider:whatsapp".into(),
|
|
];
|
|
assert_eq!(interaction::score(&meta), 1.0);
|
|
assert_eq!(
|
|
source_weight::infer_data_source(&meta),
|
|
Some(DataSource::Whatsapp)
|
|
);
|
|
assert!(source_weight::score(&meta) > 0.7);
|
|
assert_eq!(metadata_weight::score(&meta), 0.5);
|
|
|
|
let email_meta = Metadata::point_in_time(ChunkSourceKind::Email, "mail", "owner", Utc::now());
|
|
let doc_meta = Metadata::point_in_time(ChunkSourceKind::Document, "doc", "owner", Utc::now());
|
|
assert!(metadata_weight::score(&doc_meta) > metadata_weight::score(&email_meta));
|
|
assert_eq!(source_weight::score(&email_meta), 0.75);
|
|
|
|
let computed = compute_score_signals(
|
|
&meta,
|
|
"Alice from OpenHuman mentioned Phoenix migration on Friday",
|
|
120,
|
|
&extracted,
|
|
);
|
|
assert!(computed.token_count > 0.0);
|
|
assert!(computed.unique_words > 0.0);
|
|
assert_eq!(computed.llm_importance, 0.8);
|
|
assert_eq!(entity_density_score(0, &extracted), 0.0);
|
|
assert!(entity_density_score(120, &extracted) > 0.0);
|
|
|
|
let weights = SignalWeights::with_llm_enabled();
|
|
let total = combine(&computed, &weights);
|
|
let cheap_total = combine_cheap_only(&computed, &weights);
|
|
assert!((0.0..=1.0).contains(&total));
|
|
assert!((0.0..=1.0).contains(&cheap_total));
|
|
assert_eq!(
|
|
combine(&ScoreSignals::default(), &SignalWeights::default()),
|
|
0.0
|
|
);
|
|
|
|
for data_source in DataSource::all() {
|
|
assert_eq!(
|
|
DataSource::parse(data_source.as_str()).unwrap(),
|
|
*data_source
|
|
);
|
|
assert_eq!(
|
|
data_source.kind(),
|
|
DataSource::parse(data_source.as_str()).unwrap().kind()
|
|
);
|
|
}
|
|
assert!(DataSource::parse("missing").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn memory_tree_runtime_store_buffers_and_retrieval_wire_helpers() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
let namespace = "slack:#eng";
|
|
let root = tree_node(namespace, "root", "Workspace root summary");
|
|
let year = tree_node(namespace, "2026", "Year summary");
|
|
let month = tree_node(namespace, "2026/05", "Month summary");
|
|
let day = tree_node(namespace, "2026/05/29", "Day summary");
|
|
let hour = tree_node(namespace, "2026/05/29/12", "Hour leaf body");
|
|
|
|
for node in [&root, &year, &month, &day, &hour] {
|
|
tree_runtime_store::write_node(&config, node).expect("write tree node");
|
|
}
|
|
|
|
assert_eq!(
|
|
tree_runtime_store::read_node(&config, namespace, "root")
|
|
.unwrap()
|
|
.unwrap()
|
|
.summary,
|
|
"Workspace root summary"
|
|
);
|
|
assert!(tree_runtime_store::read_node(&config, namespace, "missing")
|
|
.unwrap()
|
|
.is_none());
|
|
assert_eq!(
|
|
tree_runtime_store::read_children(&config, namespace, "root")
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|node| node.node_id)
|
|
.collect::<Vec<_>>(),
|
|
vec!["2026"]
|
|
);
|
|
assert_eq!(
|
|
tree_runtime_store::read_children(&config, namespace, "2026/05/29")
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|node| node.node_id)
|
|
.collect::<Vec<_>>(),
|
|
vec!["2026/05/29/12"]
|
|
);
|
|
assert_eq!(
|
|
tree_runtime_store::read_ancestors(&config, namespace, "2026/05/29/12")
|
|
.unwrap()
|
|
.len(),
|
|
4
|
|
);
|
|
assert_eq!(
|
|
tree_runtime_store::count_nodes(&config, namespace).unwrap(),
|
|
5
|
|
);
|
|
let status = tree_runtime_store::get_tree_status(&config, namespace).unwrap();
|
|
assert_eq!(status.total_nodes, 5);
|
|
assert_eq!(status.depth, 5);
|
|
assert_eq!(
|
|
status.oldest_entry.unwrap().to_rfc3339(),
|
|
"2026-05-29T12:00:00+00:00"
|
|
);
|
|
|
|
let summaries = tree_runtime_store::collect_root_summaries_with_caps(tmp.path(), 10, 12);
|
|
assert_eq!(summaries.len(), 1);
|
|
assert_eq!(summaries[0].0, "slack_#eng");
|
|
assert!(summaries[0].1.contains("[... truncated]"));
|
|
assert_eq!(
|
|
tree_runtime_store::list_namespaces_with_root(&config).unwrap(),
|
|
vec!["slack_#eng".to_string()]
|
|
);
|
|
|
|
let ts = Utc.with_ymd_and_hms(2026, 5, 29, 13, 0, 0).unwrap();
|
|
let first_buffer = tree_runtime_store::buffer_write(
|
|
&config,
|
|
namespace,
|
|
"first buffered body",
|
|
&ts,
|
|
Some(&json!({ "source": "test" })),
|
|
)
|
|
.expect("buffer write");
|
|
let second_buffer =
|
|
tree_runtime_store::buffer_write(&config, namespace, "second buffered body", &ts, None)
|
|
.expect("buffer write second");
|
|
let buffered = tree_runtime_store::buffer_read(&config, namespace).expect("buffer read");
|
|
assert_eq!(buffered.len(), 2);
|
|
assert!(buffered
|
|
.iter()
|
|
.any(|(_, body)| body == "first buffered body"));
|
|
tree_runtime_store::buffer_delete(
|
|
&config,
|
|
namespace,
|
|
&[first_buffer
|
|
.file_name()
|
|
.unwrap()
|
|
.to_string_lossy()
|
|
.to_string()],
|
|
)
|
|
.expect("buffer delete");
|
|
assert!(!first_buffer.exists());
|
|
assert!(second_buffer.exists());
|
|
let drained = tree_runtime_store::buffer_drain(&config, namespace).expect("buffer drain");
|
|
assert_eq!(drained.len(), 1);
|
|
assert!(tree_runtime_store::buffer_read(&config, namespace)
|
|
.unwrap()
|
|
.is_empty());
|
|
|
|
assert_eq!(NodeLevel::Hour.max_tokens(), 1_000);
|
|
assert_eq!(NodeLevel::Month.parent_level(), Some(NodeLevel::Year));
|
|
assert_eq!(NodeLevel::from_str_label("DAY"), Some(NodeLevel::Day));
|
|
assert_eq!(
|
|
derive_parent_id("2026/05/29/12").as_deref(),
|
|
Some("2026/05/29")
|
|
);
|
|
assert_eq!(
|
|
derive_node_ids(&ts),
|
|
(
|
|
"2026/05/29/13".to_string(),
|
|
"2026/05/29".to_string(),
|
|
"2026/05".to_string(),
|
|
"2026".to_string(),
|
|
"root".to_string()
|
|
)
|
|
);
|
|
assert_eq!(node_id_to_path("root").to_string_lossy(), "root.md");
|
|
assert!(tree_runtime_store::validate_namespace("team").is_ok());
|
|
assert!(tree_runtime_store::validate_namespace("../bad").is_err());
|
|
assert!(tree_runtime_store::validate_node_id("2026/05/29/23").is_ok());
|
|
assert!(tree_runtime_store::validate_node_id("2026/13").is_err());
|
|
|
|
let legacy = tree_runtime_store::parse_node_markdown_pub("legacy body", namespace, "2026")
|
|
.expect("parse legacy");
|
|
assert_eq!(legacy.level, NodeLevel::Year);
|
|
assert_eq!(legacy.created_at, chrono::DateTime::<Utc>::UNIX_EPOCH);
|
|
assert_eq!(
|
|
tree_runtime_store::delete_tree(&config, namespace).unwrap(),
|
|
5
|
|
);
|
|
assert_eq!(
|
|
tree_runtime_store::delete_tree(&config, namespace).unwrap(),
|
|
0
|
|
);
|
|
|
|
let source_factory = openhuman_core::openhuman::memory_tree::tree::TreeFactory::source(
|
|
"gmail:alice@example.com|bob@example.com",
|
|
);
|
|
assert_eq!(
|
|
source_factory.profile(),
|
|
openhuman_core::openhuman::memory_tree::tree::TreeProfile::Source
|
|
);
|
|
assert_eq!(
|
|
source_factory.scope_slug(),
|
|
"alice-example-com-bob-example-com"
|
|
);
|
|
let source_tree = source_factory
|
|
.get_or_create(&config)
|
|
.expect("source tree from factory");
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_tree::tree::TreeFactory::from_tree(&source_tree).kind(),
|
|
TreeKind::Source
|
|
);
|
|
let topic_factory =
|
|
openhuman_core::openhuman::memory_tree::tree::TreeFactory::topic("email:alice@example.com");
|
|
assert!(matches!(
|
|
topic_factory.summary_tree_kind(),
|
|
openhuman_core::openhuman::memory_store::content::SummaryTreeKind::Topic
|
|
));
|
|
let topic_tree = topic_factory
|
|
.get_or_create(&config)
|
|
.expect("topic tree from factory");
|
|
assert_ne!(source_tree.id, topic_tree.id);
|
|
assert!(
|
|
openhuman_core::openhuman::memory_tree::tree::new_tree_id(TreeKind::Global)
|
|
.starts_with("global:")
|
|
);
|
|
assert!(openhuman_core::openhuman::memory_tree::tree::new_summary_id(2).contains(":L2-"));
|
|
assert!(
|
|
openhuman_core::openhuman::memory_tree::tree::registry::is_unique_violation(
|
|
&anyhow::anyhow!("UNIQUE constraint failed: mem_trees.kind, mem_trees.scope")
|
|
)
|
|
);
|
|
source_factory
|
|
.archive(&config)
|
|
.expect("archive source tree");
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_tree::tree::store::get_tree_by_scope(
|
|
&config,
|
|
TreeKind::Source,
|
|
"gmail:alice@example.com|bob@example.com"
|
|
)
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
StoredTreeStatus::Archived
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_read_rpc_score_index_and_summary_helpers_cover_dashboard_paths() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let mut config = config_in(&tmp);
|
|
config.config_path = tmp.path().join("config.toml");
|
|
config.embeddings_provider = Some("none".into());
|
|
|
|
let now = Utc.with_ymd_and_hms(2026, 5, 29, 14, 0, 0).unwrap();
|
|
let mut gmail = chunk(
|
|
"gmail:alice@example.com|bob@example.com",
|
|
0,
|
|
now.timestamp_millis(),
|
|
);
|
|
gmail.id = "read-rpc-gmail-1".into();
|
|
gmail.content =
|
|
"Alice and Bob discussed coverage, entity indexing, and dashboard recall.".into();
|
|
gmail.token_count = approx_token_count(&gmail.content);
|
|
gmail.metadata.source_kind = ChunkSourceKind::Email;
|
|
gmail.metadata.tags = vec!["sent".into(), "provider:gmail".into()];
|
|
let mut slack = chunk("slack:#eng", 1, now.timestamp_millis() - 60_000);
|
|
slack.id = "read-rpc-slack-1".into();
|
|
slack.content = "Engineering channel mentioned coverage dashboards.".into();
|
|
slack.token_count = approx_token_count(&slack.content);
|
|
slack.metadata.source_kind = ChunkSourceKind::Chat;
|
|
slack.metadata.tags = vec!["reply".into(), "provider:slack".into()];
|
|
upsert_chunks(&config, &[gmail.clone(), slack.clone()]).expect("upsert read rpc chunks");
|
|
with_connection(&config, |conn| {
|
|
conn.execute(
|
|
"UPDATE mem_tree_chunks SET embedding = X'00010203', tags_json = ?2 WHERE id = ?1",
|
|
(&gmail.id, json!(["sent", "provider:gmail"]).to_string()),
|
|
)?;
|
|
Ok(())
|
|
})
|
|
.expect("mark embedded and tags");
|
|
|
|
let entity = CanonicalEntity {
|
|
canonical_id: "email:bob@example.com".into(),
|
|
kind: EntityKind::Email,
|
|
surface: "bob@example.com".into(),
|
|
span_start: 0,
|
|
span_end: 15,
|
|
score: 0.9,
|
|
};
|
|
score_store::index_entity(
|
|
&config,
|
|
&entity,
|
|
&gmail.id,
|
|
"leaf",
|
|
now.timestamp_millis(),
|
|
None,
|
|
)
|
|
.expect("index entity");
|
|
assert_eq!(
|
|
score_store::index_entities(&config, &[], "unused", "leaf", now.timestamp_millis(), None)
|
|
.expect("empty index"),
|
|
0
|
|
);
|
|
let score_row = score_store::ScoreRow {
|
|
chunk_id: gmail.id.clone(),
|
|
total: 0.82,
|
|
signals: ScoreSignals {
|
|
token_count: 1.0,
|
|
unique_words: 0.8,
|
|
metadata_weight: 0.7,
|
|
source_weight: 0.75,
|
|
interaction: 0.5,
|
|
entity_density: 0.4,
|
|
llm_importance: 0.6,
|
|
},
|
|
dropped: false,
|
|
reason: Some("kept for coverage".into()),
|
|
computed_at_ms: now.timestamp_millis(),
|
|
llm_importance_reason: Some("explicit project signal".into()),
|
|
};
|
|
score_store::upsert_score(&config, &score_row).expect("upsert score");
|
|
assert_eq!(score_store::count_scores(&config).unwrap(), 1);
|
|
assert_eq!(score_store::count_entity_index(&config).unwrap(), 1);
|
|
assert_eq!(
|
|
score_store::list_entity_ids_for_node(&config, &gmail.id).unwrap(),
|
|
vec!["email:bob@example.com".to_string()]
|
|
);
|
|
assert_eq!(
|
|
score_store::lookup_entity(&config, "email:bob@example.com", Some(10)).unwrap()[0].node_id,
|
|
gmail.id
|
|
);
|
|
|
|
let listed = memory_read_rpc::list_chunks_rpc(
|
|
&config,
|
|
memory_read_rpc::ChunkFilter {
|
|
source_kinds: Some(vec!["email".into()]),
|
|
entity_ids: Some(vec!["email:bob@example.com".into()]),
|
|
query: Some("coverage".into()),
|
|
limit: Some(10),
|
|
..Default::default()
|
|
},
|
|
)
|
|
.await
|
|
.expect("list chunks")
|
|
.value;
|
|
assert_eq!(listed.total, 1);
|
|
assert_eq!(listed.chunks[0].id, gmail.id);
|
|
assert!(listed.chunks[0].has_embedding);
|
|
assert_eq!(listed.chunks[0].tags, vec!["sent", "provider:gmail"]);
|
|
|
|
let sources = memory_read_rpc::list_sources_rpc(&config, Some("alice@example.com".into()))
|
|
.await
|
|
.expect("list sources")
|
|
.value;
|
|
let gmail_source = sources
|
|
.iter()
|
|
.find(|source| source.source_id == "gmail:alice@example.com|bob@example.com")
|
|
.expect("gmail source");
|
|
assert_eq!(gmail_source.display_name, "bob@example.com");
|
|
|
|
let search = memory_read_rpc::search_rpc(&config, "dashboards".into(), 5)
|
|
.await
|
|
.expect("search")
|
|
.value;
|
|
assert_eq!(search.len(), 1);
|
|
assert_eq!(search[0].source_id, "slack:#eng");
|
|
|
|
let indexed = memory_read_rpc::entity_index_for_rpc(&config, gmail.id.clone())
|
|
.await
|
|
.expect("entity index")
|
|
.value;
|
|
assert_eq!(indexed[0].entity_id, "email:bob@example.com");
|
|
let chunk_ids = memory_read_rpc::chunks_for_entity_rpc(&config, "email:bob@example.com".into())
|
|
.await
|
|
.expect("chunks for entity")
|
|
.value;
|
|
assert_eq!(chunk_ids, vec![gmail.id.clone()]);
|
|
let top_entities = memory_read_rpc::top_entities_rpc(&config, Some("email".into()), 3)
|
|
.await
|
|
.expect("top entities")
|
|
.value;
|
|
assert_eq!(top_entities[0].surface, "bob@example.com");
|
|
|
|
let breakdown = memory_read_rpc::chunk_score_rpc(&config, gmail.id.clone())
|
|
.await
|
|
.expect("chunk score")
|
|
.value
|
|
.expect("score breakdown");
|
|
assert!(breakdown.kept);
|
|
assert!(breakdown.llm_consulted);
|
|
assert!(breakdown
|
|
.signals
|
|
.iter()
|
|
.any(|signal| signal.name == "llm_importance" && signal.weight == 2.0));
|
|
assert!(memory_read_rpc::chunk_score_rpc(&config, "missing".into())
|
|
.await
|
|
.expect("missing chunk score")
|
|
.value
|
|
.is_none());
|
|
|
|
let missing_delete = memory_read_rpc::delete_chunk_rpc(&config, "missing".into())
|
|
.await
|
|
.expect("delete missing")
|
|
.value;
|
|
assert!(!missing_delete.deleted);
|
|
let deleted = memory_read_rpc::delete_chunk_rpc(&config, gmail.id.clone())
|
|
.await
|
|
.expect("delete chunk")
|
|
.value;
|
|
assert!(deleted.deleted);
|
|
assert_eq!(deleted.score_rows_removed, 1);
|
|
assert_eq!(deleted.entity_index_rows_removed, 1);
|
|
assert_eq!(score_store::count_scores(&config).unwrap(), 0);
|
|
assert_eq!(score_store::count_entity_index(&config).unwrap(), 0);
|
|
|
|
let summary_input = SummaryInput {
|
|
id: "input-1".into(),
|
|
content: " The team shipped deterministic coverage tests. ".into(),
|
|
token_count: 12,
|
|
entities: vec!["email:bob@example.com".into()],
|
|
topics: vec!["coverage".into()],
|
|
time_range_start: now,
|
|
time_range_end: now,
|
|
score: 0.9,
|
|
};
|
|
let fallback = fallback_summary(&[summary_input.clone()], 4);
|
|
assert!(fallback.content.starts_with("— The"));
|
|
assert!(fallback.token_count <= 4);
|
|
assert!(fallback.entities.is_empty());
|
|
let empty_ctx = SummaryContext {
|
|
tree_id: "tree-empty",
|
|
tree_kind: TreeKind::Global,
|
|
target_level: 1,
|
|
token_budget: 100,
|
|
};
|
|
let empty =
|
|
openhuman_core::openhuman::memory_tree::summarise::summarise(&config, &[], &empty_ctx)
|
|
.await
|
|
.expect("empty summarise avoids provider");
|
|
assert_eq!(empty.token_count, 0);
|
|
|
|
let embedder =
|
|
openhuman_core::openhuman::memory_tree::score::embed::factory::build_embedder_from_config(
|
|
&config,
|
|
)
|
|
.expect("inert embedder");
|
|
assert_eq!(embedder.name(), "inert");
|
|
}
|
|
|
|
#[test]
|
|
fn memory_retrieval_embedding_and_rpc_model_helpers_round_trip() {
|
|
assert_eq!(retrieval::types::NodeKind::Leaf.as_str(), "leaf");
|
|
assert_eq!(retrieval::types::NodeKind::Summary.as_str(), "summary");
|
|
assert!(retrieval::types::QueryResponse::empty().hits.is_empty());
|
|
|
|
let now = Utc.with_ymd_and_hms(2026, 5, 29, 12, 0, 0).unwrap();
|
|
let summary = SummaryNode {
|
|
id: "sum-1".into(),
|
|
tree_id: "tree-1".into(),
|
|
tree_kind: TreeKind::Topic,
|
|
level: 2,
|
|
parent_id: Some("root".into()),
|
|
child_ids: vec!["child-1".into(), "child-2".into()],
|
|
content: "Topic summary".into(),
|
|
token_count: 3,
|
|
entities: vec!["person:alice".into()],
|
|
topics: vec!["coverage".into()],
|
|
time_range_start: now,
|
|
time_range_end: now,
|
|
score: 0.8,
|
|
sealed_at: now,
|
|
deleted: false,
|
|
embedding: None,
|
|
};
|
|
let tree = Tree {
|
|
id: "tree-1".into(),
|
|
kind: TreeKind::Topic,
|
|
scope: "topic:coverage".into(),
|
|
root_id: Some("sum-1".into()),
|
|
max_level: 2,
|
|
status: StoredTreeStatus::Active,
|
|
created_at: now,
|
|
last_sealed_at: Some(now),
|
|
};
|
|
let summary_hit = retrieval::types::hit_from_summary_with_tree(&summary, &tree);
|
|
assert_eq!(summary_hit.node_kind, retrieval::types::NodeKind::Summary);
|
|
assert_eq!(summary_hit.tree_scope, "topic:coverage");
|
|
assert_eq!(summary_hit.child_ids, vec!["child-1", "child-2"]);
|
|
|
|
let mut leaf_chunk = chunk("gmail:acct:msg-1", 0, now.timestamp_millis());
|
|
leaf_chunk.metadata.source_ref = Some(SourceRef::new("<msg-1@example.test>"));
|
|
let leaf_hit = retrieval::types::hit_from_chunk(&leaf_chunk, "tree-2", "gmail:acct", 0.4);
|
|
assert_eq!(leaf_hit.node_kind, retrieval::types::NodeKind::Leaf);
|
|
assert_eq!(leaf_hit.tree_kind, TreeKind::Source);
|
|
assert_eq!(leaf_hit.source_ref.as_deref(), Some("<msg-1@example.test>"));
|
|
let response = retrieval::types::QueryResponse::new(vec![leaf_hit], 2);
|
|
assert!(response.truncated);
|
|
assert_eq!(
|
|
retrieval::types::leaf_tree_placeholder(ChunkSourceKind::Email),
|
|
TreeKind::Source
|
|
);
|
|
|
|
assert_eq!(
|
|
TreeKind::parse(TreeKind::Global.as_str()).unwrap(),
|
|
TreeKind::Global
|
|
);
|
|
assert!(TreeKind::parse("missing").is_err());
|
|
assert_eq!(
|
|
StoredTreeStatus::parse(StoredTreeStatus::Archived.as_str()).unwrap(),
|
|
StoredTreeStatus::Archived
|
|
);
|
|
assert!(StoredTreeStatus::parse("missing").is_err());
|
|
|
|
let packed = embed::pack_checked(&vec![0.25; embed::EMBEDDING_DIM]).expect("pack checked");
|
|
let unpacked = embed::unpack_embedding(&packed).expect("unpack");
|
|
assert_eq!(unpacked.len(), embed::EMBEDDING_DIM);
|
|
assert!(embed::pack_checked(&[1.0, 2.0]).is_err());
|
|
assert!(embed::unpack_embedding(&[0, 1, 2]).is_err());
|
|
assert!(embed::decode_optional_blob(None, "none").unwrap().is_none());
|
|
assert!(embed::decode_optional_blob(Some(vec![0; 16]), "bad row").is_err());
|
|
assert_eq!(embed::cosine_similarity(&[1.0, 0.0], &[0.0, 1.0]), 0.0);
|
|
assert_eq!(embed::InertEmbedder::new().name(), "inert");
|
|
|
|
let query = QueryNamespaceRequest {
|
|
namespace: "default".into(),
|
|
query: "coverage".into(),
|
|
include_references: Some(true),
|
|
document_ids: Some(vec!["doc-1".into()]),
|
|
limit: Some(4),
|
|
max_chunks: Some(6),
|
|
};
|
|
assert_eq!(query.resolved_limit(), 6);
|
|
let recall_context = RecallContextRequest {
|
|
namespace: "default".into(),
|
|
include_references: None,
|
|
limit: Some(3),
|
|
max_chunks: None,
|
|
};
|
|
assert_eq!(recall_context.resolved_limit(), 3);
|
|
let recall_memories = RecallMemoriesRequest {
|
|
namespace: "default".into(),
|
|
min_retention: Some(0.2),
|
|
as_of: Some(1.0),
|
|
limit: Some(3),
|
|
max_chunks: Some(7),
|
|
top_k: Some(9),
|
|
};
|
|
assert_eq!(recall_memories.resolved_limit(), 9);
|
|
|
|
let envelope = ApiEnvelope {
|
|
data: Some(json!({ "ok": true })),
|
|
error: Some(ApiError {
|
|
code: "coverage".into(),
|
|
message: "covered".into(),
|
|
details: Some(json!({ "line": true })),
|
|
}),
|
|
meta: ApiMeta {
|
|
request_id: "req-1".into(),
|
|
latency_seconds: Some(0.01),
|
|
cached: Some(false),
|
|
counts: None,
|
|
pagination: Some(PaginationMeta {
|
|
limit: 10,
|
|
offset: 0,
|
|
count: 1,
|
|
}),
|
|
},
|
|
};
|
|
let encoded = serde_json::to_value(envelope).expect("api envelope json");
|
|
assert_eq!(encoded["meta"]["pagination"]["count"], 1);
|
|
|
|
let entry = MemoryEntry {
|
|
id: "mem-1".into(),
|
|
key: "preference".into(),
|
|
content: "Use deterministic tests".into(),
|
|
namespace: Some("default".into()),
|
|
category: MemoryCategory::Custom("testing".into()),
|
|
timestamp: now.to_rfc3339(),
|
|
session_id: Some("session-1".into()),
|
|
score: Some(0.9),
|
|
};
|
|
assert_eq!(entry.category.to_string(), "testing");
|
|
let opts = RecallOpts {
|
|
namespace: Some("default"),
|
|
category: Some(MemoryCategory::Conversation),
|
|
session_id: Some("session-2"),
|
|
min_score: Some(0.5),
|
|
cross_session: true,
|
|
};
|
|
assert!(opts.cross_session);
|
|
assert_eq!(opts.category.unwrap().to_string(), "conversation");
|
|
let summary = NamespaceSummary {
|
|
namespace: "default".into(),
|
|
count: 1,
|
|
last_updated: Some(now.to_rfc3339()),
|
|
};
|
|
assert_eq!(serde_json::to_value(summary).unwrap()["count"], 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_preferences_remember_redaction_and_pipeline_traits_cover_public_edges() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let memory: Arc<dyn Memory> =
|
|
Arc::new(UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).expect("memory"));
|
|
|
|
memory
|
|
.store(
|
|
USER_PREF_GENERAL_NAMESPACE,
|
|
"tone",
|
|
"Prefer concise responses.",
|
|
MemoryCategory::Core,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("store general preference");
|
|
memory
|
|
.store(
|
|
USER_PREF_GENERAL_NAMESPACE,
|
|
"empty",
|
|
" ",
|
|
MemoryCategory::Core,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("store empty general preference");
|
|
memory
|
|
.store(
|
|
USER_PREF_SITUATIONAL_NAMESPACE,
|
|
"rust-tests",
|
|
"When changing Rust code, run targeted tests first.",
|
|
MemoryCategory::Core,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("store situational preference");
|
|
|
|
let general = load_general_preferences(&memory, 10).await;
|
|
assert_eq!(general, vec!["Prefer concise responses."]);
|
|
assert!(load_general_preferences(&memory, 0).await.is_empty());
|
|
assert!(recall_situational_preferences(&memory, " ")
|
|
.await
|
|
.is_empty());
|
|
assert!(recall_related_preferences(&memory, " ", "tone", 3)
|
|
.await
|
|
.is_empty());
|
|
assert!(
|
|
recall_related_preferences(&memory, "Prefer concise responses.", "tone", 0)
|
|
.await
|
|
.is_empty()
|
|
);
|
|
|
|
for (kind, label) in [
|
|
(RememberSourceKind::ChatHistory, "chat_history"),
|
|
(RememberSourceKind::UploadedData, "uploaded_data"),
|
|
(RememberSourceKind::LlmThought, "llm_thought"),
|
|
] {
|
|
assert_eq!(kind.as_str(), label);
|
|
assert_eq!(serde_json::to_value(kind).unwrap(), json!(label));
|
|
}
|
|
|
|
assert_eq!(redact("alice@example.com").len(), 8);
|
|
assert_eq!(
|
|
redact_endpoint("https://user:p@ss@example.com:8443/path?q=alice@example.com#frag"),
|
|
"example.com:8443"
|
|
);
|
|
assert_eq!(
|
|
redact_endpoint("localhost:11434/api/chat"),
|
|
"localhost:11434"
|
|
);
|
|
|
|
#[derive(Default)]
|
|
struct RawPipeline;
|
|
|
|
#[async_trait::async_trait]
|
|
impl SyncPipeline for RawPipeline {
|
|
fn id(&self) -> &str {
|
|
"workspace:raw-coverage"
|
|
}
|
|
|
|
fn kind(&self) -> SyncPipelineKind {
|
|
SyncPipelineKind::Workspace
|
|
}
|
|
|
|
async fn init(&self, _config: &Config) -> anyhow::Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn tick(&self, _config: &Config) -> anyhow::Result<PipelineSyncOutcome> {
|
|
Ok(PipelineSyncOutcome {
|
|
records_ingested: 2,
|
|
more_pending: false,
|
|
note: Some("covered".into()),
|
|
})
|
|
}
|
|
}
|
|
|
|
let pipeline = RawPipeline;
|
|
assert_eq!(pipeline.id(), "workspace:raw-coverage");
|
|
assert_eq!(pipeline.kind().as_str(), "workspace");
|
|
pipeline
|
|
.init(&config_in(&tmp))
|
|
.await
|
|
.expect("pipeline init");
|
|
let outcome = pipeline
|
|
.tick(&config_in(&tmp))
|
|
.await
|
|
.expect("pipeline tick");
|
|
assert_eq!(outcome.records_ingested, 2);
|
|
assert_eq!(serde_json::to_value(outcome).unwrap()["note"], "covered");
|
|
assert_eq!(PipelineSyncOutcome::default().records_ingested, 0);
|
|
assert_eq!(SyncPipelineKind::Composio.as_str(), "composio");
|
|
assert_eq!(SyncPipelineKind::Mcp.as_str(), "mcp");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_tools_and_user_scope_prefs_cover_public_execution_paths() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let memory: Arc<dyn Memory> =
|
|
Arc::new(UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).expect("memory"));
|
|
let security = Arc::new(SecurityPolicy {
|
|
autonomy: AutonomyLevel::Full,
|
|
..SecurityPolicy::default()
|
|
});
|
|
|
|
let store_tool = MemoryStoreTool::new(memory.clone(), security.clone());
|
|
assert_eq!(store_tool.name(), "memory_store");
|
|
assert!(store_tool.parameters_schema()["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|field| field == "content"));
|
|
let stored = store_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"key": "rust",
|
|
"content": "Use deterministic memory coverage tests",
|
|
"category": "daily"
|
|
}))
|
|
.await
|
|
.expect("store tool");
|
|
assert!(!stored.is_error);
|
|
assert!(stored.output().contains("coverage-tools/rust"));
|
|
|
|
let custom = store_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"key": "custom",
|
|
"content": "Custom categories survive tool writes",
|
|
"category": "testing"
|
|
}))
|
|
.await
|
|
.expect("store custom category");
|
|
assert!(!custom.is_error);
|
|
assert!(
|
|
store_tool
|
|
.execute(json!({
|
|
"namespace": " ",
|
|
"key": "blank",
|
|
"content": "not written"
|
|
}))
|
|
.await
|
|
.expect("blank namespace")
|
|
.is_error
|
|
);
|
|
assert!(
|
|
store_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"key": "secret",
|
|
"content": "OPENAI_API_KEY=sk-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
}))
|
|
.await
|
|
.expect("secret rejected")
|
|
.is_error
|
|
);
|
|
|
|
let recall_tool = MemoryRecallTool::new(memory.clone());
|
|
assert_eq!(recall_tool.name(), "memory_recall");
|
|
let recalled = recall_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"query": "deterministic",
|
|
"limit": 3
|
|
}))
|
|
.await
|
|
.expect("recall tool");
|
|
assert!(!recalled.is_error);
|
|
assert!(recalled.output().contains("rust"));
|
|
assert!(recall_tool
|
|
.execute(json!({ "namespace": "coverage-tools", "query": " " }))
|
|
.await
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("query cannot be empty"));
|
|
|
|
let forget_tool = MemoryForgetTool::new(memory.clone(), security);
|
|
assert_eq!(forget_tool.name(), "memory_forget");
|
|
let missing = forget_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"key": "missing"
|
|
}))
|
|
.await
|
|
.expect("forget missing");
|
|
assert!(!missing.is_error);
|
|
assert!(missing.output().contains("No memory found"));
|
|
let forgot = forget_tool
|
|
.execute(json!({
|
|
"namespace": "coverage-tools",
|
|
"key": "rust"
|
|
}))
|
|
.await
|
|
.expect("forget existing");
|
|
assert!(!forgot.is_error);
|
|
assert!(forgot.output().contains("Forgot memory"));
|
|
|
|
let scoped_client: openhuman_core::openhuman::memory_store::MemoryClientRef =
|
|
Arc::new(MemoryClient::from_workspace_dir(tmp.path().join("scope-prefs")).unwrap());
|
|
assert_eq!(
|
|
user_scopes::load(&scoped_client, " GMAIL ").await,
|
|
UserScopePref::default()
|
|
);
|
|
let pref = UserScopePref {
|
|
read: true,
|
|
write: false,
|
|
admin: true,
|
|
};
|
|
user_scopes::save(&scoped_client, " GMAIL ", pref)
|
|
.await
|
|
.expect("save user scope pref");
|
|
assert_eq!(user_scopes::load(&scoped_client, "gmail").await, pref);
|
|
scoped_client
|
|
.kv_set(Some("composio-user-scopes"), "gmail", &json!("bad pref"))
|
|
.await
|
|
.expect("write bad pref");
|
|
assert_eq!(
|
|
user_scopes::load(&scoped_client, "gmail").await,
|
|
UserScopePref::default()
|
|
);
|
|
assert!(user_scopes::save(&scoped_client, " ", pref)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("toolkit must not be empty"));
|
|
assert_eq!(
|
|
user_scopes::load_or_default("not-ready-toolkit").await,
|
|
UserScopePref::default()
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_queue_and_tool_memory_public_stores_cover_persistence_edges() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
|
|
memory_queue::set_backfill_in_progress(false);
|
|
assert!(!memory_queue::backfill_in_progress());
|
|
memory_queue::set_backfill_in_progress(true);
|
|
assert!(memory_queue::backfill_in_progress());
|
|
memory_queue::set_backfill_in_progress(false);
|
|
|
|
for kind in [
|
|
JobKind::ExtractChunk,
|
|
JobKind::AppendBuffer,
|
|
JobKind::Seal,
|
|
JobKind::FlushStale,
|
|
JobKind::ReembedBackfill,
|
|
] {
|
|
assert_eq!(JobKind::parse(kind.as_str()).unwrap(), kind);
|
|
}
|
|
assert!(JobKind::parse("missing").is_err());
|
|
assert!(JobKind::Seal.is_llm_bound());
|
|
assert!(!JobKind::AppendBuffer.is_llm_bound());
|
|
assert!(JobStatus::parse("cancelled").unwrap().is_terminal());
|
|
assert!(JobStatus::parse("missing").is_err());
|
|
|
|
let leaf = NodeRef::Leaf {
|
|
chunk_id: "chunk-tool-memory".into(),
|
|
};
|
|
let summary = NodeRef::Summary {
|
|
summary_id: "summary-tool-memory".into(),
|
|
};
|
|
assert_eq!(leaf.dedupe_fragment(), "leaf:chunk-tool-memory");
|
|
assert_eq!(summary.dedupe_fragment(), "summary:summary-tool-memory");
|
|
|
|
let extract = ExtractChunkPayload {
|
|
chunk_id: "chunk-tool-memory".into(),
|
|
};
|
|
let source_append = AppendBufferPayload {
|
|
node: leaf.clone(),
|
|
target: AppendTarget::Source {
|
|
source_id: "slack:#raw".into(),
|
|
},
|
|
};
|
|
let topic_append = AppendBufferPayload {
|
|
node: summary.clone(),
|
|
target: AppendTarget::Topic {
|
|
tree_id: "topic:raw".into(),
|
|
},
|
|
};
|
|
assert_eq!(extract.dedupe_key(), "extract:chunk-tool-memory");
|
|
assert!(source_append
|
|
.dedupe_key()
|
|
.contains("append:source:slack:#raw:leaf:chunk-tool-memory"));
|
|
assert!(topic_append
|
|
.dedupe_key()
|
|
.contains("append:topic:topic:raw:summary:summary-tool-memory"));
|
|
assert_eq!(
|
|
SealPayload {
|
|
tree_id: "tree-1".into(),
|
|
level: 2,
|
|
force_now_ms: Some(1),
|
|
}
|
|
.dedupe_key(),
|
|
"seal:tree-1:2"
|
|
);
|
|
assert_eq!(
|
|
FlushStalePayload {
|
|
max_age_secs: Some(60)
|
|
}
|
|
.dedupe_key("2026-05-29", 4),
|
|
"flush_stale:2026-05-29-h4"
|
|
);
|
|
assert_eq!(
|
|
ReembedBackfillPayload {
|
|
signature: "sig:v1".into()
|
|
}
|
|
.dedupe_key(),
|
|
"reembed_backfill:sig:v1"
|
|
);
|
|
|
|
let first_job = NewJob::append_buffer(&source_append).expect("append job");
|
|
let first_id = memory_queue::enqueue(&config, &first_job)
|
|
.expect("enqueue")
|
|
.expect("inserted");
|
|
assert!(memory_queue::enqueue(&config, &first_job)
|
|
.expect("dedupe enqueue")
|
|
.is_none());
|
|
assert_eq!(memory_queue::count_total(&config).unwrap(), 1);
|
|
assert_eq!(
|
|
memory_queue::count_by_status(&config, JobStatus::Ready).unwrap(),
|
|
1
|
|
);
|
|
let claimed = memory_queue::claim_next(&config, DEFAULT_LOCK_DURATION_MS)
|
|
.expect("claim")
|
|
.expect("claimed");
|
|
assert_eq!(claimed.id, first_id);
|
|
assert_eq!(claimed.status, JobStatus::Running);
|
|
assert_eq!(claimed.attempts, 1);
|
|
let wake_at = Utc::now().timestamp_millis() - 1;
|
|
memory_queue::mark_deferred(&config, &claimed, wake_at, "retry later with token=secret")
|
|
.expect("defer");
|
|
let deferred = memory_queue::get_job(&config, &first_id)
|
|
.expect("get deferred")
|
|
.expect("deferred row");
|
|
assert_eq!(deferred.status, JobStatus::Ready);
|
|
assert_eq!(deferred.attempts, 0);
|
|
assert_eq!(
|
|
deferred.last_error.as_deref(),
|
|
Some("retry later with token=secret")
|
|
);
|
|
let retry_claim = memory_queue::claim_next(&config, DEFAULT_LOCK_DURATION_MS)
|
|
.expect("claim retry")
|
|
.expect("retry claimed");
|
|
memory_queue::mark_done(&config, &retry_claim).expect("done");
|
|
assert_eq!(
|
|
memory_queue::get_job(&config, &first_id)
|
|
.unwrap()
|
|
.unwrap()
|
|
.status,
|
|
JobStatus::Done
|
|
);
|
|
|
|
let mut failing_job = NewJob::extract_chunk(&extract).expect("extract job");
|
|
failing_job.max_attempts = Some(1);
|
|
let failed_id = memory_queue::enqueue(&config, &failing_job)
|
|
.expect("enqueue failing")
|
|
.expect("failing inserted");
|
|
let failed_claim = memory_queue::claim_next(&config, DEFAULT_LOCK_DURATION_MS)
|
|
.expect("claim failing")
|
|
.expect("failing claimed");
|
|
memory_queue::mark_failed(&config, &failed_claim, "fatal Bearer abc.def").expect("mark failed");
|
|
let failed = memory_queue::get_job(&config, &failed_id)
|
|
.expect("get failed")
|
|
.expect("failed row");
|
|
assert_eq!(failed.status, JobStatus::Failed);
|
|
assert_eq!(failed.last_error.as_deref(), Some("fatal Bearer abc.def"));
|
|
assert_eq!(
|
|
memory_queue::recover_stale_locks(&config).expect("recover"),
|
|
0
|
|
);
|
|
|
|
let tool_memory_dir = tmp.path().join("tool-memory");
|
|
let memory: Arc<dyn Memory> = Arc::new(
|
|
UnifiedMemory::new(&tool_memory_dir, Arc::new(NoopEmbedding), None)
|
|
.expect("tool memory backend"),
|
|
);
|
|
let store = ToolMemoryStore::new(memory.clone());
|
|
assert_eq!(tool_memory_namespace(" Shell "), "tool-shell");
|
|
assert!(ToolMemoryPriority::Critical.is_eager());
|
|
assert!(ToolMemoryPriority::High.is_eager());
|
|
assert!(!ToolMemoryPriority::Normal.is_eager());
|
|
assert_eq!(ToolMemorySource::default(), ToolMemorySource::Programmatic);
|
|
assert!(store
|
|
.record(
|
|
" ",
|
|
"blank tool rejected",
|
|
ToolMemoryPriority::High,
|
|
ToolMemorySource::UserExplicit,
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("tool_name"));
|
|
assert!(store
|
|
.record(
|
|
"shell",
|
|
" ",
|
|
ToolMemoryPriority::High,
|
|
ToolMemorySource::UserExplicit,
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("rule body"));
|
|
|
|
let critical = store
|
|
.record(
|
|
"shell",
|
|
"Never run destructive commands without confirmation.",
|
|
ToolMemoryPriority::Critical,
|
|
ToolMemorySource::UserExplicit,
|
|
vec!["safety".into()],
|
|
)
|
|
.await
|
|
.expect("record critical");
|
|
let high = store
|
|
.record(
|
|
"web_search",
|
|
"Prefer primary sources.",
|
|
ToolMemoryPriority::High,
|
|
ToolMemorySource::PostTurn,
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.expect("record high");
|
|
let normal = store
|
|
.record(
|
|
"shell",
|
|
"Use rg before slower search commands.",
|
|
ToolMemoryPriority::Normal,
|
|
ToolMemorySource::Programmatic,
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.expect("record normal");
|
|
assert_eq!(
|
|
store
|
|
.get_rule("shell", &critical.id)
|
|
.await
|
|
.expect("get critical")
|
|
.unwrap()
|
|
.created_at,
|
|
critical.created_at
|
|
);
|
|
let mut updated = critical.clone();
|
|
updated.rule = "Never run destructive commands without explicit confirmation.".into();
|
|
let updated = store.put_rule(updated).await.expect("update critical");
|
|
assert_eq!(updated.created_at, critical.created_at);
|
|
assert_ne!(updated.updated_at, "");
|
|
|
|
let listed = store.list_rules("shell").await.expect("list shell");
|
|
assert_eq!(listed[0].priority, ToolMemoryPriority::Critical);
|
|
assert!(listed.iter().any(|rule| rule.id == normal.id));
|
|
let listed_json = store
|
|
.list_rules_json("shell")
|
|
.await
|
|
.expect("list rules json");
|
|
assert!(listed_json.as_array().unwrap().len() >= 2);
|
|
let tool_names = store.list_tool_names().await.expect("list tool names");
|
|
assert!(tool_names.contains(&"shell".to_string()));
|
|
assert!(tool_names.contains(&"web_search".to_string()));
|
|
let prompt_rules = store
|
|
.rules_for_prompt(&[])
|
|
.await
|
|
.expect("prompt rules from namespaces");
|
|
assert!(prompt_rules["shell"]
|
|
.iter()
|
|
.all(|rule| rule.priority.is_eager()));
|
|
assert_eq!(TOOL_MEMORY_PROMPT_CAP, 30);
|
|
let rendered = render_tool_memory_rules(&[normal.clone(), updated.clone(), high.clone()]);
|
|
assert!(rendered.starts_with(TOOL_MEMORY_HEADING));
|
|
assert!(rendered.find("**[critical]**") < rendered.find("**[high]**"));
|
|
assert!(rendered.contains("### `shell`"));
|
|
assert!(ToolMemoryRulesSection::empty().is_empty());
|
|
assert!(!ToolMemoryRulesSection::new(vec![updated.clone()]).is_empty());
|
|
assert!(store
|
|
.delete_rule("shell", &normal.id)
|
|
.await
|
|
.expect("delete normal"));
|
|
assert!(!store
|
|
.delete_rule("shell", &normal.id)
|
|
.await
|
|
.expect("delete missing"));
|
|
assert!(store
|
|
.get_rule("shell", &normal.id)
|
|
.await
|
|
.expect("missing normal")
|
|
.is_none());
|
|
|
|
let put_tool = MemoryToolsPutTool;
|
|
assert_eq!(put_tool.name(), "memory_tools_put");
|
|
assert_eq!(put_tool.category(), ToolCategory::System);
|
|
assert!(put_tool.parameters_schema()["required"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|field| field == "rule"));
|
|
assert!(put_tool
|
|
.execute(json!({ "tool_name": "shell" }))
|
|
.await
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("invalid arguments for memory_tools_put"));
|
|
let list_tool = MemoryToolsListTool;
|
|
assert_eq!(list_tool.name(), "memory_tools_list");
|
|
assert_eq!(list_tool.permission_level(), PermissionLevel::ReadOnly);
|
|
assert!(list_tool
|
|
.execute(json!({}))
|
|
.await
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("invalid arguments for memory_tools_list"));
|
|
assert_eq!(
|
|
ToolMemoryRule::storage_key(&updated.id),
|
|
format!("rule/{}", updated.id)
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_source_sync_entrypoint_rejects_disabled_and_ingests_folder_items() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let config = config_in(&tmp);
|
|
std::fs::write(
|
|
tmp.path().join("sync-note.md"),
|
|
"# Sync note\n\nAlice documents deterministic source sync coverage.",
|
|
)
|
|
.expect("write sync note");
|
|
|
|
let mut disabled = source(SourceKind::Folder, "src_disabled");
|
|
disabled.path = Some(tmp.path().to_string_lossy().to_string());
|
|
disabled.enabled = false;
|
|
assert!(sync_source(disabled, config.clone())
|
|
.await
|
|
.unwrap_err()
|
|
.contains("disabled"));
|
|
|
|
let mut folder = source(SourceKind::Folder, "src_sync");
|
|
folder.path = Some(tmp.path().to_string_lossy().to_string());
|
|
folder.glob = Some("sync-note.md".into());
|
|
sync_source(folder, config.clone())
|
|
.await
|
|
.expect("queue folder sync");
|
|
|
|
let composite_source_id = "mem_src:src_sync:sync-note.md";
|
|
let mut synced_rows = 0_i64;
|
|
for _ in 0..40 {
|
|
synced_rows = with_connection(&config, |conn| {
|
|
Ok(conn.query_row(
|
|
"SELECT COUNT(*) FROM mem_tree_chunks WHERE source_id = ?1",
|
|
[composite_source_id],
|
|
|row| row.get::<_, i64>(0),
|
|
)?)
|
|
})
|
|
.expect("count synced chunks");
|
|
if synced_rows > 0 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
assert!(
|
|
synced_rows > 0,
|
|
"folder sync should ingest at least one chunk"
|
|
);
|
|
|
|
let mut twitter = source(SourceKind::TwitterQuery, "src_twitter_sync");
|
|
twitter.query = Some("openhuman".into());
|
|
sync_source(twitter, config)
|
|
.await
|
|
.expect("twitter placeholder queues and reports failure asynchronously");
|
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
|
}
|
|
|
|
#[test]
|
|
fn memory_tree_io_contract_types_round_trip_leaf_read_and_write_shapes() {
|
|
let now = Utc.with_ymd_and_hms(2026, 5, 29, 16, 0, 0).unwrap();
|
|
let payload = openhuman_core::openhuman::memory_tree::io::TreeLeafPayload {
|
|
chunk_id: "chunk-contract-1".into(),
|
|
token_count: 42,
|
|
timestamp: now,
|
|
content: "Leaf content for a canonical write request".into(),
|
|
entities: vec!["person:alice".into(), "email:alice@example.com".into()],
|
|
topics: vec!["coverage".into()],
|
|
score: 0.77,
|
|
};
|
|
let leaf_ref = LeafRef::from(&payload);
|
|
assert_eq!(leaf_ref.chunk_id, payload.chunk_id);
|
|
assert_eq!(leaf_ref.entities, payload.entities);
|
|
let round_trip =
|
|
openhuman_core::openhuman::memory_tree::io::TreeLeafPayload::from(leaf_ref.clone());
|
|
assert_eq!(round_trip.content, payload.content);
|
|
assert_eq!(round_trip.score, payload.score);
|
|
|
|
let write_default_json = serde_json::to_value(
|
|
openhuman_core::openhuman::memory_tree::io::TreeWriteRequest {
|
|
tree_id: "tree-contract".into(),
|
|
tree_kind: TreeKind::Source,
|
|
leaf: round_trip.clone(),
|
|
label_strategy: Default::default(),
|
|
deferred: false,
|
|
},
|
|
)
|
|
.expect("write request json");
|
|
assert_eq!(write_default_json["label_strategy"], "inherit");
|
|
assert_eq!(write_default_json["deferred"], false);
|
|
|
|
let decoded_write: openhuman_core::openhuman::memory_tree::io::TreeWriteRequest =
|
|
serde_json::from_value(json!({
|
|
"tree_id": "tree-contract",
|
|
"tree_kind": "global",
|
|
"leaf": {
|
|
"chunk_id": "chunk-contract-2",
|
|
"token_count": 5,
|
|
"timestamp": now,
|
|
"content": "minimal leaf"
|
|
},
|
|
"label_strategy": "empty",
|
|
"deferred": true
|
|
}))
|
|
.expect("decode write request");
|
|
assert_eq!(decoded_write.tree_kind, TreeKind::Global);
|
|
assert_eq!(
|
|
decoded_write.label_strategy,
|
|
openhuman_core::openhuman::memory_tree::io::TreeLabelStrategy::Empty
|
|
);
|
|
assert!(decoded_write.leaf.entities.is_empty());
|
|
assert!(decoded_write.deferred);
|
|
|
|
let outcome = openhuman_core::openhuman::memory_tree::io::TreeWriteOutcome {
|
|
new_summary_ids: vec!["summary-1".into()],
|
|
seal_pending: true,
|
|
};
|
|
let outcome_json = serde_json::to_value(outcome).expect("outcome json");
|
|
assert_eq!(outcome_json["new_summary_ids"][0], "summary-1");
|
|
assert_eq!(outcome_json["seal_pending"], true);
|
|
|
|
let read_request: openhuman_core::openhuman::memory_tree::io::TreeReadRequest =
|
|
serde_json::from_value(json!({
|
|
"tree_id": "tree-contract",
|
|
"max_depth": 2,
|
|
"query": "coverage",
|
|
"limit": 3
|
|
}))
|
|
.expect("decode read request defaults");
|
|
assert_eq!(read_request.start_node_id, None);
|
|
assert_eq!(read_request.max_depth, 2);
|
|
assert_eq!(read_request.limit, Some(3));
|
|
|
|
let hit = openhuman_core::openhuman::memory_tree::io::TreeReadHit {
|
|
node_id: "summary-1".into(),
|
|
node_kind: "summary".into(),
|
|
level: 1,
|
|
content: "Summary text".into(),
|
|
score: 0.42,
|
|
};
|
|
let result = openhuman_core::openhuman::memory_tree::io::TreeReadResult {
|
|
hits: vec![hit],
|
|
total: 4,
|
|
tree_id: "tree-contract".into(),
|
|
};
|
|
let result_json = serde_json::to_value(result).expect("read result json");
|
|
assert_eq!(result_json["hits"][0]["node_kind"], "summary");
|
|
assert_eq!(result_json["total"], 4);
|
|
|
|
let tree = Tree {
|
|
id: "empty-tree".into(),
|
|
kind: TreeKind::Source,
|
|
scope: "source:contract".into(),
|
|
root_id: None,
|
|
max_level: 0,
|
|
status: StoredTreeStatus::Active,
|
|
created_at: now,
|
|
last_sealed_at: None,
|
|
};
|
|
let empty = openhuman_core::openhuman::memory_tree::io::TreeReadResult::empty(&tree);
|
|
assert_eq!(empty.tree_id, "empty-tree");
|
|
assert!(empty.hits.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn memory_sync_profile_identity_helpers_cover_public_no_client_paths_and_rendering() {
|
|
assert_eq!(IdentityKind::parse("email"), Some(IdentityKind::Email));
|
|
assert_eq!(IdentityKind::parse("missing"), None);
|
|
assert!(IdentityKind::Email.is_matchable());
|
|
assert!(!IdentityKind::AvatarUrl.is_matchable());
|
|
assert!(IdentityKind::UserId.confidence() > IdentityKind::DisplayName.confidence());
|
|
|
|
assert_eq!(
|
|
canonicalize(IdentityKind::Email, " Alice@Example.COM "),
|
|
Some("alice@example.com".into())
|
|
);
|
|
assert_eq!(
|
|
canonicalize(IdentityKind::Handle, " @Alice "),
|
|
Some("alice".into())
|
|
);
|
|
assert_eq!(
|
|
canonicalize(IdentityKind::Phone, " +1 (555) 123-4567 "),
|
|
Some("+15551234567".into())
|
|
);
|
|
assert_eq!(
|
|
canonicalize(IdentityKind::DisplayName, " Alice\n Example "),
|
|
Some("Alice Example".into())
|
|
);
|
|
assert_eq!(canonicalize(IdentityKind::Email, " "), None);
|
|
|
|
assert!(load_connected_identities().is_empty());
|
|
assert!(!is_self_identity(
|
|
"gmail",
|
|
IdentityKind::Email,
|
|
"alice@example.com"
|
|
));
|
|
assert!(!is_self_identity(
|
|
"gmail",
|
|
IdentityKind::AvatarUrl,
|
|
"https://example.test/avatar.png"
|
|
));
|
|
assert!(!is_self_identity_any_toolkit(
|
|
IdentityKind::Email,
|
|
"alice@example.com"
|
|
));
|
|
assert_eq!(delete_connected_identity_facets("gmail", "conn-1"), 0);
|
|
|
|
let rendered = render_connected_identities_section(&[
|
|
ConnectedIdentity {
|
|
source: "gmail".into(),
|
|
identifier: "conn:1".into(),
|
|
display_name: Some("Alice\nExample".into()),
|
|
email: Some("alice@example.com".into()),
|
|
handle: None,
|
|
phone: None,
|
|
user_id: Some("U123".into()),
|
|
avatar_url: None,
|
|
profile_url: Some("https://example.test/alice|profile".into()),
|
|
},
|
|
ConnectedIdentity {
|
|
source: "slack".into(),
|
|
identifier: "workspace".into(),
|
|
display_name: None,
|
|
email: None,
|
|
handle: Some("alice".into()),
|
|
phone: None,
|
|
user_id: None,
|
|
avatar_url: None,
|
|
profile_url: None,
|
|
},
|
|
]);
|
|
assert!(rendered.starts_with("## Connected Identities"));
|
|
assert!(rendered.contains("Gmail (conn:1): Alice Example | alice@example.com"));
|
|
assert!(rendered.contains("Slack (workspace): @alice"));
|
|
assert!(!rendered.contains("U123"));
|
|
assert_eq!(
|
|
render_connected_identities_section(&[ConnectedIdentity {
|
|
source: "empty".into(),
|
|
identifier: "id".into(),
|
|
..Default::default()
|
|
}]),
|
|
""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn gmail_post_processor_and_provider_registry_cover_public_edges() {
|
|
let gmail_provider =
|
|
openhuman_core::openhuman::memory_sync::composio::providers::gmail::GmailProvider::new();
|
|
let mut raw_html_passthrough = json!({
|
|
"messages": [{ "messageId": "m-raw", "messageText": "<b>keep raw</b>" }]
|
|
});
|
|
gmail_provider.post_process_action_result(
|
|
"GMAIL_FETCH_EMAILS",
|
|
Some(&json!({ "rawHtml": true })),
|
|
&mut raw_html_passthrough,
|
|
);
|
|
assert_eq!(
|
|
raw_html_passthrough["messages"][0]["messageText"],
|
|
"<b>keep raw</b>"
|
|
);
|
|
|
|
let mut response = json!({
|
|
"data": {
|
|
"messages": [
|
|
{
|
|
"messageId": "m-1",
|
|
"threadId": "t-1",
|
|
"subject": "Launch Plan",
|
|
"sender": "Alice <alice@example.com>",
|
|
"to": "Bob <bob@example.com>",
|
|
"messageText": "fallback one",
|
|
"markdownFormatted": "Rendered body one",
|
|
"labelIds": ["INBOX"],
|
|
"payload": {
|
|
"headers": [
|
|
{ "name": "Date", "value": "Fri, 29 May 2026 12:00:00 +0000" },
|
|
{ "name": "List-Unsubscribe", "value": "<mailto:leave@example.com>" }
|
|
]
|
|
},
|
|
"attachmentList": [
|
|
{ "filename": "plan.pdf", "mimeType": "application/pdf" },
|
|
{ "filename": "", "mimeType": "text/plain" }
|
|
]
|
|
},
|
|
{
|
|
"messageId": "m-2",
|
|
"threadId": "t-2",
|
|
"subject": "Budget",
|
|
"sender": "Cara <cara@example.com>",
|
|
"to": "Alice <alice@example.com>",
|
|
"messageText": "fallback two",
|
|
"markdown_formatted": "Rendered body two"
|
|
}
|
|
],
|
|
"nextPageToken": "page-2",
|
|
"resultSizeEstimate": 2
|
|
}
|
|
});
|
|
gmail_provider.post_process_action_result("GMAIL_FETCH_EMAILS", None, &mut response);
|
|
let messages = response["data"]["messages"].as_array().expect("messages");
|
|
assert_eq!(messages.len(), 2);
|
|
assert_eq!(messages[0]["id"], "m-1");
|
|
assert_eq!(messages[0]["date"], "Fri, 29 May 2026 12:00:00 +0000");
|
|
assert_eq!(
|
|
messages[0]["list_unsubscribe"],
|
|
"<mailto:leave@example.com>"
|
|
);
|
|
assert_eq!(messages[0]["markdown"], "Rendered body one");
|
|
assert_eq!(messages[0]["attachments"][0]["filename"], "plan.pdf");
|
|
assert_eq!(messages[1]["markdown"], "Rendered body two");
|
|
assert_eq!(response["data"]["nextPageToken"], "page-2");
|
|
assert_eq!(response["data"]["resultSizeEstimate"], 2);
|
|
|
|
let mut no_container = json!({ "ok": true });
|
|
gmail_provider.post_process_action_result("GMAIL_FETCH_EMAILS", None, &mut no_container);
|
|
assert_eq!(no_container, json!({ "ok": true }));
|
|
|
|
let mut one = json!({ "messages": [{ "messageId": "m-3", "messageText": "plain" }] });
|
|
gmail_provider.post_process_action_result("GMAIL_FETCH_EMAILS", None, &mut one);
|
|
assert_eq!(one["messages"][0]["markdown"], "plain");
|
|
|
|
init_default_composio_providers();
|
|
assert!(get_provider(" gmail ").is_some());
|
|
assert!(get_provider("unknown_provider_slug").is_none());
|
|
assert!(all_composio_providers()
|
|
.iter()
|
|
.any(|provider| provider.toolkit_slug() == "slack"));
|
|
register_provider(Arc::new(RawCoverageProvider {
|
|
fail_profile: false,
|
|
}));
|
|
register_provider(Arc::new(RawCoverageProvider { fail_profile: true }));
|
|
assert_eq!(
|
|
get_provider("raw_coverage").unwrap().toolkit_slug(),
|
|
"raw_coverage"
|
|
);
|
|
let raw_count = all_composio_providers()
|
|
.iter()
|
|
.filter(|provider| provider.toolkit_slug() == "raw_coverage")
|
|
.count();
|
|
assert_eq!(raw_count, 1);
|
|
register_provider(Arc::new(EmptySlugProvider));
|
|
assert!(get_provider("").is_none());
|
|
}
|
|
|
|
struct RawCoverageProvider {
|
|
fail_profile: bool,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl ComposioProvider for RawCoverageProvider {
|
|
fn toolkit_slug(&self) -> &'static str {
|
|
"raw_coverage"
|
|
}
|
|
|
|
async fn fetch_user_profile(
|
|
&self,
|
|
_ctx: &ProviderContext,
|
|
) -> Result<ProviderUserProfile, String> {
|
|
if self.fail_profile {
|
|
Err("profile unavailable".into())
|
|
} else {
|
|
Ok(ProviderUserProfile {
|
|
toolkit: "raw_coverage".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
display_name: Some("Raw Coverage".into()),
|
|
email: Some("raw@example.com".into()),
|
|
username: None,
|
|
avatar_url: None,
|
|
profile_url: None,
|
|
extras: json!({}),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn sync(
|
|
&self,
|
|
_ctx: &ProviderContext,
|
|
reason: SyncReason,
|
|
) -> Result<ComposioSyncOutcome, String> {
|
|
Ok(ComposioSyncOutcome {
|
|
toolkit: "raw_coverage".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
reason: reason.as_str().into(),
|
|
items_ingested: 1,
|
|
started_at_ms: 10,
|
|
finished_at_ms: 25,
|
|
summary: "synced".into(),
|
|
details: json!({ "reason": reason.as_str() }),
|
|
})
|
|
}
|
|
}
|
|
|
|
struct EmptySlugProvider;
|
|
|
|
#[async_trait::async_trait]
|
|
impl ComposioProvider for EmptySlugProvider {
|
|
fn toolkit_slug(&self) -> &'static str {
|
|
""
|
|
}
|
|
|
|
async fn fetch_user_profile(
|
|
&self,
|
|
_ctx: &ProviderContext,
|
|
) -> Result<ProviderUserProfile, String> {
|
|
Ok(ProviderUserProfile::default())
|
|
}
|
|
|
|
async fn sync(
|
|
&self,
|
|
_ctx: &ProviderContext,
|
|
reason: SyncReason,
|
|
) -> Result<ComposioSyncOutcome, String> {
|
|
Ok(ComposioSyncOutcome {
|
|
toolkit: String::new(),
|
|
connection_id: None,
|
|
reason: reason.as_str().into(),
|
|
items_ingested: 0,
|
|
started_at_ms: 0,
|
|
finished_at_ms: 0,
|
|
summary: String::new(),
|
|
details: Value::Null,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_sync_provider_trait_defaults_and_connection_hook_are_deterministic() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let ctx = ProviderContext {
|
|
config: Arc::new(config_in(&tmp)),
|
|
toolkit: "raw_coverage".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
usage: Default::default(),
|
|
};
|
|
let provider = RawCoverageProvider { fail_profile: true };
|
|
assert_eq!(provider.sync_interval_secs(), Some(15 * 60));
|
|
assert!(provider.curated_tools().is_none());
|
|
assert!(provider
|
|
.fetch_tasks(&ctx, &TaskFetchFilter::default())
|
|
.await
|
|
.unwrap_err()
|
|
.contains("provider has no task-fetch surface"));
|
|
|
|
let mut action_data = json!({ "ok": true });
|
|
provider.post_process_action_result("RAW_ACTION", None, &mut action_data);
|
|
assert_eq!(action_data, json!({ "ok": true }));
|
|
provider
|
|
.on_trigger(&ctx, "raw.trigger", &json!({ "payload": true }))
|
|
.await
|
|
.expect("default trigger no-op");
|
|
assert_eq!(
|
|
provider.identity_set(&ProviderUserProfile {
|
|
toolkit: "raw_coverage".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
display_name: Some("No client".into()),
|
|
..Default::default()
|
|
}),
|
|
1
|
|
);
|
|
let memory_client = ctx.memory_client().expect("test memory client");
|
|
memory_client
|
|
.kv_set(Some("provider-context"), "covered", &json!(true))
|
|
.await
|
|
.expect("write through provider context memory client");
|
|
assert_eq!(
|
|
memory_client
|
|
.kv_get(Some("provider-context"), "covered")
|
|
.await
|
|
.expect("read provider context kv"),
|
|
Some(json!(true))
|
|
);
|
|
|
|
provider
|
|
.on_connection_created(&ctx)
|
|
.await
|
|
.expect("profile failure still syncs");
|
|
assert!(!tmp.path().join("PROFILE.md").exists());
|
|
|
|
let profile_provider = RawCoverageProvider {
|
|
fail_profile: false,
|
|
};
|
|
profile_provider
|
|
.on_connection_created(&ctx)
|
|
.await
|
|
.expect("profile success syncs");
|
|
let profile_md = std::fs::read_to_string(tmp.path().join("PROFILE.md")).expect("profile md");
|
|
assert!(profile_md.contains("Raw Coverage"));
|
|
assert!(profile_md.contains("raw@example.com"));
|
|
}
|
|
|
|
#[test]
|
|
fn turn_state_mirror_persists_progress_edges_from_public_events() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let store = TurnStateStore::new(tmp.path().to_path_buf());
|
|
let mut mirror = TurnStateMirror::new(store.clone(), "thread/mirror", "request-mirror");
|
|
assert!(store
|
|
.get("thread/mirror")
|
|
.expect("initial snapshot")
|
|
.is_some());
|
|
|
|
assert!(mirror.observe(&AgentProgress::TurnStarted));
|
|
assert!(mirror.observe(&AgentProgress::IterationStarted {
|
|
iteration: 2,
|
|
max_iterations: 5,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::ThinkingDelta {
|
|
delta: "thinking ".into(),
|
|
iteration: 2,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::TextDelta {
|
|
delta: "visible".into(),
|
|
iteration: 2,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::ToolCallArgsDelta {
|
|
call_id: "call-1".into(),
|
|
tool_name: "memory.search".into(),
|
|
delta: "{\"q\":\"coverage\"}".into(),
|
|
iteration: 2,
|
|
}));
|
|
assert!(mirror.observe(&AgentProgress::ToolCallStarted {
|
|
call_id: "call-1".into(),
|
|
tool_name: "memory.search".into(),
|
|
arguments: json!({ "q": "coverage" }),
|
|
iteration: 2,
|
|
}));
|
|
assert!(mirror.observe(&AgentProgress::ToolCallCompleted {
|
|
call_id: "call-1".into(),
|
|
tool_name: "memory.search".into(),
|
|
success: false,
|
|
output_chars: 0,
|
|
elapsed_ms: 11,
|
|
iteration: 2,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::TurnCostUpdated {
|
|
model: "coverage-model".into(),
|
|
iteration: 2,
|
|
input_tokens: 10,
|
|
output_tokens: 3,
|
|
cached_input_tokens: 2,
|
|
total_usd: 0.001,
|
|
}));
|
|
|
|
assert!(mirror.observe(&AgentProgress::SubagentSpawned {
|
|
agent_id: "researcher".into(),
|
|
task_id: "task-1".into(),
|
|
mode: "typed".into(),
|
|
dedicated_thread: true,
|
|
prompt_chars: 99,
|
|
worker_thread_id: None,
|
|
display_name: Some("Researcher".into()),
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::SubagentIterationStarted {
|
|
agent_id: "researcher".into(),
|
|
task_id: "task-1".into(),
|
|
iteration: 1,
|
|
max_iterations: 3,
|
|
extended_policy: false,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::SubagentToolCallStarted {
|
|
agent_id: "researcher".into(),
|
|
task_id: "task-1".into(),
|
|
call_id: "child-call".into(),
|
|
tool_name: "memory.read".into(),
|
|
iteration: 1,
|
|
}));
|
|
assert!(!mirror.observe(&AgentProgress::SubagentToolCallCompleted {
|
|
agent_id: "researcher".into(),
|
|
task_id: "task-1".into(),
|
|
call_id: "child-call".into(),
|
|
tool_name: "memory.read".into(),
|
|
success: true,
|
|
output_chars: 44,
|
|
elapsed_ms: 22,
|
|
iteration: 1,
|
|
}));
|
|
assert!(mirror.observe(&AgentProgress::SubagentFailed {
|
|
agent_id: "researcher".into(),
|
|
task_id: "task-1".into(),
|
|
error: "child failed".into(),
|
|
}));
|
|
|
|
let board = TaskBoard {
|
|
thread_id: "thread/mirror".into(),
|
|
cards: vec![TaskBoardCard {
|
|
id: "card-1".into(),
|
|
title: "Mirror coverage".into(),
|
|
status: TaskCardStatus::Todo,
|
|
objective: None,
|
|
plan: vec!["exercise public events".into()],
|
|
assigned_agent: None,
|
|
allowed_tools: Vec::new(),
|
|
approval_mode: None,
|
|
acceptance_criteria: Vec::new(),
|
|
evidence: Vec::new(),
|
|
notes: None,
|
|
blocker: None,
|
|
source_metadata: None,
|
|
order: 0,
|
|
updated_at: "2026-05-29T16:00:00Z".into(),
|
|
}],
|
|
updated_at: "2026-05-29T16:00:00Z".into(),
|
|
};
|
|
assert!(mirror.observe(&AgentProgress::TaskBoardUpdated {
|
|
board: board.clone()
|
|
}));
|
|
|
|
let snapshot = store
|
|
.get("thread/mirror")
|
|
.expect("read mirror snapshot")
|
|
.expect("snapshot");
|
|
assert_eq!(snapshot.lifecycle, TurnLifecycle::Streaming);
|
|
assert_eq!(snapshot.phase, Some(TurnPhase::Thinking));
|
|
assert!(snapshot.active_tool.is_none());
|
|
assert!(snapshot.active_subagent.is_none());
|
|
assert_eq!(snapshot.streaming_text, "visible");
|
|
assert_eq!(snapshot.thinking, "thinking ");
|
|
assert_eq!(snapshot.task_board, Some(board));
|
|
assert!(snapshot
|
|
.tool_timeline
|
|
.iter()
|
|
.any(|entry| entry.id == "call-1" && entry.status == ToolTimelineStatus::Error));
|
|
assert!(snapshot.tool_timeline.iter().any(|entry| {
|
|
entry.id == "subagent:task-1"
|
|
&& entry.status == ToolTimelineStatus::Error
|
|
&& entry
|
|
.subagent
|
|
.as_ref()
|
|
.is_some_and(|activity| activity.tool_calls.len() == 1)
|
|
}));
|
|
|
|
mirror.finish();
|
|
let interrupted = store
|
|
.get("thread/mirror")
|
|
.expect("read interrupted snapshot")
|
|
.expect("interrupted snapshot");
|
|
assert_eq!(interrupted.lifecycle, TurnLifecycle::Interrupted);
|
|
|
|
let mut complete = TurnStateMirror::new(store.clone(), "thread/completed", "request-complete");
|
|
assert!(complete.observe(&AgentProgress::TurnCompleted { iterations: 2 }));
|
|
complete.finish();
|
|
assert!(store
|
|
.get("thread/completed")
|
|
.expect("completed snapshot lookup")
|
|
.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn memory_sync_profile_markdown_and_status_helpers_are_idempotent() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let mut profile = ProviderUserProfile {
|
|
toolkit: "gmail".into(),
|
|
connection_id: Some("conn-1".into()),
|
|
display_name: Some("Jane\nDoe".into()),
|
|
email: Some("jane@example.com".into()),
|
|
username: Some("jane\tdoe".into()),
|
|
avatar_url: None,
|
|
profile_url: Some("https://example.test/jane|profile".into()),
|
|
extras: json!({ "source": "coverage" }),
|
|
};
|
|
|
|
merge_provider_into_profile_md(tmp.path(), &profile).expect("merge profile");
|
|
profile.display_name = Some("Jane D.".into());
|
|
merge_provider_into_profile_md(tmp.path(), &profile).expect("merge profile update");
|
|
let profile_path = tmp.path().join("PROFILE.md");
|
|
let body = std::fs::read_to_string(&profile_path).expect("read profile");
|
|
assert!(body.contains(&block_start("connected-accounts")));
|
|
assert!(body.contains("Jane D."));
|
|
assert!(!body.contains("Jane\nDoe"));
|
|
assert_eq!(body.matches("acct:gmail:conn-1").count(), 1);
|
|
|
|
replace_managed_block(
|
|
tmp.path(),
|
|
"style",
|
|
"## Style",
|
|
"Use plain language.".into(),
|
|
)
|
|
.expect("replace style");
|
|
replace_managed_block(tmp.path(), "goals", "## Goals", String::new()).expect("replace goals");
|
|
let body = std::fs::read_to_string(&profile_path).expect("read profile after blocks");
|
|
assert!(body.contains(&block_start("style")));
|
|
assert!(body.contains("Use plain language."));
|
|
assert!(body.contains("*(no entries yet)*"));
|
|
assert!(body.contains(&block_end("goals")));
|
|
|
|
remove_provider_from_profile_md(tmp.path(), "gmail", "conn-1").expect("remove provider");
|
|
let body = std::fs::read_to_string(&profile_path).expect("read profile after remove");
|
|
assert!(!body.contains("acct:gmail:conn-1"));
|
|
|
|
let skipped = TempDir::new().expect("tempdir");
|
|
let skipped_profile = ProviderUserProfile {
|
|
toolkit: "gmail".into(),
|
|
connection_id: None,
|
|
display_name: Some("Skipped".into()),
|
|
email: None,
|
|
username: None,
|
|
avatar_url: None,
|
|
profile_url: None,
|
|
extras: serde_json::Value::Null,
|
|
};
|
|
merge_provider_into_profile_md(skipped.path(), &skipped_profile).expect("skip profile");
|
|
assert!(!skipped.path().join("PROFILE.md").exists());
|
|
remove_provider_from_profile_md(skipped.path(), "", "").expect("remove missing no-op");
|
|
|
|
let now = 1_700_000_000_000_i64;
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::from_age_ms(
|
|
Some(now - 30_000),
|
|
now
|
|
),
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::Active
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::from_age_ms(
|
|
Some(now - 30_001),
|
|
now
|
|
),
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::Recent
|
|
);
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::from_age_ms(
|
|
None, now
|
|
),
|
|
openhuman_core::openhuman::memory_sync::sync_status::types::FreshnessLabel::Idle
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_source_types_and_freshness_cover_validation_matrix() {
|
|
let kinds = [
|
|
SourceKind::Composio,
|
|
SourceKind::Folder,
|
|
SourceKind::GithubRepo,
|
|
SourceKind::TwitterQuery,
|
|
SourceKind::RssFeed,
|
|
SourceKind::WebPage,
|
|
];
|
|
for kind in kinds {
|
|
let encoded = serde_json::to_string(&kind).expect("kind json");
|
|
let decoded: SourceKind = serde_json::from_str(&encoded).expect("kind decode");
|
|
assert_eq!(decoded, kind);
|
|
}
|
|
|
|
let now = 1_700_000_000_000_i64;
|
|
assert_eq!(FreshnessLabel::from_age_ms(None, now), FreshnessLabel::Idle);
|
|
assert_eq!(
|
|
FreshnessLabel::from_age_ms(Some(now - 30_000), now),
|
|
FreshnessLabel::Active
|
|
);
|
|
assert_eq!(
|
|
FreshnessLabel::from_age_ms(Some(now - 30_001), now),
|
|
FreshnessLabel::Recent
|
|
);
|
|
assert_eq!(
|
|
FreshnessLabel::from_age_ms(Some(now - 5 * 60_000 - 1), now),
|
|
FreshnessLabel::Idle
|
|
);
|
|
|
|
let mut composio_source = source(SourceKind::Composio, "cmp");
|
|
assert!(composio_source.validate().unwrap_err().contains("toolkit"));
|
|
composio_source.toolkit = Some("gmail".into());
|
|
assert!(composio_source
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("connection_id"));
|
|
composio_source.connection_id = Some("conn-1".into());
|
|
assert!(composio_source.validate().is_ok());
|
|
|
|
let mut folder = source(SourceKind::Folder, "folder");
|
|
assert!(folder.validate().unwrap_err().contains("path"));
|
|
folder.path = Some("/tmp".into());
|
|
assert!(folder.validate().is_ok());
|
|
|
|
let mut github = source(SourceKind::GithubRepo, "github");
|
|
assert!(github.validate().unwrap_err().contains("url"));
|
|
github.url = Some("https://github.com/tinyhumansai/openhuman".into());
|
|
assert!(github.validate().is_ok());
|
|
|
|
let item = SourceItem {
|
|
id: "item-1".into(),
|
|
title: "Item".into(),
|
|
updated_at_ms: Some(now),
|
|
};
|
|
assert_eq!(serde_json::to_value(item).unwrap()["updated_at_ms"], now);
|
|
let content = SourceContent {
|
|
id: "item-1".into(),
|
|
title: "Item".into(),
|
|
body: "Body".into(),
|
|
content_type: ContentType::Markdown,
|
|
metadata: json!({ "source": "test" }),
|
|
};
|
|
assert_eq!(
|
|
serde_json::to_value(content).unwrap()["content_type"],
|
|
"markdown"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_state_store_persists_lists_marks_and_clears_snapshots() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let workspace = tmp.path().to_path_buf();
|
|
let mut first = TurnState::started("thread/a", "request-1", 4, "2026-05-29T12:00:00Z");
|
|
first.lifecycle = TurnLifecycle::Streaming;
|
|
first.phase = Some(TurnPhase::Subagent);
|
|
first.active_subagent = Some("research".into());
|
|
first.tool_timeline.push(ToolTimelineEntry {
|
|
id: "subagent-1".into(),
|
|
name: "subagent:research".into(),
|
|
round: 2,
|
|
status: ToolTimelineStatus::Running,
|
|
args_buffer: None,
|
|
display_name: Some("Research".into()),
|
|
detail: None,
|
|
source_tool_name: None,
|
|
subagent: Some(SubagentActivity {
|
|
task_id: "task-1".into(),
|
|
agent_id: "agent-1".into(),
|
|
status: Some("running".into()),
|
|
mode: Some("focused".into()),
|
|
dedicated_thread: Some(true),
|
|
child_iteration: Some(1),
|
|
child_max_iterations: Some(3),
|
|
iterations: Some(1),
|
|
elapsed_ms: Some(250),
|
|
output_chars: Some(42),
|
|
worker_thread_id: None,
|
|
tool_calls: vec![SubagentToolCall {
|
|
call_id: "call-1".into(),
|
|
tool_name: "memory.search".into(),
|
|
status: ToolTimelineStatus::Success,
|
|
iteration: Some(1),
|
|
elapsed_ms: Some(100),
|
|
output_chars: Some(10),
|
|
}],
|
|
}),
|
|
});
|
|
let second = TurnState::started("thread/b", "request-2", 2, "2026-05-29T12:01:00Z");
|
|
|
|
turn_state::store::put(workspace.clone(), &first).expect("put first");
|
|
turn_state::store::put(workspace.clone(), &second).expect("put second");
|
|
assert_eq!(
|
|
turn_state::store::get(workspace.clone(), "thread/a")
|
|
.unwrap()
|
|
.unwrap()
|
|
.active_subagent
|
|
.as_deref(),
|
|
Some("research")
|
|
);
|
|
assert!(turn_state::store::get(workspace.clone(), "missing")
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
let mut listed = turn_state::store::list(workspace.clone()).expect("list states");
|
|
listed.sort_by(|a, b| a.thread_id.cmp(&b.thread_id));
|
|
assert_eq!(listed.len(), 2);
|
|
let wire = serde_json::to_value(ListTurnStatesResponse {
|
|
turn_states: listed.clone(),
|
|
count: listed.len(),
|
|
})
|
|
.expect("list response json");
|
|
assert_eq!(wire["count"], 2);
|
|
assert_eq!(wire["turnStates"][0]["threadId"], "thread/a");
|
|
|
|
let marked = turn_state::store::mark_all_interrupted(workspace.clone(), "2026-05-29T12:02:00Z")
|
|
.expect("mark interrupted");
|
|
assert_eq!(marked, 2);
|
|
let marked_again =
|
|
turn_state::store::mark_all_interrupted(workspace.clone(), "2026-05-29T12:03:00Z")
|
|
.expect("mark interrupted again");
|
|
assert_eq!(marked_again, 0);
|
|
let interrupted = turn_state::store::get(workspace.clone(), "thread/a")
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(interrupted.lifecycle, TurnLifecycle::Interrupted);
|
|
assert!(interrupted.active_subagent.is_none());
|
|
assert_eq!(interrupted.updated_at, "2026-05-29T12:02:00Z");
|
|
|
|
assert!(turn_state::store::delete(workspace.clone(), "thread/a").expect("delete one"));
|
|
assert!(!turn_state::store::delete(workspace.clone(), "thread/a").expect("delete missing"));
|
|
let removed = turn_state::store::clear_all(workspace.clone()).expect("clear all");
|
|
assert_eq!(removed, 1);
|
|
assert!(turn_state::store::list(workspace).unwrap().is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn threads_rpc_ops_cover_crud_title_fallback_and_turn_state_cleanup() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let config = Config::load_or_init().await.expect("init isolated config");
|
|
let workspace_dir = config.workspace_dir.clone();
|
|
|
|
let thread = thread_ops::thread_upsert(UpsertConversationThreadRequest {
|
|
id: "thread/raw-crud".into(),
|
|
title: "Chat Jan 1 1:00 AM".into(),
|
|
created_at: "2026-05-29T12:00:00Z".into(),
|
|
parent_thread_id: Some("parent-thread".into()),
|
|
labels: Some(vec!["work".into(), "coverage".into()]),
|
|
personality_id: Some("personality-1".into()),
|
|
})
|
|
.await
|
|
.expect("upsert thread")
|
|
.value
|
|
.data
|
|
.expect("thread summary");
|
|
assert_eq!(thread.id, "thread/raw-crud");
|
|
assert_eq!(thread.parent_thread_id.as_deref(), Some("parent-thread"));
|
|
|
|
let created = thread_ops::thread_create_new(CreateConversationThreadRequest {
|
|
labels: Some(vec!["scratch".into()]),
|
|
personality_id: None,
|
|
})
|
|
.await
|
|
.expect("create new thread")
|
|
.value
|
|
.data
|
|
.expect("created thread");
|
|
assert!(created.id.starts_with("thread-"));
|
|
assert_eq!(created.labels, vec!["scratch"]);
|
|
|
|
let message = ConversationMessageRecord {
|
|
id: "msg-1".into(),
|
|
content: "Please summarize launch blockers. Then inspect follow ups.".into(),
|
|
message_type: "text".into(),
|
|
extra_metadata: json!({ "before": true }),
|
|
sender: "user".into(),
|
|
created_at: "2026-05-29T12:01:00Z".into(),
|
|
};
|
|
let appended = thread_ops::message_append(AppendConversationMessageRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
message: message.clone(),
|
|
})
|
|
.await
|
|
.expect("append message")
|
|
.value
|
|
.data
|
|
.expect("appended message");
|
|
assert_eq!(appended.id, "msg-1");
|
|
assert!(
|
|
thread_ops::message_append(AppendConversationMessageRequest {
|
|
thread_id: "missing-thread".into(),
|
|
message,
|
|
})
|
|
.await
|
|
.is_err()
|
|
);
|
|
|
|
let listed_messages = thread_ops::messages_list(ConversationMessagesRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
})
|
|
.await
|
|
.expect("list messages")
|
|
.value
|
|
.data
|
|
.expect("messages");
|
|
assert_eq!(listed_messages.count, 1);
|
|
|
|
let fallback_title =
|
|
thread_ops::thread_generate_title(GenerateConversationThreadTitleRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
assistant_message: Some(" ".into()),
|
|
})
|
|
.await
|
|
.expect("fallback title")
|
|
.value
|
|
.data
|
|
.expect("fallback summary");
|
|
assert_eq!(fallback_title.title, "Please summarize launch blockers");
|
|
|
|
assert!(
|
|
thread_ops::thread_update_title(UpdateConversationThreadTitleRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
title: " ".into(),
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("title must not be empty")
|
|
);
|
|
let renamed = thread_ops::thread_update_title(UpdateConversationThreadTitleRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
title: " Manual coverage title ".into(),
|
|
})
|
|
.await
|
|
.expect("manual title")
|
|
.value
|
|
.data
|
|
.expect("renamed");
|
|
assert_eq!(renamed.title, "Manual coverage title");
|
|
|
|
let relabeled = thread_ops::thread_update_labels(UpdateConversationThreadLabelsRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
labels: Vec::new(),
|
|
})
|
|
.await
|
|
.expect("clear labels")
|
|
.value
|
|
.data
|
|
.expect("relabeled");
|
|
assert!(relabeled.labels.is_empty());
|
|
|
|
let updated_message = thread_ops::message_update(UpdateConversationMessageRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
message_id: "msg-1".into(),
|
|
extra_metadata: Some(json!({ "after": true })),
|
|
})
|
|
.await
|
|
.expect("update message")
|
|
.value
|
|
.data
|
|
.expect("updated message");
|
|
assert_eq!(updated_message.extra_metadata["after"], true);
|
|
assert!(
|
|
thread_ops::message_update(UpdateConversationMessageRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
message_id: "missing".into(),
|
|
extra_metadata: None,
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("message missing not found")
|
|
);
|
|
|
|
let all_threads = thread_ops::threads_list(EmptyRequest {})
|
|
.await
|
|
.expect("list threads")
|
|
.value
|
|
.data
|
|
.expect("threads");
|
|
assert!(all_threads.count >= 2);
|
|
assert!(all_threads
|
|
.threads
|
|
.iter()
|
|
.any(|thread| thread.title == "Manual coverage title"));
|
|
|
|
let mut turn = TurnState::started("thread/raw-crud", "request-raw", 3, "2026-05-29T12:02:00Z");
|
|
turn.lifecycle = TurnLifecycle::Streaming;
|
|
turn.phase = Some(TurnPhase::Thinking);
|
|
turn_state::store::put(workspace_dir.clone(), &turn).expect("put turn state");
|
|
let turn_get = thread_ops::turn_state_get(GetTurnStateRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
})
|
|
.await
|
|
.expect("turn get")
|
|
.value
|
|
.data
|
|
.expect("turn response");
|
|
assert_eq!(turn_get.turn_state.unwrap().request_id, "request-raw");
|
|
let turn_list = thread_ops::turn_state_list(EmptyRequest {})
|
|
.await
|
|
.expect("turn list")
|
|
.value
|
|
.data
|
|
.expect("turn list response");
|
|
assert_eq!(turn_list.count, 1);
|
|
assert!(
|
|
thread_ops::turn_state_clear(ClearTurnStateRequest {
|
|
thread_id: "missing".into(),
|
|
})
|
|
.await
|
|
.expect("clear missing")
|
|
.value
|
|
.data
|
|
.expect("clear response")
|
|
.cleared
|
|
== false
|
|
);
|
|
turn_state::store::put(workspace_dir.clone(), &turn).expect("restore turn state");
|
|
|
|
let deleted = thread_ops::thread_delete(DeleteConversationThreadRequest {
|
|
thread_id: "thread/raw-crud".into(),
|
|
deleted_at: "2026-05-29T12:03:00Z".into(),
|
|
})
|
|
.await
|
|
.expect("delete thread")
|
|
.value
|
|
.data
|
|
.expect("delete response");
|
|
assert!(deleted.deleted);
|
|
assert!(turn_state::store::get(workspace_dir, "thread/raw-crud")
|
|
.unwrap()
|
|
.is_none());
|
|
|
|
let purged = thread_ops::threads_purge(EmptyRequest {})
|
|
.await
|
|
.expect("purge")
|
|
.value
|
|
.data
|
|
.expect("purge response");
|
|
assert!(purged.agent_threads_deleted >= 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn threads_title_generation_branches_cover_noop_and_not_found_paths() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
Config::load_or_init().await.expect("init isolated config");
|
|
|
|
let manual = thread_ops::thread_upsert(UpsertConversationThreadRequest {
|
|
id: "thread/manual-title".into(),
|
|
title: "Manual launch review".into(),
|
|
created_at: "2026-05-29T13:00:00Z".into(),
|
|
parent_thread_id: None,
|
|
labels: None,
|
|
personality_id: None,
|
|
})
|
|
.await
|
|
.expect("upsert manual thread")
|
|
.value
|
|
.data
|
|
.expect("manual thread");
|
|
assert_eq!(manual.title, "Manual launch review");
|
|
|
|
let unchanged_manual =
|
|
thread_ops::thread_generate_title(GenerateConversationThreadTitleRequest {
|
|
thread_id: "thread/manual-title".into(),
|
|
assistant_message: Some("Assistant reply that should not be used".into()),
|
|
})
|
|
.await
|
|
.expect("manual title skips generation")
|
|
.value
|
|
.data
|
|
.expect("manual title response");
|
|
assert_eq!(unchanged_manual.title, "Manual launch review");
|
|
|
|
let placeholder = thread_ops::thread_upsert(UpsertConversationThreadRequest {
|
|
id: "thread/no-user-message".into(),
|
|
title: "Chat Jan 1 1:23 AM".into(),
|
|
created_at: "2026-05-29T13:01:00Z".into(),
|
|
parent_thread_id: None,
|
|
labels: None,
|
|
personality_id: None,
|
|
})
|
|
.await
|
|
.expect("upsert placeholder thread")
|
|
.value
|
|
.data
|
|
.expect("placeholder thread");
|
|
assert_eq!(placeholder.title, "Chat Jan 1 1:23 AM");
|
|
|
|
let no_user_message =
|
|
thread_ops::thread_generate_title(GenerateConversationThreadTitleRequest {
|
|
thread_id: "thread/no-user-message".into(),
|
|
assistant_message: None,
|
|
})
|
|
.await
|
|
.expect("no user message leaves placeholder")
|
|
.value
|
|
.data
|
|
.expect("no user response");
|
|
assert_eq!(no_user_message.title, "Chat Jan 1 1:23 AM");
|
|
|
|
let missing = thread_ops::thread_generate_title(GenerateConversationThreadTitleRequest {
|
|
thread_id: "thread/missing-title".into(),
|
|
assistant_message: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
let missing_text: String = missing.into();
|
|
assert!(missing_text.contains("ThreadNotFound"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_sources_registry_rpc_and_schema_handlers_cover_crud_edges() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
Config::load_or_init().await.expect("init isolated config");
|
|
std::fs::write(tmp.path().join("reader-note.md"), "# Reader note").expect("write note");
|
|
|
|
let schemas = all_memory_sources_controller_schemas();
|
|
let controllers = all_memory_sources_registered_controllers();
|
|
assert!(
|
|
schemas.len() >= 9,
|
|
"expected at least 9 memory_sources schemas, got {}",
|
|
schemas.len()
|
|
);
|
|
assert_eq!(schemas.len(), controllers.len());
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_sources::schemas::schemas("read_item").function,
|
|
"read_item"
|
|
);
|
|
|
|
let add_controller = controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "add")
|
|
.expect("add controller");
|
|
let mut bad_params = Map::new();
|
|
bad_params.insert("kind".into(), Value::String("folder".into()));
|
|
assert!((add_controller.handler)(bad_params)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("missing field `label`"));
|
|
|
|
let invalid_folder = memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest {
|
|
kind: SourceKind::Folder,
|
|
label: "Invalid folder".into(),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: None,
|
|
glob: None,
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: None,
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
})
|
|
.await
|
|
.unwrap_err();
|
|
assert!(invalid_folder.contains("path"));
|
|
|
|
let added = memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest {
|
|
kind: SourceKind::Folder,
|
|
label: "Folder source".into(),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: Some(tmp.path().to_string_lossy().to_string()),
|
|
glob: Some("*.md".into()),
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: Some(4),
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
})
|
|
.await
|
|
.expect("add folder")
|
|
.value
|
|
.source;
|
|
assert_eq!(added.kind, SourceKind::Folder);
|
|
assert!(memory_sources_rpc::add_rpc(memory_sources_rpc::AddRequest {
|
|
kind: SourceKind::Folder,
|
|
label: "Duplicate".into(),
|
|
enabled: true,
|
|
toolkit: None,
|
|
connection_id: None,
|
|
path: Some(tmp.path().to_string_lossy().to_string()),
|
|
glob: None,
|
|
url: None,
|
|
branch: None,
|
|
paths: Vec::new(),
|
|
query: None,
|
|
since_days: None,
|
|
max_items: None,
|
|
max_commits: None,
|
|
max_issues: None,
|
|
max_prs: None,
|
|
selector: None,
|
|
max_tokens_per_sync: None,
|
|
max_cost_per_sync_usd: None,
|
|
sync_depth_days: None,
|
|
})
|
|
.await
|
|
.is_ok());
|
|
|
|
let enabled_folders = registry::list_enabled_by_kind(SourceKind::Folder)
|
|
.await
|
|
.expect("enabled folders");
|
|
assert!(enabled_folders.len() >= 2);
|
|
assert_eq!(
|
|
memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest {
|
|
id: added.id.clone(),
|
|
})
|
|
.await
|
|
.expect("get source")
|
|
.value
|
|
.source
|
|
.unwrap()
|
|
.label,
|
|
"Folder source"
|
|
);
|
|
assert!(memory_sources_rpc::get_rpc(memory_sources_rpc::GetRequest {
|
|
id: "missing".into(),
|
|
})
|
|
.await
|
|
.expect("get missing")
|
|
.value
|
|
.source
|
|
.is_none());
|
|
|
|
let list_items = memory_sources_rpc::list_items_rpc(memory_sources_rpc::ListItemsRequest {
|
|
source_id: added.id.clone(),
|
|
})
|
|
.await
|
|
.expect("list items")
|
|
.value
|
|
.items;
|
|
assert!(list_items.iter().any(|item| item.id == "reader-note.md"));
|
|
let read_item = memory_sources_rpc::read_item_rpc(memory_sources_rpc::ReadItemRequest {
|
|
source_id: added.id.clone(),
|
|
item_id: "reader-note.md".into(),
|
|
})
|
|
.await
|
|
.expect("read item")
|
|
.value
|
|
.content;
|
|
assert_eq!(read_item.content_type, ContentType::Markdown);
|
|
|
|
let disabled = memory_sources_rpc::update_rpc(memory_sources_rpc::UpdateRequest {
|
|
id: added.id.clone(),
|
|
patch: serde_json::from_value(json!({
|
|
"label": "Disabled folder",
|
|
"enabled": false,
|
|
"glob": "**/*.md",
|
|
"max_items": 2
|
|
}))
|
|
.expect("patch"),
|
|
})
|
|
.await
|
|
.expect("update source")
|
|
.value
|
|
.source;
|
|
assert_eq!(disabled.label, "Disabled folder");
|
|
assert!(!disabled.enabled);
|
|
assert!(
|
|
memory_sources_rpc::sync_rpc(memory_sources_rpc::SyncRequest {
|
|
source_id: added.id.clone(),
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("disabled")
|
|
);
|
|
assert!(
|
|
memory_sources_rpc::update_rpc(memory_sources_rpc::UpdateRequest {
|
|
id: "missing".into(),
|
|
patch: Default::default(),
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("not found")
|
|
);
|
|
assert!(
|
|
memory_sources_rpc::list_items_rpc(memory_sources_rpc::ListItemsRequest {
|
|
source_id: "missing".into(),
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("not found")
|
|
);
|
|
|
|
let statuses = memory_sources_rpc::status_list_rpc()
|
|
.await
|
|
.expect("status list")
|
|
.value
|
|
.statuses;
|
|
assert!(statuses.iter().any(|status| status.source_id == added.id));
|
|
|
|
assert!(
|
|
memory_sources_rpc::remove_rpc(memory_sources_rpc::RemoveRequest {
|
|
id: added.id.clone(),
|
|
})
|
|
.await
|
|
.expect("remove source")
|
|
.value
|
|
.removed
|
|
);
|
|
assert!(
|
|
!memory_sources_rpc::remove_rpc(memory_sources_rpc::RemoveRequest { id: added.id })
|
|
.await
|
|
.expect("remove missing")
|
|
.value
|
|
.removed
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_ops_public_handlers_cover_document_file_kv_graph_and_envelopes() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
|
|
let init = openhuman_core::openhuman::memory::ops::memory_init(MemoryInitRequest {
|
|
jwt_token: Some("ignored-token".into()),
|
|
})
|
|
.await
|
|
.expect("memory init")
|
|
.value
|
|
.data
|
|
.expect("init data");
|
|
assert!(init.initialized);
|
|
assert!(init.memory_dir.ends_with("/memory"));
|
|
let memory_dir = std::path::PathBuf::from(&init.memory_dir);
|
|
|
|
let sync_channel = openhuman_core::openhuman::memory::ops::memory_sync_channel(
|
|
openhuman_core::openhuman::memory::ops::SyncChannelParams {
|
|
channel_id: "conn-not-present".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("sync channel request")
|
|
.value;
|
|
assert!(sync_channel.requested);
|
|
assert_eq!(sync_channel.channel_id, "conn-not-present");
|
|
let sync_all = openhuman_core::openhuman::memory::ops::memory_sync_all()
|
|
.await
|
|
.expect("sync all request")
|
|
.value;
|
|
assert!(sync_all.requested);
|
|
let ingestion = openhuman_core::openhuman::memory::ops::memory_ingestion_status()
|
|
.await
|
|
.expect("ingestion status")
|
|
.value;
|
|
assert_eq!(ingestion.queue_depth, 0);
|
|
let learn_none = openhuman_core::openhuman::memory::ops::memory_learn_all(
|
|
openhuman_core::openhuman::memory::ops::LearnAllParams {
|
|
namespaces: Some(Vec::new()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("learn empty request")
|
|
.value;
|
|
assert_eq!(learn_none.namespaces_processed, 0);
|
|
assert!(learn_none.results.is_empty());
|
|
|
|
let write =
|
|
openhuman_core::openhuman::memory::ops::ai_write_memory_file(WriteMemoryFileRequest {
|
|
relative_path: "notes/raw.md".into(),
|
|
content: "Memory file coverage".into(),
|
|
})
|
|
.await
|
|
.expect("write memory file")
|
|
.value
|
|
.data
|
|
.expect("write data");
|
|
assert!(write.written);
|
|
assert_eq!(write.bytes_written, "Memory file coverage".len());
|
|
|
|
let read = openhuman_core::openhuman::memory::ops::ai_read_memory_file(ReadMemoryFileRequest {
|
|
relative_path: "notes/raw.md".into(),
|
|
})
|
|
.await
|
|
.expect("read memory file")
|
|
.value
|
|
.data
|
|
.expect("read data");
|
|
assert_eq!(read.content, "Memory file coverage");
|
|
|
|
std::fs::write(memory_dir.join("root.md"), "root").expect("root note");
|
|
std::fs::write(memory_dir.join("memory.db"), "hidden").expect("sqlite stub");
|
|
let root_files =
|
|
openhuman_core::openhuman::memory::ops::ai_list_memory_files(ListMemoryFilesRequest {
|
|
relative_dir: "".into(),
|
|
})
|
|
.await
|
|
.expect("list root memory files")
|
|
.value
|
|
.data
|
|
.expect("root list data");
|
|
assert_eq!(root_files.files, vec!["root.md"]);
|
|
|
|
let listed =
|
|
openhuman_core::openhuman::memory::ops::ai_list_memory_files(ListMemoryFilesRequest {
|
|
relative_dir: "notes".into(),
|
|
})
|
|
.await
|
|
.expect("list memory files")
|
|
.value
|
|
.data
|
|
.expect("list data");
|
|
assert_eq!(listed.files, vec!["raw.md"]);
|
|
assert!(
|
|
openhuman_core::openhuman::memory::ops::ai_list_memory_files(ListMemoryFilesRequest {
|
|
relative_dir: "../escape".into(),
|
|
})
|
|
.await
|
|
.unwrap_err()
|
|
.contains("traversal")
|
|
);
|
|
|
|
let namespace = "ops-raw-coverage";
|
|
let document_id = openhuman_core::openhuman::memory::ops::doc_put(
|
|
openhuman_core::openhuman::memory::ops::PutDocParams {
|
|
namespace: namespace.into(),
|
|
key: "doc-1".into(),
|
|
title: "Ops coverage document".into(),
|
|
content: "Alice owns deterministic coverage for memory ops.".into(),
|
|
source_type: "test".into(),
|
|
priority: "high".into(),
|
|
tags: vec!["coverage".into()],
|
|
metadata: json!({ "fixture": true }),
|
|
category: "core".into(),
|
|
session_id: Some("session-ops".into()),
|
|
document_id: Some("doc-ops-raw".into()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("doc put")
|
|
.value
|
|
.document_id;
|
|
assert_eq!(document_id, "doc-ops-raw");
|
|
|
|
let namespaces = openhuman_core::openhuman::memory::ops::namespace_list()
|
|
.await
|
|
.expect("namespace list")
|
|
.value;
|
|
assert!(namespaces.iter().any(|candidate| candidate == namespace));
|
|
let learn_disabled = openhuman_core::openhuman::memory::ops::memory_learn_all(
|
|
openhuman_core::openhuman::memory::ops::LearnAllParams {
|
|
namespaces: Some(vec![namespace.into(), namespace.into(), "missing".into()]),
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(learn_disabled.contains("local_ai.runtime_enabled=true"));
|
|
|
|
let direct_docs = openhuman_core::openhuman::memory::ops::doc_list(Some(
|
|
openhuman_core::openhuman::memory::ops::NamespaceOnlyParams {
|
|
namespace: namespace.into(),
|
|
},
|
|
))
|
|
.await
|
|
.expect("doc list")
|
|
.value;
|
|
assert!(direct_docs["documents"]
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|doc| doc["documentId"] == "doc-ops-raw"));
|
|
|
|
let envelope_docs =
|
|
openhuman_core::openhuman::memory::ops::memory_list_documents(ListDocumentsRequest {
|
|
namespace: Some(namespace.into()),
|
|
})
|
|
.await
|
|
.expect("memory list documents")
|
|
.value;
|
|
assert_eq!(envelope_docs.data.as_ref().unwrap().count, 1);
|
|
assert_eq!(
|
|
envelope_docs
|
|
.meta
|
|
.counts
|
|
.as_ref()
|
|
.unwrap()
|
|
.get("num_documents"),
|
|
Some(&1)
|
|
);
|
|
|
|
let query = openhuman_core::openhuman::memory::ops::context_query(
|
|
openhuman_core::openhuman::memory::ops::QueryNamespaceParams {
|
|
namespace: namespace.into(),
|
|
query: "who owns deterministic coverage".into(),
|
|
limit: Some(5),
|
|
},
|
|
)
|
|
.await
|
|
.expect("context query")
|
|
.value;
|
|
assert!(query.to_lowercase().contains("coverage"));
|
|
let recalled = openhuman_core::openhuman::memory::ops::context_recall(
|
|
openhuman_core::openhuman::memory::ops::RecallNamespaceParams {
|
|
namespace: namespace.into(),
|
|
limit: Some(5),
|
|
},
|
|
)
|
|
.await
|
|
.expect("context recall")
|
|
.value
|
|
.expect("recall text");
|
|
assert!(recalled.contains("Ops coverage document"));
|
|
|
|
openhuman_core::openhuman::memory::ops::kv_set(
|
|
openhuman_core::openhuman::memory::ops::KvSetParams {
|
|
namespace: Some(namespace.into()),
|
|
key: "state".into(),
|
|
value: json!({ "covered": true }),
|
|
},
|
|
)
|
|
.await
|
|
.expect("kv set");
|
|
let kv = openhuman_core::openhuman::memory::ops::kv_get(
|
|
openhuman_core::openhuman::memory::ops::KvGetDeleteParams {
|
|
namespace: Some(namespace.into()),
|
|
key: "state".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("kv get")
|
|
.value;
|
|
assert_eq!(kv, Some(json!({ "covered": true })));
|
|
let kv_rows = openhuman_core::openhuman::memory::ops::kv_list_namespace(
|
|
openhuman_core::openhuman::memory::ops::NamespaceOnlyParams {
|
|
namespace: namespace.into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("kv list")
|
|
.value;
|
|
assert!(kv_rows.iter().any(|row| row["key"] == "state"));
|
|
assert!(
|
|
openhuman_core::openhuman::memory::ops::kv_delete(
|
|
openhuman_core::openhuman::memory::ops::KvGetDeleteParams {
|
|
namespace: Some(namespace.into()),
|
|
key: "state".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("kv delete")
|
|
.value
|
|
);
|
|
|
|
openhuman_core::openhuman::memory::ops::graph_upsert(
|
|
openhuman_core::openhuman::memory::ops::GraphUpsertParams {
|
|
namespace: Some(namespace.into()),
|
|
subject: "Alice".into(),
|
|
predicate: "OWNS".into(),
|
|
object: "Memory Ops Coverage".into(),
|
|
attrs: json!({ "source": "raw-test" }),
|
|
},
|
|
)
|
|
.await
|
|
.expect("graph upsert");
|
|
let relations = openhuman_core::openhuman::memory::ops::graph_query(
|
|
openhuman_core::openhuman::memory::ops::GraphQueryParams {
|
|
namespace: Some(namespace.into()),
|
|
subject: Some("Alice".into()),
|
|
predicate: Some("OWNS".into()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("graph query")
|
|
.value;
|
|
assert_eq!(relations[0]["object"], "MEMORY OPS COVERAGE");
|
|
|
|
let tool_rule = openhuman_core::openhuman::memory::ops::tool_rule_put(
|
|
openhuman_core::openhuman::memory::ops::ToolRulePutParams {
|
|
tool_name: "shell".into(),
|
|
rule: "Use dry-run flags before changing files.".into(),
|
|
priority: Some(ToolMemoryPriority::High),
|
|
source: Some(ToolMemorySource::UserExplicit),
|
|
tags: vec!["safety".into()],
|
|
id: Some("ops-rule-1".into()),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rule put")
|
|
.value;
|
|
assert_eq!(tool_rule.id, "ops-rule-1");
|
|
assert_eq!(tool_rule.priority, ToolMemoryPriority::High);
|
|
let fetched_rule = openhuman_core::openhuman::memory::ops::tool_rule_get(
|
|
openhuman_core::openhuman::memory::ops::ToolRuleRefParams {
|
|
tool_name: "shell".into(),
|
|
id: "ops-rule-1".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rule get")
|
|
.value
|
|
.expect("stored tool rule");
|
|
assert_eq!(
|
|
fetched_rule.rule,
|
|
"Use dry-run flags before changing files."
|
|
);
|
|
let listed_rules = openhuman_core::openhuman::memory::ops::tool_rule_list(
|
|
openhuman_core::openhuman::memory::ops::ToolRuleListParams {
|
|
tool_name: "shell".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rule list")
|
|
.value;
|
|
assert!(listed_rules.iter().any(|rule| rule.id == "ops-rule-1"));
|
|
let prompt_rules = openhuman_core::openhuman::memory::ops::tool_rules_for_prompt(
|
|
openhuman_core::openhuman::memory::ops::ToolRulesForPromptParams {
|
|
tools: vec!["shell".into()],
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rules prompt")
|
|
.value;
|
|
assert!(prompt_rules.rendered.contains("Use dry-run flags"));
|
|
assert_eq!(prompt_rules.rules[0].id, "ops-rule-1");
|
|
let tool_rules_json = openhuman_core::openhuman::memory::ops::tool_rules_json(
|
|
openhuman_core::openhuman::memory::ops::ToolRuleListParams {
|
|
tool_name: "shell".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rules json")
|
|
.value;
|
|
assert!(tool_rules_json
|
|
.as_array()
|
|
.unwrap()
|
|
.iter()
|
|
.any(|rule| rule["id"] == "ops-rule-1" && rule["priority"] == "high"));
|
|
assert!(
|
|
openhuman_core::openhuman::memory::ops::tool_rule_delete(
|
|
openhuman_core::openhuman::memory::ops::ToolRuleRefParams {
|
|
tool_name: "shell".into(),
|
|
id: "ops-rule-1".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rule delete")
|
|
.value
|
|
);
|
|
assert!(openhuman_core::openhuman::memory::ops::tool_rule_get(
|
|
openhuman_core::openhuman::memory::ops::ToolRuleRefParams {
|
|
tool_name: "shell".into(),
|
|
id: "ops-rule-1".into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("tool rule missing")
|
|
.value
|
|
.is_none());
|
|
|
|
let delete_missing =
|
|
openhuman_core::openhuman::memory::ops::memory_delete_document(DeleteDocumentRequest {
|
|
namespace: namespace.into(),
|
|
document_id: "missing".into(),
|
|
})
|
|
.await
|
|
.expect("delete missing")
|
|
.value
|
|
.data
|
|
.expect("delete missing data");
|
|
assert_eq!(delete_missing.status, "not_found");
|
|
|
|
let deleted = openhuman_core::openhuman::memory::ops::doc_delete(
|
|
openhuman_core::openhuman::memory::ops::DeleteDocParams {
|
|
namespace: namespace.into(),
|
|
document_id,
|
|
},
|
|
)
|
|
.await
|
|
.expect("doc delete")
|
|
.value;
|
|
assert_eq!(deleted["deleted"], true);
|
|
let cleared = openhuman_core::openhuman::memory::ops::clear_namespace(
|
|
openhuman_core::openhuman::memory::ops::ClearNamespaceParams {
|
|
namespace: namespace.into(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("clear namespace")
|
|
.value;
|
|
assert!(cleared.cleared);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_tree_retrieval_rpc_and_schema_wrappers_cover_empty_and_invalid_paths() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let config = config_in(&tmp);
|
|
|
|
let schemas =
|
|
openhuman_core::openhuman::memory_tree::retrieval::schemas::all_controller_schemas();
|
|
let controllers =
|
|
openhuman_core::openhuman::memory_tree::retrieval::schemas::all_registered_controllers();
|
|
assert_eq!(schemas.len(), 4);
|
|
assert_eq!(schemas.len(), controllers.len());
|
|
assert_eq!(
|
|
openhuman_core::openhuman::memory_tree::retrieval::schemas::schemas("missing").function,
|
|
"unknown"
|
|
);
|
|
assert!(schemas
|
|
.iter()
|
|
.find(|schema| schema.function == "fetch_leaves")
|
|
.unwrap()
|
|
.description
|
|
.contains("Batch-fetch"));
|
|
|
|
let source = openhuman_core::openhuman::memory_tree::retrieval::rpc::query_source_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::QuerySourceRequest {
|
|
source_id: Some("slack:#raw".into()),
|
|
source_kind: Some("chat".into()),
|
|
time_window_days: Some(7),
|
|
query: None,
|
|
limit: Some(2),
|
|
},
|
|
)
|
|
.await
|
|
.expect("query source rpc");
|
|
assert!(source.value.hits.is_empty());
|
|
assert!(source.logs[0].contains("has_source_id=true"));
|
|
assert!(!source.logs[0].contains("slack:#raw"));
|
|
assert!(
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::query_source_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::QuerySourceRequest {
|
|
source_id: None,
|
|
source_kind: Some("bogus".into()),
|
|
time_window_days: None,
|
|
query: None,
|
|
limit: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("unknown source kind")
|
|
);
|
|
|
|
let search = openhuman_core::openhuman::memory_tree::retrieval::rpc::search_entities_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::SearchEntitiesRequest {
|
|
query: "alice".into(),
|
|
kinds: Some(vec!["email".into()]),
|
|
limit: Some(10),
|
|
},
|
|
)
|
|
.await
|
|
.expect("search entities rpc");
|
|
assert!(search.value.matches.is_empty());
|
|
assert!(search.logs[0].contains("has_kinds=true"));
|
|
assert!(
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::search_entities_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::SearchEntitiesRequest {
|
|
query: "alice".into(),
|
|
kinds: Some(vec!["missing".into()]),
|
|
limit: None,
|
|
},
|
|
)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("unknown entity kind")
|
|
);
|
|
|
|
let drill = openhuman_core::openhuman::memory_tree::retrieval::rpc::drill_down_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::DrillDownRequest {
|
|
node_id: "summary:source:redacted".into(),
|
|
max_depth: None,
|
|
query: None,
|
|
limit: Some(3),
|
|
},
|
|
)
|
|
.await
|
|
.expect("drill down rpc");
|
|
assert!(drill.value.hits.is_empty());
|
|
assert!(drill.logs[0].contains("node_kind=summary"));
|
|
assert!(!drill.logs[0].contains("redacted"));
|
|
|
|
let fetch = openhuman_core::openhuman::memory_tree::retrieval::rpc::fetch_leaves_rpc(
|
|
&config,
|
|
openhuman_core::openhuman::memory_tree::retrieval::rpc::FetchLeavesRequest {
|
|
chunk_ids: vec!["missing-1".into(), "missing-2".into()],
|
|
},
|
|
)
|
|
.await
|
|
.expect("fetch leaves rpc");
|
|
assert!(fetch.value.hits.is_empty());
|
|
|
|
let fetch_controller = controllers
|
|
.iter()
|
|
.find(|controller| controller.schema.function == "fetch_leaves")
|
|
.expect("fetch controller");
|
|
let mut bad_params = Map::new();
|
|
bad_params.insert("chunk_ids".into(), json!("not-an-array"));
|
|
assert!((fetch_controller.handler)(bad_params)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("invalid params"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_query_backend_and_tree_flush_wrappers_cover_public_edges() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let mut config = Config::load_or_init().await.expect("init isolated config");
|
|
config.memory_tree.embedding_endpoint = None;
|
|
config.memory_tree.embedding_model = None;
|
|
config.memory_tree.embedding_strict = false;
|
|
|
|
let source_result = MemoryTreeQuerySourceTool
|
|
.execute(json!({
|
|
"source_id": "slack:#backend",
|
|
"time_window_days": 1,
|
|
"limit": 0
|
|
}))
|
|
.await
|
|
.expect("source query tool");
|
|
let source_response: retrieval::types::QueryResponse =
|
|
serde_json::from_str(&source_result.text()).expect("source response json");
|
|
assert!(source_response.hits.is_empty());
|
|
assert_eq!(source_response.total, 0);
|
|
|
|
let kind_result = MemoryTreeQuerySourceTool
|
|
.execute(json!({ "source_kind": "chat", "limit": 3 }))
|
|
.await
|
|
.expect("query source kind");
|
|
let kind_response: retrieval::types::QueryResponse =
|
|
serde_json::from_str(&kind_result.text()).expect("kind response json");
|
|
assert!(kind_response.hits.is_empty());
|
|
|
|
let drill_result = MemoryTreeDrillDownTool
|
|
.execute(json!({
|
|
"node_id": "summary:missing",
|
|
"max_depth": 1,
|
|
"limit": 2
|
|
}))
|
|
.await
|
|
.expect("drill down tool");
|
|
let drill: Vec<retrieval::types::RetrievalHit> =
|
|
serde_json::from_str(&drill_result.text()).expect("drill response json");
|
|
assert!(drill.is_empty());
|
|
let leaves_result = MemoryTreeFetchLeavesTool
|
|
.execute(json!({ "chunk_ids": [] }))
|
|
.await
|
|
.expect("fetch leaves tool");
|
|
let leaves: Vec<retrieval::types::RetrievalHit> =
|
|
serde_json::from_str(&leaves_result.text()).expect("leaves response json");
|
|
assert!(leaves.is_empty());
|
|
|
|
let no_stale =
|
|
openhuman_core::openhuman::memory_tree::tree::flush::flush_stale_buffers_default(
|
|
&config,
|
|
&openhuman_core::openhuman::memory_tree::tree::LabelStrategy::Empty,
|
|
)
|
|
.await
|
|
.expect("flush empty buffers");
|
|
assert_eq!(no_stale, 0);
|
|
let missing_flush = openhuman_core::openhuman::memory_tree::tree::flush::force_flush_tree(
|
|
&config,
|
|
"tree:missing",
|
|
None,
|
|
&openhuman_core::openhuman::memory_tree::tree::LabelStrategy::Empty,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(missing_flush.to_string().contains("no tree with id"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tree_summarizer_ops_cover_validation_query_and_local_provider_guards() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let mut config = config_in(&tmp);
|
|
config.local_ai.runtime_enabled = false;
|
|
|
|
let empty_content =
|
|
openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_ingest(
|
|
&config, "ops_ns", " ", None, None,
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(empty_content.contains("content must not be empty"));
|
|
|
|
let ts = Utc.with_ymd_and_hms(2026, 5, 29, 17, 0, 0).unwrap();
|
|
let ingest = openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_ingest(
|
|
&config,
|
|
" ops_ns ",
|
|
"buffered raw content for summarizer ops",
|
|
Some(ts),
|
|
Some(&json!({ "source": "coverage" })),
|
|
)
|
|
.await
|
|
.expect("ingest buffer");
|
|
assert_eq!(ingest.value["buffered"], true);
|
|
assert_eq!(ingest.value["namespace"], "ops_ns");
|
|
assert_eq!(ingest.value["has_metadata"], true);
|
|
|
|
let status = openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_status(
|
|
&config, "ops_ns",
|
|
)
|
|
.await
|
|
.expect("status");
|
|
assert_eq!(status.value["namespace"], "ops_ns");
|
|
assert_eq!(status.value["total_nodes"], 0);
|
|
|
|
let node = tree_node("ops_ns", "root", "Root summary from ops");
|
|
tree_runtime_store::write_node(&config, &node).expect("write ops node");
|
|
let query = openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_query(
|
|
&config, "ops_ns", None,
|
|
)
|
|
.await
|
|
.expect("query root");
|
|
assert_eq!(query.value["node"]["node_id"], "root");
|
|
assert!(query.logs[0].contains("queried node 'root'"));
|
|
|
|
let missing = openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_query(
|
|
&config,
|
|
"ops_ns",
|
|
Some("2026/05/29/17"),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(missing.contains("node '2026/05/29/17' not found"));
|
|
|
|
let provider_guard =
|
|
openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_run(
|
|
&config, "ops_ns",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
// No local AI + cloud-summarization opt-in defaults off ⇒ the guard names the
|
|
// local-AI remediation in user-facing prose ("enable local AI ...").
|
|
assert!(provider_guard.contains("local AI"));
|
|
let rebuild_guard =
|
|
openhuman_core::openhuman::memory_tree::tree_runtime::ops::tree_summarizer_rebuild(
|
|
&config, "ops_ns",
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert!(rebuild_guard.contains("local AI"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn memory_sources_types_registry_and_sync_state_cover_public_persistence_edges() {
|
|
let _lock = env_lock();
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let _workspace = EnvVarGuard::set_to_path("OPENHUMAN_WORKSPACE", tmp.path());
|
|
let _config = Config::load_or_init().await.expect("init isolated config");
|
|
openhuman_core::openhuman::memory_sources::reconcile::ensure_composio_sources().await;
|
|
|
|
let decoded_default: MemorySourceEntry = serde_json::from_value(json!({
|
|
"id": "src_default",
|
|
"kind": "rss_feed",
|
|
"label": "Default enabled",
|
|
"url": "https://example.test/feed.xml"
|
|
}))
|
|
.expect("deserialize source with default enabled");
|
|
assert!(decoded_default.enabled);
|
|
|
|
let mut invalid = source(SourceKind::Folder, "");
|
|
assert_eq!(invalid.validate().unwrap_err(), "id is required");
|
|
invalid.id = "src_missing_label".into();
|
|
invalid.label.clear();
|
|
assert_eq!(invalid.validate().unwrap_err(), "label is required");
|
|
invalid.label = "Missing path".into();
|
|
assert!(invalid.validate().unwrap_err().contains("path is required"));
|
|
assert!(source(SourceKind::RssFeed, "rss_missing")
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("url is required"));
|
|
assert!(source(SourceKind::WebPage, "web_missing")
|
|
.validate()
|
|
.unwrap_err()
|
|
.contains("url is required"));
|
|
|
|
let mut entry = source(SourceKind::GithubRepo, "src_repo");
|
|
entry.url = Some("https://github.com/tinyhumansai/openhuman".into());
|
|
let added = registry::add_source(entry.clone())
|
|
.await
|
|
.expect("add repo source");
|
|
assert_eq!(added.kind.as_str(), "github_repo");
|
|
assert!(registry::add_source(entry)
|
|
.await
|
|
.unwrap_err()
|
|
.contains("already exists"));
|
|
|
|
let patch: registry::MemorySourcePatch = serde_json::from_value(json!({
|
|
"label": "Updated repo",
|
|
"enabled": false,
|
|
"toolkit": "github",
|
|
"connection_id": "conn_repo",
|
|
"path": "/tmp/repo",
|
|
"glob": "**/*.md",
|
|
"url": "https://github.com/tinyhumansai/openhuman-skills",
|
|
"branch": "main",
|
|
"paths": ["skills", "README.md"],
|
|
"query": "is:open",
|
|
"since_days": 14,
|
|
"max_items": 9,
|
|
"selector": "main"
|
|
}))
|
|
.expect("patch");
|
|
let updated = registry::update_source("src_repo", patch)
|
|
.await
|
|
.expect("update repo source");
|
|
assert_eq!(updated.label, "Updated repo");
|
|
assert!(!updated.enabled);
|
|
assert_eq!(updated.toolkit.as_deref(), Some("github"));
|
|
assert_eq!(updated.connection_id.as_deref(), Some("conn_repo"));
|
|
assert_eq!(updated.path.as_deref(), Some("/tmp/repo"));
|
|
assert_eq!(updated.glob.as_deref(), Some("**/*.md"));
|
|
assert_eq!(
|
|
updated.url.as_deref(),
|
|
Some("https://github.com/tinyhumansai/openhuman-skills")
|
|
);
|
|
assert_eq!(updated.branch.as_deref(), Some("main"));
|
|
assert_eq!(updated.paths, vec!["skills", "README.md"]);
|
|
assert_eq!(updated.query.as_deref(), Some("is:open"));
|
|
assert_eq!(updated.since_days, Some(14));
|
|
assert_eq!(updated.max_items, Some(9));
|
|
assert_eq!(updated.selector.as_deref(), Some("main"));
|
|
|
|
let memory = Arc::new(
|
|
MemoryClient::from_workspace_dir(tmp.path().join("memory-sync-state"))
|
|
.expect("memory client"),
|
|
);
|
|
let fresh = SyncState::load(&memory, "gmail", "conn-raw")
|
|
.await
|
|
.expect("fresh state");
|
|
assert_eq!(fresh.toolkit, "gmail");
|
|
assert_eq!(fresh.connection_id, "conn-raw");
|
|
|
|
let mut saved = SyncState::new("gmail", "conn-raw");
|
|
saved.advance_cursor("cursor-raw");
|
|
saved.mark_synced("msg-1");
|
|
saved.daily_budget.date = "2000-01-01".into();
|
|
saved.daily_budget.requests_used = DEFAULT_DAILY_REQUEST_LIMIT;
|
|
saved.save(&memory).await.expect("save state");
|
|
|
|
let loaded = SyncState::load(&memory, "gmail", "conn-raw")
|
|
.await
|
|
.expect("load saved state");
|
|
assert_eq!(loaded.cursor.as_deref(), Some("cursor-raw"));
|
|
assert!(loaded.is_synced("msg-1"));
|
|
assert_eq!(loaded.daily_budget.requests_used, 0);
|
|
assert_eq!(loaded.budget_remaining(), DEFAULT_DAILY_REQUEST_LIMIT);
|
|
|
|
memory
|
|
.kv_set(
|
|
Some("composio-sync-state"),
|
|
"gmail:bad-json",
|
|
&json!("not a sync state"),
|
|
)
|
|
.await
|
|
.expect("write bad state");
|
|
assert!(SyncState::load(&memory, "gmail", "bad-json")
|
|
.await
|
|
.unwrap_err()
|
|
.contains("deserialize failed"));
|
|
}
|
|
|
|
#[test]
|
|
fn email_clean_helpers_cover_reply_footer_truncation_and_date_edges() {
|
|
assert_eq!(
|
|
email_clean::drop_reply_chain("Fresh note\n\nOn Tue, 21 Apr 2026, Bob wrote:\n> old")
|
|
.trim(),
|
|
"Fresh note"
|
|
);
|
|
assert_eq!(
|
|
email_clean::collapse_blank_runs("a\n\n\n\nb\n\n").as_str(),
|
|
"a\n\nb"
|
|
);
|
|
assert_eq!(email_clean::truncate_body(" short ", 10), "short");
|
|
assert_eq!(email_clean::truncate_body("abcdef", 3), "abc…");
|
|
assert_eq!(
|
|
email_clean::md_escape("a_b*\nnext|`"),
|
|
"a\\_b\\* next\\|\\`"
|
|
);
|
|
assert_eq!(
|
|
email_clean::extract_email("Alice <alice@example.com>").as_deref(),
|
|
Some("alice@example.com")
|
|
);
|
|
assert_eq!(
|
|
email_clean::extract_email("bare@example.com").as_deref(),
|
|
Some("bare@example.com")
|
|
);
|
|
assert!(email_clean::extract_email("Alice Example").is_none());
|
|
|
|
assert!(email_clean::parse_message_date(&json!({ "date": "" })).is_none());
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "1717000000000" }))
|
|
.unwrap()
|
|
.timestamp_millis(),
|
|
1_717_000_000_000
|
|
);
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "2026-05-29T12:00:00Z" }))
|
|
.unwrap()
|
|
.timestamp(),
|
|
1_780_056_000
|
|
);
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "Fri, 29 May 2026 12:00:00 +0000" }))
|
|
.unwrap()
|
|
.timestamp(),
|
|
1_780_056_000
|
|
);
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "Mon, 29 May 2026 12:00:00 +0000" }))
|
|
.unwrap()
|
|
.timestamp(),
|
|
1_780_056_000
|
|
);
|
|
assert_eq!(
|
|
email_clean::parse_message_date(&json!({ "date": "2026-05-29" }))
|
|
.unwrap()
|
|
.timestamp(),
|
|
1_780_012_800
|
|
);
|
|
assert!(email_clean::parse_message_date(&json!({ "date": "Nope, 29 May 2026" })).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn welcome_migration_public_entrypoint_covers_empty_marker_and_transcript_paths() {
|
|
let tmp = TempDir::new().expect("tempdir");
|
|
let workspace = tmp.path();
|
|
|
|
let session_raw = workspace.join("session_raw");
|
|
std::fs::create_dir_all(&session_raw).expect("raw dir");
|
|
std::fs::write(session_raw.join("skip.txt"), "not jsonl").expect("skip file");
|
|
std::fs::write(
|
|
session_raw.join("1715000000_welcome_thread-abc.jsonl"),
|
|
"{\"_meta\":{\"agent\":\"welcome_thread-abc\",\"thread_id\":\"thread-abc\"}}\n{\"role\":\"user\",\"content\":\"hi\"}\n",
|
|
)
|
|
.expect("raw transcript");
|
|
let markdown = workspace.join("sessions/2026_05_01/1715000000_welcome_thread-abc.md");
|
|
std::fs::create_dir_all(markdown.parent().unwrap()).expect("markdown dir");
|
|
std::fs::write(&markdown, "# Session transcript\n").expect("markdown");
|
|
|
|
let result = openhuman_core::openhuman::threads::migrate_welcome_agent_artifacts(workspace)
|
|
.expect("migrate welcome artifacts");
|
|
assert_eq!(result.threads_updated, 0);
|
|
assert_eq!(result.transcripts_updated, 1);
|
|
assert_eq!(result.transcript_files_renamed, 1);
|
|
assert_eq!(result.markdown_files_renamed, 1);
|
|
assert!(workspace
|
|
.join("session_raw/1715000000_orchestrator_thread-abc.jsonl")
|
|
.exists());
|
|
assert!(workspace
|
|
.join("sessions/2026_05_01/1715000000_orchestrator_thread-abc.md")
|
|
.exists());
|
|
|
|
let second = openhuman_core::openhuman::threads::migrate_welcome_agent_artifacts(workspace)
|
|
.expect("second migration");
|
|
assert!(second.already_done);
|
|
}
|