#![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
use evorule_reactor::{Fact, FactId, FactIdGenerator, IoType, Reactor};
use evorule_tcb::JsonValue;
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::time::Duration;
use tokio::time::timeout;
fn serde_to_tcb(v: serde_json::Value) -> JsonValue {
match v {
serde_json::Value::Null => JsonValue::Null,
serde_json::Value::Bool(b) => JsonValue::Bool(b),
serde_json::Value::Number(n) => {
if let Some(i) = n.as_i64() {
JsonValue::Integer(i)
} else {
JsonValue::String(n.to_string())
}
}
serde_json::Value::String(s) => JsonValue::String(s),
serde_json::Value::Array(arr) => {
JsonValue::Array(arr.into_iter().map(serde_to_tcb).collect())
}
serde_json::Value::Object(obj) => {
let mut map = BTreeMap::new();
for (k, v) in obj {
map.insert(k, serde_to_tcb(v));
}
JsonValue::Object(map)
}
}
}
fn load_core_eval() -> Vec<JsonValue> {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let core_eval_path = manifest_dir.join("../evorule-tcb/core_eval.json");
let json_str = std::fs::read_to_string(&core_eval_path).unwrap_or_else(|e| {
panic!(
"Failed to read core_eval.json at {:?}: {}",
core_eval_path, e
)
});
let json: serde_json::Value =
serde_json::from_str(&json_str).expect("Failed to parse core_eval.json");
json.get("transform")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().cloned().map(serde_to_tcb).collect())
.unwrap_or_default()
}
fn make_instruction(typ: &str, attr: &str, delta: i64) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string(attr));
params.insert("delta".to_string(), JsonValue::Integer(delta));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string(typ));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn make_call_external_instruction(prompt: &str) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("prompt".to_string(), JsonValue::string(prompt));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("call_external"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
#[tokio::test]
async fn test_simple_increment() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instruction = make_instruction("increment", "x", 5);
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { final_snapshot, .. } => return Some(final_snapshot),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let snapshot = result.unwrap();
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(5)));
}
#[tokio::test]
async fn test_io_request_detection() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instruction = make_call_external_instruction("Hello");
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let (request_id, io_type, params) = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::IoRequest {
id,
io_type,
params,
..
} => return Some((id, io_type, params)),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap()
.expect("IoRequest not received");
assert_eq!(io_type, IoType::call_external());
assert_eq!(params.get("prompt").and_then(|v| v.as_str()), Some("Hello"));
let result = JsonValue::string("response from LLM");
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result,
error: None,
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { final_snapshot, .. } => return Some(final_snapshot),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let snapshot = result.unwrap();
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("response from LLM"),
"llm_response business field should be set from __io_result__"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared after being consumed"
);
}
#[tokio::test]
async fn test_unknown_io_response_ignored() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id: FactId(999),
result: JsonValue::string("spurious"),
error: None,
})
.unwrap();
let instruction = make_instruction("increment", "x", 5);
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { final_snapshot, .. } => return Some(final_snapshot),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let snapshot = result.unwrap();
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(5)));
}
#[tokio::test]
async fn test_facts_log_records_all_facts() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instruction = make_instruction("increment", "x", 5);
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { .. } => return Some(()),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let history = facts_log.history();
assert!(
history.len() >= 3,
"Expected at least 3 facts in log, got {}",
history.len()
);
assert!(matches!(history[0], Fact::Command { .. }));
assert!(matches!(history.last().unwrap(), Fact::Stable { .. }));
let version = facts_log.version();
assert!(version >= 1, "Version should be >= 1, got {}", version);
let all = facts_log.read_from(0);
assert_eq!(all.len(), history.len());
}
#[tokio::test]
async fn test_facts_log_with_io_request() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instruction = make_call_external_instruction("test prompt");
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let request_id = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::IoRequest { id, .. } => return Some(id),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap()
.expect("IoRequest not received");
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result: JsonValue::string("llm result"),
error: None,
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { .. } => return Some(()),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let history = facts_log.history();
let has_io_request = history.iter().any(|f| matches!(f, Fact::IoRequest { .. }));
let has_io_response = history.iter().any(|f| matches!(f, Fact::IoResponse { .. }));
assert!(has_io_request, "FactsLog should contain IoRequest");
assert!(has_io_response, "FactsLog should contain IoResponse");
let command_id = history
.iter()
.find_map(|f| match f {
Fact::Command { id, .. } => Some(*id),
_ => None,
})
.expect("Should have Command");
let io_request_cause = history
.iter()
.find_map(|f| match f {
Fact::IoRequest { cause, .. } => Some(*cause),
_ => None,
})
.expect("Should have IoRequest");
assert_eq!(
io_request_cause, command_id,
"IoRequest cause should point to Command id"
);
}
#[tokio::test]
async fn test_io_response_with_error_field() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instruction = make_call_external_instruction("test error");
tx.send(Fact::Command {
id: gen.next_id(),
instruction,
})
.unwrap();
let request_id = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::IoRequest { id, .. } => return Some(id),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap()
.expect("IoRequest not received");
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result: JsonValue::Null,
error: Some("LLM API timeout".to_string()),
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { .. } => return Some(()),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap();
assert!(result.is_some());
let history = facts_log.history();
let io_resp = history.iter().find_map(|f| match f {
Fact::IoResponse { error, .. } => Some(error.clone()),
_ => None,
});
assert!(io_resp.is_some(), "Should have IoResponse in log");
assert_eq!(
io_resp.unwrap(),
Some("LLM API timeout".to_string()),
"Error field should be preserved"
);
}
fn make_set_instruction(attr: &str, value: i64) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string(attr));
params.insert("value".to_string(), JsonValue::Integer(value));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("set"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn make_decrement_instruction(attr: &str, delta: i64) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("attr".to_string(), JsonValue::string(attr));
params.insert("delta".to_string(), JsonValue::Integer(delta));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("decrement"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn make_sequence_instruction(instructions: Vec<JsonValue>) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("instructions".to_string(), JsonValue::Array(instructions));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("sequence"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
async fn wait_for_stable(rx: &mut evorule_reactor::EventReceiver) -> Option<JsonValue> {
timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Stable { final_snapshot, .. } => return Some(final_snapshot),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap()
}
#[tokio::test]
async fn test_decrement_instruction() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_set_instruction("x", 10),
})
.unwrap();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_decrement_instruction("x", 3),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(7)));
}
#[tokio::test]
async fn test_set_instruction() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_set_instruction("y", 99),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot.get("y"), Some(&JsonValue::Integer(99)));
}
#[tokio::test]
async fn test_sequence_instruction_expansion() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instructions = vec![
make_instruction("increment", "x", 1),
make_instruction("increment", "x", 2),
make_instruction("increment", "x", 3),
];
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_sequence_instruction(instructions),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(6)));
}
#[tokio::test]
async fn test_max_rounds_exceeded() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(3).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let instructions = vec![
make_instruction("increment", "x", 1),
make_instruction("increment", "x", 1),
make_instruction("increment", "x", 1),
];
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_sequence_instruction(instructions),
})
.unwrap();
let result = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::Error { message, .. } => return Some(message),
Fact::Stable { .. } => panic!("Should not reach Stable"),
_ => {}
}
}
None
})
.await
.unwrap()
.expect("Error fact not received");
assert!(
result.contains("max rounds exceeded"),
"Expected max rounds error, got: {}",
result
);
let history = facts_log.history();
let has_error = history.iter().any(|f| matches!(f, Fact::Error { .. }));
assert!(has_error, "FactsLog should contain Error fact");
}
#[tokio::test]
async fn test_payload_update() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::PayloadUpdate {
id: gen.next_id(),
path: "x".to_string(),
value: JsonValue::Integer(42),
})
.unwrap();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 5),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(47)));
}
#[tokio::test]
async fn test_payload_update_existing_field() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_set_instruction("x", 10),
})
.unwrap();
tx.send(Fact::PayloadUpdate {
id: gen.next_id(),
path: "y".to_string(),
value: JsonValue::string("hello"),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot.get("x"), Some(&JsonValue::Integer(10)));
assert_eq!(snapshot.get("y").and_then(|v| v.as_str()), Some("hello"));
}
#[tokio::test]
async fn test_multiple_commands_batch() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 5),
})
.unwrap();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 10),
})
.unwrap();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 20),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(
snapshot.get("x"),
Some(&JsonValue::Integer(35)),
"All 3 commands should be executed: expected 35, got {:?}",
snapshot.get("x")
);
}
#[tokio::test]
async fn test_channel_closed() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, _rx, _event_tx, handle, _facts_log) = reactor.spawn();
drop(tx);
let result = handle.join().await;
assert!(
result.is_ok(),
"Expected graceful shutdown Ok(()), got: {:?}",
result
);
}
#[tokio::test]
async fn test_state_transition_cause_chain() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let command_id = gen.next_id();
tx.send(Fact::Command {
id: command_id,
instruction: make_instruction("increment", "x", 7),
})
.unwrap();
let _ = wait_for_stable(&mut rx).await.expect("Stable not received");
let history = facts_log.history();
let command = history
.iter()
.find_map(|f| match f {
Fact::Command { id, .. } => Some(*id),
_ => None,
})
.expect("Should have Command");
let state_transition = history
.iter()
.find_map(|f| match f {
Fact::StateTransition { cause, .. } => Some(*cause),
_ => None,
})
.expect("Should have StateTransition");
assert_eq!(command, command_id, "Command id should match sent id");
assert_eq!(
state_transition, command,
"StateTransition cause should point to Command id"
);
}
#[tokio::test]
async fn test_noop_instruction() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("noop"));
tx.send(Fact::Command {
id: gen.next_id(),
instruction: JsonValue::Object(instr),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot, JsonValue::empty_object());
}
#[tokio::test]
async fn test_unknown_instruction_falls_to_noop() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let mut instr = BTreeMap::new();
instr.insert(
"type".to_string(),
JsonValue::string("unknown_instruction_type"),
);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: JsonValue::Object(instr),
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable not received");
assert_eq!(snapshot, JsonValue::empty_object());
}
#[tokio::test]
async fn test_facts_log_version_tracking() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 5),
})
.unwrap();
let _ = wait_for_stable(&mut rx).await.expect("Stable not received");
let version = facts_log.version();
assert!(
version >= 1,
"Version should be >= 1 after StateTransition, got {}",
version
);
let stable_version = facts_log.last_stable_version();
assert_eq!(
stable_version, version,
"last_stable_version should equal current version after Stable"
);
let (snap, _, _) = facts_log.snapshot();
assert_eq!(snap.get("x"), Some(&JsonValue::Integer(5)));
}
#[tokio::test]
async fn test_read_from_for_audit_replay() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_instruction("increment", "x", 5),
})
.unwrap();
let _ = wait_for_stable(&mut rx).await.expect("Stable not received");
let all_facts = facts_log.read_from(0);
assert!(
all_facts.len() >= 3,
"Should have at least 3 facts (Command + StateTransition + Stable), got {}",
all_facts.len()
);
assert!(matches!(all_facts[0], Fact::Command { .. }));
assert!(matches!(all_facts.last().unwrap(), Fact::Stable { .. }));
}
async fn wait_for_io_request(rx: &mut evorule_reactor::EventReceiver) -> Option<(FactId, IoType)> {
timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
match fact {
Fact::IoRequest { id, io_type, .. } => return Some((id, io_type)),
Fact::Error { message, .. } => panic!("Error: {}", message),
_ => {}
}
}
None
})
.await
.unwrap()
}
fn make_query_db_instruction(query: &str) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("query".to_string(), JsonValue::string(query));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("query_db"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
#[tokio::test]
async fn test_consecutive_different_io_requests_no_interference() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_call_external_instruction("Hello"),
make_query_db_instruction("SELECT 1"),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let (request_id_1, io_type_1) = wait_for_io_request(&mut rx).await.expect("IoRequest 1");
assert_eq!(io_type_1, IoType::call_external());
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id: request_id_1,
result: JsonValue::string("llm answer"),
error: None,
})
.unwrap();
let (request_id_2, io_type_2) = wait_for_io_request(&mut rx).await.expect("IoRequest 2");
assert_eq!(io_type_2, IoType::query_db());
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id: request_id_2,
result: JsonValue::string("db rows"),
error: None,
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("llm answer"),
"call_external should set llm_response"
);
assert_eq!(
snapshot.get("db_result").and_then(|v| v.as_str()),
Some("db rows"),
"query_db should set db_result from its own IoResponse (not残留的 llm answer)"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared after consumption"
);
}
#[tokio::test]
async fn test_io_result_consumed_to_business_field() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, _handle, facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_call_external_instruction("summarize"),
})
.unwrap();
let (request_id, _) = wait_for_io_request(&mut rx).await.expect("IoRequest");
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result: JsonValue::string("summary ok"),
error: None,
})
.unwrap();
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("summary ok")
);
let history = facts_log.history();
let has_command = history.iter().any(|f| matches!(f, Fact::Command { .. }));
let has_io_request = history.iter().any(|f| matches!(f, Fact::IoRequest { .. }));
let has_io_response = history.iter().any(|f| matches!(f, Fact::IoResponse { .. }));
let has_stable = history.iter().any(|f| matches!(f, Fact::Stable { .. }));
let state_transitions = history
.iter()
.filter(|f| matches!(f, Fact::StateTransition { .. }))
.count();
assert!(has_command, "Should have Command");
assert!(has_io_request, "Should have IoRequest");
assert!(has_io_response, "Should have IoResponse");
assert!(has_stable, "Should have Stable");
assert!(
state_transitions >= 1,
"Should have at least 1 StateTransition (recovery execution), got {}",
state_transitions
);
}
fn make_http_get_instruction(url: &str) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("url".to_string(), JsonValue::string(url));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("http_get"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn make_save_memory_instruction(key: &str, value: &str) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("key".to_string(), JsonValue::string(key));
params.insert("value".to_string(), JsonValue::string(value));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("save_memory"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn make_call_service_instruction(service_name: &str) -> JsonValue {
let mut params = BTreeMap::new();
params.insert("service_name".to_string(), JsonValue::string(service_name));
let mut instr = BTreeMap::new();
instr.insert("type".to_string(), JsonValue::string("call_service"));
instr.insert("params".to_string(), JsonValue::Object(params));
JsonValue::Object(instr)
}
fn send_io_response(
tx: &evorule_reactor::FactSender,
gen: &mut FactIdGenerator,
request_id: FactId,
result: &str,
) {
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result: JsonValue::string(result),
error: None,
})
.unwrap();
}
#[tokio::test]
async fn test_three_different_io_types_sequence() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(200).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_call_external_instruction("prompt-1"),
make_query_db_instruction("SELECT * FROM users"),
make_http_get_instruction("https://api.example.com/data"),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let (rid_1, ty_1) = wait_for_io_request(&mut rx).await.expect("IoRequest 1");
assert_eq!(ty_1, IoType::call_external());
send_io_response(&tx, &mut gen, rid_1, "llm-result");
let (rid_2, ty_2) = wait_for_io_request(&mut rx).await.expect("IoRequest 2");
assert_eq!(ty_2, IoType::query_db());
send_io_response(&tx, &mut gen, rid_2, "db-rows");
let (rid_3, ty_3) = wait_for_io_request(&mut rx).await.expect("IoRequest 3");
assert_eq!(ty_3, IoType::http_get());
send_io_response(&tx, &mut gen, rid_3, "http-body");
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("llm-result"),
"call_external should set llm_response"
);
assert_eq!(
snapshot.get("db_result").and_then(|v| v.as_str()),
Some("db-rows"),
"query_db should set db_result"
);
assert_eq!(
snapshot.get("http_response").and_then(|v| v.as_str()),
Some("http-body"),
"http_get should set http_response"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared after all I/O consumed"
);
}
#[tokio::test]
async fn test_same_io_type_twice_no_stale_consumption() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(200).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_call_external_instruction("first prompt"),
make_call_external_instruction("second prompt"),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let (rid_1, ty_1) = wait_for_io_request(&mut rx).await.expect("IoRequest 1");
assert_eq!(ty_1, IoType::call_external());
send_io_response(&tx, &mut gen, rid_1, "first-answer");
let (rid_2, ty_2) = wait_for_io_request(&mut rx).await.expect("IoRequest 2");
assert_eq!(ty_2, IoType::call_external());
send_io_response(&tx, &mut gen, rid_2, "second-answer");
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("second-answer"),
"llm_response should be from the second call_external (not stale first-answer)"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared"
);
}
#[tokio::test]
async fn test_io_interleaved_with_normal_instructions() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(200).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_instruction("increment", "x", 5),
make_call_external_instruction("mixed prompt"),
make_instruction("increment", "y", 10),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let (rid, ty) = wait_for_io_request(&mut rx).await.expect("IoRequest");
assert_eq!(ty, IoType::call_external());
send_io_response(&tx, &mut gen, rid, "mixed-result");
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("x"),
Some(&JsonValue::Integer(5)),
"increment x=5 should execute before call_external"
);
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("mixed-result"),
"call_external should set llm_response"
);
assert_eq!(
snapshot.get("y"),
Some(&JsonValue::Integer(10)),
"increment y=10 should execute after call_external (not affected by I/O)"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared"
);
}
#[tokio::test]
async fn test_all_five_io_types_sequence() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(500).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_call_external_instruction("llm-prompt"),
make_query_db_instruction("SELECT 1"),
make_http_get_instruction("https://example.com"),
make_save_memory_instruction("key1", "value1"),
make_call_service_instruction("calculator"),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let expected_types = [
IoType::call_external(),
IoType::query_db(),
IoType::http_get(),
IoType::save_memory(),
IoType::call_service(),
];
let expected_results = [
"llm-output",
"db-output",
"http-output",
"memory-output",
"tool-output",
];
let expected_fields = [
"llm_response",
"db_result",
"http_response",
"memory_result",
"service_result",
];
for (i, expected_ty) in expected_types.iter().enumerate() {
let (rid, ty) = wait_for_io_request(&mut rx)
.await
.unwrap_or_else(|| panic!("IoRequest {} not received", i + 1));
assert_eq!(
ty,
*expected_ty,
"IoRequest {} should be {:?}",
i + 1,
expected_ty
);
send_io_response(&tx, &mut gen, rid, expected_results[i]);
}
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
for (i, field) in expected_fields.iter().enumerate() {
assert_eq!(
snapshot.get(field).and_then(|v| v.as_str()),
Some(expected_results[i]),
"Field {} should be set to {}",
field,
expected_results[i]
);
}
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared after all 5 I/O consumed"
);
}
#[tokio::test]
async fn test_io_response_with_null_result_clears_properly() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(200).build();
let (tx, mut rx, _event_tx, _handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
let sequence_instr = make_sequence_instruction(vec![
make_call_external_instruction("null-test"),
make_query_db_instruction("SELECT 1"),
]);
tx.send(Fact::Command {
id: gen.next_id(),
instruction: sequence_instr,
})
.unwrap();
let (rid_1, _) = wait_for_io_request(&mut rx).await.expect("IoRequest 1");
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id: rid_1,
result: JsonValue::Null,
error: None,
})
.unwrap();
let (rid_2, ty_2) = wait_for_io_request(&mut rx).await.expect("IoRequest 2");
assert_eq!(ty_2, IoType::query_db());
send_io_response(&tx, &mut gen, rid_2, "db-data");
let snapshot = wait_for_stable(&mut rx).await.expect("Stable");
assert_eq!(
snapshot.get("llm_response"),
Some(&JsonValue::Null),
"llm_response should be Null (from first IoResponse)"
);
assert_eq!(
snapshot.get("db_result").and_then(|v| v.as_str()),
Some("db-data"),
"db_result should be from its own IoResponse"
);
assert!(
snapshot.get("__io_result__").is_none(),
"__io_result__ should be cleared even when first result was Null"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_snapshot_updates_during_executing_loop() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(600).build();
let (tx, mut rx, _event_tx, handle, _facts_log) = reactor.spawn();
let increments: Vec<JsonValue> = (0..500)
.map(|_| make_instruction("increment", "x", 1))
.collect();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_sequence_instruction(increments),
})
.unwrap();
let mut max_step_seen = 0usize;
let mut saw_step_ge_100 = false;
let result = tokio::time::timeout(Duration::from_secs(10), async {
loop {
tokio::select! {
fact = rx.recv() => {
match fact {
Ok(Fact::Stable { .. }) => break,
Ok(Fact::Error { message, .. }) => panic!("Error: {}", message),
Ok(_) => {}
Err(_) => break,
}
}
_ = tokio::time::sleep(Duration::from_micros(200)) => {
if let Some(step) = handle.current_step() {
if step > max_step_seen {
max_step_seen = step;
}
if step >= 100 {
saw_step_ge_100 = true;
}
}
}
}
}
})
.await;
assert!(result.is_ok(), "测试超时(10s 内未收到 Stable)");
assert!(
saw_step_ge_100,
"期望在 Executing 循环中观察到 steps >= 100(SNAPSHOT_UPDATE_INTERVAL=100),\
实际观察到的最大 steps: {}。\
这表明 Executing 循环中的定期快照更新未生效。",
max_step_seen
);
let snap = handle.snapshot().expect("snapshot should be readable");
assert!(
!snap.finished,
"反应器应仍在运行(长驻模式),finished 应为 false"
);
}
#[tokio::test]
async fn test_inspect_returns_pending_io() {
let core_eval = load_core_eval();
let reactor = Reactor::builder(core_eval).max_rounds(100).build();
let (tx, mut rx, _event_tx, handle, _facts_log) = reactor.spawn();
let mut gen = FactIdGenerator::new();
tx.send(Fact::Command {
id: gen.next_id(),
instruction: make_call_external_instruction("test prompt"),
})
.unwrap();
let request_id = timeout(Duration::from_secs(5), async {
while let Ok(fact) = rx.recv().await {
if let Fact::IoRequest { id, .. } = fact {
return id;
}
if let Fact::Error { message, .. } = fact {
panic!("Error: {}", message);
}
}
panic!("IoRequest 未收到");
})
.await
.expect("等待 IoRequest 超时");
tokio::time::sleep(Duration::from_millis(50)).await;
let pending_count = handle.pending_io_count().unwrap_or(0);
assert_eq!(
pending_count, 1,
"pending_io_count 应返回 1,got {}",
pending_count
);
tx.send(Fact::IoResponse {
id: gen.next_id(),
request_id,
result: JsonValue::string("llm result"),
error: None,
})
.unwrap();
let snapshot = wait_for_stable(&mut rx)
.await
.expect("回复 IoResponse 后应收到 Stable");
assert_eq!(
snapshot.get("llm_response").and_then(|v| v.as_str()),
Some("llm result"),
"llm_response 应被设置"
);
let pending_after = handle.pending_io_count().unwrap_or(0);
assert!(
pending_after == 0,
"Stable 后 pending_io_count 应为 0,got {}",
pending_after
);
drop(tx);
let _ = handle.join().await;
}