Files
openhuman/tests/memory_artifacts_e2e.rs
T

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')
}));
}