mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use common::{
ScriptedModel, TestServer, agent_factory, counter, get_json, memory_store, post, post_json,
register_agent, sample_toml, text_response,
};
use reqwest::StatusCode;
use salvor_core::Effect;
use salvor_llm::Config;
use salvor_runtime::Agent;
use salvor_server::{AgentFactory, AppState, BuiltAgent, ToolRegistry};
use salvor_tools::{DynTool, ToolCtx, ToolError, ToolOutcome};
use serde_json::{Value, json};
struct PublishTool {
calls: Arc<AtomicUsize>,
}
#[async_trait::async_trait]
impl DynTool for PublishTool {
fn name(&self) -> &str {
"publish"
}
fn description(&self) -> &str {
"a publish test tool"
}
fn effect(&self) -> Effect {
Effect::Write
}
fn input_schema(&self) -> Value {
json!({ "type": "object" })
}
async fn call_json(
&self,
_ctx: &ToolCtx,
input: Value,
) -> Result<ToolOutcome<Value>, ToolError> {
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(ToolOutcome::Output(json!({ "published": input })))
}
}
fn model_only_factory(model_uri: String) -> AgentFactory {
Arc::new(move |_definition| {
let model_uri = model_uri.clone();
Box::pin(async move {
let agent = Agent::builder()
.model(
Config::new().with_base_url(&model_uri).with_max_retries(0),
"test-model",
)
.system_prompt("You are a test agent.")
.build()
.map_err(|error| error.to_string())?;
Ok(BuiltAgent {
agent,
servers: vec![],
})
})
})
}
fn graph_state(factory: AgentFactory, registry: ToolRegistry) -> AppState {
AppState::new(memory_store(), factory)
.with_hooks(common::fixed_clock(), common::fixed_random())
.with_poll_interval(Duration::from_millis(10))
.with_tool_registry(Arc::new(registry))
}
async fn wait_for_state(client: &reqwest::Client, base: &str, run: &str, want: &str) -> Value {
for _ in 0..200 {
let (_, body) = get_json(client, &format!("{base}/v1/runs/{run}"), None).await;
if body["status"]["state"] == want {
return body;
}
tokio::time::sleep(Duration::from_millis(15)).await;
}
panic!("run {run} never reached state {want}");
}
#[tokio::test]
async fn graph_run_parks_resumes_through_the_existing_endpoint_and_completes() {
let model = ScriptedModel::mount(vec![(1, text_response("reviewed", 5, 3), None)]).await;
let publish_calls = counter();
let registry = ToolRegistry::new().with_tool(Arc::new(PublishTool {
calls: publish_calls.clone(),
}));
let state = graph_state(model_only_factory(model.uri()), registry);
let server = TestServer::spawn(state).await;
let client = reqwest::Client::new();
let agent_hash = register_agent(&client, &server.base, sample_toml(), None).await;
let document = json!({
"schema_version": 1,
"nodes": [
{ "kind": "agent", "payload": { "id": "work", "agent_hash": agent_hash } },
{ "kind": "gate", "payload": { "id": "approve", "approval_schema": {
"type": "object",
"properties": { "approved": { "type": "boolean" } }
} } },
{ "kind": "tool", "payload": { "id": "publish", "tool": "publish" } }
],
"edges": [ { "from": "work", "to": "approve" }, { "from": "approve", "to": "publish" } ]
});
let (status, body) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document,
None,
)
.await;
assert_eq!(status, StatusCode::CREATED, "submit: {body}");
let graph_hash = body["graph"].as_str().expect("graph hash").to_owned();
assert_eq!(body["created"], true);
let (status, body) = post_json(
&client,
&format!("{}/v1/graph-runs", server.base),
json!({ "graph_hash": graph_hash }),
None,
)
.await;
assert_eq!(status, StatusCode::CREATED, "graph-run: {body}");
let run = body["run"].as_str().expect("run id").to_owned();
wait_for_state(&client, &server.base, &run, "suspended").await;
assert_eq!(
publish_calls.load(Ordering::SeqCst),
0,
"the write waits behind the gate"
);
let (_, projection) = get_json(
&client,
&format!("{}/v1/runs/{run}/graph", server.base),
None,
)
.await;
assert_eq!(projection["graph_hash"], graph_hash);
assert_eq!(projection["current_node"], "approve");
let (status, body) = post_json(
&client,
&format!("{}/v1/runs/{run}/resume", server.base),
json!({ "input": { "approved": true } }),
None,
)
.await;
assert_eq!(status, StatusCode::ACCEPTED, "resume: {body}");
let completed = wait_for_state(&client, &server.base, &run, "completed").await;
assert_eq!(
completed["status"]["output"],
json!({ "published": { "approved": true } })
);
assert_eq!(
publish_calls.load(Ordering::SeqCst),
1,
"the write ran exactly once, after approval"
);
let (_, list) = get_json(&client, &format!("{}/v1/runs", server.base), None).await;
let entry = list["runs"]
.as_array()
.expect("runs array")
.iter()
.find(|entry| entry["run"] == run)
.expect("the graph run is listed");
assert_eq!(entry["status"]["state"], "completed");
assert!(entry["usage"]["input_tokens"].is_number());
assert!(entry["step_count"].as_u64().expect("step_count") >= 1);
assert!(
entry.get("agent_def_hash").is_none(),
"a graph run has no single agent_def_hash to claim"
);
let (_, projection) = get_json(
&client,
&format!("{}/v1/runs/{run}/graph", server.base),
None,
)
.await;
assert!(projection.get("current_node").is_none());
let nodes = projection["nodes"].as_array().expect("nodes");
for id in ["work", "approve", "publish"] {
let node = nodes.iter().find(|n| n["node"] == id).expect(id);
assert_eq!(node["state"], "exited", "node {id} exited");
}
}
#[tokio::test]
async fn submit_is_strict_and_idempotent() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let document = json!({
"schema_version": 1,
"nodes": [
{ "kind": "gate", "payload": {
"id": "approve", "name": "Approve the draft", "approval_schema": { "type": "object" }
} }
],
"edges": []
});
let (status, first) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document.clone(),
None,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(first["created"], true);
let hash = first["graph"].as_str().expect("hash").to_owned();
let (status, second) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document,
None,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(second["created"], false, "re-submit is idempotent");
assert_eq!(second["graph"], hash, "same document, same hash");
let (status, got) = get_json(&client, &format!("{}/v1/graphs/{hash}", server.base), None).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(got["graph"], hash);
assert_eq!(got["document"]["schema_version"], 1);
assert_eq!(
got["document"]["nodes"][0]["payload"]["name"], "Approve the draft",
"the node's optional display name round-trips byte-faithfully"
);
let (_, list) = get_json(&client, &format!("{}/v1/graphs", server.base), None).await;
let entry = list["graphs"]
.as_array()
.expect("graphs array")
.iter()
.find(|entry| entry["graph"] == hash)
.expect("the graph is listed");
assert_eq!(entry["node_count"], 1);
assert_eq!(entry["entry_nodes"], json!(["approve"]));
}
#[tokio::test]
async fn submit_rejects_an_invalid_document_with_the_full_error_list() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let document = json!({
"schema_version": 1,
"nodes": [
{ "kind": "gate", "payload": { "id": "approve", "approval_schema": { "type": "object" } } }
],
"edges": [ { "from": "approve", "to": "ghost" } ]
});
let (status, body) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document,
None,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"]["code"], "invalid_graph");
let errors = body["error"]["details"]["errors"]
.as_array()
.expect("error list");
assert!(
errors.iter().any(|e| e["code"] == "dangling_edge"
&& e["edge"]["to"] == "ghost"
&& e["missing"] == "ghost"),
"the dangling edge is named node/edge-precise: {errors:?}"
);
}
#[tokio::test]
async fn validate_only_reports_validity_without_storing() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let good = json!({
"schema_version": 1,
"nodes": [ { "kind": "gate", "payload": { "id": "approve", "approval_schema": { "type": "object" } } } ],
"edges": []
});
let (status, body) = post_json(
&client,
&format!("{}/v1/graphs/validate", server.base),
good,
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["valid"], true);
assert_eq!(body["summary"]["node_count"], 1);
let hash = body["graph"].as_str().expect("hash").to_owned();
let (status, _) = get_json(&client, &format!("{}/v1/graphs/{hash}", server.base), None).await;
assert_eq!(status, StatusCode::NOT_FOUND, "validate never stores");
let bad = json!({
"schema_version": 1,
"nodes": [ { "kind": "gate", "payload": { "id": "approve", "approval_schema": { "type": "object" } } } ],
"edges": [ { "from": "approve", "to": "ghost" } ]
});
let (status, body) = post_json(
&client,
&format!("{}/v1/graphs/validate", server.base),
bad,
None,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["valid"], false);
assert!(!body["errors"].as_array().expect("errors").is_empty());
}
#[tokio::test]
async fn graph_run_of_unknown_graph_is_404() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let (status, body) = post_json(
&client,
&format!("{}/v1/graph-runs", server.base),
json!({ "graph_hash": "sha256:deadbeef" }),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "unknown_graph");
}
#[tokio::test]
async fn graph_run_with_an_unregistered_tool_is_404_unknown_tool() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new().with_tool(Arc::new(PublishTool { calls: counter() })),
))
.await;
let client = reqwest::Client::new();
let document = json!({
"schema_version": 1,
"nodes": [ { "kind": "tool", "payload": { "id": "step", "tool": "missing" } } ],
"edges": []
});
let (_, submit) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document,
None,
)
.await;
let hash = submit["graph"].as_str().expect("hash");
let (status, body) = post_json(
&client,
&format!("{}/v1/graph-runs", server.base),
json!({ "graph_hash": hash }),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "unknown_tool");
assert!(
body["error"]["message"]
.as_str()
.expect("message")
.contains("step"),
"the error names the node"
);
}
#[tokio::test]
async fn graph_run_with_an_unregistered_agent_is_404_unknown_agent() {
let server = TestServer::spawn(graph_state(
agent_factory(
String::new(),
"unused",
Effect::Read,
common::CountBehavior::Record,
counter(),
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let document = json!({
"schema_version": 1,
"nodes": [ { "kind": "agent", "payload": {
"id": "work",
"agent_hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
} } ],
"edges": []
});
let (_, submit) = post_json(
&client,
&format!("{}/v1/graphs", server.base),
document,
None,
)
.await;
let hash = submit["graph"].as_str().expect("hash");
let (status, body) = post_json(
&client,
&format!("{}/v1/graph-runs", server.base),
json!({ "graph_hash": hash }),
None,
)
.await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["error"]["code"], "unknown_agent");
}
#[tokio::test]
async fn graph_projection_of_an_agent_run_is_409_not_a_graph_run() {
let calls = counter();
let model = ScriptedModel::mount(vec![(1, text_response("done", 4, 2), None)]).await;
let server = TestServer::spawn(graph_state(
agent_factory(
model.uri(),
"noop",
Effect::Read,
common::CountBehavior::Record,
calls,
),
ToolRegistry::new(),
))
.await;
let client = reqwest::Client::new();
let agent = register_agent(&client, &server.base, sample_toml(), None).await;
let (status, body) = post(
&client,
&format!("{}/v1/runs", server.base),
"application/json",
json!({ "agent": agent, "input": {} }).to_string(),
None,
)
.await;
assert_eq!(status, StatusCode::CREATED, "start: {body}");
let run = body["run"].as_str().expect("run id").to_owned();
wait_for_state(&client, &server.base, &run, "completed").await;
let (status, body) = get_json(
&client,
&format!("{}/v1/runs/{run}/graph", server.base),
None,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(body["error"]["code"], "not_a_graph_run");
}