refactor(rust): move large inline tests to files (#2987)

This commit is contained in:
Steven Enamakel
2026-05-29 15:14:46 -07:00
committed by GitHub
parent 97bf23c53a
commit 525eaf74d7
46 changed files with 9628 additions and 9660 deletions
+2 -3556
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -60,7 +60,7 @@ mod bughunt_tests;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
mod test_support_test;
mod test_support_tests;
#[cfg(test)]
mod harness_gap_tests;
@@ -487,5 +487,5 @@ fn with_ownership_boundary(prompt: &str, ownership: Option<&str>) -> String {
}
#[cfg(test)]
#[path = "spawn_parallel_agents_test.rs"]
#[path = "spawn_parallel_agents_tests.rs"]
mod tests;
+1 -1
View File
@@ -1121,7 +1121,7 @@ fn store_get_clear_composio_api_key_roundtrip() {
// 400-class user error.
// * `direct_execute` accepts a None-arguments call and falls
// through to the underlying tool surface (which then errors on the
// network call — covered by the integration test in `ops_test.rs`).
// network call — covered by the integration test in `ops_tests.rs`).
// * `direct_list_connections` is a thin mapper; the real coverage
// for its row → ComposioConnection translation lives in the
// `connected_account_*` tests in `composio_tests.rs`.
+1 -1
View File
@@ -2371,7 +2371,7 @@ pub async fn composio_clear_api_key(config: &Config) -> OpResult<RpcOutcome<serd
}
#[cfg(test)]
#[path = "ops_test.rs"]
#[path = "ops_tests.rs"]
mod tests;
// ── Helpers re-exported so callers can pull connection/tool types without
+1 -1
View File
@@ -426,5 +426,5 @@ fn generate_id() -> String {
}
#[cfg(test)]
#[path = "registry_test.rs"]
#[path = "registry_tests.rs"]
mod tests;
@@ -1,7 +1,7 @@
//! Unit tests for [`super::JailRegistry`].
//!
//! Lives next to `registry.rs` (wired in via `#[cfg(test)] #[path =
//! "registry_test.rs"] mod tests;`) so the production module stays under
//! "registry_tests.rs"] mod tests;`) so the production module stays under
//! the ~500-line guideline.
use super::*;
+2 -2
View File
@@ -1088,5 +1088,5 @@ fn redact_endpoint(url: &str) -> String {
// ── Unit tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
#[path = "factory_test.rs"]
mod factory_test;
#[path = "factory_tests.rs"]
mod factory_tests;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -244,5 +244,5 @@ impl Provider for RouterProvider {
}
#[cfg(test)]
#[path = "router_test.rs"]
mod router_test;
#[path = "router_tests.rs"]
mod router_tests;
+1 -1
View File
@@ -793,5 +793,5 @@ pub async fn spawn_fake_integration_backend() -> FakeIntegrationBackend {
}
#[cfg(test)]
#[path = "test_support_test.rs"]
#[path = "test_support_tests.rs"]
mod tests;
+2 -912
View File
@@ -1416,915 +1416,5 @@ fn json_type_name(value: &Value) -> &'static str {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() {
let config = crate::openhuman::config::Config::default();
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk",
"tree.browse",
"tree.top_entities",
"tree.list_sources",
"memory.store",
"memory.note",
"tree.tag",
]
);
}
#[test]
fn list_tools_emits_annotations_for_every_tool() {
// Exercise the searxng-enabled config so the annotation contract covers
// every shipping tool, not just the base set.
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let tools = result["tools"].as_array().expect("tools array");
for tool in tools {
let name = tool["name"].as_str().expect("tool name");
assert!(
tool.get("annotations")
.map(Value::is_object)
.unwrap_or(false),
"tool `{name}` is missing a serialized `annotations` object",
);
}
}
#[test]
fn read_only_tools_are_marked_read_only_and_closed_world() {
// Every tool except the act-capable ones reads local OpenHuman state
// (memory tree / agent registry) or queries an external read-only
// search engine. Per MCP spec defaults these would be
// `readOnlyHint: false` and `openWorldHint: true`, so we MUST set
// `readOnlyHint` explicitly to communicate accurate safety affordances
// to clients. (`searxng_search` is read-only but openWorld, so it
// verifies the read-only axis here and is exempt from the
// openWorld=false check below.)
let act_tool_names = [
"agent.run_subagent",
"memory.store",
"memory.note",
"tree.tag",
];
let open_world_read_only = ["searxng_search"];
for spec in tool_specs() {
if act_tool_names.contains(&spec.name) {
continue;
}
let annotations = &spec.annotations;
assert_eq!(
annotations.get("readOnlyHint").and_then(Value::as_bool),
Some(true),
"expected `{}` to advertise readOnlyHint=true",
spec.name
);
let expected_open_world = open_world_read_only.contains(&spec.name);
assert_eq!(
annotations.get("openWorldHint").and_then(Value::as_bool),
Some(expected_open_world),
"expected `{}` to advertise openWorldHint={}",
spec.name,
expected_open_world
);
// Per spec these are meaningful only when readOnlyHint == false.
// Emitting them on a read-only tool would be misleading.
assert!(
annotations.get("destructiveHint").is_none(),
"read-only tool `{}` should not emit destructiveHint",
spec.name
);
assert!(
annotations.get("idempotentHint").is_none(),
"read-only tool `{}` should not emit idempotentHint",
spec.name
);
}
}
#[test]
fn run_subagent_annotations_signal_act_semantics() {
let spec = tool_specs()
.into_iter()
.find(|spec| spec.name == "agent.run_subagent")
.expect("agent.run_subagent must be registered");
assert_eq!(
spec.annotations
.get("readOnlyHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("destructiveHint")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
spec.annotations
.get("idempotentHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("openWorldHint")
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn list_tools_includes_searxng_when_enabled() {
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert!(names.contains(&"searxng_search"));
}
#[test]
fn mapped_rpc_methods_are_registered() {
for spec in tool_specs() {
if let Some(rpc_method) = spec.rpc_method {
assert!(
all::schema_for_rpc_method(rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
rpc_method
);
}
}
}
#[test]
fn build_rpc_params_parses_run_subagent_arguments() {
let params = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause."
}),
)
.expect("params should parse");
assert_eq!(
params.get("agent_id").and_then(Value::as_str),
Some("researcher")
);
assert_eq!(
params.get("prompt").and_then(Value::as_str),
Some("Find the root cause.")
);
}
#[test]
fn build_rpc_params_rejects_extra_run_subagent_fields() {
let err = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause.",
"toolkit": "gmail"
}),
)
.expect_err("unexpected field should be rejected");
assert!(
matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument"))
);
}
#[test]
fn memory_search_params_trim_query_and_use_default_k() {
let params = build_rpc_params(
"memory.search",
json!({
"query": " phoenix migration ",
}),
)
.expect("params");
assert_eq!(params["query"], "phoenix migration");
assert_eq!(params["k"], DEFAULT_LIMIT);
}
#[test]
fn searxng_search_params_accept_optional_fields() {
let params = build_rpc_params(
"searxng_search",
json!({
"query": " rust async ",
"categories": ["web", "news"],
"language": " en ",
"max_results": 12
}),
)
.expect("params");
assert_eq!(params["query"], "rust async");
assert_eq!(params["categories"], json!(["web", "news"]));
assert_eq!(params["language"], "en");
assert_eq!(params["max_results"], 12);
}
#[test]
fn searxng_search_rejects_unknown_category() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"categories": ["videos"]
}),
)
.expect_err("must reject");
assert!(err.message().contains("unsupported SearXNG category"));
}
#[test]
fn searxng_search_rejects_max_results_above_max() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"max_results": SEARXNG_MAX_RESULTS + 1
}),
)
.expect_err("must reject");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn memory_search_rejects_k_above_max() {
// Reject (don't silent-clamp) so the LLM can self-correct on the next
// call. Silent clamping makes the model believe it got the page size
// it asked for and prevents the corrective feedback loop.
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"k": MAX_LIMIT + 1
}),
)
.expect_err("must reject k > MAX_LIMIT");
let message = err.message();
assert!(
message.contains("must not exceed"),
"error should mention the cap, got: {message}"
);
assert!(
message.contains(&MAX_LIMIT.to_string()),
"error should mention the limit value, got: {message}"
);
}
#[test]
fn memory_search_accepts_k_at_max() {
let params = build_rpc_params(
"memory.search",
json!({ "query": "phoenix", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT must be accepted (boundary inclusive)");
assert_eq!(params["k"], MAX_LIMIT);
}
#[test]
fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() {
let err = ToolCallError::InvalidParams("missing query".to_string());
assert_eq!(err.code(), -32602);
assert_eq!(err.jsonrpc_message(), "Invalid params");
assert_eq!(err.message(), "missing query");
}
#[test]
fn tool_call_error_internal_maps_to_jsonrpc_internal_error() {
// Server-side failures (config load, missing resources) must surface
// as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP
// client doesn't mislead the user / LLM into retrying with different
// arguments.
let err = ToolCallError::Internal("disk read failed".to_string());
assert_eq!(err.code(), -32603);
assert_eq!(err.jsonrpc_message(), "Internal error");
assert_eq!(err.message(), "disk read failed");
}
#[test]
fn memory_recall_requires_query() {
let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `query`"));
}
#[test]
fn memory_search_rejects_undocumented_limit_alias() {
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"limit": 5
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_read_chunk_maps_chunk_id_to_controller_id() {
let params =
build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params");
assert_eq!(params["id"], "abc");
assert!(!params.contains_key("chunk_id"));
}
#[test]
fn tree_read_chunk_rejects_unknown_arguments() {
let err = build_rpc_params(
"tree.read_chunk",
json!({
"chunk_id": "abc",
"unused": true
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `unused`"));
}
#[test]
fn non_object_arguments_are_invalid() {
let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject");
assert!(err.message().contains("arguments must be an object"));
}
// ── tree.browse ────────────────────────────────────────────────────
#[test]
fn tree_browse_no_args_sends_default_limit_only() {
// Empty filter is a valid request — the controller treats unset filters
// as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT
// so the LLM doesn't accidentally pull the controller's 50-row default
// when it asked for nothing.
let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid");
assert_eq!(params.len(), 1);
assert_eq!(params["limit"], DEFAULT_LIMIT);
}
#[test]
fn tree_browse_passes_through_filters_and_renames_k_to_limit() {
let params = build_rpc_params(
"tree.browse",
json!({
"source_kinds": ["email", "chat"],
"source_ids": ["acme-thread-1"],
"entity_ids": ["person:Alice"],
"since_ms": 1_700_000_000_000_i64,
"until_ms": 1_710_000_000_000_i64,
"query": "Q3 plan",
"k": 20,
"offset": 10
}),
)
.expect("params");
assert_eq!(params["limit"], 20);
assert!(!params.contains_key("k"));
assert_eq!(params["source_kinds"], json!(["email", "chat"]));
assert_eq!(params["source_ids"], json!(["acme-thread-1"]));
assert_eq!(params["entity_ids"], json!(["person:Alice"]));
assert_eq!(params["since_ms"], 1_700_000_000_000_i64);
assert_eq!(params["until_ms"], 1_710_000_000_000_i64);
assert_eq!(params["query"], "Q3 plan");
assert_eq!(params["offset"], 10);
}
#[test]
fn tree_browse_rejects_k_above_max() {
// Same reject-don't-clamp policy as memory.search / memory.recall so the
// LLM gets corrective feedback instead of silently receiving fewer rows
// than it asked for.
let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 }))
.expect_err("must reject k > MAX_LIMIT");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn tree_browse_rejects_unknown_argument() {
let err = build_rpc_params("tree.browse", json!({ "limit": 10 }))
.expect_err("must reject the controller's `limit` alias");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_browse_rejects_non_array_source_kinds() {
let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" }))
.expect_err("must reject scalar where array is required");
assert!(err.message().contains("must be an array of strings"));
}
#[test]
fn tree_browse_rejects_non_integer_since_ms() {
let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" }))
.expect_err("must reject ISO-style date for ms field");
assert!(err.message().contains("must be an integer"));
}
#[test]
fn tree_browse_drops_blank_array_entries_silently() {
// Empty / whitespace strings inside an array are tolerated — clients
// sometimes send `["", "email"]` after a partial UI selection and the
// intent ("filter to email") is unambiguous. A fully-blank array is OK
// too and produces an empty filter (same as omitting the field).
let params = build_rpc_params(
"tree.browse",
json!({ "source_kinds": ["", "email", " "] }),
)
.expect("blank entries don't fail the whole call");
assert_eq!(params["source_kinds"], json!(["email"]));
}
// ── tree.top_entities ──────────────────────────────────────────────
#[test]
fn tree_top_entities_defaults_limit_and_omits_kind() {
let params =
build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid");
assert_eq!(params["limit"], DEFAULT_LIMIT);
assert!(!params.contains_key("kind"));
}
#[test]
fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() {
let params = build_rpc_params(
"tree.top_entities",
json!({ "kind": "person", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT is the boundary, inclusive");
assert_eq!(params["kind"], "person");
assert_eq!(params["limit"], MAX_LIMIT);
}
#[test]
fn tree_top_entities_rejects_empty_kind() {
// Blank kind is a client bug — the controller would happily run it as
// "no filter" but that's exactly what *omitting* the field already
// means. Rejecting nudges the LLM to drop the field instead.
let err = build_rpc_params("tree.top_entities", json!({ "kind": " " }))
.expect_err("must reject blank-only kind");
assert!(err.message().contains("must not be empty"));
}
// ── tree.list_sources ──────────────────────────────────────────────
#[test]
fn tree_list_sources_accepts_empty_args() {
let params =
build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case");
assert!(params.is_empty());
}
#[test]
fn tree_list_sources_passes_user_email_hint() {
let params = build_rpc_params(
"tree.list_sources",
json!({ "user_email_hint": "me@example.com" }),
)
.expect("params");
assert_eq!(params["user_email_hint"], "me@example.com");
}
#[test]
fn tree_list_sources_rejects_unknown_argument() {
let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 }))
.expect_err("list_sources takes no pagination");
assert!(err.message().contains("unexpected argument `limit`"));
}
// ── memory.store ──────────────────────────────────────────────────
#[test]
fn memory_store_requires_title_and_content() {
let err = build_rpc_params("memory.store", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `title`"));
let err =
build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `content`"));
}
#[test]
fn memory_store_defaults_namespace_to_mcp() {
let params = build_rpc_params(
"memory.store",
json!({ "title": "My note", "content": "Hello world" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["title"], "My note");
assert_eq!(params["content"], "Hello world");
assert_eq!(params["source_type"], "mcp");
assert!(params["key"].as_str().unwrap().starts_with("mcp-store-"));
}
#[test]
fn memory_store_accepts_custom_namespace_and_tags() {
let params = build_rpc_params(
"memory.store",
json!({
"title": "Project Plan",
"content": "Q3 milestones",
"namespace": "work",
"tags": ["project", "planning"]
}),
)
.expect("params");
assert_eq!(params["namespace"], "work");
assert_eq!(params["tags"], json!(["project", "planning"]));
}
#[test]
fn memory_store_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.store",
json!({ "title": "T", "content": "C", "priority": "high" }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `priority`"));
}
// ── memory.note ───────────────────────────────────────────────────
#[test]
fn memory_note_requires_chunk_id_and_note_text() {
let err = build_rpc_params("memory.note", json!({})).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `chunk_id`"));
let err =
build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `note_text`"));
}
#[test]
fn memory_note_builds_annotation_document() {
let params = build_rpc_params(
"memory.note",
json!({ "chunk_id": "chunk-42", "note_text": "Important context" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-note-chunk-42");
assert!(params["title"].as_str().unwrap().contains("chunk-42"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("Important context"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("chunk_id=chunk-42"));
assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42");
assert_eq!(params["source_type"], "mcp");
}
#[test]
fn memory_note_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.note",
json!({ "chunk_id": "abc", "note_text": "N", "extra": true }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `extra`"));
}
// ── tree.tag ──────────────────────────────────────────────────────
#[test]
fn tree_tag_requires_chunk_id_and_tags() {
let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject");
assert!(
err.message()
.contains("missing required argument `chunk_id`"),
"got: {}",
err.message()
);
let err =
build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(
err.message().contains("missing required argument `tags`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_empty_tags_array() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] }))
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_all_blank_tags() {
// After blank-trim the list is empty — same failure mode as `[]`.
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [" ", ""] }),
)
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_non_string_tags() {
// Numeric entries inside `tags` get caught by the string-array helper.
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `tags` must contain only strings"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_builds_tag_record_document() {
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }),
)
.expect("params");
// Document key is deterministic on chunk_id only → re-tagging
// the same chunk upserts.
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-tag-chunk-42");
assert_eq!(params["source_type"], "mcp");
// Title surfaces the target chunk for human-readable recall.
assert!(
params["title"]
.as_str()
.expect("title is a string")
.contains("chunk-42"),
"title was: {}",
params["title"]
);
// Top-level `tags` flows to the document tag index (queryable
// via `doc_list` / search filters) — this is the key differentiator
// from `memory.note` whose payload is opaque free-form text.
assert_eq!(params["tags"], json!(["todo", "q3-planning"]));
// Metadata carries the back-reference plus a mirrored tag list,
// so consumers reading the metadata view don't need to also
// join against the top-level `tags` field.
let metadata = params["metadata"]
.as_object()
.expect("metadata is an object");
assert_eq!(metadata["tags_for_chunk_id"], "chunk-42");
assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"]));
}
#[test]
fn tree_tag_trims_blanks_but_keeps_real_tags() {
// Mixed list — blanks are silently dropped (matches existing
// `optional_string_array` behaviour) but the resulting set is
// still non-empty so the call succeeds.
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }),
)
.expect("params");
assert_eq!(params["tags"], json!(["important", "todo"]));
}
#[test]
fn tree_tag_rejects_empty_chunk_id() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `chunk_id` must not be empty"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_unknown_argument() {
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }),
)
.expect_err("must reject");
assert!(
err.message().contains("unexpected argument `priority`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_tag_array() {
// Per-graycyrus #2316 review: cap the tag-array length so a
// misbehaving client can't flood a chunk's tag-record document
// with hundreds of categorical labels. Builds an over-cap
// array and asserts the dedicated rejection message.
let oversize: Vec<String> = (0..(TREE_TAG_MAX_TAGS + 1))
.map(|i| format!("tag-{i}"))
.collect();
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize }))
.expect_err("must reject");
assert!(
err.message().contains("accepts at most"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_individual_tag() {
// Per-graycyrus #2316 review: a single oversize tag is almost
// certainly free-form text that should be `memory.note` instead
// of going through the categorical tag surface — reject up-front
// so the misuse is visible rather than silently writing a giant
// token into the queryable `tags` index.
let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1);
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [oversize_tag] }),
)
.expect_err("must reject");
assert!(err.message().contains("exceeds"), "got: {}", err.message());
}
#[test]
fn tree_tag_accepts_max_size_tags() {
// Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is
// "at most N", not "fewer than N") with each entry at exactly
// TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the
// inclusive-vs-exclusive bound so a future off-by-one
// refactor breaks the test, not user calls.
let max_tags: Vec<String> = (0..TREE_TAG_MAX_TAGS)
.map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4))
.collect();
// Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars.
assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH));
let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags }))
.expect("at the cap must succeed");
// The built params should preserve all TREE_TAG_MAX_TAGS entries.
assert_eq!(
params["tags"].as_array().expect("tags is array").len(),
TREE_TAG_MAX_TAGS
);
}
#[tokio::test]
async fn call_tool_records_write_argument_rejection() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let config = config_rpc::load_config_with_timeout()
.await
.expect("config");
let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test")
.await
.expect_err("missing content should reject");
assert!(
err.message()
.contains("missing required argument `content`"),
"got: {}",
err.message()
);
let mut rows = Vec::new();
for _ in 0..50 {
rows = crate::openhuman::mcp_audit::list_writes(
&config,
&crate::openhuman::mcp_audit::McpWriteListQuery::default(),
)
.expect("list writes");
if rows.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(rows.len(), 1);
assert!(!rows[0].success);
assert_eq!(rows[0].tool_name, "memory.store");
assert_eq!(rows[0].client_info, "mcp:test");
assert!(rows[0]
.error_message
.as_deref()
.unwrap_or_default()
.contains("missing required argument `content`"));
assert!(rows[0].args_summary.get("content").is_none());
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
// ── slug_from ─────────────────────────────────────────────────────
#[test]
fn slug_from_produces_clean_slug() {
assert_eq!(slug_from("Hello World!"), "hello-world");
assert_eq!(slug_from(" spaces "), "spaces");
assert_eq!(slug_from("CamelCase123"), "camelcase123");
assert_eq!(slug_from("a--b"), "a-b");
}
#[test]
fn slug_from_truncates_long_titles() {
let long = "a".repeat(100);
let slug = slug_from(&long);
assert!(slug.len() <= 64);
}
#[test]
fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() {
// Non-alphanumeric titles should produce "untitled-<hash>" with a
// stable, deterministic hash suffix.
let slug_bang = slug_from("!!!");
let slug_at = slug_from("@@@");
assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}");
assert!(slug_at.starts_with("untitled-"), "got: {slug_at}");
// Different inputs → different slugs
assert_ne!(slug_bang, slug_at);
// Empty title also gets a fallback
assert!(slug_from("").starts_with("untitled-"));
// Stable across calls
assert_eq!(slug_from("!!!"), slug_bang);
}
#[test]
fn slug_from_unicode_only_titles_are_unique_and_stable() {
let chinese = slug_from("会议记录");
let russian = slug_from("Протокол");
let emoji = slug_from("🦀🚀");
// All produce hash-based fallbacks
assert!(chinese.starts_with("untitled-"), "got: {chinese}");
assert!(russian.starts_with("untitled-"), "got: {russian}");
assert!(emoji.starts_with("untitled-"), "got: {emoji}");
// All distinct
assert_ne!(chinese, russian);
assert_ne!(chinese, emoji);
assert_ne!(russian, emoji);
// Stable
assert_eq!(slug_from("会议记录"), chinese);
assert_eq!(slug_from("Протокол"), russian);
}
}
#[path = "tools_tests.rs"]
mod tests;
+906
View File
@@ -0,0 +1,906 @@
use super::*;
#[test]
fn list_tools_exposes_base_mcp_surface_when_searxng_disabled() {
let config = crate::openhuman::config::Config::default();
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"core.list_tools",
"core.tool_instructions",
"agent.list_subagents",
"agent.run_subagent",
"memory.search",
"memory.recall",
"tree.read_chunk",
"tree.browse",
"tree.top_entities",
"tree.list_sources",
"memory.store",
"memory.note",
"tree.tag",
]
);
}
#[test]
fn list_tools_emits_annotations_for_every_tool() {
// Exercise the searxng-enabled config so the annotation contract covers
// every shipping tool, not just the base set.
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let tools = result["tools"].as_array().expect("tools array");
for tool in tools {
let name = tool["name"].as_str().expect("tool name");
assert!(
tool.get("annotations")
.map(Value::is_object)
.unwrap_or(false),
"tool `{name}` is missing a serialized `annotations` object",
);
}
}
#[test]
fn read_only_tools_are_marked_read_only_and_closed_world() {
// Every tool except the act-capable ones reads local OpenHuman state
// (memory tree / agent registry) or queries an external read-only
// search engine. Per MCP spec defaults these would be
// `readOnlyHint: false` and `openWorldHint: true`, so we MUST set
// `readOnlyHint` explicitly to communicate accurate safety affordances
// to clients. (`searxng_search` is read-only but openWorld, so it
// verifies the read-only axis here and is exempt from the
// openWorld=false check below.)
let act_tool_names = [
"agent.run_subagent",
"memory.store",
"memory.note",
"tree.tag",
];
let open_world_read_only = ["searxng_search"];
for spec in tool_specs() {
if act_tool_names.contains(&spec.name) {
continue;
}
let annotations = &spec.annotations;
assert_eq!(
annotations.get("readOnlyHint").and_then(Value::as_bool),
Some(true),
"expected `{}` to advertise readOnlyHint=true",
spec.name
);
let expected_open_world = open_world_read_only.contains(&spec.name);
assert_eq!(
annotations.get("openWorldHint").and_then(Value::as_bool),
Some(expected_open_world),
"expected `{}` to advertise openWorldHint={}",
spec.name,
expected_open_world
);
// Per spec these are meaningful only when readOnlyHint == false.
// Emitting them on a read-only tool would be misleading.
assert!(
annotations.get("destructiveHint").is_none(),
"read-only tool `{}` should not emit destructiveHint",
spec.name
);
assert!(
annotations.get("idempotentHint").is_none(),
"read-only tool `{}` should not emit idempotentHint",
spec.name
);
}
}
#[test]
fn run_subagent_annotations_signal_act_semantics() {
let spec = tool_specs()
.into_iter()
.find(|spec| spec.name == "agent.run_subagent")
.expect("agent.run_subagent must be registered");
assert_eq!(
spec.annotations
.get("readOnlyHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("destructiveHint")
.and_then(Value::as_bool),
Some(true)
);
assert_eq!(
spec.annotations
.get("idempotentHint")
.and_then(Value::as_bool),
Some(false)
);
assert_eq!(
spec.annotations
.get("openWorldHint")
.and_then(Value::as_bool),
Some(true)
);
}
#[test]
fn list_tools_includes_searxng_when_enabled() {
let mut config = crate::openhuman::config::Config::default();
config.searxng.enabled = true;
let result = list_tools_result_for_config(&config);
let names = result["tools"]
.as_array()
.expect("tools array")
.iter()
.map(|tool| tool["name"].as_str().expect("tool name"))
.collect::<Vec<_>>();
assert!(names.contains(&"searxng_search"));
}
#[test]
fn mapped_rpc_methods_are_registered() {
for spec in tool_specs() {
if let Some(rpc_method) = spec.rpc_method {
assert!(
all::schema_for_rpc_method(rpc_method).is_some(),
"missing registered RPC method for {} -> {}",
spec.name,
rpc_method
);
}
}
}
#[test]
fn build_rpc_params_parses_run_subagent_arguments() {
let params = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause."
}),
)
.expect("params should parse");
assert_eq!(
params.get("agent_id").and_then(Value::as_str),
Some("researcher")
);
assert_eq!(
params.get("prompt").and_then(Value::as_str),
Some("Find the root cause.")
);
}
#[test]
fn build_rpc_params_rejects_extra_run_subagent_fields() {
let err = build_rpc_params(
"agent.run_subagent",
json!({
"agent_id": "researcher",
"prompt": "Find the root cause.",
"toolkit": "gmail"
}),
)
.expect_err("unexpected field should be rejected");
assert!(
matches!(err, ToolCallError::InvalidParams(message) if message.contains("unexpected argument"))
);
}
#[test]
fn memory_search_params_trim_query_and_use_default_k() {
let params = build_rpc_params(
"memory.search",
json!({
"query": " phoenix migration ",
}),
)
.expect("params");
assert_eq!(params["query"], "phoenix migration");
assert_eq!(params["k"], DEFAULT_LIMIT);
}
#[test]
fn searxng_search_params_accept_optional_fields() {
let params = build_rpc_params(
"searxng_search",
json!({
"query": " rust async ",
"categories": ["web", "news"],
"language": " en ",
"max_results": 12
}),
)
.expect("params");
assert_eq!(params["query"], "rust async");
assert_eq!(params["categories"], json!(["web", "news"]));
assert_eq!(params["language"], "en");
assert_eq!(params["max_results"], 12);
}
#[test]
fn searxng_search_rejects_unknown_category() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"categories": ["videos"]
}),
)
.expect_err("must reject");
assert!(err.message().contains("unsupported SearXNG category"));
}
#[test]
fn searxng_search_rejects_max_results_above_max() {
let err = build_rpc_params(
"searxng_search",
json!({
"query": "rust",
"max_results": SEARXNG_MAX_RESULTS + 1
}),
)
.expect_err("must reject");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn memory_search_rejects_k_above_max() {
// Reject (don't silent-clamp) so the LLM can self-correct on the next
// call. Silent clamping makes the model believe it got the page size
// it asked for and prevents the corrective feedback loop.
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"k": MAX_LIMIT + 1
}),
)
.expect_err("must reject k > MAX_LIMIT");
let message = err.message();
assert!(
message.contains("must not exceed"),
"error should mention the cap, got: {message}"
);
assert!(
message.contains(&MAX_LIMIT.to_string()),
"error should mention the limit value, got: {message}"
);
}
#[test]
fn memory_search_accepts_k_at_max() {
let params = build_rpc_params(
"memory.search",
json!({ "query": "phoenix", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT must be accepted (boundary inclusive)");
assert_eq!(params["k"], MAX_LIMIT);
}
#[test]
fn tool_call_error_invalid_params_maps_to_jsonrpc_invalid_params() {
let err = ToolCallError::InvalidParams("missing query".to_string());
assert_eq!(err.code(), -32602);
assert_eq!(err.jsonrpc_message(), "Invalid params");
assert_eq!(err.message(), "missing query");
}
#[test]
fn tool_call_error_internal_maps_to_jsonrpc_internal_error() {
// Server-side failures (config load, missing resources) must surface
// as `-32603 Internal error`, not `-32602 Invalid params`, so the MCP
// client doesn't mislead the user / LLM into retrying with different
// arguments.
let err = ToolCallError::Internal("disk read failed".to_string());
assert_eq!(err.code(), -32603);
assert_eq!(err.jsonrpc_message(), "Internal error");
assert_eq!(err.message(), "disk read failed");
}
#[test]
fn memory_recall_requires_query() {
let err = build_rpc_params("memory.recall", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `query`"));
}
#[test]
fn memory_search_rejects_undocumented_limit_alias() {
let err = build_rpc_params(
"memory.search",
json!({
"query": "phoenix",
"limit": 5
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_read_chunk_maps_chunk_id_to_controller_id() {
let params = build_rpc_params("tree.read_chunk", json!({"chunk_id": "abc"})).expect("params");
assert_eq!(params["id"], "abc");
assert!(!params.contains_key("chunk_id"));
}
#[test]
fn tree_read_chunk_rejects_unknown_arguments() {
let err = build_rpc_params(
"tree.read_chunk",
json!({
"chunk_id": "abc",
"unused": true
}),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `unused`"));
}
#[test]
fn non_object_arguments_are_invalid() {
let err = build_rpc_params("memory.search", json!("query")).expect_err("must reject");
assert!(err.message().contains("arguments must be an object"));
}
// ── tree.browse ────────────────────────────────────────────────────
#[test]
fn tree_browse_no_args_sends_default_limit_only() {
// Empty filter is a valid request — the controller treats unset filters
// as "no constraint" — and the MCP layer still applies its own DEFAULT_LIMIT
// so the LLM doesn't accidentally pull the controller's 50-row default
// when it asked for nothing.
let params = build_rpc_params("tree.browse", json!({})).expect("empty args are valid");
assert_eq!(params.len(), 1);
assert_eq!(params["limit"], DEFAULT_LIMIT);
}
#[test]
fn tree_browse_passes_through_filters_and_renames_k_to_limit() {
let params = build_rpc_params(
"tree.browse",
json!({
"source_kinds": ["email", "chat"],
"source_ids": ["acme-thread-1"],
"entity_ids": ["person:Alice"],
"since_ms": 1_700_000_000_000_i64,
"until_ms": 1_710_000_000_000_i64,
"query": "Q3 plan",
"k": 20,
"offset": 10
}),
)
.expect("params");
assert_eq!(params["limit"], 20);
assert!(!params.contains_key("k"));
assert_eq!(params["source_kinds"], json!(["email", "chat"]));
assert_eq!(params["source_ids"], json!(["acme-thread-1"]));
assert_eq!(params["entity_ids"], json!(["person:Alice"]));
assert_eq!(params["since_ms"], 1_700_000_000_000_i64);
assert_eq!(params["until_ms"], 1_710_000_000_000_i64);
assert_eq!(params["query"], "Q3 plan");
assert_eq!(params["offset"], 10);
}
#[test]
fn tree_browse_rejects_k_above_max() {
// Same reject-don't-clamp policy as memory.search / memory.recall so the
// LLM gets corrective feedback instead of silently receiving fewer rows
// than it asked for.
let err = build_rpc_params("tree.browse", json!({ "k": MAX_LIMIT + 1 }))
.expect_err("must reject k > MAX_LIMIT");
assert!(err.message().contains("must not exceed"));
}
#[test]
fn tree_browse_rejects_unknown_argument() {
let err = build_rpc_params("tree.browse", json!({ "limit": 10 }))
.expect_err("must reject the controller's `limit` alias");
assert!(err.message().contains("unexpected argument `limit`"));
}
#[test]
fn tree_browse_rejects_non_array_source_kinds() {
let err = build_rpc_params("tree.browse", json!({ "source_kinds": "email" }))
.expect_err("must reject scalar where array is required");
assert!(err.message().contains("must be an array of strings"));
}
#[test]
fn tree_browse_rejects_non_integer_since_ms() {
let err = build_rpc_params("tree.browse", json!({ "since_ms": "yesterday" }))
.expect_err("must reject ISO-style date for ms field");
assert!(err.message().contains("must be an integer"));
}
#[test]
fn tree_browse_drops_blank_array_entries_silently() {
// Empty / whitespace strings inside an array are tolerated — clients
// sometimes send `["", "email"]` after a partial UI selection and the
// intent ("filter to email") is unambiguous. A fully-blank array is OK
// too and produces an empty filter (same as omitting the field).
let params = build_rpc_params(
"tree.browse",
json!({ "source_kinds": ["", "email", " "] }),
)
.expect("blank entries don't fail the whole call");
assert_eq!(params["source_kinds"], json!(["email"]));
}
// ── tree.top_entities ──────────────────────────────────────────────
#[test]
fn tree_top_entities_defaults_limit_and_omits_kind() {
let params = build_rpc_params("tree.top_entities", json!({})).expect("empty args are valid");
assert_eq!(params["limit"], DEFAULT_LIMIT);
assert!(!params.contains_key("kind"));
}
#[test]
fn tree_top_entities_passes_kind_through_and_caps_limit_at_max() {
let params = build_rpc_params(
"tree.top_entities",
json!({ "kind": "person", "k": MAX_LIMIT }),
)
.expect("k = MAX_LIMIT is the boundary, inclusive");
assert_eq!(params["kind"], "person");
assert_eq!(params["limit"], MAX_LIMIT);
}
#[test]
fn tree_top_entities_rejects_empty_kind() {
// Blank kind is a client bug — the controller would happily run it as
// "no filter" but that's exactly what *omitting* the field already
// means. Rejecting nudges the LLM to drop the field instead.
let err = build_rpc_params("tree.top_entities", json!({ "kind": " " }))
.expect_err("must reject blank-only kind");
assert!(err.message().contains("must not be empty"));
}
// ── tree.list_sources ──────────────────────────────────────────────
#[test]
fn tree_list_sources_accepts_empty_args() {
let params =
build_rpc_params("tree.list_sources", json!({})).expect("no args is the common case");
assert!(params.is_empty());
}
#[test]
fn tree_list_sources_passes_user_email_hint() {
let params = build_rpc_params(
"tree.list_sources",
json!({ "user_email_hint": "me@example.com" }),
)
.expect("params");
assert_eq!(params["user_email_hint"], "me@example.com");
}
#[test]
fn tree_list_sources_rejects_unknown_argument() {
let err = build_rpc_params("tree.list_sources", json!({ "limit": 5 }))
.expect_err("list_sources takes no pagination");
assert!(err.message().contains("unexpected argument `limit`"));
}
// ── memory.store ──────────────────────────────────────────────────
#[test]
fn memory_store_requires_title_and_content() {
let err = build_rpc_params("memory.store", json!({})).expect_err("must reject");
assert!(err.message().contains("missing required argument `title`"));
let err = build_rpc_params("memory.store", json!({ "title": "T" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `content`"));
}
#[test]
fn memory_store_defaults_namespace_to_mcp() {
let params = build_rpc_params(
"memory.store",
json!({ "title": "My note", "content": "Hello world" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["title"], "My note");
assert_eq!(params["content"], "Hello world");
assert_eq!(params["source_type"], "mcp");
assert!(params["key"].as_str().unwrap().starts_with("mcp-store-"));
}
#[test]
fn memory_store_accepts_custom_namespace_and_tags() {
let params = build_rpc_params(
"memory.store",
json!({
"title": "Project Plan",
"content": "Q3 milestones",
"namespace": "work",
"tags": ["project", "planning"]
}),
)
.expect("params");
assert_eq!(params["namespace"], "work");
assert_eq!(params["tags"], json!(["project", "planning"]));
}
#[test]
fn memory_store_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.store",
json!({ "title": "T", "content": "C", "priority": "high" }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `priority`"));
}
// ── memory.note ───────────────────────────────────────────────────
#[test]
fn memory_note_requires_chunk_id_and_note_text() {
let err = build_rpc_params("memory.note", json!({})).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `chunk_id`"));
let err =
build_rpc_params("memory.note", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(err
.message()
.contains("missing required argument `note_text`"));
}
#[test]
fn memory_note_builds_annotation_document() {
let params = build_rpc_params(
"memory.note",
json!({ "chunk_id": "chunk-42", "note_text": "Important context" }),
)
.expect("params");
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-note-chunk-42");
assert!(params["title"].as_str().unwrap().contains("chunk-42"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("Important context"));
assert!(params["content"]
.as_str()
.unwrap()
.contains("chunk_id=chunk-42"));
assert_eq!(params["metadata"]["annotates_chunk_id"], "chunk-42");
assert_eq!(params["source_type"], "mcp");
}
#[test]
fn memory_note_rejects_unknown_argument() {
let err = build_rpc_params(
"memory.note",
json!({ "chunk_id": "abc", "note_text": "N", "extra": true }),
)
.expect_err("must reject");
assert!(err.message().contains("unexpected argument `extra`"));
}
// ── tree.tag ──────────────────────────────────────────────────────
#[test]
fn tree_tag_requires_chunk_id_and_tags() {
let err = build_rpc_params("tree.tag", json!({})).expect_err("must reject");
assert!(
err.message()
.contains("missing required argument `chunk_id`"),
"got: {}",
err.message()
);
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc" })).expect_err("must reject");
assert!(
err.message().contains("missing required argument `tags`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_empty_tags_array() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": [] }))
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_all_blank_tags() {
// After blank-trim the list is empty — same failure mode as `[]`.
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [" ", ""] }),
)
.expect_err("must reject");
assert!(
err.message().contains("at least one non-empty string"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_non_string_tags() {
// Numeric entries inside `tags` get caught by the string-array helper.
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": ["ok", 42] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `tags` must contain only strings"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_builds_tag_record_document() {
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-42", "tags": ["todo", "q3-planning"] }),
)
.expect("params");
// Document key is deterministic on chunk_id only → re-tagging
// the same chunk upserts.
assert_eq!(params["namespace"], "mcp");
assert_eq!(params["key"], "mcp-tag-chunk-42");
assert_eq!(params["source_type"], "mcp");
// Title surfaces the target chunk for human-readable recall.
assert!(
params["title"]
.as_str()
.expect("title is a string")
.contains("chunk-42"),
"title was: {}",
params["title"]
);
// Top-level `tags` flows to the document tag index (queryable
// via `doc_list` / search filters) — this is the key differentiator
// from `memory.note` whose payload is opaque free-form text.
assert_eq!(params["tags"], json!(["todo", "q3-planning"]));
// Metadata carries the back-reference plus a mirrored tag list,
// so consumers reading the metadata view don't need to also
// join against the top-level `tags` field.
let metadata = params["metadata"]
.as_object()
.expect("metadata is an object");
assert_eq!(metadata["tags_for_chunk_id"], "chunk-42");
assert_eq!(metadata["applied_tags"], json!(["todo", "q3-planning"]));
}
#[test]
fn tree_tag_trims_blanks_but_keeps_real_tags() {
// Mixed list — blanks are silently dropped (matches existing
// `optional_string_array` behaviour) but the resulting set is
// still non-empty so the call succeeds.
let params = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "chunk-7", "tags": [" important ", "", " ", "todo"] }),
)
.expect("params");
assert_eq!(params["tags"], json!(["important", "todo"]));
}
#[test]
fn tree_tag_rejects_empty_chunk_id() {
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "", "tags": ["todo"] }))
.expect_err("must reject");
assert!(
err.message()
.contains("argument `chunk_id` must not be empty"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_unknown_argument() {
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": ["t"], "priority": "high" }),
)
.expect_err("must reject");
assert!(
err.message().contains("unexpected argument `priority`"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_tag_array() {
// Per-graycyrus #2316 review: cap the tag-array length so a
// misbehaving client can't flood a chunk's tag-record document
// with hundreds of categorical labels. Builds an over-cap
// array and asserts the dedicated rejection message.
let oversize: Vec<String> = (0..(TREE_TAG_MAX_TAGS + 1))
.map(|i| format!("tag-{i}"))
.collect();
let err = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": oversize }))
.expect_err("must reject");
assert!(
err.message().contains("accepts at most"),
"got: {}",
err.message()
);
}
#[test]
fn tree_tag_rejects_oversize_individual_tag() {
// Per-graycyrus #2316 review: a single oversize tag is almost
// certainly free-form text that should be `memory.note` instead
// of going through the categorical tag surface — reject up-front
// so the misuse is visible rather than silently writing a giant
// token into the queryable `tags` index.
let oversize_tag = "a".repeat(TREE_TAG_MAX_TAG_LENGTH + 1);
let err = build_rpc_params(
"tree.tag",
json!({ "chunk_id": "abc", "tags": [oversize_tag] }),
)
.expect_err("must reject");
assert!(err.message().contains("exceeds"), "got: {}", err.message());
}
#[test]
fn tree_tag_accepts_max_size_tags() {
// Boundary: exactly TREE_TAG_MAX_TAGS entries (the cap is
// "at most N", not "fewer than N") with each entry at exactly
// TREE_TAG_MAX_TAG_LENGTH chars must succeed. Locks the
// inclusive-vs-exclusive bound so a future off-by-one
// refactor breaks the test, not user calls.
let max_tags: Vec<String> = (0..TREE_TAG_MAX_TAGS)
.map(|i| format!("tag-{i:0width$}", width = TREE_TAG_MAX_TAG_LENGTH - 4))
.collect();
// Sanity: each entry is == TREE_TAG_MAX_TAG_LENGTH chars.
assert!(max_tags.iter().all(|t| t.len() == TREE_TAG_MAX_TAG_LENGTH));
let params = build_rpc_params("tree.tag", json!({ "chunk_id": "abc", "tags": max_tags }))
.expect("at the cap must succeed");
// The built params should preserve all TREE_TAG_MAX_TAGS entries.
assert_eq!(
params["tags"].as_array().expect("tags is array").len(),
TREE_TAG_MAX_TAGS
);
}
#[tokio::test]
async fn call_tool_records_write_argument_rejection() {
let _env_lock = crate::openhuman::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|err| err.into_inner());
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("OPENHUMAN_WORKSPACE", tmp.path());
}
let config = config_rpc::load_config_with_timeout()
.await
.expect("config");
let err = call_tool("memory.store", json!({ "title": "T" }), "mcp:test")
.await
.expect_err("missing content should reject");
assert!(
err.message()
.contains("missing required argument `content`"),
"got: {}",
err.message()
);
let mut rows = Vec::new();
for _ in 0..50 {
rows = crate::openhuman::mcp_audit::list_writes(
&config,
&crate::openhuman::mcp_audit::McpWriteListQuery::default(),
)
.expect("list writes");
if rows.len() == 1 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(rows.len(), 1);
assert!(!rows[0].success);
assert_eq!(rows[0].tool_name, "memory.store");
assert_eq!(rows[0].client_info, "mcp:test");
assert!(rows[0]
.error_message
.as_deref()
.unwrap_or_default()
.contains("missing required argument `content`"));
assert!(rows[0].args_summary.get("content").is_none());
unsafe {
std::env::remove_var("OPENHUMAN_WORKSPACE");
}
}
// ── slug_from ─────────────────────────────────────────────────────
#[test]
fn slug_from_produces_clean_slug() {
assert_eq!(slug_from("Hello World!"), "hello-world");
assert_eq!(slug_from(" spaces "), "spaces");
assert_eq!(slug_from("CamelCase123"), "camelcase123");
assert_eq!(slug_from("a--b"), "a-b");
}
#[test]
fn slug_from_truncates_long_titles() {
let long = "a".repeat(100);
let slug = slug_from(&long);
assert!(slug.len() <= 64);
}
#[test]
fn slug_from_returns_hash_fallback_for_non_alphanumeric_titles() {
// Non-alphanumeric titles should produce "untitled-<hash>" with a
// stable, deterministic hash suffix.
let slug_bang = slug_from("!!!");
let slug_at = slug_from("@@@");
assert!(slug_bang.starts_with("untitled-"), "got: {slug_bang}");
assert!(slug_at.starts_with("untitled-"), "got: {slug_at}");
// Different inputs → different slugs
assert_ne!(slug_bang, slug_at);
// Empty title also gets a fallback
assert!(slug_from("").starts_with("untitled-"));
// Stable across calls
assert_eq!(slug_from("!!!"), slug_bang);
}
#[test]
fn slug_from_unicode_only_titles_are_unique_and_stable() {
let chinese = slug_from("会议记录");
let russian = slug_from("Протокол");
let emoji = slug_from("🦀🚀");
// All produce hash-based fallbacks
assert!(chinese.starts_with("untitled-"), "got: {chinese}");
assert!(russian.starts_with("untitled-"), "got: {russian}");
assert!(emoji.starts_with("untitled-"), "got: {emoji}");
// All distinct
assert_ne!(chinese, russian);
assert_ne!(chinese, emoji);
assert_ne!(russian, emoji);
// Stable
assert_eq!(slug_from("会议记录"), chinese);
assert_eq!(slug_from("Протокол"), russian);
}
+2 -2
View File
@@ -36,9 +36,9 @@ pub mod tree_source;
pub mod tree_topic;
#[cfg(test)]
mod sync_pipeline_e2e_test;
mod sync_pipeline_e2e_tests;
#[cfg(test)]
mod tree_e2e_test;
mod tree_e2e_tests;
pub use ingestion::{
ExtractedEntity, ExtractedRelation, ExtractionMode, IngestionJob, IngestionQueue,
IngestionState, IngestionStatusSnapshot, MemoryIngestionConfig, MemoryIngestionRequest,
+2 -781
View File
@@ -1730,784 +1730,5 @@ fn parse_source_kind_str(s: &str) -> Option<SourceKind> {
// ── Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE;
use crate::openhuman::embeddings::NoopEmbedding;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_store::unified::UnifiedMemory;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page;
use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage;
use chrono::{TimeZone, Utc};
use rusqlite::params;
use std::sync::Arc;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
// Point config_path inside the tempdir so any persistence during
// tests stays inside disposable workspace state.
cfg.config_path = tmp.path().join("config.toml");
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
// Default llm is Cloud — but the cloud provider needs a bearer
// token to actually fire. Tests that exercise the LLM path
// override either the backend or the extractor. The read RPCs
// below don't touch the LLM, so this default is fine.
(tmp, cfg)
}
async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) {
let batch = ChatBatch {
platform: "slack".into(),
channel_label: source.into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: body.into(),
source_ref: Some("slack://x".into()),
}],
};
ingest_chat(cfg, source, "alice", vec![], batch)
.await
.unwrap();
}
async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String {
let msg = SlackMessage {
channel_id: "C123".into(),
channel_name: "engineering".into(),
is_private: false,
author: "alice".into(),
author_id: "U123".into(),
text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(),
timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
ts_raw: "1700000000.000100".into(),
thread_ts: None,
permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()),
};
ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg])
.await
.expect("seed slack ingest");
drain_until_idle(cfg).await.expect("drain slack ingest");
list_chunks_rpc(cfg, ChunkFilter::default())
.await
.expect("list chunks")
.value
.chunks
.into_iter()
.find(|chunk| chunk.source_id == "slack:conn-slack-1")
.expect("seeded slack chunk")
.id
}
fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) {
with_connection(cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks
SET timestamp_ms = ?1,
time_range_start_ms = ?1,
time_range_end_ms = ?1
WHERE id = ?2",
params![timestamp_ms, chunk_id],
)?;
Ok(())
})
.unwrap();
}
fn insert_raw_chunk(
cfg: &Config,
id: &str,
source_kind: &str,
source_id: &str,
timestamp_ms: i64,
tags_json: &str,
content: &str,
token_count: i64,
) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner, timestamp_ms,
time_range_start_ms, time_range_end_ms, tags_json, content,
token_count, seq_in_source, created_at_ms, lifecycle_status, content_path
) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)",
params![id, source_kind, source_id, timestamp_ms, tags_json, content, token_count],
)?;
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn list_chunks_returns_seeded_chunk() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await;
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(!resp.chunks.is_empty());
assert_eq!(resp.total, resp.chunks.len() as u64);
}
#[tokio::test]
async fn list_chunks_filters_by_source_id() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let only_a = list_chunks_rpc(
&cfg,
ChunkFilter {
source_ids: Some(vec!["slack:#a".into()]),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a"));
assert!(only_a.total >= 1);
}
#[tokio::test]
async fn list_chunks_query_substring_works() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
query: Some("phoenix".into()),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(resp.chunks.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "first chat").await;
seed_chat_chunk(&cfg, "slack:#b", "second chat").await;
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec!["chat".into()]),
limit: Some(1),
offset: Some(1),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.total, 2);
assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat"));
}
#[tokio::test]
async fn list_chunks_filters_by_entity_id_and_time_window() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await;
seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await;
let seeded = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let alice = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("alice@example.com")
})
.expect("alice chunk present");
let bob = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("bob@example.com")
})
.expect("bob chunk present");
update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100);
update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900);
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
entity_ids: Some(vec!["email:alice@example.com".into()]),
since_ms: Some(1_700_000_000_000),
until_ms: Some(1_700_000_000_500),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.total, 1);
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.chunks[0].id, alice.id);
}
#[tokio::test]
async fn list_chunks_ignores_empty_filter_lists_and_blank_query() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec![]),
source_ids: Some(vec![]),
entity_ids: Some(vec![]),
query: Some(" ".into()),
limit: Some(10),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(resp.total, 2);
assert_eq!(resp.chunks.len(), 2);
}
#[tokio::test]
async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"raw-empty",
"document",
"notion:page-1",
1_700_000_000_123,
"not-json",
"",
-7,
);
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
let row = resp
.chunks
.into_iter()
.find(|chunk| chunk.id == "raw-empty")
.expect("raw chunk listed");
assert_eq!(row.token_count, 0);
assert_eq!(row.tags, Vec::<String>::new());
assert_eq!(row.content_preview, None);
assert!(!row.has_embedding);
}
#[tokio::test]
async fn list_sources_aggregates() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "x").await;
seed_chat_chunk(&cfg, "slack:#a", "y").await;
seed_chat_chunk(&cfg, "slack:#b", "z").await;
let sources = list_sources_rpc(&cfg, None).await.unwrap().value;
let a = sources
.iter()
.find(|s| s.source_id == "slack:#a")
.expect("expected slack:#a");
let b = sources
.iter()
.find(|s| s.source_id == "slack:#b")
.expect("expected slack:#b");
assert_eq!(a.chunk_count, 2);
assert_eq!(b.chunk_count, 1);
}
#[tokio::test]
async fn list_sources_formats_email_threads_with_trimmed_user_hint() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"email-thread",
"email",
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
1_700_000_000_123,
"[]",
"thread body",
12,
);
let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into()))
.await
.unwrap()
.value;
let source = sources
.iter()
.find(|row| {
row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com"
})
.expect("email thread source present");
assert_eq!(source.display_name, "bob@example.com, carol@example.com");
}
#[tokio::test]
async fn entity_index_for_returns_extracted_entities() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
// Find the chunk we just seeded.
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(
refs.iter().any(|r| r.entity_id.contains("alice")),
"expected alice entity in index, got: {refs:?}"
);
}
#[tokio::test]
async fn chunks_for_entity_returns_leaf_chunk_ids_only() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks[0]
.id
.clone();
let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into())
.await
.unwrap()
.value;
assert_eq!(rows, vec![chunk_id]);
}
#[tokio::test]
async fn top_entities_returns_most_frequent() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await;
seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await;
seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await;
let top = top_entities_rpc(&cfg, Some("email".into()), 10)
.await
.unwrap()
.value;
assert!(top
.iter()
.any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2));
}
#[tokio::test]
async fn delete_chunk_removes_chunk_and_dependent_rows() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = chunks[0].id.clone();
let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(resp.deleted);
// Re-list — the chunk should be gone.
let after = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(after.chunks.iter().all(|c| c.id != id));
}
#[tokio::test]
async fn delete_missing_chunk_is_idempotent() {
let (_tmp, cfg) = test_config();
let resp = delete_chunk_rpc(&cfg, "does-not-exist".into())
.await
.unwrap()
.value;
assert!(!resp.deleted);
assert_eq!(resp.score_rows_removed, 0);
}
#[tokio::test]
async fn chunk_score_returns_breakdown_after_ingest() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"alice@example.com owns the phoenix migration",
)
.await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(breakdown.is_some(), "expected score row after ingest");
let b = breakdown.unwrap();
assert!(b.signals.iter().any(|s| s.name == "metadata_weight"));
assert!(b.threshold > 0.0);
}
#[tokio::test]
async fn search_returns_matching_chunks() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value;
assert!(hits.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn read_chunk_row_returns_preview_and_metadata() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"phoenix migration scheduled friday with context and source refs",
)
.await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.id, chunk.id);
assert_eq!(row.source_kind, "chat");
assert_eq!(row.source_id, "slack:#eng");
assert_eq!(row.source_ref.as_deref(), Some("slack://x"));
assert_eq!(row.owner, "alice");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(row.content_path.is_some());
assert!(row
.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix migration scheduled friday"));
}
#[tokio::test]
async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() {
let (_tmp, cfg) = test_config();
let body = "sqlite preview survives missing file";
seed_chat_chunk(&cfg, "slack:#eng", body).await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let rel_path = chunk.content_path.clone().expect("content path present");
let abs_path = cfg.memory_tree_content_root().join(rel_path);
std::fs::remove_file(&abs_path).expect("remove chunk file");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.content_path, chunk.content_path);
assert!(row.content_preview.as_deref().unwrap_or("").contains(body));
}
#[tokio::test]
async fn flush_now_enqueues_once_and_reports_stale_buffers() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"Phoenix migration ships Friday after the release checklist closes.",
)
.await;
drain_until_idle(&cfg).await.expect("drain jobs");
let first = flush_now_rpc(&cfg).await.expect("flush_now first");
assert!(first.value.enqueued, "first flush should enqueue work");
assert!(
first.value.stale_buffers >= 1,
"expected at least one stale buffer after ingest"
);
let second = flush_now_rpc(&cfg).await.expect("flush_now second");
assert!(
!second.value.enqueued,
"same 3-hour window should dedupe duplicate flush triggers"
);
assert!(
second.value.stale_buffers >= 1,
"deduped flush should still report current stale buffer count"
);
}
#[tokio::test]
async fn reset_tree_preserves_raw_archive_and_source_registry() {
let (_tmp, cfg) = test_config();
let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await;
let content_root = cfg.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(), "raw archive should exist before reset");
assert!(
source_file.exists(),
"source registry should exist before reset"
);
let stale_summary = content_root
.join("wiki")
.join("summaries")
.join("source-slack-conn-slack-1")
.join("L1")
.join("summary-stale.md");
std::fs::create_dir_all(
stale_summary
.parent()
.expect("stale summary parent should exist"),
)
.expect("create stale summary dir");
std::fs::write(&stale_summary, "stale summary body").expect("write stale summary");
assert!(stale_summary.exists(), "stale summary fixture should exist");
let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree");
assert_eq!(outcome.value.chunks_requeued, 1);
assert_eq!(outcome.value.jobs_enqueued, 1);
assert!(
outcome.value.tree_rows_deleted >= 1,
"buffer/tree rows should be removed during reset"
);
let row = read_chunk_row(&cfg, &chunk_id)
.expect("read chunk row")
.expect("chunk row present after reset");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(raw_file.exists(), "raw archive must survive reset_tree");
assert!(
source_file.exists(),
"source registry must survive reset_tree"
);
assert!(
!content_root.join("wiki").join("summaries").exists(),
"derived wiki summaries should be removed"
);
}
#[test]
fn read_chunk_row_returns_none_for_missing_chunk() {
let (_tmp, cfg) = test_config();
assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none());
}
#[test]
fn display_name_unslugs_email_thread_with_user_hint() {
let name = display_name_for_source(
"gmail:alice@example.com|bob@example.com",
Some("alice@example.com"),
);
assert_eq!(name, "bob@example.com");
}
#[test]
fn display_name_falls_back_to_arrow_when_user_unknown() {
let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None);
assert!(name.contains("alice@example.com"));
assert!(name.contains("bob@example.com"));
assert!(name.contains(""));
}
#[test]
fn display_name_strips_platform_prefix() {
assert_eq!(
display_name_for_source("slack:#engineering", None),
"#engineering"
);
}
#[test]
fn display_name_handles_multiple_participants_and_trimmed_hint() {
let name = display_name_for_source(
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
Some(" alice@example.com "),
);
assert_eq!(name, "bob@example.com, carol@example.com");
}
#[test]
fn display_name_handles_no_prefix() {
assert_eq!(display_name_for_source("loose-id", None), "loose-id");
}
#[test]
fn sanitize_basename_replaces_windows_illegal_characters() {
assert_eq!(
sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#),
"chat-slack-#eng-name------"
);
assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md");
}
#[test]
fn parse_source_kind_str_accepts_known_values_only() {
assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat));
assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email));
assert_eq!(
parse_source_kind_str("document"),
Some(SourceKind::Document)
);
assert_eq!(parse_source_kind_str("unknown"), None);
}
#[test]
fn clear_composio_sync_state_removes_only_target_namespace() {
let tmp = TempDir::new().unwrap();
let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
let db_path = tmp.path().join("memory").join("memory.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES (?1, 'cursor', '{}', 1.0)",
params![KV_NAMESPACE],
)
.unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES ('other-namespace', 'cursor', '{}', 2.0)",
[],
)
.unwrap();
drop(conn);
let removed = clear_composio_sync_state(&db_path).unwrap();
assert_eq!(removed, 1);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let composio_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1",
params![KV_NAMESPACE],
|row| row.get(0),
)
.unwrap();
let other_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(composio_count, 0);
assert_eq!(other_count, 1);
}
#[tokio::test]
async fn obsidian_status_registered_when_override_config_lists_content_root() {
let (_tmp, cfg) = test_config();
let content_root = cfg.memory_tree_content_root();
// A separate dir standing in for a non-standard Obsidian config
// location, with an obsidian.json that registers the content root.
let cfg_dir = TempDir::new().unwrap();
let body = format!(
"{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}",
serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap()
);
std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(outcome.value.registered);
assert!(outcome.value.config_found);
assert_eq!(
outcome.value.content_root_abs,
content_root.to_string_lossy().to_string()
);
// The log reports the booleans but redacts the absolute path (it
// embeds the user's home / username).
assert!(
outcome.logs[0].contains("registered=true"),
"log: {}",
outcome.logs[0]
);
assert!(
!outcome.logs[0].contains(content_root.to_str().unwrap()),
"log leaked content root: {}",
outcome.logs[0]
);
}
#[tokio::test]
async fn obsidian_status_not_registered_for_empty_override_dir() {
let (_tmp, cfg) = test_config();
// Empty override dir → no obsidian.json there → content root is not a
// registered vault. (A temp content root can't be under any real host
// vault either, so this stays false regardless of the dev machine.)
let cfg_dir = TempDir::new().unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
#[tokio::test]
async fn obsidian_status_blank_override_is_treated_as_none() {
// A whitespace-only override must be normalized to None rather than
// resolving to "." and probing a stray local ./obsidian.json. The temp
// content root isn't under any real host vault, so this stays false.
let (_tmp, cfg) = test_config();
let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
}
#[path = "read_rpc_tests.rs"]
mod tests;
+785
View File
@@ -0,0 +1,785 @@
use super::*;
use crate::openhuman::composio::providers::sync_state::KV_NAMESPACE;
use crate::openhuman::embeddings::NoopEmbedding;
use crate::openhuman::memory::ingest_pipeline::ingest_chat;
use crate::openhuman::memory_queue::drain_until_idle;
use crate::openhuman::memory_store::unified::UnifiedMemory;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::memory_sync::composio::providers::slack::ingest::ingest_page_into_memory_tree as ingest_slack_page;
use crate::openhuman::memory_sync::composio::providers::slack::SlackMessage;
use chrono::{TimeZone, Utc};
use rusqlite::params;
use std::sync::Arc;
use tempfile::TempDir;
fn test_config() -> (TempDir, Config) {
let tmp = TempDir::new().unwrap();
let mut cfg = Config::default();
cfg.workspace_dir = tmp.path().to_path_buf();
// Point config_path inside the tempdir so any persistence during
// tests stays inside disposable workspace state.
cfg.config_path = tmp.path().join("config.toml");
cfg.memory_tree.embedding_endpoint = None;
cfg.memory_tree.embedding_model = None;
cfg.memory_tree.embedding_strict = false;
// Default llm is Cloud — but the cloud provider needs a bearer
// token to actually fire. Tests that exercise the LLM path
// override either the backend or the extractor. The read RPCs
// below don't touch the LLM, so this default is fine.
(tmp, cfg)
}
async fn seed_chat_chunk(cfg: &Config, source: &str, body: &str) {
let batch = ChatBatch {
platform: "slack".into(),
channel_label: source.into(),
messages: vec![ChatMessage {
author: "alice".into(),
timestamp: Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
text: body.into(),
source_ref: Some("slack://x".into()),
}],
};
ingest_chat(cfg, source, "alice", vec![], batch)
.await
.unwrap();
}
async fn seed_slack_chunk_with_raw_archive(cfg: &Config) -> String {
let msg = SlackMessage {
channel_id: "C123".into(),
channel_name: "engineering".into(),
is_private: false,
author: "alice".into(),
author_id: "U123".into(),
text: "Phoenix migration launch window is Friday at 22:00 UTC.".into(),
timestamp: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
ts_raw: "1700000000.000100".into(),
thread_ts: None,
permalink: Some("https://slack.example.test/archives/C123/p1700000000000100".into()),
};
ingest_slack_page(cfg, "alice", "conn-slack-1", &[msg])
.await
.expect("seed slack ingest");
drain_until_idle(cfg).await.expect("drain slack ingest");
list_chunks_rpc(cfg, ChunkFilter::default())
.await
.expect("list chunks")
.value
.chunks
.into_iter()
.find(|chunk| chunk.source_id == "slack:conn-slack-1")
.expect("seeded slack chunk")
.id
}
fn update_chunk_timestamp(cfg: &Config, chunk_id: &str, timestamp_ms: i64) {
with_connection(cfg, |conn| {
conn.execute(
"UPDATE mem_tree_chunks
SET timestamp_ms = ?1,
time_range_start_ms = ?1,
time_range_end_ms = ?1
WHERE id = ?2",
params![timestamp_ms, chunk_id],
)?;
Ok(())
})
.unwrap();
}
fn insert_raw_chunk(
cfg: &Config,
id: &str,
source_kind: &str,
source_id: &str,
timestamp_ms: i64,
tags_json: &str,
content: &str,
token_count: i64,
) {
with_connection(cfg, |conn| {
conn.execute(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner, timestamp_ms,
time_range_start_ms, time_range_end_ms, tags_json, content,
token_count, seq_in_source, created_at_ms, lifecycle_status, content_path
) VALUES (?1, ?2, ?3, NULL, 'tester', ?4, ?4, ?4, ?5, ?6, ?7, 0, ?4, 'seeded', NULL)",
params![
id,
source_kind,
source_id,
timestamp_ms,
tags_json,
content,
token_count
],
)?;
Ok(())
})
.unwrap();
}
#[tokio::test]
async fn list_chunks_returns_seeded_chunk() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "hello @alice phoenix migration").await;
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(!resp.chunks.is_empty());
assert_eq!(resp.total, resp.chunks.len() as u64);
}
#[tokio::test]
async fn list_chunks_filters_by_source_id() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let only_a = list_chunks_rpc(
&cfg,
ChunkFilter {
source_ids: Some(vec!["slack:#a".into()]),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(only_a.chunks.iter().all(|c| c.source_id == "slack:#a"));
assert!(only_a.total >= 1);
}
#[tokio::test]
async fn list_chunks_query_substring_works() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration ships friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
query: Some("phoenix".into()),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert!(resp.chunks.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn list_chunks_filters_by_source_kind_and_applies_limit_offset() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "first chat").await;
seed_chat_chunk(&cfg, "slack:#b", "second chat").await;
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec!["chat".into()]),
limit: Some(1),
offset: Some(1),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.total, 2);
assert!(filtered.chunks.iter().all(|c| c.source_kind == "chat"));
}
#[tokio::test]
async fn list_chunks_filters_by_entity_id_and_time_window() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com handles phoenix").await;
seed_chat_chunk(&cfg, "slack:#eng", "bob@example.com handles atlas").await;
let seeded = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let alice = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("alice@example.com")
})
.expect("alice chunk present");
let bob = seeded
.iter()
.find(|chunk| {
chunk
.content_preview
.as_deref()
.unwrap_or("")
.contains("bob@example.com")
})
.expect("bob chunk present");
update_chunk_timestamp(&cfg, &alice.id, 1_700_000_000_100);
update_chunk_timestamp(&cfg, &bob.id, 1_700_000_000_900);
let filtered = list_chunks_rpc(
&cfg,
ChunkFilter {
entity_ids: Some(vec!["email:alice@example.com".into()]),
since_ms: Some(1_700_000_000_000),
until_ms: Some(1_700_000_000_500),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(filtered.total, 1);
assert_eq!(filtered.chunks.len(), 1);
assert_eq!(filtered.chunks[0].id, alice.id);
}
#[tokio::test]
async fn list_chunks_ignores_empty_filter_lists_and_blank_query() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alpha").await;
seed_chat_chunk(&cfg, "slack:#b", "beta").await;
let resp = list_chunks_rpc(
&cfg,
ChunkFilter {
source_kinds: Some(vec![]),
source_ids: Some(vec![]),
entity_ids: Some(vec![]),
query: Some(" ".into()),
limit: Some(10),
..ChunkFilter::default()
},
)
.await
.unwrap()
.value;
assert_eq!(resp.total, 2);
assert_eq!(resp.chunks.len(), 2);
}
#[tokio::test]
async fn list_chunks_normalizes_invalid_tags_negative_tokens_and_empty_content() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"raw-empty",
"document",
"notion:page-1",
1_700_000_000_123,
"not-json",
"",
-7,
);
let resp = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
let row = resp
.chunks
.into_iter()
.find(|chunk| chunk.id == "raw-empty")
.expect("raw chunk listed");
assert_eq!(row.token_count, 0);
assert_eq!(row.tags, Vec::<String>::new());
assert_eq!(row.content_preview, None);
assert!(!row.has_embedding);
}
#[tokio::test]
async fn list_sources_aggregates() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "x").await;
seed_chat_chunk(&cfg, "slack:#a", "y").await;
seed_chat_chunk(&cfg, "slack:#b", "z").await;
let sources = list_sources_rpc(&cfg, None).await.unwrap().value;
let a = sources
.iter()
.find(|s| s.source_id == "slack:#a")
.expect("expected slack:#a");
let b = sources
.iter()
.find(|s| s.source_id == "slack:#b")
.expect("expected slack:#b");
assert_eq!(a.chunk_count, 2);
assert_eq!(b.chunk_count, 1);
}
#[tokio::test]
async fn list_sources_formats_email_threads_with_trimmed_user_hint() {
let (_tmp, cfg) = test_config();
insert_raw_chunk(
&cfg,
"email-thread",
"email",
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
1_700_000_000_123,
"[]",
"thread body",
12,
);
let sources = list_sources_rpc(&cfg, Some(" alice@example.com ".into()))
.await
.unwrap()
.value;
let source = sources
.iter()
.find(|row| row.source_id == "gmail:Alice@Example.com|bob@example.com|carol@example.com")
.expect("email thread source present");
assert_eq!(source.display_name, "bob@example.com, carol@example.com");
}
#[tokio::test]
async fn entity_index_for_returns_extracted_entities() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
// Find the chunk we just seeded.
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let refs = entity_index_for_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(
refs.iter().any(|r| r.entity_id.contains("alice")),
"expected alice entity in index, got: {refs:?}"
);
}
#[tokio::test]
async fn chunks_for_entity_returns_leaf_chunk_ids_only() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunk_id = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks[0]
.id
.clone();
let rows = chunks_for_entity_rpc(&cfg, "email:alice@example.com".into())
.await
.unwrap()
.value;
assert_eq!(rows, vec![chunk_id]);
}
#[tokio::test]
async fn top_entities_returns_most_frequent() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#a", "alice@example.com x").await;
seed_chat_chunk(&cfg, "slack:#b", "alice@example.com y").await;
seed_chat_chunk(&cfg, "slack:#c", "bob@example.com z").await;
let top = top_entities_rpc(&cfg, Some("email".into()), 10)
.await
.unwrap()
.value;
assert!(top
.iter()
.any(|e| e.entity_id == "email:alice@example.com" && e.count >= 2));
}
#[tokio::test]
async fn delete_chunk_removes_chunk_and_dependent_rows() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "alice@example.com owns it").await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = chunks[0].id.clone();
let resp = delete_chunk_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(resp.deleted);
// Re-list — the chunk should be gone.
let after = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value;
assert!(after.chunks.iter().all(|c| c.id != id));
}
#[tokio::test]
async fn delete_missing_chunk_is_idempotent() {
let (_tmp, cfg) = test_config();
let resp = delete_chunk_rpc(&cfg, "does-not-exist".into())
.await
.unwrap()
.value;
assert!(!resp.deleted);
assert_eq!(resp.score_rows_removed, 0);
}
#[tokio::test]
async fn chunk_score_returns_breakdown_after_ingest() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"alice@example.com owns the phoenix migration",
)
.await;
let chunks = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks;
let id = &chunks[0].id;
let breakdown = chunk_score_rpc(&cfg, id.clone()).await.unwrap().value;
assert!(breakdown.is_some(), "expected score row after ingest");
let b = breakdown.unwrap();
assert!(b.signals.iter().any(|s| s.name == "metadata_weight"));
assert!(b.threshold > 0.0);
}
#[tokio::test]
async fn search_returns_matching_chunks() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(&cfg, "slack:#eng", "phoenix migration scheduled friday").await;
seed_chat_chunk(&cfg, "slack:#eng", "different unrelated text").await;
let hits = search_rpc(&cfg, "phoenix".into(), 10).await.unwrap().value;
assert!(hits.iter().any(|c| {
c.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix")
}));
}
#[tokio::test]
async fn read_chunk_row_returns_preview_and_metadata() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"phoenix migration scheduled friday with context and source refs",
)
.await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.id, chunk.id);
assert_eq!(row.source_kind, "chat");
assert_eq!(row.source_id, "slack:#eng");
assert_eq!(row.source_ref.as_deref(), Some("slack://x"));
assert_eq!(row.owner, "alice");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(row.content_path.is_some());
assert!(row
.content_preview
.as_deref()
.unwrap_or("")
.contains("phoenix migration scheduled friday"));
}
#[tokio::test]
async fn read_chunk_row_falls_back_to_sqlite_preview_when_file_missing() {
let (_tmp, cfg) = test_config();
let body = "sqlite preview survives missing file";
seed_chat_chunk(&cfg, "slack:#eng", body).await;
let chunk = list_chunks_rpc(&cfg, ChunkFilter::default())
.await
.unwrap()
.value
.chunks
.into_iter()
.next()
.expect("seeded chunk");
let rel_path = chunk.content_path.clone().expect("content path present");
let abs_path = cfg.memory_tree_content_root().join(rel_path);
std::fs::remove_file(&abs_path).expect("remove chunk file");
let row = read_chunk_row(&cfg, &chunk.id).unwrap().expect("chunk row");
assert_eq!(row.content_path, chunk.content_path);
assert!(row.content_preview.as_deref().unwrap_or("").contains(body));
}
#[tokio::test]
async fn flush_now_enqueues_once_and_reports_stale_buffers() {
let (_tmp, cfg) = test_config();
seed_chat_chunk(
&cfg,
"slack:#eng",
"Phoenix migration ships Friday after the release checklist closes.",
)
.await;
drain_until_idle(&cfg).await.expect("drain jobs");
let first = flush_now_rpc(&cfg).await.expect("flush_now first");
assert!(first.value.enqueued, "first flush should enqueue work");
assert!(
first.value.stale_buffers >= 1,
"expected at least one stale buffer after ingest"
);
let second = flush_now_rpc(&cfg).await.expect("flush_now second");
assert!(
!second.value.enqueued,
"same 3-hour window should dedupe duplicate flush triggers"
);
assert!(
second.value.stale_buffers >= 1,
"deduped flush should still report current stale buffer count"
);
}
#[tokio::test]
async fn reset_tree_preserves_raw_archive_and_source_registry() {
let (_tmp, cfg) = test_config();
let chunk_id = seed_slack_chunk_with_raw_archive(&cfg).await;
let content_root = cfg.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(), "raw archive should exist before reset");
assert!(
source_file.exists(),
"source registry should exist before reset"
);
let stale_summary = content_root
.join("wiki")
.join("summaries")
.join("source-slack-conn-slack-1")
.join("L1")
.join("summary-stale.md");
std::fs::create_dir_all(
stale_summary
.parent()
.expect("stale summary parent should exist"),
)
.expect("create stale summary dir");
std::fs::write(&stale_summary, "stale summary body").expect("write stale summary");
assert!(stale_summary.exists(), "stale summary fixture should exist");
let outcome = reset_tree_rpc(&cfg).await.expect("reset_tree");
assert_eq!(outcome.value.chunks_requeued, 1);
assert_eq!(outcome.value.jobs_enqueued, 1);
assert!(
outcome.value.tree_rows_deleted >= 1,
"buffer/tree rows should be removed during reset"
);
let row = read_chunk_row(&cfg, &chunk_id)
.expect("read chunk row")
.expect("chunk row present after reset");
assert_eq!(row.lifecycle_status, "pending_extraction");
assert!(raw_file.exists(), "raw archive must survive reset_tree");
assert!(
source_file.exists(),
"source registry must survive reset_tree"
);
assert!(
!content_root.join("wiki").join("summaries").exists(),
"derived wiki summaries should be removed"
);
}
#[test]
fn read_chunk_row_returns_none_for_missing_chunk() {
let (_tmp, cfg) = test_config();
assert!(read_chunk_row(&cfg, "missing-chunk").unwrap().is_none());
}
#[test]
fn display_name_unslugs_email_thread_with_user_hint() {
let name = display_name_for_source(
"gmail:alice@example.com|bob@example.com",
Some("alice@example.com"),
);
assert_eq!(name, "bob@example.com");
}
#[test]
fn display_name_falls_back_to_arrow_when_user_unknown() {
let name = display_name_for_source("gmail:alice@example.com|bob@example.com", None);
assert!(name.contains("alice@example.com"));
assert!(name.contains("bob@example.com"));
assert!(name.contains(""));
}
#[test]
fn display_name_strips_platform_prefix() {
assert_eq!(
display_name_for_source("slack:#engineering", None),
"#engineering"
);
}
#[test]
fn display_name_handles_multiple_participants_and_trimmed_hint() {
let name = display_name_for_source(
"gmail:Alice@Example.com|bob@example.com|carol@example.com",
Some(" alice@example.com "),
);
assert_eq!(name, "bob@example.com, carol@example.com");
}
#[test]
fn display_name_handles_no_prefix() {
assert_eq!(display_name_for_source("loose-id", None), "loose-id");
}
#[test]
fn sanitize_basename_replaces_windows_illegal_characters() {
assert_eq!(
sanitize_basename(r#"chat:slack/#eng\name*?"<>|"#),
"chat-slack-#eng-name------"
);
assert_eq!(sanitize_basename("safe-name.md"), "safe-name.md");
}
#[test]
fn parse_source_kind_str_accepts_known_values_only() {
assert_eq!(parse_source_kind_str("chat"), Some(SourceKind::Chat));
assert_eq!(parse_source_kind_str("email"), Some(SourceKind::Email));
assert_eq!(
parse_source_kind_str("document"),
Some(SourceKind::Document)
);
assert_eq!(parse_source_kind_str("unknown"), None);
}
#[test]
fn clear_composio_sync_state_removes_only_target_namespace() {
let tmp = TempDir::new().unwrap();
let _memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap();
let db_path = tmp.path().join("memory").join("memory.db");
let conn = rusqlite::Connection::open(&db_path).unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES (?1, 'cursor', '{}', 1.0)",
params![KV_NAMESPACE],
)
.unwrap();
conn.execute(
"INSERT INTO kv_namespace (namespace, key, value_json, updated_at)
VALUES ('other-namespace', 'cursor', '{}', 2.0)",
[],
)
.unwrap();
drop(conn);
let removed = clear_composio_sync_state(&db_path).unwrap();
assert_eq!(removed, 1);
let conn = rusqlite::Connection::open(&db_path).unwrap();
let composio_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = ?1",
params![KV_NAMESPACE],
|row| row.get(0),
)
.unwrap();
let other_count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM kv_namespace WHERE namespace = 'other-namespace'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(composio_count, 0);
assert_eq!(other_count, 1);
}
#[tokio::test]
async fn obsidian_status_registered_when_override_config_lists_content_root() {
let (_tmp, cfg) = test_config();
let content_root = cfg.memory_tree_content_root();
// A separate dir standing in for a non-standard Obsidian config
// location, with an obsidian.json that registers the content root.
let cfg_dir = TempDir::new().unwrap();
let body = format!(
"{{ \"vaults\": {{ \"id0\": {{ \"path\": {}, \"open\": true }} }} }}",
serde_json::to_string(&content_root.to_string_lossy().to_string()).unwrap()
);
std::fs::write(cfg_dir.path().join("obsidian.json"), body).unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(outcome.value.registered);
assert!(outcome.value.config_found);
assert_eq!(
outcome.value.content_root_abs,
content_root.to_string_lossy().to_string()
);
// The log reports the booleans but redacts the absolute path (it
// embeds the user's home / username).
assert!(
outcome.logs[0].contains("registered=true"),
"log: {}",
outcome.logs[0]
);
assert!(
!outcome.logs[0].contains(content_root.to_str().unwrap()),
"log leaked content root: {}",
outcome.logs[0]
);
}
#[tokio::test]
async fn obsidian_status_not_registered_for_empty_override_dir() {
let (_tmp, cfg) = test_config();
// Empty override dir → no obsidian.json there → content root is not a
// registered vault. (A temp content root can't be under any real host
// vault either, so this stays false regardless of the dev machine.)
let cfg_dir = TempDir::new().unwrap();
let outcome =
obsidian_vault_status_rpc(&cfg, Some(cfg_dir.path().to_string_lossy().to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
#[tokio::test]
async fn obsidian_status_blank_override_is_treated_as_none() {
// A whitespace-only override must be normalized to None rather than
// resolving to "." and probing a stray local ./obsidian.json. The temp
// content root isn't under any real host vault, so this stays false.
let (_tmp, cfg) = test_config();
let outcome = obsidian_vault_status_rpc(&cfg, Some(" ".to_string()))
.await
.unwrap();
assert!(!outcome.value.registered);
}
@@ -218,7 +218,7 @@ pub async fn sync_status_rpc(
// runner: the existing pattern across this module is to assert factory
// dispatch + error wrapping rather than mock the upstream HTTP. The
// network-touching paths are smoke-tested upstream in
// `composio::client_tests` / `composio::ops_test` and the
// `composio::client_tests` / `composio::ops_tests` and the
// direct-mode-toggle test in `action_tool.rs`.
#[cfg(test)]
@@ -155,5 +155,5 @@ pub async fn load_or_default(toolkit: &str) -> UserScopePref {
}
#[cfg(test)]
#[path = "user_scopes_test.rs"]
#[path = "user_scopes_tests.rs"]
mod tests;
+1 -1
View File
@@ -30,7 +30,7 @@ pub mod types;
#[cfg(test)]
mod benchmarks;
#[cfg(test)]
mod integration_test;
mod integration_tests;
pub use drill_down::drill_down;
pub use fetch::fetch_leaves;
+1 -1
View File
@@ -14,7 +14,7 @@ pub mod types;
pub mod decision_log;
#[cfg(test)]
mod integration_test;
mod integration_tests;
pub use engine::SubconsciousEngine;
pub use reflection::{Reflection, ReflectionKind, MAX_REFLECTIONS_PER_TICK};
+1 -1
View File
@@ -495,5 +495,5 @@ fn title_from_function(function: &str) -> String {
}
#[cfg(test)]
#[path = "ops_test.rs"]
#[path = "ops_tests.rs"]
mod tests;
+1 -1
View File
@@ -25,7 +25,7 @@ mod ops;
pub mod wechat_ingest;
#[cfg(test)]
#[path = "wechat_ingest_test.rs"]
#[path = "wechat_ingest_tests.rs"]
mod tests;
pub use ops::detect_webview_logins;