use somatize_compiler::CompileMode;
use somatize_core::cache::CacheKey;
use somatize_core::effect::{Effect, EffectResult, LlmRequest, LlmResponse, StopReason};
use somatize_core::error::Result;
use somatize_core::filter::{Distribution, Filter, FilterKind, FilterMeta, StreamMode};
use somatize_core::graph::{Edge, Graph, Node};
use somatize_core::message::Message;
use somatize_core::step::{Step, StepCtx, StepMeta, Transition};
use somatize_core::value::Value;
use somatize_runtime::GraphSession;
use somatize_runtime::cache::fs_store::FsActionStore;
use somatize_runtime::effects::{EffectDriver, EffectHandler, EffectJournal};
use somatize_runtime::node_catalog::NodeCatalog;
use std::sync::Arc;
struct Shout;
impl Filter for Shout {
fn config_hash(&self) -> CacheKey {
CacheKey::from_parts(&[b"Shout"])
}
fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
Ok(Value::Empty)
}
fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
Ok(Value::text(x.as_text().unwrap_or_default().to_uppercase()))
}
fn meta(&self) -> FilterMeta {
FilterMeta {
name: "Shout".into(),
kind: FilterKind::Stateless,
cacheable: false,
differentiable: false,
deterministic: true,
stream_mode: StreamMode::FixedState,
distribution: Distribution::Local,
input_schema: None,
output_schema: None,
}
}
}
struct FakeLlm;
impl EffectHandler for FakeLlm {
fn handles(&self, effect: &Effect) -> bool {
matches!(effect, Effect::Llm(_))
}
fn perform(&self, effect: &Effect) -> Result<EffectResult> {
let Effect::Llm(req) = effect else {
unreachable!()
};
let asked = req.messages.last().map(|m| m.text()).unwrap_or_default();
Ok(EffectResult::Llm(LlmResponse {
message: Message::assistant(format!("answer to: {asked}")),
stop_reason: StopReason::EndTurn,
usage: Default::default(),
model: None,
}))
}
}
struct AskOnce;
impl Step for AskOnce {
fn config_hash(&self) -> CacheKey {
CacheKey::from_parts(&[b"AskOnce"])
}
fn meta(&self) -> StepMeta {
StepMeta::new("AskOnce")
}
fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
match ctx.result() {
None => Ok(Transition::Await(vec![Effect::Llm(LlmRequest::new(
"claude-opus-5",
vec![Message::user(ctx.input.as_text().unwrap_or_default())].into(),
))])),
Some(EffectResult::Llm(r)) => Ok(Transition::Done(Value::text(r.message.text()))),
Some(other) => Err(somatize_core::error::SomaError::Execution {
node_id: ctx.node_id.to_string(),
message: format!("unexpected effect result: {other:?}"),
}),
}
}
}
fn driver(dir: &tempfile::TempDir) -> EffectDriver {
let store = Arc::new(FsActionStore::new(dir.path()).unwrap());
let journal = EffectJournal::new(store.clone(), store);
EffectDriver::new(journal).with_handler(Arc::new(FakeLlm))
}
fn catalog() -> NodeCatalog {
let mut catalog = NodeCatalog::new();
catalog.register("prep", Box::new(Shout));
catalog.register("shout", Box::new(Shout));
catalog.register_step("ask", Box::new(AskOnce));
catalog
}
fn mixed_graph() -> Graph {
let mut g = Graph::new();
g.add_node(Node::filter_with_id("prep", "prep"));
g.add_node(Node::step("ask", "AskOnce"));
g.add_node(Node::filter_with_id("shout", "shout"));
g.add_edge(Edge::data("e1", "prep", "ask"));
g.add_edge(Edge::data("e2", "ask", "shout"));
g
}
#[test]
fn graph_session_runs_a_mixed_graph() {
let dir = tempfile::tempdir().unwrap();
let session = GraphSession::new(mixed_graph(), catalog()).with_driver(driver(&dir));
let out = session.forward(&Value::text("hi")).unwrap();
assert_eq!(out.as_text(), Some("ANSWER TO: HI"));
}
#[test]
fn graph_session_run_drives_steps() {
let dir = tempfile::tempdir().unwrap();
let mut g = Graph::new();
g.add_node(Node::step("ask", "AskOnce"));
let mut session = GraphSession::new(g, catalog()).with_driver(driver(&dir));
let outputs = session.run(CompileMode::NoCache).unwrap();
assert!(
outputs.get("ask").and_then(Value::as_text).is_some(),
"the step should have produced an answer, got {outputs:?}"
);
}
#[test]
fn graph_session_fit_drives_steps() {
let dir = tempfile::tempdir().unwrap();
let mut session = GraphSession::new(mixed_graph(), catalog()).with_driver(driver(&dir));
let outputs = session.fit(&Value::text("hi"), None).unwrap();
assert_eq!(
outputs.get("shout").and_then(Value::as_text),
Some("ANSWER TO: HI"),
"the whole chain should have run, got {outputs:?}"
);
}
#[test]
fn graph_session_without_a_driver_names_the_fix() {
let session = GraphSession::new(mixed_graph(), catalog());
let err = session.forward(&Value::text("hi")).unwrap_err();
assert!(
err.to_string().contains("effect driver"),
"the error should say what is missing: {err}"
);
}