use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{json, Value};
use orchestral_core::action::{Action, ActionContext, ActionInput, ActionMeta, ActionResult};
use orchestral_core::spi::lifecycle::{LifecycleHook, StepContext, StepDecision, TurnContext};
use orchestral_core::types::Step;
use orchestral_runtime::sdk::Orchestral;
use std::fs;
struct DoubleAction;
#[async_trait]
impl Action for DoubleAction {
fn name(&self) -> &str {
"double"
}
fn description(&self) -> &str {
"Double a number"
}
fn metadata(&self) -> ActionMeta {
ActionMeta::new("double", "Double a number. Returns result = input * 2.")
.with_input_schema(json!({
"type": "object",
"properties": {
"number": { "type": "number", "description": "Number to double" }
},
"required": ["number"]
}))
.with_output_schema(json!({
"type": "object",
"properties": {
"result": { "type": "number" }
},
"required": ["result"]
}))
}
async fn run(&self, input: ActionInput, _ctx: ActionContext) -> ActionResult {
let number = input
.params
.get("number")
.and_then(Value::as_f64)
.unwrap_or(0.0);
ActionResult::success_with(HashMap::from([("result".to_string(), json!(number * 2.0))]))
}
}
struct CountingHook {
step_count: AtomicUsize,
turn_started: AtomicUsize,
}
impl CountingHook {
fn new() -> Self {
Self {
step_count: AtomicUsize::new(0),
turn_started: AtomicUsize::new(0),
}
}
}
#[async_trait]
impl LifecycleHook for CountingHook {
async fn on_turn_start(&self, _ctx: &TurnContext) {
self.turn_started.fetch_add(1, Ordering::SeqCst);
}
async fn before_step(&self, _step: &Step, _ctx: &StepContext) -> StepDecision {
self.step_count.fetch_add(1, Ordering::SeqCst);
StepDecision::Continue
}
}
fn load_env() {
for path in [".env.local", "../../.env.local", "../../../.env.local"] {
if let Ok(content) = std::fs::read_to_string(path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
if std::env::var(key).is_err() {
std::env::set_var(key, value.trim());
}
}
}
break;
}
}
}
#[tokio::test]
async fn test_sdk_run_with_custom_action() {
load_env();
if std::env::var("OPENROUTER_API_KEY").is_err() {
eprintln!("OPENROUTER_API_KEY not set, skipping SDK integration test");
return;
}
let app = Orchestral::builder()
.action(DoubleAction)
.planner_backend("openrouter")
.planner_model("anthropic/claude-sonnet-4.5")
.max_planner_iterations(4)
.build()
.await
.expect("build orchestral app");
let result = app
.run("Use the double action to double the number 21")
.await;
match result {
Ok(r) => {
println!("Status: {}", r.status);
println!("Message: {}", r.message);
assert!(
r.status == "completed" || r.status == "failed",
"unexpected status: {}",
r.status
);
}
Err(e) => {
println!("SDK run error (may be expected): {}", e);
}
}
}
#[tokio::test]
async fn test_sdk_lifecycle_hooks_fire() {
load_env();
if std::env::var("OPENROUTER_API_KEY").is_err() {
eprintln!("OPENROUTER_API_KEY not set, skipping SDK integration test");
return;
}
let hook = Arc::new(CountingHook::new());
let app = Orchestral::builder()
.action(DoubleAction)
.hook_arc(hook.clone() as Arc<dyn LifecycleHook>)
.planner_backend("openrouter")
.planner_model("anthropic/claude-sonnet-4.5")
.max_planner_iterations(4)
.build()
.await
.expect("build orchestral app");
let _ = app.run("Double the number 5 using the double action").await;
let turns = hook.turn_started.load(Ordering::SeqCst);
let steps = hook.step_count.load(Ordering::SeqCst);
println!("Hooks fired: turns={}, steps={}", turns, steps);
assert!(turns >= 1, "on_turn_start should have fired");
assert!(steps >= 1, "before_step should have fired at least once");
}
#[tokio::test]
async fn test_sdk_multi_step_pipeline() {
load_env();
if std::env::var("OPENROUTER_API_KEY").is_err() {
eprintln!("OPENROUTER_API_KEY not set, skipping");
return;
}
let dir = std::env::temp_dir().join(format!(
"orchestral-sdk-pipeline-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis()
));
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("data.yaml"),
"regions:\n - name: North\n revenue: 1200000\n - name: South\n revenue: 850000\n - name: East\n revenue: 960000\n",
)
.unwrap();
let hook = Arc::new(CountingHook::new());
let app = Orchestral::builder()
.action(DoubleAction)
.hook_arc(hook.clone() as Arc<dyn LifecycleHook>)
.planner_backend("openrouter")
.planner_model("anthropic/claude-sonnet-4.5")
.max_planner_iterations(6)
.config_path("../../configs/orchestral.cli.runtime.override.yaml")
.build()
.await
.expect("build orchestral app");
let prompt = format!(
"1. Read the file {data} to get revenue data.\n\
2. Write a markdown report to {report} summarizing total revenue across all regions.\n\
Include the total sum and a list of each region with its revenue.",
data = dir.join("data.yaml").display(),
report = dir.join("report.md").display(),
);
let result = app.run(&prompt).await;
let steps = hook.step_count.load(Ordering::SeqCst);
let turns = hook.turn_started.load(Ordering::SeqCst);
println!("Pipeline hooks: turns={}, steps={}", turns, steps);
match result {
Ok(r) => {
println!("Status: {}", r.status);
println!("Message: {}", r.message);
assert_eq!(r.status, "completed", "got: {}", r.status);
assert!(steps >= 2, "expected >= 2 steps, got {}", steps);
let report_path = dir.join("report.md");
assert!(
report_path.exists(),
"report.md should have been created at {}",
report_path.display()
);
let report_content = fs::read_to_string(&report_path).unwrap();
println!("Report:\n{}", report_content);
assert!(
report_content.contains("North") || report_content.contains("revenue"),
"report should contain region data: {}",
report_content
);
}
Err(e) => panic!("SDK pipeline failed: {}", e),
}
let _ = fs::remove_dir_all(&dir);
}