mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 23:14:37 +00:00
refactor(rust): move large inline tests to files (#2987)
This commit is contained in:
+2
-3556
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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
@@ -244,5 +244,5 @@ impl Provider for RouterProvider {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "router_test.rs"]
|
||||
mod router_test;
|
||||
#[path = "router_tests.rs"]
|
||||
mod router_tests;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -495,5 +495,5 @@ fn title_from_function(function: &str) -> String {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ops_test.rs"]
|
||||
#[path = "ops_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user