#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use meerkat_client::{LlmClient, LlmError, LlmEvent, LlmRequest};
use meerkat_core::types::StopReason;
use meerkat_mob::{MobDefinition, SpawnMemberSpec};
use meerkat_mobkit::{UnifiedRuntime, handle_unified_rpc_json};
use serde_json::{Value, json};
#[path = "support/llm_usage.rs"]
mod llm_usage;
const WORKGRAPH_E2E_TOML: &str = r#"
[mob]
id = "workgraph-e2e-mob"
[profiles.worker]
model = "gpt-5.5"
runtime_mode = "autonomous_host"
external_addressable = true
[profiles.worker.tools]
comms = true
workgraph = true
"#;
#[derive(Clone, Default)]
struct CaptureClient {
requests: Arc<std::sync::Mutex<Vec<Vec<String>>>>,
notify: Arc<tokio::sync::Notify>,
}
impl CaptureClient {
fn captured(&self) -> Vec<Vec<String>> {
self.requests.lock().unwrap().clone()
}
async fn wait_for_request(&self, minimum: usize) -> Vec<Vec<String>> {
tokio::time::timeout(Duration::from_secs(30), async {
loop {
let captured = self.captured();
if captured.len() >= minimum {
return captured;
}
self.notify.notified().await;
}
})
.await
.expect("member turn should reach the LLM client")
}
}
impl LlmClient for CaptureClient {
fn project_replay_messages(
&self,
messages: &[meerkat_core::Message],
) -> Result<Vec<meerkat_core::Message>, LlmError> {
Ok(messages.to_vec())
}
fn stream<'a>(
&'a self,
request: &'a LlmRequest,
) -> Pin<Box<dyn futures::Stream<Item = Result<LlmEvent, LlmError>> + Send + 'a>> {
let tool_names = request
.tools
.iter()
.map(|tool| tool.name.to_string())
.collect();
self.requests.lock().unwrap().push(tool_names);
self.notify.notify_waiters();
let [usage, done] =
llm_usage::usage_then_done(request, meerkat::Provider::OpenAI, StopReason::EndTurn);
Box::pin(async_stream::stream! {
yield Ok(LlmEvent::TextDelta {
delta: "ok".to_string(),
meta: None,
});
yield Ok(usage);
yield Ok(done);
})
}
fn provider(&self) -> meerkat::Provider {
meerkat::Provider::OpenAI
}
fn health_check<'life0, 'async_trait>(
&'life0 self,
) -> Pin<Box<dyn Future<Output = Result<(), LlmError>> + Send + 'async_trait>>
where
'life0: 'async_trait,
Self: 'async_trait,
{
Box::pin(async { Ok(()) })
}
}
async fn rpc(runtime: &UnifiedRuntime, method: &str, params: Value) -> Value {
let request = json!({
"jsonrpc": "2.0",
"id": "wg-e2e",
"method": method,
"params": params,
})
.to_string();
let response =
handle_unified_rpc_json(runtime, &request, Duration::from_secs(5), None, None).await;
serde_json::from_str(&response).expect("rpc response json")
}
fn has_tool(tools: &[String], name: &str) -> bool {
tools.iter().any(|tool| tool == name)
}
#[tokio::test(flavor = "multi_thread")]
async fn member_tools_and_attention_overlay_reach_live_turns() {
let state_dir = tempfile::tempdir().expect("state dir");
let client = CaptureClient::default();
let runtime = Box::pin(
UnifiedRuntime::builder()
.definition(MobDefinition::from_toml(WORKGRAPH_E2E_TOML).expect("parse e2e definition"))
.persistent_state(state_dir.path())
.default_llm_client(Arc::new(client.clone()))
.build(),
)
.await
.expect("persistent workgraph runtime builds");
assert!(
state_dir
.path()
.join(meerkat_mobkit::workgraph_wiring::WORKGRAPH_STORE_FILE)
.exists(),
"persistent runtimes must open workgraph.sqlite3"
);
runtime
.spawn_many(vec![SpawnMemberSpec::from_wire(
"worker".to_string(),
"helper".to_string(),
None,
None,
None,
)])
.await
.expect("spawn workgraph-enabled member");
meerkat_mobkit::send_message_on_mob(&runtime.mob_handle(), "helper", "hello".to_string())
.await
.expect("first send");
let captured = client.wait_for_request(1).await;
let unbound_tools = &captured[0];
for tool in [
"workgraph_create",
"workgraph_get",
"workgraph_claim",
"workgraph_close",
"workgraph_attention_reassign",
] {
assert!(
has_tool(unbound_tools, tool),
"profile tools.workgraph=true must compose {tool}: {unbound_tools:?}"
);
}
assert!(
has_tool(unbound_tools, "send_message"),
"comms tools present on unbound turns: {unbound_tools:?}"
);
let response = rpc(
&runtime,
"mobkit/workgraph/goal/create",
json!({
"title": "watch the queue",
"target": { "kind": "identity", "identity": "helper" },
}),
)
.await;
assert!(response["error"].is_null(), "{response:#?}");
let binding_id = response["result"]["attention"]["binding_id"]
.as_str()
.expect("binding id")
.to_string();
assert_eq!(
response["result"]["attention"]["target"]["owner_key"]["id"],
json!("mob/workgraph-e2e-mob/agent/helper"),
"identity target lowers against the runtime's mob id"
);
let status = rpc(
&runtime,
"mobkit/workgraph/goal/status",
json!({ "binding_id": binding_id }),
)
.await;
assert_eq!(
status["result"]["attention"]["status"]["state"],
json!("active")
);
let listed = rpc(&runtime, "mobkit/workgraph/attention/list", json!({})).await;
assert_eq!(
listed["result"]["attention"].as_array().unwrap().len(),
1,
"{listed:#?}"
);
meerkat_mobkit::send_message_on_mob(
&runtime.mob_handle(),
"helper",
"how is the queue?".to_string(),
)
.await
.expect("second send");
let captured = client.wait_for_request(2).await;
let bound_tools = &captured[1];
assert!(
has_tool(bound_tools, "workgraph_get"),
"pursue allow-set must include workgraph_get: {bound_tools:?}"
);
assert!(
has_tool(bound_tools, "workgraph_add_evidence"),
"pursue allow-set must include workgraph_add_evidence: {bound_tools:?}"
);
for blocked in [
"workgraph_create",
"workgraph_claim",
"workgraph_attention_reassign",
"send_message",
] {
assert!(
!has_tool(bound_tools, blocked),
"attention-scoped turns must not expose {blocked}: {bound_tools:?}"
);
}
assert!(
bound_tools.len() < unbound_tools.len(),
"the overlay must narrow the tool surface: {} -> {}",
unbound_tools.len(),
bound_tools.len()
);
let binding_revision = status["result"]["attention"]["machine_state"]["revision"]
.as_u64()
.expect("binding revision");
let paused = rpc(
&runtime,
"mobkit/workgraph/attention/pause",
json!({ "binding_id": binding_id, "expected_revision": binding_revision }),
)
.await;
assert!(paused["error"].is_null(), "{paused:#?}");
meerkat_mobkit::send_message_on_mob(
&runtime.mob_handle(),
"helper",
"back to normal".to_string(),
)
.await
.expect("third send");
let captured = client.wait_for_request(3).await;
let unpaused_tools = &captured[2];
assert!(
has_tool(unpaused_tools, "workgraph_create") && has_tool(unpaused_tools, "send_message"),
"pausing the binding must restore the full tool surface: {unpaused_tools:?}"
);
let teardown = runtime.stop_mob_for_teardown().await;
assert!(
teardown.teardown_may_proceed(),
"teardown must not be blocked by a member's runtime readiness: {teardown:?}"
);
}