use khive_pack_knowledge as _;
use async_trait::async_trait;
use khive_mcp::server::KhiveMcpServer;
use khive_runtime::{
KhiveRuntime, Namespace, NamespaceToken, PackRuntime, RuntimeConfig, RuntimeError,
VerbRegistry, VerbRegistryBuilder,
};
use khive_types::{
Details, ErrorCode as KhiveErrorCode, ErrorDomain, HandlerDef, KhiveError, Pack, VerbCategory,
Visibility,
};
use rmcp::{
model::{CallToolRequestParams, CallToolResult, ClientInfo, ErrorCode},
ClientHandler, ServerHandler, ServiceError, ServiceExt,
};
use serde_json::{json, Value};
fn disable_daemon() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| std::env::set_var("KHIVE_NO_DAEMON", "1"));
}
fn make_server() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "gtd".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("in-memory runtime");
KhiveMcpServer::new(runtime).expect("server builds with kg+gtd")
}
#[derive(Clone, Default)]
struct DummyClient;
impl ClientHandler for DummyClient {
fn get_info(&self) -> ClientInfo {
ClientInfo::default()
}
}
async fn connect(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_server();
tokio::spawn(async move {
if let Ok(server_service) = server.serve(server_transport).await {
let _ = server_service.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
fn first_text(r: &CallToolResult) -> String {
r.content
.first()
.and_then(|c| c.raw.as_text())
.map(|t| t.text.clone())
.unwrap_or_default()
}
async fn call(
client: &impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>,
name: impl Into<String>,
args: Value,
) -> anyhow::Result<CallToolResult> {
let params = CallToolRequestParams::new(name.into())
.with_arguments(args.as_object().expect("args must be JSON object").clone());
Ok(client.call_tool(params).await?)
}
async fn ok_one(
client: &impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>,
ops: &str,
) -> anyhow::Result<Value> {
let result = call(
client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = body["results"].get(0).cloned().unwrap_or(Value::Null);
assert_eq!(
first["ok"],
json!(true),
"expected op to succeed, got: {first}"
);
Ok(first["result"].clone())
}
#[tokio::test]
async fn server_info_advertises_request_tool_only() {
let server = make_server();
let info = server.get_info();
assert_eq!(info.server_info.name, "khive-mcp");
let instructions = info.instructions.unwrap_or_default();
assert!(
instructions.contains("request-only"),
"instructions should explain the request-only surface"
);
assert!(instructions.contains("assign"), "gtd verb should appear");
assert!(instructions.contains("create"), "kg verb should appear");
}
#[tokio::test]
async fn list_tools_returns_only_request() -> anyhow::Result<()> {
let client = connect().await?;
let result = client.list_tools(None).await?;
let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_ref()).collect();
assert_eq!(names, vec!["request"], "surface should be a single tool");
Ok(())
}
#[tokio::test]
async fn request_tool_description_contains_dynamic_verb_catalog() -> anyhow::Result<()> {
let client = connect().await?;
let listed = client.list_tools(None).await?;
let request = listed
.tools
.iter()
.find(|t| t.name == "request")
.expect("request tool must be present");
let desc = request.description.as_deref().unwrap_or("");
for verb in [
"create",
"get",
"list",
"update",
"delete",
"merge",
"search",
"link",
"neighbors",
"traverse",
"query",
] {
assert!(
desc.contains(verb),
"request description missing verb {verb:?}: {desc}"
);
}
Ok(())
}
#[tokio::test]
async fn create_entity_via_dsl() -> anyhow::Result<()> {
let client = connect().await?;
let result = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="LoRA")"#,
)
.await?;
assert_eq!(result["kind"], "concept");
assert_eq!(result["name"], "LoRA");
Ok(())
}
#[tokio::test]
async fn parallel_batch_of_independent_creates_all_succeed() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({
"ops": r#"[create(kind="entity", entity_kind="concept", name="A"), create(kind="entity", entity_kind="concept", name="B"), create(kind="entity", entity_kind="concept", name="C")]"#
}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("array");
assert_eq!(results.len(), 3);
for r in results {
assert_eq!(r["ok"], json!(true), "op should succeed: {r}");
}
assert_eq!(body["summary"]["succeeded"], json!(3));
assert_eq!(body["summary"]["failed"], json!(0));
Ok(())
}
#[tokio::test]
async fn create_then_list_across_separate_request_calls() -> anyhow::Result<()> {
let client = connect().await?;
call(
&client,
"request",
json!({
"ops": r#"[create(kind="entity", entity_kind="concept", name="A"), create(kind="entity", entity_kind="concept", name="B")]"#
}),
)
.await?;
let listed = ok_one(&client, r#"list(kind="entity")"#).await?;
let entities = listed
.as_array()
.expect("entities array (list returns array directly)");
let names: Vec<&str> = entities.iter().filter_map(|e| e["name"].as_str()).collect();
assert!(names.contains(&"A"), "entity A missing: {names:?}");
assert!(names.contains(&"B"), "entity B missing: {names:?}");
Ok(())
}
#[tokio::test]
async fn invalid_kind_failure_does_not_abort_batch() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"[create(kind="entity", entity_kind="concept", name="ok"), create(kind="entity", entity_kind="bogus", name="bad")]"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
assert_eq!(body["summary"]["total"], 2);
assert_eq!(body["summary"]["succeeded"], 1);
assert_eq!(body["summary"]["failed"], 1);
assert_eq!(body["results"][0]["ok"], true);
assert_eq!(body["results"][1]["ok"], false);
assert!(body["results"][1]["error"]
.as_str()
.unwrap()
.contains("bogus"));
Ok(())
}
#[tokio::test]
async fn empty_batch_returns_invalid_params() -> anyhow::Result<()> {
let client = connect().await?;
let err = call(&client, "request", json!({"ops": "[]"})).await.err();
let svc = err.as_ref().and_then(|e| e.downcast_ref::<ServiceError>());
assert!(
matches!(
svc,
Some(ServiceError::McpError(e)) if e.code == ErrorCode::INVALID_PARAMS
),
"UE4-H2: empty batch must return INVALID_PARAMS, got {err:?}"
);
let err2 = call(&client, "request", json!({"ops": "[]"})).await.err();
let svc2 = err2.as_ref().and_then(|e| e.downcast_ref::<ServiceError>());
assert!(
matches!(
svc2,
Some(ServiceError::McpError(e)) if e.code == ErrorCode::INVALID_PARAMS
),
"UE4-H2: empty JSON batch must return INVALID_PARAMS, got {err2:?}"
);
Ok(())
}
#[tokio::test]
async fn malformed_dsl_returns_invalid_params() -> anyhow::Result<()> {
let client = connect().await?;
let err = call(&client, "request", json!({"ops": "create("}))
.await
.err();
let svc = err.as_ref().and_then(|e| e.downcast_ref::<ServiceError>());
assert!(
matches!(
svc,
Some(ServiceError::McpError(e)) if e.code == ErrorCode::INVALID_PARAMS
),
"expected invalid_params for malformed DSL, got {err:?}"
);
Ok(())
}
#[tokio::test]
async fn assign_then_next_then_complete() -> anyhow::Result<()> {
let client = connect().await?;
let assigned = ok_one(
&client,
r#"gtd.assign(title="ship release", status="next", priority="p0")"#,
)
.await?;
let id = assigned["full_id"].as_str().unwrap().to_string();
assert_eq!(assigned["kind"], "task");
assert_eq!(assigned["status"], "next");
let next_list = ok_one(&client, "gtd.next()").await?;
let arr = next_list.as_array().unwrap();
assert!(arr.iter().any(|t| t["full_id"] == id));
let completed = ok_one(
&client,
&format!(r#"gtd.complete(id="{id}", result="shipped via request")"#),
)
.await?;
assert_eq!(completed["to"], "done");
Ok(())
}
#[tokio::test]
async fn transition_lifecycle_rejection_is_per_op_not_protocol_error() -> anyhow::Result<()> {
let client = connect().await?;
let assigned = ok_one(&client, r#"gtd.assign(title="lifecycle")"#).await?;
let id = assigned["full_id"].as_str().unwrap().to_string();
ok_one(
&client,
&format!(r#"gtd.transition(id="{id}", status="done")"#),
)
.await?;
let result = call(
&client,
"request",
json!({"ops": format!(r#"gtd.transition(id="{id}", status="inbox")"#)}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false);
assert!(
first["error"].as_str().unwrap().contains("terminal state"),
"expected terminal-state rejection, got: {}",
first["error"]
);
Ok(())
}
#[tokio::test]
async fn parallel_assign_batch_creates_n_tasks() -> anyhow::Result<()> {
let client = connect().await?;
let ops = r#"[
gtd.assign(title="t1", priority="p0"),
gtd.assign(title="t2", priority="p1"),
gtd.assign(title="t3", priority="p2")
]"#;
let result = call(&client, "request", json!({"ops": ops})).await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
assert_eq!(body["summary"]["succeeded"], 3);
Ok(())
}
#[tokio::test]
async fn unknown_verb_returns_per_op_failure_not_invalid_params() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(&client, "request", json!({"ops": "retire()"})).await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false);
assert!(first["error"].as_str().unwrap().contains("unknown verb"));
Ok(())
}
#[tokio::test]
async fn pack_only_kg_omits_gtd_verbs_from_catalog() {
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let server = KhiveMcpServer::new(runtime).expect("server builds with kg");
let info = server.get_info();
let instructions = info.instructions.unwrap_or_default();
assert!(instructions.contains("create"), "kg verb missing");
assert!(
!instructions.contains("gtd.assign"),
"gtd verb should not be in catalog when only kg is loaded"
);
}
#[tokio::test]
async fn pack_gtd_without_kg_fails_at_boot() {
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["gtd".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
match KhiveMcpServer::new(runtime) {
Ok(_) => panic!("gtd without kg must fail: missing dependency is a boot error (ADR-027)"),
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("kg") || msg.contains("unknown pack"),
"error must name the missing dependency: {msg}"
);
}
}
}
#[tokio::test]
async fn pack_gtd_with_kg_explicit_works() {
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "gtd".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).unwrap();
let server = KhiveMcpServer::new(runtime).expect("kg+gtd builds");
let info = server.get_info();
let instructions = info.instructions.unwrap_or_default();
assert!(instructions.contains("assign"), "gtd verb must be present");
assert!(instructions.contains("create"), "kg verb must be present");
}
#[tokio::test]
async fn json_form_request_works_identically() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"[{"tool":"gtd.assign","args":{"title":"json form","priority":"p1"}}]"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
assert_eq!(body["summary"]["succeeded"], 1);
assert_eq!(body["results"][0]["result"]["title"], "json form");
Ok(())
}
#[tokio::test]
async fn kg_create_with_note_kind_task_invokes_gtd_hook_defaults() -> anyhow::Result<()> {
let client = connect().await?;
let created = ok_one(
&client,
r#"create(kind="note", note_kind="task", title="ship release", priority="p0")"#,
)
.await?;
assert_eq!(created["kind"], "task", "note stored with kind=task");
assert_eq!(created["name"], "ship release", "title folded into name");
assert_eq!(
created["properties"]["status"], "inbox",
"TaskHook applies default status"
);
assert_eq!(
created["properties"]["priority"], "p0",
"user-supplied priority preserved in properties"
);
Ok(())
}
#[tokio::test]
async fn kg_create_note_kind_task_resolves_depends_on_against_task_target() -> anyhow::Result<()> {
let client = connect().await?;
let blocker = ok_one(&client, r#"gtd.assign(title="write spec")"#).await?;
let blocker_full = blocker["full_id"].as_str().unwrap().to_string();
let task = ok_one(
&client,
&format!(
r#"create(kind="note", note_kind="task", title="depends on something", depends_on=["{}"])"#,
blocker_full
),
)
.await?;
let deps = task["properties"]["depends_on"].as_array().unwrap();
assert_eq!(deps.len(), 1, "exactly one resolved dependency");
let resolved = deps[0].as_str().unwrap();
assert!(
resolved.contains('-'),
"depends_on stored as full UUID string, got: {resolved}"
);
assert_eq!(resolved, &blocker_full, "depends_on resolves to blocker");
Ok(())
}
#[tokio::test]
async fn kg_create_note_kind_task_rejects_non_task_depends_on_before_write() -> anyhow::Result<()> {
let client = connect().await?;
let entity = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="DependencyTarget")"#,
)
.await?;
let entity_full = entity["id"].as_str().unwrap().to_string();
let result = call(
&client,
"request",
json!({"ops": format!(
r#"create(kind="note", note_kind="task", title="depends on entity", depends_on=["{}"])"#,
entity_full
)}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false, "expected rejection: {first}");
let err = first["error"].as_str().unwrap();
assert!(
err.contains("must be a task note"),
"error must point to the GTD edge rule: {err}"
);
let listed = ok_one(&client, r#"list(kind="note", note_kind="task")"#).await?;
let notes = listed.as_array().expect("note list");
let titles: Vec<&str> = notes.iter().filter_map(|n| n["name"].as_str()).collect();
assert!(
!titles.contains(&"depends on entity"),
"task must not be persisted when depends_on validation fails: {titles:?}"
);
Ok(())
}
#[tokio::test]
async fn gtd_assign_creates_depends_on_edge_between_two_tasks() -> anyhow::Result<()> {
let client = connect().await?;
let blocker = ok_one(&client, r#"gtd.assign(title="write spec")"#).await?;
let blocker_full = blocker["full_id"].as_str().unwrap().to_string();
let dependent = ok_one(
&client,
&format!(
r#"gtd.assign(title="implement feature", depends_on=["{}"])"#,
blocker_full
),
)
.await?;
let dep_full = dependent["full_id"].as_str().unwrap().to_string();
let neighbors = ok_one(
&client,
&format!(
r#"neighbors(node_id="{}", direction="out", relations=["depends_on"])"#,
dep_full
),
)
.await?;
let hits = neighbors.as_array().expect("neighbors returns array");
let targets: Vec<&str> = hits.iter().filter_map(|h| h["id"].as_str()).collect();
assert!(
targets.iter().any(|t| *t == blocker_full),
"task→task depends_on edge missing — got targets {targets:?}"
);
Ok(())
}
#[tokio::test]
async fn kg_create_unknown_note_kind_lists_merged_pack_vocabulary() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"create(kind="note", note_kind="bogus", content="x")"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false);
let err = first["error"].as_str().unwrap();
assert!(err.contains("bogus"), "error names the bad kind: {err}");
assert!(
err.contains("task"),
"error must list gtd-registered 'task' kind: {err}"
);
assert!(
err.contains("observation"),
"error must list kg's 'observation' kind: {err}"
);
Ok(())
}
#[tokio::test]
async fn create_with_granular_entity_kind() -> anyhow::Result<()> {
let client = connect().await?;
let result = ok_one(
&client,
r#"create(kind="concept", name="GraphAttention", description="self-attention over graph neighborhoods")"#,
)
.await?;
assert_eq!(result["kind"], "concept", "stored under concept kind");
assert_eq!(result["name"], "GraphAttention");
Ok(())
}
#[tokio::test]
async fn create_with_granular_note_kind() -> anyhow::Result<()> {
let client = connect().await?;
let result = ok_one(
&client,
r#"create(kind="observation", content="qwen3.5 retains long-context recall up to 64k")"#,
)
.await?;
assert_eq!(
result["kind"], "observation",
"stored under observation kind"
);
Ok(())
}
#[tokio::test]
async fn create_granular_kind_conflicts_with_legacy_subfield() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"create(kind="concept", entity_kind="document", name="Conflict")"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false, "expected contradiction error: {first}");
let err = first["error"].as_str().unwrap();
assert!(
err.contains("contradicts"),
"error should explain the contradiction: {err}"
);
Ok(())
}
#[tokio::test]
async fn list_with_granular_entity_kind_filters_results() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(&client, r#"create(kind="concept", name="GranularListA")"#).await?;
ok_one(&client, r#"create(kind="document", name="GranularListB")"#).await?;
let listed = ok_one(&client, r#"list(kind="concept")"#).await?;
let arr = listed.as_array().expect("array");
let names: Vec<&str> = arr.iter().filter_map(|n| n["name"].as_str()).collect();
assert!(
names.contains(&"GranularListA"),
"concept missing: {names:?}"
);
assert!(
!names.contains(&"GranularListB"),
"document leaked into concept filter: {names:?}"
);
Ok(())
}
#[tokio::test]
async fn list_with_granular_task_kind_lists_only_tasks() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(&client, r#"gtd.assign(title="GranularTaskA")"#).await?;
ok_one(
&client,
r#"create(kind="observation", content="not a task")"#,
)
.await?;
let listed = ok_one(&client, r#"list(kind="task")"#).await?;
let arr = listed.as_array().expect("array");
let titles: Vec<&str> = arr.iter().filter_map(|n| n["name"].as_str()).collect();
assert!(
titles.contains(&"GranularTaskA"),
"task missing: {titles:?}"
);
assert!(
!titles.iter().any(|t| t.contains("not a task")),
"observation leaked into task list: {titles:?}"
);
Ok(())
}
#[tokio::test]
async fn search_with_granular_entity_kind() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"create(kind="concept", name="HybridSearchConcept", description="needle for search")"#,
)
.await?;
ok_one(
&client,
r#"create(kind="document", name="HybridSearchDocument", description="needle for search")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="concept", query="HybridSearch needle", limit=10)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(!arr.is_empty(), "expected at least one hit");
for hit in arr {
let id = hit["id"].as_str().unwrap().to_string();
let got = ok_one(&client, &format!(r#"get(id="{}")"#, id)).await?;
assert_eq!(
got["kind"], "concept",
"search(kind=\"concept\") returned non-concept: {got}"
);
}
Ok(())
}
#[tokio::test]
async fn search_with_granular_task_kind() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(&client, r#"gtd.assign(title="urgent search needle one")"#).await?;
ok_one(
&client,
r#"create(kind="observation", content="urgent search needle two")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="task", query="urgent search needle", limit=10)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(!arr.is_empty(), "expected task hits");
for hit in arr {
let id = hit["id"].as_str().unwrap().to_string();
let got = ok_one(&client, &format!(r#"get(id="{}")"#, id)).await?;
assert_eq!(
got["kind"], "task",
"search(kind=\"task\") returned non-task: {got}"
);
}
Ok(())
}
#[tokio::test]
async fn search_substrate_wide_note_kind_still_works() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"gtd.assign(title="quasiparticle task entry", description="quasiparticle decoherence backlog")"#,
)
.await?;
ok_one(
&client,
r#"create(kind="observation", content="quasiparticle decoherence drives loss in transmons")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="note", query="quasiparticle decoherence", limit=10)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(
arr.len() >= 2,
"kind=note should range over task AND observation; got {arr:?}"
);
Ok(())
}
#[tokio::test]
async fn search_unknown_kind_lists_all_valid_options() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"search(kind="bogus", query="anything")"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false);
let err = first["error"].as_str().unwrap();
assert!(err.contains("bogus"), "error names the bad kind: {err}");
for expected in ["entity", "note", "edge", "concept", "task"] {
assert!(
err.contains(expected),
"error must list {expected:?}: {err}"
);
}
Ok(())
}
#[tokio::test]
async fn search_substrate_kind_entity_with_legacy_entity_kind_sub_filter() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"create(kind="concept", name="SubFilterEntityConcept", description="zaphod beeblebrox marker")"#,
)
.await?;
ok_one(
&client,
r#"create(kind="document", name="SubFilterEntityDoc", description="zaphod beeblebrox marker")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="entity", entity_kind="concept", query="zaphod beeblebrox", limit=10)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(!arr.is_empty(), "expected concept hits, got: {arr:?}");
for hit in arr {
let id = hit["id"].as_str().unwrap().to_string();
let got = ok_one(&client, &format!(r#"get(id="{}")"#, id)).await?;
assert_eq!(
got["kind"], "concept",
"search(kind=\"entity\", entity_kind=\"concept\") returned non-concept: {got}"
);
}
Ok(())
}
#[tokio::test]
async fn search_substrate_kind_note_with_legacy_note_kind_sub_filter() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"gtd.assign(title="ghyll task entry", description="ghyll mistral foxtrot marker")"#,
)
.await?;
ok_one(
&client,
r#"create(kind="observation", content="ghyll mistral foxtrot marker observation")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="note", note_kind="task", query="ghyll mistral foxtrot", limit=10)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(!arr.is_empty(), "expected task hits, got: {arr:?}");
for hit in arr {
let id = hit["id"].as_str().unwrap().to_string();
let got = ok_one(&client, &format!(r#"get(id="{}")"#, id)).await?;
assert_eq!(
got["kind"], "task",
"search(kind=\"note\", note_kind=\"task\") returned non-task: {got}"
);
}
Ok(())
}
#[tokio::test]
async fn search_granular_kind_contradicting_legacy_subfield_is_rejected() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({"ops": r#"search(kind="concept", entity_kind="document", query="anything", limit=5)"#}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false, "expected contradiction error: {first}");
let err = first["error"].as_str().unwrap();
assert!(
err.contains("contradicts"),
"error should explain the contradiction: {err}"
);
Ok(())
}
#[tokio::test]
async fn search_kind_filter_surfaces_right_kind_when_wrong_kind_outranks() -> anyhow::Result<()> {
let client = connect().await?;
for i in 0..5 {
ok_one(
&client,
&format!(
r#"create(kind="document", name="WrongKindDoc{i}", description="orthogonal wavelet quibble marker")"#
),
)
.await?;
}
ok_one(
&client,
r#"create(kind="concept", name="RightKindConcept", description="orthogonal wavelet quibble marker")"#,
)
.await?;
let hits = ok_one(
&client,
r#"search(kind="concept", query="orthogonal wavelet quibble", limit=2)"#,
)
.await?;
let arr = hits.as_array().expect("array");
assert!(
!arr.is_empty(),
"right-kind hit must surface even when wrong-kind hits outrank it; got: {arr:?}"
);
for hit in arr {
let id = hit["id"].as_str().unwrap().to_string();
let got = ok_one(&client, &format!(r#"get(id="{}")"#, id)).await?;
assert_eq!(
got["kind"], "concept",
"search(kind=\"concept\") must only return concepts: {got}"
);
}
Ok(())
}
struct ErrorInjectPack;
impl khive_types::Pack for ErrorInjectPack {
const NAME: &'static str = "error-inject";
const NOTE_KINDS: &'static [&'static str] = &[];
const ENTITY_KINDS: &'static [&'static str] = &[];
const HANDLERS: &'static [HandlerDef] = &[HandlerDef {
name: "always_fail",
description: "always returns a KhiveError::unavailable with code + details",
visibility: Visibility::Verb,
category: VerbCategory::Assertive,
params: &[],
}];
}
#[async_trait]
impl PackRuntime for ErrorInjectPack {
fn name(&self) -> &str {
"error-inject"
}
fn note_kinds(&self) -> &'static [&'static str] {
&[]
}
fn entity_kinds(&self) -> &'static [&'static str] {
&[]
}
fn handlers(&self) -> &'static [HandlerDef] {
ErrorInjectPack::HANDLERS
}
async fn dispatch(
&self,
_verb: &str,
_params: serde_json::Value,
_registry: &VerbRegistry,
_token: &NamespaceToken,
) -> Result<serde_json::Value, RuntimeError> {
let err = KhiveError::unavailable("downstream service offline")
.with_code(KhiveErrorCode::new(ErrorDomain::Runtime, 10))
.with_details(Details::new([
("service", "embed"),
("region", "us-east-1"),
]));
Err(RuntimeError::Khive(err))
}
}
fn make_error_inject_server() -> KhiveMcpServer {
disable_daemon();
let mut builder = VerbRegistryBuilder::new();
builder.register(ErrorInjectPack);
let registry = builder.build().expect("error-inject registry builds");
KhiveMcpServer::from_registry(registry)
}
async fn connect_error_inject(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_error_inject_server();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn runtime_khive_error_serializes_as_structured_object() -> anyhow::Result<()> {
let client = connect_error_inject().await?;
let result = call(
&client,
"request",
serde_json::json!({"ops": "always_fail()"}),
)
.await?;
let body: serde_json::Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], false, "expected op failure: {first}");
let error = &first["error"];
assert!(
error.is_object(),
"error must be a JSON object (not a string); got: {error}"
);
assert!(
error["kind"].is_string(),
"error.kind must be a string; got: {error}"
);
assert!(
error["message"].is_string(),
"error.message must be a string; got: {error}"
);
assert!(
error["code"].is_string(),
"error.code must be a wire string (e.g. 'runtime:10'); got: {error}"
);
assert!(
error["details"].is_object(),
"error.details must be a JSON object; got: {error}"
);
assert_eq!(
error["kind"].as_str().unwrap(),
"unavailable",
"KhiveError::unavailable should map to kind='unavailable'"
);
assert_eq!(
error["code"].as_str().unwrap(),
"runtime:10",
"ErrorCode(Runtime, 10) should serialize as 'runtime:10'"
);
assert_eq!(
error["details"]["service"].as_str().unwrap(),
"embed",
"details key 'service' should be preserved"
);
Ok(())
}
#[test]
fn engine_config_three_engines_all_registered() {
use khive_runtime::{
runtime_config_from_khive_config, KhiveConfig, KhiveRuntime, RuntimeConfig,
};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
r#"
[[engines]]
name = "primary"
model = "all-minilm-l6-v2"
default = true
[[engines]]
name = "para"
model = "paraphrase-multilingual-minilm-l12-v2"
[[engines]]
name = "bge-small"
model = "bge-small-en-v1.5"
"#
)
.unwrap();
let khive_cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file should be found");
assert_eq!(khive_cfg.engines.len(), 3);
let base = RuntimeConfig {
db_path: None,
embedding_model: None,
additional_embedding_models: vec![],
..RuntimeConfig::default()
};
let config = runtime_config_from_khive_config(&khive_cfg, base);
assert!(
config.embedding_model.is_some(),
"default engine should set embedding_model"
);
assert_eq!(
config.additional_embedding_models.len(),
2,
"two non-default engines should appear in additional_embedding_models"
);
let rt = KhiveRuntime::new(config).expect("runtime should build");
let mut names = rt.registered_embedding_model_names();
names.sort();
let expected_substring_check = [
"all-minilm-l6-v2",
"bge-small-en-v1.5",
"paraphrase-multilingual-minilm-l12-v2",
];
assert_eq!(
names.len(),
3,
"all 3 engines should be registered; got {names:?}"
);
for expected in &expected_substring_check {
assert!(
names.iter().any(|n| n.contains(expected)),
"expected a registered model containing {expected:?}; registered: {names:?}"
);
}
}
#[tokio::test]
async fn test_prev_dot_id_resolves() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({
"ops": r#"gtd.assign(title="chain-prev-id-test", status="next") | gtd.complete(id=$prev.id)"#,
"presentation": "verbose"
}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("results array");
assert_eq!(results.len(), 2, "expected 2 ops in chain result");
assert_eq!(
results[0]["ok"],
json!(true),
"gtd.assign (op 0) must succeed: {}",
results[0]
);
assert_eq!(
results[1]["ok"],
json!(true),
"gtd.complete (op 1) must succeed — $prev.id was not resolved: {}",
results[1]
);
assert_eq!(body["summary"]["succeeded"], json!(2));
assert_eq!(body["summary"]["failed"], json!(0));
assert_eq!(body["summary"]["aborted"], json!(0));
let complete_result = &results[1]["result"];
assert_eq!(
complete_result["to"].as_str().unwrap_or(""),
"done",
"completed task must have to=done: {complete_result}"
);
Ok(())
}
#[tokio::test]
async fn test_prev_dotted_path_resolves() -> anyhow::Result<()> {
let client = connect().await?;
let target = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="PrevDottedTarget")"#,
)
.await?;
let target_id = target["id"]
.as_str()
.expect("id field on entity result")
.to_string();
let ops = format!(
r#"create(kind="entity", entity_kind="concept", name="PrevDottedSource") | link(source_id=$prev.id, target_id="{target_id}", relation="extends") | get(id=$prev.id)"#
);
let result = call(
&client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("results array");
assert_eq!(results.len(), 3, "expected 3 ops");
assert_eq!(
results[0]["ok"],
json!(true),
"create failed: {}",
results[0]
);
assert_eq!(
results[1]["ok"],
json!(true),
"link failed — $prev.id (create result) not resolved: {}",
results[1]
);
assert_eq!(
results[2]["ok"],
json!(true),
"get failed — $prev.id (link result) not resolved: {}",
results[2]
);
assert_eq!(body["summary"]["succeeded"], json!(3));
assert_eq!(body["summary"]["aborted"], json!(0));
let source_id = results[0]["result"]["id"]
.as_str()
.unwrap_or_else(|| results[0]["result"]["full_id"].as_str().unwrap_or(""));
let link_source = results[1]["result"]["source_id"].as_str().unwrap_or("");
assert!(
link_source.starts_with(source_id) || source_id.starts_with(link_source),
"link.source_id {link_source:?} should match created entity {source_id:?}"
);
Ok(())
}
#[tokio::test]
async fn test_prev_unresolvable_aborts_chain() -> anyhow::Result<()> {
let client = connect().await?;
let ops = r#"create(kind="entity", entity_kind="concept", name="AbortSource") | get(id=$prev.bogus_field_xyz) | create(kind="entity", entity_kind="concept", name="AbortSink")"#;
let result = call(
&client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("results array");
assert_eq!(results.len(), 3, "expected 3 ops in chain result");
assert_eq!(
results[0]["ok"],
json!(true),
"create (op 0) must succeed: {}",
results[0]
);
assert_eq!(
results[1]["ok"],
json!(false),
"get with bogus $prev path (op 1) must fail: {}",
results[1]
);
let err_obj = &results[1]["error"];
let err_str = err_obj
.as_str()
.unwrap_or_else(|| err_obj["message"].as_str().unwrap_or(""));
assert!(
err_str.contains("bogus_field_xyz") || err_str.contains("not found"),
"error must mention the unresolvable path; got: {err_str}"
);
assert_ne!(
results[1]["aborted"],
json!(true),
"the failing op (op 1) must not be marked aborted: {}",
results[1]
);
assert_eq!(
results[2]["ok"],
json!(false),
"aborted op (op 2) must have ok=false: {}",
results[2]
);
assert_eq!(
results[2]["aborted"],
json!(true),
"aborted op (op 2) must have aborted=true: {}",
results[2]
);
assert_eq!(body["summary"]["total"], json!(3));
assert_eq!(body["summary"]["succeeded"], json!(1));
assert_eq!(body["summary"]["failed"], json!(1));
assert_eq!(body["summary"]["aborted"], json!(1));
Ok(())
}
#[tokio::test]
async fn test_ue4_h1_bare_prev_map_produces_clear_substitution_error() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({
"ops": r#"gtd.assign(title="bare-prev-test") | gtd.complete(id=$prev.id, result=$prev)"#,
"presentation": "verbose"
}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("results array");
assert_eq!(results.len(), 2, "expected 2 ops");
assert_eq!(
results[0]["ok"],
json!(true),
"assign must succeed: {}",
results[0]
);
assert_eq!(
results[1]["ok"],
json!(false),
"bare $prev -> map must cause op 1 to fail: {}",
results[1]
);
let error = &results[1]["error"];
let err_msg = error["message"]
.as_str()
.unwrap_or_else(|| error.as_str().unwrap_or(""));
assert!(
err_msg.contains("dotted path") || err_msg.contains("$prev"),
"UE4-H1: error must mention dotted path or $prev; got: {err_msg}"
);
assert!(
err_msg.contains("result") || error["kind"].as_str() == Some("substitution_error"),
"UE4-H1: error must reference the offending arg or be a substitution_error; got: {error}"
);
let mentions_field = err_msg.contains("id")
|| err_msg.contains("title")
|| err_msg.contains("kind")
|| err_msg.contains("full_id");
assert!(
mentions_field,
"UE4-H1: error must list available top-level fields from prior result; got: {err_msg}"
);
assert_eq!(body["summary"]["failed"], json!(1));
Ok(())
}
#[tokio::test]
async fn test_h3_prev_nonexistent_field_error_lists_available_fields() -> anyhow::Result<()> {
let client = connect().await?;
let ops = r#"create(kind="entity", entity_kind="concept", name="H3Test") | get(id=$prev.nonexistent_field)"#;
let result = call(
&client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let results = body["results"].as_array().expect("results array");
assert_eq!(results.len(), 2, "expected 2 ops");
assert_eq!(results[0]["ok"], json!(true), "create must succeed");
assert_eq!(
results[1]["ok"],
json!(false),
"get with nonexistent field must fail: {}",
results[1]
);
let err_obj = &results[1]["error"];
let err_msg = err_obj
.as_str()
.unwrap_or_else(|| err_obj["message"].as_str().unwrap_or(""));
assert!(
err_msg.contains("Available top-level fields"),
"H3: error must contain 'Available top-level fields'; got: {err_msg}"
);
let mentions_field =
err_msg.contains("id") || err_msg.contains("kind") || err_msg.contains("full_id");
assert!(
mentions_field,
"H3: available-fields hint must name at least one known field; got: {err_msg}"
);
Ok(())
}
fn make_full_server() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec![
"kg".to_string(),
"gtd".to_string(),
"memory".to_string(),
"brain".to_string(),
],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("in-memory runtime with all packs");
KhiveMcpServer::new(runtime).expect("server builds with kg+gtd+memory+brain")
}
async fn connect_full(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_full_server();
tokio::spawn(async move {
if let Ok(server_service) = server.serve(server_transport).await {
let _ = server_service.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
async fn help_schema(
client: &impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>,
verb: &str,
) -> anyhow::Result<Value> {
let ops = format!("{verb}(help=true)");
let result = call(client, "request", json!({"ops": &ops})).await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = body["results"].get(0).cloned().unwrap_or(Value::Null);
assert_eq!(
first["ok"],
json!(true),
"{verb}(help=true) must succeed, got: {first}"
);
Ok(first["result"].clone())
}
#[tokio::test]
async fn help_recall_params_non_empty_with_query_param() -> anyhow::Result<()> {
let client = connect_full().await?;
let schema = help_schema(&client, "memory.recall").await?;
let params = schema["params"]
.as_array()
.expect("params must be an array");
assert!(
!params.is_empty(),
"recall help=true must return non-empty params; got empty slice"
);
let has_query = params.iter().any(|p| p["name"] == json!("query"));
assert!(
has_query,
"recall params must include 'query'; got: {params:?}"
);
Ok(())
}
#[tokio::test]
async fn help_brain_feedback_params_non_empty_with_target_and_signal() -> anyhow::Result<()> {
let client = connect_full().await?;
let schema = help_schema(&client, "brain.feedback").await?;
let params = schema["params"]
.as_array()
.expect("params must be an array");
assert!(
!params.is_empty(),
"brain.feedback help=true must return non-empty params"
);
let has_target_id = params.iter().any(|p| p["name"] == json!("target_id"));
assert!(
has_target_id,
"brain.feedback params must include 'target_id'; got: {params:?}"
);
let has_signal = params.iter().any(|p| p["name"] == json!("signal"));
assert!(
has_signal,
"brain.feedback params must include 'signal'; got: {params:?}"
);
Ok(())
}
#[tokio::test]
async fn help_propose_params_non_empty_with_title_description_changeset() -> anyhow::Result<()> {
let client = connect_full().await?;
let schema = help_schema(&client, "propose").await?;
let params = schema["params"]
.as_array()
.expect("params must be an array");
assert!(
!params.is_empty(),
"propose help=true must return non-empty params"
);
let has_title = params.iter().any(|p| p["name"] == json!("title"));
assert!(
has_title,
"propose params must include 'title'; got: {params:?}"
);
let has_description = params.iter().any(|p| p["name"] == json!("description"));
assert!(
has_description,
"propose params must include 'description'; got: {params:?}"
);
let has_changeset = params.iter().any(|p| p["name"] == json!("changeset"));
assert!(
has_changeset,
"propose params must include 'changeset'; got: {params:?}"
);
Ok(())
}
fn make_comm_schedule_server() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "comm".to_string(), "schedule".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("in-memory runtime with comm+schedule");
KhiveMcpServer::new(runtime).expect("server builds with kg+comm+schedule")
}
async fn connect_comm_schedule(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_comm_schedule_server();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn send_help_returns_required_to_and_content() -> anyhow::Result<()> {
let client = connect_comm_schedule().await?;
let result = ok_one(&client, "comm.send(help=true)").await?;
assert_eq!(result["verb"], "comm.send");
assert_eq!(result["pack"], "comm");
let params = result["params"]
.as_array()
.expect("params must be an array");
assert!(!params.is_empty(), "send help must have non-empty params");
let to = params
.iter()
.find(|p| p["name"] == "to")
.expect("send help must include 'to'");
assert_eq!(to["required"], serde_json::json!(true));
let content = params
.iter()
.find(|p| p["name"] == "content")
.expect("send help must include 'content'");
assert_eq!(content["required"], serde_json::json!(true));
Ok(())
}
#[tokio::test]
async fn inbox_help_returns_optional_limit_and_status() -> anyhow::Result<()> {
let client = connect_comm_schedule().await?;
let result = ok_one(&client, "comm.inbox(help=true)").await?;
assert_eq!(result["verb"], "comm.inbox");
assert_eq!(result["pack"], "comm");
let params = result["params"]
.as_array()
.expect("params must be an array");
assert!(!params.is_empty(), "inbox help must have non-empty params");
let limit = params
.iter()
.find(|p| p["name"] == "limit")
.expect("inbox help must include 'limit'");
assert_eq!(limit["required"], serde_json::json!(false));
let status = params
.iter()
.find(|p| p["name"] == "status")
.expect("inbox help must include 'status'");
assert_eq!(status["required"], serde_json::json!(false));
Ok(())
}
#[tokio::test]
async fn schedule_help_returns_required_action_and_at() -> anyhow::Result<()> {
let client = connect_comm_schedule().await?;
let result = ok_one(&client, "schedule.schedule(help=true)").await?;
assert_eq!(result["verb"], "schedule.schedule");
assert_eq!(result["pack"], "schedule");
let params = result["params"]
.as_array()
.expect("params must be an array");
assert!(
!params.is_empty(),
"schedule help must have non-empty params"
);
let action = params
.iter()
.find(|p| p["name"] == "action")
.expect("schedule help must include 'action'");
assert_eq!(action["required"], serde_json::json!(true));
let at = params
.iter()
.find(|p| p["name"] == "at")
.expect("schedule help must include 'at'");
assert_eq!(at["required"], serde_json::json!(true));
Ok(())
}
#[tokio::test]
async fn remind_help_returns_required_content_and_at() -> anyhow::Result<()> {
let client = connect_comm_schedule().await?;
let result = ok_one(&client, "schedule.remind(help=true)").await?;
assert_eq!(result["verb"], "schedule.remind");
assert_eq!(result["pack"], "schedule");
let params = result["params"]
.as_array()
.expect("params must be an array");
assert!(!params.is_empty(), "remind help must have non-empty params");
let content = params
.iter()
.find(|p| p["name"] == "content")
.expect("remind help must include 'content'");
assert_eq!(content["required"], serde_json::json!(true));
let at = params
.iter()
.find(|p| p["name"] == "at")
.expect("remind help must include 'at'");
assert_eq!(at["required"], serde_json::json!(true));
let repeat = params
.iter()
.find(|p| p["name"] == "repeat")
.expect("remind help must include 'repeat'");
assert_eq!(repeat["required"], serde_json::json!(false));
Ok(())
}
#[tokio::test]
async fn startup_migrations_applied_to_fresh_file_backed_db() -> anyhow::Result<()> {
let db_file = tempfile::NamedTempFile::new()?;
let config = RuntimeConfig {
db_path: Some(db_file.path().to_path_buf()),
default_namespace: Namespace::parse("fix1test").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("fresh file-backed runtime");
let server = KhiveMcpServer::new(runtime).expect("server builds");
let (server_transport, client_transport) = tokio::io::duplex(65536);
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
let entity = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="MigrationTarget")"#,
)
.await?;
let eid = entity["id"].as_str().unwrap().to_string();
let ops = serde_json::to_string(&json!([{
"tool": "propose",
"args": {
"title": "migration regression test",
"description": "fix1: run_migrations at startup",
"changeset": {
"kind": "add_entity",
"entity": {
"kind": "concept",
"name": format!("fix1-{eid}")
}
}
}
}]))
.unwrap();
let result = call(
&client,
"request",
json!({
"ops": ops,
"presentation": "verbose"
}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"], true,
"propose must succeed on a freshly-migrated DB; got: {first}"
);
Ok(())
}
fn make_brain_server() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("braintest").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "brain".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("kg+brain runtime");
KhiveMcpServer::new(runtime).expect("server builds with kg+brain")
}
#[tokio::test]
async fn subhandler_verbs_are_blocked_at_mcp_boundary() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_brain_server();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
for verb in &["brain.state", "brain.config", "brain.events", "brain.emit"] {
let result = call(&client, "request", json!({"ops": format!("{verb}()")})).await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"], false,
"Subhandler verb {verb:?} must be blocked: got {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("permission denied") || err.contains("subhandler"),
"error for {verb:?} must mention permission/subhandler: {err}"
);
}
Ok(())
}
#[tokio::test]
async fn subhandler_verb_help_introspection_still_works() -> anyhow::Result<()> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_brain_server();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
let result = ok_one(&client, r#"brain.state(help=true)"#).await?;
let text = serde_json::to_string(&result).unwrap_or_default();
assert!(
text.contains("brain.state") || text.contains("params") || text.contains("help"),
"help response for Subhandler verb must return introspection data: {text}"
);
Ok(())
}
#[tokio::test]
async fn get_returns_flat_shape_with_full_uuid_in_default_agent_mode() -> anyhow::Result<()> {
let client = connect().await?;
let created = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="FlatGetEntity")"#,
)
.await?;
let full_id = created["id"].as_str().unwrap().to_string();
assert_eq!(full_id.len(), 36, "verbose create must have full UUID");
let result = call(
&client,
"request",
json!({"ops": format!(r#"get(id="{full_id}")"#)}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], true, "get must succeed: {first}");
let entity = &first["result"];
assert_eq!(
entity["kind"], "concept",
"get flat response must have top-level kind=concept (entity_kind); got {entity}"
);
assert!(
entity.get("data").is_none(),
"get must NOT wrap in {{data: ...}}; got {entity}"
);
let returned_id = entity["id"].as_str().unwrap_or("");
assert_eq!(
returned_id.len(),
36,
"get (AlwaysVerbose) must return full 36-char UUID in id; got {returned_id:?}"
);
assert_eq!(
returned_id, full_id,
"returned id must match the created entity's full UUID"
);
Ok(())
}
#[tokio::test]
async fn link_is_always_verbose_returns_full_uuids_in_agent_mode() -> anyhow::Result<()> {
let client = connect().await?;
let a = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="LinkVerboseA")"#,
)
.await?;
let b = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="LinkVerboseB")"#,
)
.await?;
let a_id = a["id"].as_str().unwrap().to_string();
let b_id = b["id"].as_str().unwrap().to_string();
assert_eq!(a_id.len(), 36);
assert_eq!(b_id.len(), 36);
let result = call(
&client,
"request",
json!({
"ops": format!(
r#"link(source_id="{a_id}", target_id="{b_id}", relation="extends")"#
)
}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], true, "link must succeed: {first}");
let edge = &first["result"];
let src = edge["source_id"].as_str().unwrap_or("");
let tgt = edge["target_id"].as_str().unwrap_or("");
assert_eq!(
src.len(),
36,
"link source_id must be full 36-char UUID in Agent mode (AlwaysVerbose); got {src:?}"
);
assert_eq!(
tgt.len(),
36,
"link target_id must be full 36-char UUID in Agent mode (AlwaysVerbose); got {tgt:?}"
);
let edge_id = edge["id"].as_str().unwrap_or("");
assert_eq!(
edge_id.len(),
36,
"link edge id must be full UUID in Agent mode (AlwaysVerbose); got {edge_id:?}"
);
let result_verbose = call(
&client,
"request",
json!({
"ops": format!(
r#"link(source_id="{a_id}", target_id="{b_id}", relation="variant_of")"#
),
"presentation": "verbose"
}),
)
.await?;
let body_v: Value = serde_json::from_str(&first_text(&result_verbose))?;
let first_v = &body_v["results"][0];
assert_eq!(first_v["ok"], true, "verbose link must succeed: {first_v}");
let edge_v = &first_v["result"];
assert_eq!(
edge_v["source_id"].as_str().unwrap_or("").len(),
36,
"link source_id must be 36-char in verbose mode"
);
Ok(())
}
#[tokio::test]
async fn get_proposal_id_returns_proposal_created_payload() -> anyhow::Result<()> {
let client = connect().await?;
let parent_ops = serde_json::to_string(&json!([{
"tool": "propose",
"args": {
"title": "parent proposal",
"description": "base proposal that the amendment will reference",
"changeset": {
"kind": "add_entity",
"entity": { "kind": "concept", "name": "ParentProposalEntity" }
}
}
}]))
.unwrap();
let parent_result = call(
&client,
"request",
json!({"ops": parent_ops, "presentation": "verbose"}),
)
.await?;
let parent_body: Value = serde_json::from_str(&first_text(&parent_result))?;
let parent_first = &parent_body["results"][0];
assert_eq!(
parent_first["ok"], true,
"parent propose must succeed; got: {parent_first}"
);
let parent_id = parent_first["result"]["proposal_id"]
.as_str()
.expect("parent proposal_id")
.to_string();
assert_eq!(parent_id.len(), 36, "parent proposal_id must be full UUID");
let ops = serde_json::to_string(&json!([{
"tool": "propose",
"args": {
"title": "get-payload regression",
"description": "ADR-046:299 regression — description must survive get()",
"changeset": {
"kind": "add_entity",
"entity": {
"kind": "concept",
"name": "PayloadRegressionEntity"
}
},
"reviewers": ["alice", "bob"],
"parent_id": parent_id
}
}]))
.unwrap();
let result = call(
&client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], true, "propose must succeed; got: {first}");
let proposal_id = first["result"]["proposal_id"]
.as_str()
.expect("propose must return proposal_id")
.to_string();
assert_eq!(
proposal_id.len(),
36,
"proposal_id from propose must be full UUID"
);
let get_result = ok_one(&client, &format!(r#"get(id="{proposal_id}")"#)).await?;
assert_eq!(
get_result["description"].as_str().unwrap_or(""),
"ADR-046:299 regression — description must survive get()",
"get(id=proposal_id) must return description from ProposalCreated payload"
);
let reviewers = get_result["reviewers"]
.as_array()
.expect("get(id=proposal_id) must return reviewers array");
assert_eq!(
reviewers.len(),
2,
"get(id=proposal_id) must return all reviewers; got: {reviewers:?}"
);
assert!(
reviewers.iter().any(|r| r.as_str() == Some("alice")),
"reviewers must include alice; got: {reviewers:?}"
);
assert!(
reviewers.iter().any(|r| r.as_str() == Some("bob")),
"reviewers must include bob; got: {reviewers:?}"
);
let changeset = &get_result["changeset"];
assert!(
!changeset.is_null(),
"get(id=proposal_id) must return changeset; got null"
);
assert_eq!(
changeset["kind"].as_str().unwrap_or(""),
"add_entity",
"changeset kind must be add_entity; got: {changeset}"
);
assert!(
!get_result["parent_id"].is_null(),
"get(id=proposal_id) must return parent_id when set; got: {get_result}"
);
Ok(())
}
#[tokio::test]
async fn list_proposals_without_status_returns_all_rows() -> anyhow::Result<()> {
let client = connect().await?;
let ops = serde_json::to_string(&json!([
{
"tool": "propose",
"args": {
"title": "audit-row-A",
"description": "first proposal",
"changeset": {
"kind": "add_entity",
"entity": {"kind": "concept", "name": "AuditEntityA"}
},
"reviewers": []
}
},
{
"tool": "propose",
"args": {
"title": "audit-row-B",
"description": "second proposal",
"changeset": {
"kind": "add_entity",
"entity": {"kind": "concept", "name": "AuditEntityB"}
},
"reviewers": []
}
}
]))
.unwrap();
let result = call(
&client,
"request",
json!({"ops": ops, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
assert_eq!(body["results"][0]["ok"], true, "first propose must succeed");
assert_eq!(
body["results"][1]["ok"], true,
"second propose must succeed"
);
let pid_a = body["results"][0]["result"]["proposal_id"]
.as_str()
.unwrap()
.to_string();
let ops_withdraw = serde_json::to_string(&json!([{
"tool": "withdraw",
"args": {
"proposal_id": pid_a,
"rationale": "test withdrawal for audit list"
}
}]))
.unwrap();
let wr = call(
&client,
"request",
json!({"ops": ops_withdraw, "presentation": "verbose"}),
)
.await?;
let wr_body: Value = serde_json::from_str(&first_text(&wr))?;
assert_eq!(
wr_body["results"][0]["ok"], true,
"withdraw must succeed; got: {}",
wr_body["results"][0]
);
let list_result = ok_one(&client, r#"list(kind="proposal")"#).await?;
let items = list_result
.as_array()
.expect("list(kind=proposal) must return a JSON array");
assert!(
items.len() >= 2,
"list(kind=proposal) without status must include all rows (audit trail); \
got {} items — withdrawn proposal must not be hidden",
items.len()
);
let list_open = ok_one(&client, r#"list(kind="proposal", status="open")"#).await?;
let open_items = list_open
.as_array()
.expect("list(kind=proposal, status=open) must return a JSON array");
assert!(
open_items
.iter()
.all(|i| i["status"].as_str() == Some("open")),
"list(kind=proposal, status=open) must return only open proposals; got: {open_items:?}"
);
Ok(())
}
#[test]
fn actor_precedence_default_local_with_no_config() {
use khive_runtime::{Namespace, RuntimeConfig};
let config = RuntimeConfig::default();
assert_eq!(
config.default_namespace,
Namespace::parse("local").unwrap(),
"RuntimeConfig::default() must produce namespace 'local' (tier-4 hard default)"
);
}
#[test]
fn actor_precedence_config_actor_applied_when_no_cli() {
use khive_runtime::{runtime_config_from_khive_config, KhiveConfig, Namespace, RuntimeConfig};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
"[actor]\nid = \"lambda:from-config\"\n"
)
.unwrap();
let khive_cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file found");
assert_eq!(khive_cfg.actor.id.as_deref(), Some("lambda:from-config"));
let base = RuntimeConfig::default();
let resolved = runtime_config_from_khive_config(&khive_cfg, base);
assert_eq!(
resolved.default_namespace,
Namespace::parse("lambda:from-config").unwrap(),
"config actor.id must override the hard default when no CLI actor is set"
);
}
#[test]
fn actor_precedence_explicit_namespace_local_wins_over_config() {
use khive_runtime::{runtime_config_from_khive_config, KhiveConfig, Namespace, RuntimeConfig};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
"[actor]\nid = \"lambda:from-config\"\n"
)
.unwrap();
let khive_cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file found");
let mut effective_cfg = khive_cfg;
effective_cfg.actor.id = None;
let base = RuntimeConfig {
default_namespace: Namespace::parse("local").unwrap(), additional_embedding_models: vec![],
..RuntimeConfig::default()
};
let resolved = runtime_config_from_khive_config(&effective_cfg, base);
assert_eq!(
resolved.default_namespace,
Namespace::parse("local").unwrap(),
"--namespace local (explicit) must win over config actor.id"
);
}
#[test]
fn actor_precedence_cli_actor_wins_over_config() {
use khive_runtime::{runtime_config_from_khive_config, KhiveConfig, Namespace, RuntimeConfig};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
"[actor]\nid = \"lambda:from-config\"\n"
)
.unwrap();
let khive_cfg = KhiveConfig::load(Some(&path))
.expect("load should succeed")
.expect("file found");
let mut effective_cfg = khive_cfg;
effective_cfg.actor.id = None;
let base = RuntimeConfig {
default_namespace: Namespace::parse("lambda:cli-actor").unwrap(),
additional_embedding_models: vec![],
..RuntimeConfig::default()
};
let resolved = runtime_config_from_khive_config(&effective_cfg, base);
assert_eq!(
resolved.default_namespace,
Namespace::parse("lambda:cli-actor").unwrap(),
"--actor lambda:cli-actor must win over config actor.id"
);
}
#[test]
fn actor_invalid_config_id_fails_at_load() {
use khive_runtime::{ConfigError, KhiveConfig};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
"[actor]\nid = \"bad namespace\"\n"
)
.unwrap();
let err = KhiveConfig::load(Some(&path)).expect_err("invalid actor.id must fail at load");
assert!(
matches!(err, ConfigError::InvalidActorId { .. }),
"expected ConfigError::InvalidActorId, got {err:?}"
);
}
#[test]
fn actor_empty_string_id_fails_at_load() {
use khive_runtime::{ConfigError, KhiveConfig};
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
writeln!(
std::fs::File::create(&path).unwrap(),
"[actor]\nid = \"\"\n"
)
.unwrap();
let err = KhiveConfig::load(Some(&path)).expect_err("empty actor.id must fail at load");
assert!(
matches!(err, ConfigError::InvalidActorId { .. }),
"expected ConfigError::InvalidActorId for empty string, got {err:?}"
);
}
struct ClearEnvGuard {
vars: Vec<&'static str>,
}
impl ClearEnvGuard {
fn new(vars: &[&'static str]) -> Self {
for &v in vars {
std::env::remove_var(v);
}
Self {
vars: vars.to_vec(),
}
}
}
impl Drop for ClearEnvGuard {
fn drop(&mut self) {
for &v in &self.vars {
std::env::remove_var(v);
}
}
}
#[test]
#[serial_test::serial]
fn cli_args_actor_flag_is_explicit() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from(["khive-mcp", "--actor", "lambda:cli-actor"]).unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(explicit, "--actor must mark namespace as explicit");
assert_eq!(ns, Namespace::parse("lambda:cli-actor").unwrap());
}
#[test]
#[serial_test::serial]
fn cli_args_actor_local_is_explicit() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from(["khive-mcp", "--actor", "local"]).unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(
explicit,
"--actor local must be explicit, not treated as absent default"
);
assert_eq!(ns, Namespace::parse("local").unwrap());
}
#[test]
#[serial_test::serial]
fn cli_args_namespace_flag_is_explicit() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from(["khive-mcp", "--namespace", "lambda:ns-flag"]).unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(explicit, "--namespace must mark namespace as explicit");
assert_eq!(ns, Namespace::parse("lambda:ns-flag").unwrap());
}
#[test]
#[serial_test::serial]
fn cli_args_namespace_local_is_explicit() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from(["khive-mcp", "--namespace", "local"]).unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(
explicit,
"--namespace local must be explicit (regression: was previously treated as absent)"
);
assert_eq!(ns, Namespace::parse("local").unwrap());
}
#[test]
#[serial_test::serial]
fn cli_args_actor_wins_over_namespace_when_both_supplied() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from([
"khive-mcp",
"--actor",
"lambda:actor-wins",
"--namespace",
"lambda:ns-loses",
])
.unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(explicit);
assert_eq!(
ns,
Namespace::parse("lambda:actor-wins").unwrap(),
"--actor must win over --namespace when both are supplied"
);
}
#[test]
#[serial_test::serial]
fn cli_args_no_flags_gives_local_default() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_ACTOR", "KHIVE_NAMESPACE"]);
let args = Args::try_parse_from(["khive-mcp"]).unwrap();
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(!explicit, "no flags must not be treated as explicit");
assert_eq!(
ns,
Namespace::parse("local").unwrap(),
"default namespace must be 'local' when no CLI flags are supplied"
);
}
#[test]
#[serial_test::serial]
fn cli_args_khive_namespace_env_is_explicit() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_NAMESPACE", "KHIVE_ACTOR"]);
std::env::set_var("KHIVE_NAMESPACE", "lambda:from-env");
let args = Args::try_parse_from(["khive-mcp"]).unwrap();
std::env::remove_var("KHIVE_NAMESPACE");
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(
explicit,
"KHIVE_NAMESPACE env must mark namespace as explicit"
);
assert_eq!(ns, Namespace::parse("lambda:from-env").unwrap());
}
#[test]
#[serial_test::serial]
fn cli_args_khive_actor_env_is_explicit_and_wins() {
use clap::Parser;
use khive_mcp::args::{resolve_cli_namespace, Args};
use khive_runtime::Namespace;
let _guard = ClearEnvGuard::new(&["KHIVE_NAMESPACE", "KHIVE_ACTOR"]);
std::env::set_var("KHIVE_ACTOR", "lambda:actor-env");
std::env::set_var("KHIVE_NAMESPACE", "lambda:ns-env");
let args = Args::try_parse_from(["khive-mcp"]).unwrap();
std::env::remove_var("KHIVE_ACTOR");
std::env::remove_var("KHIVE_NAMESPACE");
let (explicit, ns) = resolve_cli_namespace(&args).unwrap();
assert!(explicit);
assert_eq!(
ns,
Namespace::parse("lambda:actor-env").unwrap(),
"KHIVE_ACTOR env must win over KHIVE_NAMESPACE"
);
}
#[tokio::test]
async fn update_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect().await?;
let entity = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="UpdateUnknownKwargTest")"#,
)
.await?;
let id = entity["id"].as_str().unwrap();
let result = call(
&client,
"request",
json!({ "ops": format!(r#"update(id="{id}", nonexistent_field="x")"#) }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"update with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("nonexistent_field") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn remember_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_full().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"memory.remember(content="test memory", garbage_arg="xyz")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"remember with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("garbage_arg") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn remember_aliases_still_accepted() -> anyhow::Result<()> {
let client = connect_full().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"memory.remember(content="alias test", salience=0.8, decay=0.005)"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(true),
"remember with aliases salience/decay must succeed; got: {first}"
);
Ok(())
}
#[tokio::test]
async fn entity_create_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
let result = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="TimestampTest-Entity")"#,
)
.await?;
let created_at = result["created_at"].as_str().unwrap_or("");
let updated_at = result["updated_at"].as_str().unwrap_or("");
assert!(
!created_at.is_empty(),
"entity create created_at must be a string, got: {:?}",
result["created_at"]
);
assert!(
created_at.starts_with("20"),
"entity create created_at must be ISO-8601, got: {created_at:?}"
);
assert!(
updated_at.starts_with("20"),
"entity create updated_at must be ISO-8601, got: {updated_at:?}"
);
Ok(())
}
#[tokio::test]
async fn note_create_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
let result = ok_one(
&client,
r#"create(kind="note", content="timestamp test note")"#,
)
.await?;
let created_at = result["created_at"].as_str().unwrap_or("");
let updated_at = result["updated_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"note create created_at must be ISO-8601, got: {created_at:?}"
);
assert!(
updated_at.starts_with("20"),
"note create updated_at must be ISO-8601, got: {updated_at:?}"
);
Ok(())
}
#[tokio::test]
async fn entity_get_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
let created = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="TimestampGet-Entity")"#,
)
.await?;
let id = created["id"].as_str().unwrap();
let result = call(
&client,
"request",
json!({"ops": format!(r#"get(id="{id}")"#)}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(first["ok"], true, "get must succeed: {first}");
let entity = &first["result"];
let created_at = entity["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"entity get created_at must be ISO-8601, got: {created_at:?}"
);
Ok(())
}
#[tokio::test]
async fn entity_list_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="TimestampList-Entity")"#,
)
.await?;
let result = ok_one(&client, r#"list(kind="entity", limit=3)"#).await?;
let items = result
.as_array()
.expect("list(kind=entity) returns array of entities");
assert!(!items.is_empty(), "list must return at least one entity");
for item in items {
let created_at = item["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"entity list created_at must be ISO-8601, got: {created_at:?} in {item}"
);
}
Ok(())
}
#[tokio::test]
async fn entity_update_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
let created = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="TimestampUpdate-Entity")"#,
)
.await?;
let id = created["id"].as_str().unwrap();
let result = ok_one(
&client,
&format!(r#"update(id="{id}", description="updated")"#),
)
.await?;
let updated_at = result["updated_at"].as_str().unwrap_or("");
assert!(
updated_at.starts_with("20"),
"entity update updated_at must be ISO-8601, got: {updated_at:?}"
);
Ok(())
}
#[tokio::test]
async fn recall_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_full().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"memory.recall(query="test", typo_kwarg="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"recall with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("typo_kwarg") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn list_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"list(kind="entity", typo_kwarg="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"list with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("typo_kwarg") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn remember_returns_iso8601_timestamp() -> anyhow::Result<()> {
let client = connect_full().await?;
let result = ok_one(
&client,
r#"memory.remember(content="r3 timestamp test", salience=0.7)"#,
)
.await?;
let created_at = result["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"remember created_at must be ISO-8601 string, got: {:?}",
result["created_at"]
);
Ok(())
}
#[tokio::test]
async fn recall_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect_full().await?;
ok_one(
&client,
r#"memory.remember(content="r3 recall timestamp seed")"#,
)
.await?;
let result = ok_one(
&client,
r#"memory.recall(query="r3 recall timestamp seed", limit=1)"#,
)
.await?;
let hits = result.as_array().expect("recall returns array");
assert!(!hits.is_empty(), "recall must return at least one hit");
let created_at = hits[0]["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"recall hit created_at must be ISO-8601 string, got: {:?}",
hits[0]["created_at"]
);
Ok(())
}
fn make_comm_server_only() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("commtest").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "comm".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("kg+comm runtime");
KhiveMcpServer::new(runtime).expect("server builds with kg+comm")
}
async fn connect_comm_only(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_comm_server_only();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn send_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect_comm_only().await?;
ok_one(
&client,
r#"comm.send(to="commtest", content="r3 timestamp test message")"#,
)
.await?;
let result = call(
&client,
"request",
json!({"ops": r#"list(kind="note", limit=1)"#, "presentation": "verbose"}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(true),
"list(kind=note) must succeed: {first}"
);
let items = first["result"].as_array().expect("list returns array");
assert!(!items.is_empty(), "must have at least one message note");
let created_at = items[0]["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"message note created_at must be ISO-8601 string, got: {:?}",
items[0]["created_at"]
);
Ok(())
}
fn make_schedule_server_only() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("schedtest").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "schedule".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("kg+schedule runtime");
KhiveMcpServer::new(runtime).expect("server builds with kg+schedule")
}
async fn connect_schedule_only(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_schedule_server_only();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn agenda_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect_schedule_only().await?;
ok_one(
&client,
r#"schedule.remind(content="r3 agenda ts test", at="2099-01-01T00:00:00Z")"#,
)
.await?;
let result = ok_one(&client, r#"schedule.agenda()"#).await?;
let items = result["events"]
.as_array()
.expect("agenda returns events array");
assert!(!items.is_empty(), "agenda must return at least one event");
let created_at = items[0]["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"agenda event created_at must be ISO-8601 string, got: {:?}",
items[0]["created_at"]
);
Ok(())
}
async fn connect_brain_only(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("braintest2").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "brain".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("kg+brain runtime");
let server = KhiveMcpServer::new(runtime).expect("server builds");
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn brain_profiles_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect_brain_only().await?;
let result = ok_one(&client, r#"brain.profiles()"#).await?;
let profiles = result["profiles"]
.as_array()
.expect("brain.profiles returns profiles array");
assert!(
!profiles.is_empty(),
"brain.profiles must return at least one profile"
);
let created_at = profiles[0]["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"brain.profiles created_at must be ISO-8601 string, got: {:?}",
profiles[0]["created_at"]
);
Ok(())
}
#[tokio::test]
async fn proposal_list_returns_iso8601_timestamps() -> anyhow::Result<()> {
let client = connect().await?;
ok_one(
&client,
r#"propose(title="r3 ts test proposal", description="r3 timestamp regression test", changeset={"kind": "add_entity", "entity": {"kind": "concept", "name": "R3TsEntity"}})"#,
)
.await?;
let result = ok_one(&client, r#"list(kind="proposal")"#).await?;
let proposals = result
.as_array()
.expect("list(kind=proposal) returns array");
assert!(!proposals.is_empty(), "must have at least one proposal");
let created_at = proposals[0]["created_at"].as_str().unwrap_or("");
assert!(
created_at.starts_with("20"),
"proposal list created_at must be ISO-8601 string, got: {:?}",
proposals[0]["created_at"]
);
Ok(())
}
#[tokio::test]
async fn create_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"create(kind="concept", name="X", unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"create with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn assign_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_full().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"gtd.assign(title="GTD unknown kwarg test", unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"assign with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn send_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_comm_only().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"comm.send(to="alice", content="test", unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"send with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn agenda_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_schedule_only().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"schedule.agenda(unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"agenda with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn brain_profile_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_brain_only().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"brain.profile(id="balanced-recall-v1", unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"brain.profile with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
fn make_knowledge_server() -> KhiveMcpServer {
disable_daemon();
let config = RuntimeConfig {
db_path: None,
default_namespace: Namespace::parse("knowtest").unwrap(),
embedding_model: None,
additional_embedding_models: vec![],
packs: vec!["kg".to_string(), "knowledge".to_string()],
..RuntimeConfig::default()
};
let runtime = KhiveRuntime::new(config).expect("kg+knowledge runtime");
KhiveMcpServer::new(runtime).expect("server builds with kg+knowledge")
}
async fn connect_knowledge(
) -> anyhow::Result<impl std::ops::Deref<Target = rmcp::service::Peer<rmcp::RoleClient>>> {
let (server_transport, client_transport) = tokio::io::duplex(65536);
let server = make_knowledge_server();
tokio::spawn(async move {
if let Ok(svc) = server.serve(server_transport).await {
let _ = svc.waiting().await;
}
});
let client = DummyClient.serve(client_transport).await?;
Ok(client)
}
#[tokio::test]
async fn topic_rejects_unknown_kwarg() -> anyhow::Result<()> {
let client = connect_knowledge().await?;
let result = call(
&client,
"request",
json!({ "ops": r#"knowledge.topic(unknownkw="oops")"# }),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(false),
"topic with unknown kwarg must fail; got: {first}"
);
let err = first["error"].as_str().unwrap_or("");
assert!(
err.contains("unknownkw") || err.contains("unknown field"),
"error must mention the unknown field; got: {err}"
);
Ok(())
}
#[tokio::test]
async fn brain_feedback_default_agent_response_preserves_full_target_id() -> anyhow::Result<()> {
let client = connect_brain_only().await?;
let created = ok_one(
&client,
r#"create(kind="entity", entity_kind="concept", name="BrainFeedbackTarget")"#,
)
.await?;
let target_id = created["id"]
.as_str()
.expect("created entity id")
.to_string();
assert_eq!(
target_id.len(),
36,
"entity id from verbose ok_one must be 36-char"
);
let result = call(
&client,
"request",
json!({"ops": format!(r#"brain.feedback(target_id="{target_id}", signal="useful")"#)}),
)
.await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(true),
"brain.feedback must succeed: {first}"
);
let returned = first["result"]["target_id"].as_str().unwrap_or("");
assert_eq!(
returned.len(),
36,
"brain.feedback Agent response target_id must be full 36-char UUID, got: {returned:?}"
);
assert_eq!(returned, target_id, "returned target_id must match input");
Ok(())
}
#[tokio::test]
async fn schedule_agenda_agent_preserves_properties_trigger_at_verbatim() -> anyhow::Result<()> {
let client = connect_schedule_only().await?;
let trigger_at = "2099-01-01T00:00:00Z";
ok_one(
&client,
&format!(r#"schedule.remind(content="agent trigger_at fidelity", at="{trigger_at}")"#),
)
.await?;
let result = call(&client, "request", json!({"ops": "schedule.agenda()"})).await?;
let body: Value = serde_json::from_str(&first_text(&result))?;
let first = &body["results"][0];
assert_eq!(
first["ok"],
json!(true),
"schedule.agenda must succeed: {first}"
);
let events = first["result"]["events"].as_array().expect("events array");
assert!(!events.is_empty(), "agenda must have at least one event");
let actual = events[0]["properties"]["trigger_at"].as_str().unwrap_or("");
assert_eq!(
actual, trigger_at,
"trigger_at inside properties must be preserved verbatim in Agent mode"
);
assert_ne!(
actual, "2099-01-01T00:00",
"trigger_at must not be truncated to minute granularity"
);
Ok(())
}