mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
222 lines
9.2 KiB
Rust
222 lines
9.2 KiB
Rust
//! Integration coverage for on-disk memory artifacts.
|
|
//!
|
|
//! Uses a real Slack ingest to create raw/source artifacts, then stages a
|
|
//! mocked summary record to verify the Obsidian-compatible vault layout and
|
|
//! frontmatter contract without requiring a live summarizer model.
|
|
|
|
use tempfile::tempdir;
|
|
|
|
use chrono::{TimeZone, Utc};
|
|
|
|
use openhuman_core::openhuman::config::Config;
|
|
use openhuman_core::openhuman::memory::ingest_pipeline::ingest_chat;
|
|
use openhuman_core::openhuman::memory::tree_source::registry::get_or_create_source_tree;
|
|
use openhuman_core::openhuman::memory_queue::drain_until_idle;
|
|
use openhuman_core::openhuman::memory_store::content::atomic::stage_summary;
|
|
use openhuman_core::openhuman::memory_store::content::obsidian::ensure_obsidian_defaults;
|
|
use openhuman_core::openhuman::memory_store::content::raw::{write_raw_items, RawItem, RawKind};
|
|
use openhuman_core::openhuman::memory_store::content::wiki_git::{
|
|
get_read_pointer_tag, set_read_pointer_tag,
|
|
};
|
|
use openhuman_core::openhuman::memory_store::content::{SummaryComposeInput, SummaryTreeKind};
|
|
use openhuman_core::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
|
|
use openhuman_core::openhuman::memory_tree::ingest::{ingest_summary, SummaryIngestInput};
|
|
|
|
fn make_config(workspace_dir: &std::path::Path) -> Config {
|
|
let mut config = Config::default();
|
|
config.workspace_dir = workspace_dir.to_path_buf();
|
|
config
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn sync_raw_artifacts_and_mocked_summary_match_obsidian_contract() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(&workspace_dir).expect("workspace dir");
|
|
let config = make_config(&workspace_dir);
|
|
|
|
let ts = Utc.timestamp_opt(1_700_000_000, 0).single().unwrap();
|
|
write_raw_items(
|
|
&config.memory_tree_content_root(),
|
|
"slack:conn-slack-1",
|
|
&[RawItem {
|
|
uid: "1700000000.000100",
|
|
created_at_ms: ts.timestamp_millis(),
|
|
markdown: "**Channel:** #engineering\n**Author:** alice\n\nPhoenix migration launch window is Friday at 22:00 UTC.",
|
|
kind: RawKind::Chat,
|
|
}],
|
|
)
|
|
.expect("seed raw Slack artifact");
|
|
let batch = ChatBatch {
|
|
platform: "slack".into(),
|
|
channel_label: "#engineering".into(),
|
|
messages: vec![ChatMessage {
|
|
author: "alice".into(),
|
|
timestamp: ts,
|
|
text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(),
|
|
source_ref: Some("https://slack.example.test/archives/C123/p1700000000000100".into()),
|
|
}],
|
|
};
|
|
ingest_chat(
|
|
&config,
|
|
"slack:conn-slack-1",
|
|
"alice",
|
|
vec!["slack".into(), "ingested".into()],
|
|
batch,
|
|
)
|
|
.await
|
|
.expect("seed slack sync");
|
|
drain_until_idle(&config).await.expect("drain sync jobs");
|
|
|
|
let content_root = config.memory_tree_content_root();
|
|
let raw_file = content_root
|
|
.join("raw")
|
|
.join("slack-conn-slack-1")
|
|
.join("chats")
|
|
.join("1700000000000_1700000000.000100.md");
|
|
let source_file = content_root
|
|
.join("raw")
|
|
.join("slack-conn-slack-1")
|
|
.join("_source.md");
|
|
assert!(raw_file.exists(), "expected raw markdown artifact");
|
|
assert!(source_file.exists(), "expected source registry mirror");
|
|
|
|
let raw_body = std::fs::read_to_string(&raw_file).expect("read raw artifact");
|
|
assert!(raw_body.contains("**Channel:** #engineering"));
|
|
assert!(raw_body.contains("**Author:** alice"));
|
|
assert!(raw_body.contains("Phoenix migration launch window"));
|
|
|
|
ensure_obsidian_defaults(&content_root).expect("stage obsidian defaults");
|
|
|
|
let sealed_at = Utc.with_ymd_and_hms(2026, 5, 24, 22, 0, 0).unwrap();
|
|
let child_ids = vec!["chunk-1".to_string()];
|
|
let child_basenames = vec![Some("1700000000000_1700000000.000100".to_string())];
|
|
let staged = stage_summary(
|
|
&content_root,
|
|
&SummaryComposeInput {
|
|
summary_id: "summary:1760000000000:L1-phoenix-window",
|
|
tree_kind: SummaryTreeKind::Source,
|
|
tree_id: "source:slack",
|
|
tree_scope: "slack:conn-slack-1",
|
|
level: 1,
|
|
child_ids: &child_ids,
|
|
child_basenames: Some(&child_basenames),
|
|
child_count: 1,
|
|
time_range_start: ts,
|
|
time_range_end: ts,
|
|
sealed_at,
|
|
body: "Phoenix migration launch window confirmed for Friday 22:00 UTC.",
|
|
},
|
|
"slack-conn-slack-1",
|
|
)
|
|
.expect("stage mocked summary");
|
|
|
|
let summary_path = content_root.join(&staged.content_path);
|
|
assert!(summary_path.exists(), "summary markdown should be written");
|
|
let summary_body = std::fs::read_to_string(&summary_path).expect("read summary markdown");
|
|
assert!(summary_body.contains("tree_kind: source"));
|
|
assert!(summary_body.contains("tree_scope: \"slack:conn-slack-1\""));
|
|
assert!(summary_body.contains("time_range_start: 2023-11-14T22:13:20+00:00"));
|
|
assert!(summary_body.contains("time_range_end: 2023-11-14T22:13:20+00:00"));
|
|
assert!(summary_body.contains("sealed_at: 2026-05-24T22:00:00+00:00"));
|
|
assert!(summary_body.contains("[[1700000000000_1700000000.000100]]"));
|
|
|
|
let graph_json = content_root.join(".obsidian").join("graph.json");
|
|
let types_json = content_root.join(".obsidian").join("types.json");
|
|
assert!(graph_json.exists(), "graph defaults should exist");
|
|
assert!(types_json.exists(), "type hints should exist");
|
|
let types_body = std::fs::read_to_string(types_json).expect("read types.json");
|
|
assert!(types_body.contains("\"time_range_start\": \"date\""));
|
|
assert!(types_body.contains("\"sealed_at\": \"datetime\""));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn summary_ingest_records_summary_only_git_history_and_timestamped_read_tags() {
|
|
let tmp = tempdir().expect("tempdir");
|
|
let workspace_dir = tmp.path().join("workspace");
|
|
std::fs::create_dir_all(&workspace_dir).expect("workspace dir");
|
|
let config = make_config(&workspace_dir);
|
|
let content_root = config.memory_tree_content_root();
|
|
let raw_path = content_root.join("wiki/raw/not-tracked.md");
|
|
std::fs::create_dir_all(raw_path.parent().unwrap()).expect("raw dir");
|
|
std::fs::write(&raw_path, "should stay out of git history").expect("seed raw file");
|
|
|
|
let tree = get_or_create_source_tree(&config, "github:tinyhumansai/openhuman")
|
|
.expect("create source tree");
|
|
let start = Utc.with_ymd_and_hms(2026, 6, 26, 9, 0, 0).unwrap();
|
|
let end = Utc.with_ymd_and_hms(2026, 6, 26, 9, 30, 0).unwrap();
|
|
let outcome = ingest_summary(
|
|
&config,
|
|
&tree,
|
|
SummaryIngestInput {
|
|
content: "Memory wiki git history now records summary-node seals.".to_string(),
|
|
token_count: 64,
|
|
entities: Vec::new(),
|
|
topics: vec!["memory".to_string()],
|
|
time_range_start: start,
|
|
time_range_end: end,
|
|
score: 0.8,
|
|
child_labels: vec!["commit:abc123".to_string(), "issue:4142".to_string()],
|
|
child_basenames: Vec::new(),
|
|
},
|
|
)
|
|
.await
|
|
.expect("ingest summary");
|
|
|
|
let wiki_root = content_root.join("wiki");
|
|
let repo = git2::Repository::open(&wiki_root).expect("wiki git repo should be initialized");
|
|
let head = repo.head().expect("wiki head").peel_to_commit().unwrap();
|
|
let tree_obj = head.tree().expect("wiki commit tree");
|
|
|
|
let repo_summary_path = outcome
|
|
.content_path
|
|
.strip_prefix("wiki/")
|
|
.expect("summary path should live under wiki/");
|
|
tree_obj
|
|
.get_path(std::path::Path::new(repo_summary_path))
|
|
.expect("summary markdown should be tracked");
|
|
assert!(
|
|
tree_obj
|
|
.get_path(std::path::Path::new("raw/not-tracked.md"))
|
|
.is_err(),
|
|
"git history should remain scoped to summaries"
|
|
);
|
|
|
|
let message = head.message().expect("commit message");
|
|
assert!(message.contains("Seal memory tree github:tinyhumansai/openhuman L1 summaries"));
|
|
assert!(message.contains("Reason: summary_ingest"));
|
|
assert!(message.contains("Summary-Count: 1"));
|
|
assert!(message.contains("Child-Count: 2"));
|
|
assert!(message.contains("Token-Count: 64"));
|
|
assert!(message.contains(&outcome.summary_id));
|
|
assert!(message.contains(repo_summary_path));
|
|
|
|
let head_id = head.id().to_string();
|
|
let tagged = set_read_pointer_tag(&content_root, "agent:e2e", None)
|
|
.expect("set timestamped read pointer tag");
|
|
assert_eq!(tagged, head_id);
|
|
assert_eq!(
|
|
get_read_pointer_tag(&content_root, "agent:e2e")
|
|
.expect("read latest pointer")
|
|
.as_deref(),
|
|
Some(head_id.as_str())
|
|
);
|
|
|
|
let tag_prefix = format!("refs/tags/read/{}/", hex::encode("agent:e2e".as_bytes()));
|
|
let tags = repo.references().unwrap().fold(Vec::new(), |mut acc, r| {
|
|
let r = r.unwrap();
|
|
let name = r.name().unwrap();
|
|
if name.starts_with(&tag_prefix) {
|
|
acc.push(name.to_string());
|
|
}
|
|
acc
|
|
});
|
|
assert!(tags.iter().any(|name| name.ends_with("/latest")));
|
|
assert!(tags.iter().any(|name| {
|
|
let suffix = name.strip_prefix(&tag_prefix).unwrap_or_default();
|
|
suffix.len() == "20260626T090000.000000000Z".len()
|
|
&& suffix.ends_with('Z')
|
|
&& suffix.contains('T')
|
|
}));
|
|
}
|