use super::*;
use crate::language::source::SourceFile;
use crate::language::compiler::compile_with_provenance;
use crate::language::diff::{FieldChange, blueprint_diff};
use crate::language::testkit as lang_testkit;
use crate::language::types::Origin;
const DIFF_BASE: &str = r#"
graph flow {
start plan
channel messages append
node plan {
kind model
model "default"
routes {
research -> work
done -> END
}
}
node work {
kind tool_executor
tools ["lookup_user"]
next END
}
}
"#;
const DIFF_NEW: &str = r#"
graph flow {
start plan
channel messages append
node plan {
kind model
model "default"
routes {
research -> review
done -> END
}
}
node review {
kind interrupt
prompt "ok?"
next work
}
node work {
kind tool_executor
tools ["lookup_user"]
next END
}
}
"#;
#[test]
fn blueprint_diff_reports_added_node_and_changed_route() {
let old = lang_testkit::blueprint(DIFF_BASE);
let new = lang_testkit::blueprint(DIFF_NEW);
let diff = blueprint_diff(&old, &new);
assert!(!diff.is_empty());
assert_eq!(diff.nodes_added, vec!["review".to_string()]);
assert!(diff.nodes_removed.is_empty());
assert_eq!(diff.nodes_changed.len(), 1, "{diff:?}");
let plan_change = &diff.nodes_changed[0];
assert_eq!(plan_change.name, "plan");
assert_eq!(
plan_change.fields,
vec![FieldChange {
field: "routing".to_string(),
old: "{ research -> work, done -> END }".to_string(),
new: "{ research -> review, done -> END }".to_string(),
}]
);
let rendered = diff.to_string();
assert!(rendered.contains("+ node review"), "{rendered}");
assert!(rendered.contains("~ node plan"), "{rendered}");
assert!(rendered.contains("routing:"), "{rendered}");
}
#[test]
fn blueprint_diff_of_identical_blueprints_is_empty() {
let a = lang_testkit::blueprint(DIFF_BASE);
let b = lang_testkit::blueprint(DIFF_BASE);
let diff = blueprint_diff(&a, &b);
assert!(diff.is_empty(), "{diff:?}");
assert_eq!(diff.to_string(), "no changes");
}
const DIFF_COMMAND_BASE: &str = r#"
graph flow2 {
start plan
node plan {
kind agent
model "default"
command {
goto work
update {
status "planned"
}
}
}
node work {
kind tool_executor
next END
}
}
"#;
const DIFF_COMMAND_NEW: &str = r#"
graph flow2 {
start plan
node plan {
kind agent
model "default"
command {
goto work
update {
status "revised"
}
}
}
node work {
kind tool_executor
next END
}
}
"#;
#[test]
fn blueprint_diff_reports_command_only_change() {
let old = lang_testkit::blueprint(DIFF_COMMAND_BASE);
let new = lang_testkit::blueprint(DIFF_COMMAND_NEW);
let diff = blueprint_diff(&old, &new);
assert!(!diff.is_empty(), "command-only change must not be silent");
assert!(diff.nodes_added.is_empty());
assert!(diff.nodes_removed.is_empty());
assert_eq!(diff.nodes_changed.len(), 1, "{diff:?}");
let plan_change = &diff.nodes_changed[0];
assert_eq!(plan_change.name, "plan");
assert!(
plan_change.fields.iter().any(|f| f.field == "command"),
"{plan_change:?}"
);
let rendered = diff.to_string();
assert!(rendered.contains("command:"), "{rendered}");
}
#[test]
fn blueprint_diff_preserves_declaration_ordering() {
let old = lang_testkit::blueprint(
r#"
graph order {
start a
channel x append
channel y append
node a { kind model next END }
node b { kind model }
node c { kind model }
b -> a
c -> a
}
"#,
);
let new = lang_testkit::blueprint(
r#"
graph order {
start a
channel p append
channel q append
node a { kind model next END }
node d { kind model }
node e { kind model }
d -> a
e -> a
}
"#,
);
let diff = blueprint_diff(&old, &new);
assert_eq!(diff.nodes_added, vec!["d".to_string(), "e".to_string()]);
assert_eq!(diff.nodes_removed, vec!["b".to_string(), "c".to_string()]);
assert_eq!(diff.channels_added, vec!["p".to_string(), "q".to_string()]);
assert_eq!(
diff.channels_removed,
vec!["x".to_string(), "y".to_string()]
);
let added: Vec<(String, String)> = diff
.edges_added
.iter()
.map(|e| (e.from.clone(), e.to.clone()))
.collect();
assert_eq!(
added,
vec![
("d".to_string(), "a".to_string()),
("e".to_string(), "a".to_string()),
]
);
let removed: Vec<(String, String)> = diff
.edges_removed
.iter()
.map(|e| (e.from.clone(), e.to.clone()))
.collect();
assert_eq!(
removed,
vec![
("b".to_string(), "a".to_string()),
("c".to_string(), "a".to_string()),
]
);
}
#[test]
fn blueprint_diff_serializes_round_trip() {
let old = lang_testkit::blueprint(DIFF_BASE);
let new = lang_testkit::blueprint(DIFF_NEW);
let diff = blueprint_diff(&old, &new);
let json = serde_json::to_string(&diff).unwrap();
let back: crate::language::diff::BlueprintDiff = serde_json::from_str(&json).unwrap();
assert_eq!(diff, back);
}
#[test]
fn provenance_points_each_node_at_its_span() {
let program = parse_str(DIFF_NEW).unwrap();
let bp = compile_with_provenance(&program, Origin::file("flow.rag"))
.unwrap()
.remove(0);
let prov = bp.provenance().expect("provenance attached");
assert_eq!(prov.origin, Origin::File("flow.rag".to_string()));
let file = SourceFile::new("flow.rag", DIFF_NEW);
for node in &bp.nodes {
let span = prov
.node_span(&node.name)
.unwrap_or_else(|| panic!("no span for node `{}`", node.name));
assert_eq!(&DIFF_NEW[span.start..span.end], "node");
let line = file
.line_text(span.line)
.unwrap_or_else(|| panic!("no source line {}", span.line));
assert!(
line.contains(&format!("node {}", node.name)),
"node `{}` span anchors at line `{line}`, not its declaration",
node.name
);
}
assert!(prov.channel_span("messages").is_some());
}
#[test]
fn plain_compile_leaves_provenance_none() {
let bp = lang_testkit::blueprint(DIFF_BASE);
assert!(bp.provenance().is_none());
}
#[test]
fn generated_origin_renders_label() {
let program = parse_str(DIFF_BASE).unwrap();
let bp = compile_with_provenance(&program, Origin::generated_by("repl-7"))
.unwrap()
.remove(0);
let prov = bp.provenance().unwrap();
assert_eq!(prov.origin.as_display(), "generated by repl-7");
}
#[test]
fn testkit_assertions_inspect_lowered_topology() {
let bp = lang_testkit::blueprint(DIFF_NEW);
lang_testkit::assert_kind(&bp, "review", "interrupt");
lang_testkit::assert_next(&bp, "review", "work");
lang_testkit::assert_terminal(&bp, "work");
lang_testkit::assert_route(&bp, "plan", "research", "review");
}
#[test]
fn testkit_try_compile_surfaces_errors() {
let err = lang_testkit::try_compile("graph g { start missing node a { kind model next END } }")
.unwrap_err();
assert!(matches!(err, crate::error::TinyAgentsError::Compile(_)));
}
pub(super) mod testkit {
use async_trait::async_trait;
use serde_json::json;
use crate::error::Result;
use crate::harness::model::{ChatModel, ModelRequest, ModelResponse};
use crate::harness::tool::{Tool, ToolCall, ToolResult, ToolSchema};
use super::TestState;
pub(crate) struct EchoModel;
#[async_trait]
impl ChatModel<TestState> for EchoModel {
async fn invoke(
&self,
_state: &TestState,
_request: ModelRequest,
) -> Result<ModelResponse> {
Ok(ModelResponse::assistant("echo"))
}
}
pub(crate) struct NoopTool;
#[async_trait]
impl Tool<TestState> for NoopTool {
fn name(&self) -> &str {
"lookup_user"
}
fn description(&self) -> &str {
"noop"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("lookup_user", "noop", json!({"type": "object"}))
}
async fn call(&self, _state: &TestState, call: ToolCall) -> Result<ToolResult> {
Ok(ToolResult::text(call.id, call.name, "ok"))
}
}
}