use super::ast::{
AglSpec, ArgRef, DataType, InvariantRule, StateAction, TransitionTarget, TypedParam,
};
fn render_arg(arg: &ArgRef) -> String {
match arg {
ArgRef::Var(name) => name.clone(),
ArgRef::Literal(text) => format!("\"{text}\""),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum Target {
Claude,
Cursor,
Codex,
}
const PRIMER: &str = r#"## How to execute an AGL graph
This document contains a spec written in Agent Graph Language (AGL): a
task compiled into a static directed graph, not a free-form instruction.
- `state NAME -> ACTION -> TARGET`: a single step. Run `ACTION`
(`call(...)`, `map(...)`, `evaluate(...)`, `gate(...)`, `fan(...)`, or
`watch(...)`), then follow `TARGET` (`next`, `branch`, a named state, or
`TERMINATE("msg")`).
- `flow { ... }`: the full graph, starting at its first state.
- `branch NAME { if COND -> TARGET ... }`: evaluate conditions in the order
written and follow the first one that matches.
- `gate(NAME)`: a mandatory approval checkpoint. Do not perform the next
action until a human has explicitly approved continuing past this gate.
- `fan(SpecName, iterable)`: run the named spec once per item of
`iterable`, respecting that spec's own gates and invariants every round.
`fan(SpecName, "5")` with a quoted count instead of a collection means
run it up to that many bounded rounds - there's no pre-existing set to
iterate, just a cap. Never exceed the count. If the collection or count
isn't yet known, resolve it first, then fan.
- `watch(CONDITION)`: poll an external condition (a build finishing, a CI
check going green) until it resolves. This is not a human approval, do
not treat it like `gate()`. If the condition text names a time bound and
it's exceeded, stop and report which check is stuck rather than waiting
indefinitely.
- `invariant { deny: ACTION(TARGET) without gate(NAME) ... }`: a hard rule.
Never perform an action an `invariant` denies, even if a later step
seems to require it - this holds regardless of what any state says.
- `TERMINATE("msg")`: stop immediately and return `msg` as the result.
Execute this graph exactly as written. Do not skip states, do not reorder
them, and do not invent states that aren't declared. Stop at every
`gate(...)` and wait for explicit human approval before continuing past
it. Never take an action an `invariant` denies."#;
pub fn primer() -> &'static str {
PRIMER
}
pub fn render(spec: &AglSpec, target: Target, templates: &[(String, String)]) -> String {
let body = render_body(spec, templates);
match target {
Target::Claude => render_claude(spec, &body),
Target::Cursor => format!("{PRIMER}\n\n{body}"),
Target::Codex => format!("## {}\n\n{PRIMER}\n\n{body}", spec.name),
}
}
pub fn referenced_template_names(spec: &AglSpec) -> Vec<String> {
let mut names = Vec::new();
for state in &spec.flow {
if let StateAction::Evaluate { expression } = &state.action {
for word in expression.split_whitespace() {
let word = word.to_string();
if !names.contains(&word) {
names.push(word);
}
}
}
}
names
}
pub fn referenced_fan_specs(spec: &AglSpec) -> Vec<String> {
let mut names = Vec::new();
for state in &spec.flow {
if let StateAction::Fan { spec_name, .. } = &state.action {
if !names.contains(spec_name) {
names.push(spec_name.clone());
}
}
}
names
}
pub fn resolved_skill_name(spec: &AglSpec) -> String {
spec.skill.clone().unwrap_or_else(|| kebab_case(&spec.name))
}
fn resolved_description(spec: &AglSpec, name: &str) -> String {
spec.description
.clone()
.unwrap_or_else(|| format!("Runs the {name} AGL graph"))
}
pub fn render_agent_file(spec: &AglSpec, templates: &[(String, String)]) -> String {
let name = resolved_skill_name(spec);
let description = resolved_description(spec, &name).replace('"', "\\\"");
let body = render_body(spec, templates);
let mut out = format!("---\nname: {name}\ndescription: \"{description}\"\n");
if !spec.requires.is_empty() {
out.push_str(&format!("tools: {}\n", spec.requires.join(", ")));
}
out.push_str(&format!("model: sonnet\n---\n\n{PRIMER}\n\n{body}"));
out
}
pub fn render_skill_dispatcher(spec: &AglSpec) -> String {
let name = resolved_skill_name(spec);
let description = resolved_description(spec, &name).replace('"', "\\\"");
format!(
"---\nname: {name}\ndescription: \"{description}\"\n---\n\n\
Dispatch to the `{name}` subagent (Agent tool, subagent_type: \"{name}\") to run this \
graph. Do not execute the graph's states yourself in this context - the subagent's \
`tools:` allowlist is what makes the Preflight check in that graph meaningful; running \
it here instead would just be free-form instructions again.\n"
)
}
fn render_body(spec: &AglSpec, templates: &[(String, String)]) -> String {
let mut out = String::new();
let preflight = render_preflight(spec);
if !preflight.is_empty() {
out.push_str(&preflight);
out.push('\n');
}
let cache_section = render_cache(spec);
if !cache_section.is_empty() {
out.push_str(&cache_section);
out.push('\n');
}
let templates_section = render_templates(templates);
if !templates_section.is_empty() {
out.push_str(&templates_section);
out.push('\n');
}
out.push_str(RUN_ORDER);
out.push_str("\n\n## Flow\n\n```\n");
out.push_str(&render_ascii_flow(spec));
out.push_str("```\n\n```agl\n");
out.push_str(&render_agl_source(spec));
out.push_str("```\n");
out
}
fn render_claude(spec: &AglSpec, body: &str) -> String {
let name = resolved_skill_name(spec);
let description = resolved_description(spec, &name).replace('"', "\\\"");
let mut out = format!("---\nname: {name}\ndescription: \"{description}\"\n");
if !spec.requires.is_empty() {
out.push_str(&format!("allowed-tools: {}\n", spec.requires.join(", ")));
}
out.push_str(&format!("---\n\n{PRIMER}\n\n{body}"));
out
}
fn render_preflight(spec: &AglSpec) -> String {
if spec.requires.is_empty() {
return String::new();
}
let mut out = String::from(
"## Preflight\n\nBefore executing any state in this graph, confirm every tool below is \
available in your current session/toolset. If any are missing, stop immediately - do \
not execute any state - and report exactly which tools are missing.\n\n",
);
for tool in &spec.requires {
out.push_str(&format!("- {tool}\n"));
}
out
}
pub fn cache_file_path(name: &str) -> String {
format!("~/.kazam/agl/cache/{name}.jsonl")
}
fn render_cache(spec: &AglSpec) -> String {
if spec.cache.is_empty() {
return String::new();
}
let mut out = String::from(
"## Cache\n\nThis graph reads and writes local JSONL caches, outside this skill file \
entirely - `kazam agl load` regenerating this file never touches them. Creating the \
file is optional, not a prerequisite: it may not exist yet, especially the first time \
this graph runs on a given machine (a teammate running a shared skill has their own \
empty cache, not the author's). A missing file is exactly the same as an empty one, \
not an error - don't stop or ask permission over it. For each cache below, before \
doing a lookup it would otherwise repeat, check the file (if it exists) for the most \
recent line matching what you need (whichever field identifies a record, like \
`customer`). Use it if found. Otherwise resolve normally, then append a new JSON line \
with every field you now know, creating the file (and its parent directory) if this \
is the first line.\n\n",
);
for block in &spec.cache {
out.push_str(&format!("### {}\n\n", block.name));
out.push_str(&format!("File: `{}`\n\n", cache_file_path(&block.name)));
out.push_str("Fields (each line one JSON object):\n");
for field in &block.fields {
out.push_str(&format!(
"- {}: {}\n",
field.name,
render_type(&field.data_type)
));
}
out.push('\n');
}
out
}
fn split_template(content: &str) -> (String, Option<String>) {
if let Some(idx) = content.find("<!--samples-->") {
let shape = content[..idx].replace("<!--spec-->", "");
let samples = content[idx + "<!--samples-->".len()..].to_string();
(shape.trim().to_string(), Some(samples.trim().to_string()))
} else {
(content.replace("<!--spec-->", "").trim().to_string(), None)
}
}
fn render_templates(templates: &[(String, String)]) -> String {
if templates.is_empty() {
return String::new();
}
let mut out = String::from(
"## Templates\n\nReferenced by name in this graph's evaluate(...) states. Follow the \
shape exactly; the examples (when present) are known-good outputs to match tone and \
structure against, not to copy verbatim.\n\n",
);
for (name, content) in templates {
let (shape, samples) = split_template(content);
out.push_str(&format!("### {name}\n\n{shape}\n\n"));
if let Some(samples) = samples {
out.push_str(&format!("**Known-good examples:**\n\n{samples}\n\n"));
}
}
out
}
const RUN_ORDER: &str = "## Before you start
1. If a Preflight section is above, confirm every tool listed is available. Stop and report immediately if any are missing - do not execute any state.
2. Show the flow diagram below to the user so they can see what you're about to do.
3. Begin executing the graph. Do not wait for approval to start - only stop where the graph itself defines a `gate(...)`.";
pub fn render_ascii_flow(spec: &AglSpec) -> String {
let mut out = String::new();
for (i, state) in spec.flow.iter().enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str(&format!(
"{} {}\n",
state.name,
render_action(&state.action)
));
match &state.transition {
TransitionTarget::Branch(name) => match spec.branches.get(name) {
Some(block) => {
let last = block.cases.len().saturating_sub(1);
for (j, case) in block.cases.iter().enumerate() {
let corner = if j == last { "\u{2514}" } else { "\u{251c}" };
out.push_str(&format!(
" {corner}\u{2500} if {} -> {}\n",
case.condition,
render_target(&case.target)
));
}
}
None => out.push_str(" \u{2514}\u{2500}> branch (undefined)\n"),
},
other => out.push_str(&format!(" \u{2514}\u{2500}> {}\n", render_target(other))),
}
}
out
}
pub fn kebab_case(name: &str) -> String {
let mut out = String::new();
let mut prev_lower_or_digit = false;
for c in name.chars() {
if c == '_' || c == ' ' {
out.push('-');
prev_lower_or_digit = false;
continue;
}
if c.is_uppercase() && prev_lower_or_digit {
out.push('-');
}
out.extend(c.to_lowercase());
prev_lower_or_digit = c.is_lowercase() || c.is_numeric();
}
out
}
fn render_type(dt: &DataType) -> String {
match dt {
DataType::String => "str".to_string(),
DataType::Int => "int".to_string(),
DataType::Bool => "bool".to_string(),
DataType::List(inner) => format!("list[{}]", render_type(inner)),
DataType::Custom(name) => name.clone(),
}
}
fn render_params(params: &[TypedParam]) -> String {
params
.iter()
.map(|p| format!("{}: {}", p.name, render_type(&p.data_type)))
.collect::<Vec<_>>()
.join(", ")
}
fn render_action(action: &StateAction) -> String {
match action {
StateAction::Call { function, args } => {
let rendered: Vec<String> = args.iter().map(render_arg).collect();
format!("call({function}, {})", rendered.join(", "))
}
StateAction::Map { function, iterable } => format!("map({function}, {iterable})"),
StateAction::Evaluate { expression } => format!("evaluate({expression})"),
StateAction::Gate { gate_name } => format!("gate({gate_name})"),
StateAction::Fan {
spec_name,
iterable,
} => {
format!("fan({spec_name}, {})", render_arg(iterable))
}
StateAction::Watch { condition } => format!("watch({condition})"),
}
}
fn render_target(target: &TransitionTarget) -> String {
match target {
TransitionTarget::Next => "next".to_string(),
TransitionTarget::Branch(_) => "branch".to_string(),
TransitionTarget::Goto(name) => name.clone(),
TransitionTarget::Terminate(msg) => format!("TERMINATE(\"{msg}\")"),
}
}
fn render_invariant(rule: &InvariantRule) -> String {
match rule {
InvariantRule::DenyWithoutGate {
action,
target,
required_gate,
} => format!("deny: {action}({target}) without gate({required_gate})"),
InvariantRule::DenyWithoutEvaluate {
action,
target,
required_evaluate,
} => {
format!("deny: {action}({target}) without evaluate({required_evaluate})")
}
InvariantRule::DenyAlways { action, target } => format!("deny: {action}({target})"),
InvariantRule::DenyConstraint {
action,
target,
condition,
} => format!("deny: {action}({target}) where {condition}"),
}
}
pub fn render_agl_source(spec: &AglSpec) -> String {
let mut out = String::new();
out.push_str(&format!("spec {} {{\n", spec.name));
out.push_str(&format!(" in: {}\n", render_params(&spec.inputs)));
out.push_str(&format!(" out: {}\n", render_params(&spec.outputs)));
if let Some(description) = &spec.description {
out.push_str(&format!(
" description: \"{}\"\n",
description.replace('"', "\\\"")
));
}
if !spec.requires.is_empty() {
out.push_str(&format!(" requires: {}\n", spec.requires.join(", ")));
}
if let Some(skill_name) = &spec.skill {
out.push_str(&format!(" skill: {skill_name}\n"));
}
if let Some(publish) = &spec.publish {
out.push_str(&format!(
" publish: \"{}\"\n",
publish.replace('"', "\\\"")
));
}
for block in &spec.cache {
out.push_str(&format!(
" cache {} {{ {} }}\n",
block.name,
render_params(&block.fields)
));
}
if !spec.invariants.is_empty() {
out.push_str("\n invariant {\n");
for rule in &spec.invariants {
out.push_str(&format!(" {}\n", render_invariant(rule)));
}
out.push_str(" }\n");
}
out.push_str("\n flow {\n");
for state in &spec.flow {
out.push_str(&format!(
" state {} -> {} -> {}\n",
state.name,
render_action(&state.action),
render_target(&state.transition)
));
if let TransitionTarget::Branch(name) = &state.transition {
if let Some(block) = spec.branches.get(name) {
out.push_str(&format!("\n branch {name} {{\n"));
for case in &block.cases {
out.push_str(&format!(
" if {} -> {}\n",
case.condition,
render_target(&case.target)
));
}
out.push_str(" }\n\n");
}
}
}
out.push_str(" }\n");
out.push_str("}\n");
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agl::parser::parse;
const SAMPLE: &str = r#"
spec MeetingPrep {
in: calendar_event: str, slack_channels: list[str]
out: agenda_update: str
invariant {
deny: write(calendar) without gate(human_approval)
}
flow {
state FETCH_CALENDAR -> call(GoogleCalendar.get, calendar_event) -> next
state PROPOSE_UPDATE -> gate(human_approval) -> EXECUTE_WRITE
state EXECUTE_WRITE -> call(GoogleCalendar.update, agenda) -> TERMINATE("Done")
}
}
"#;
#[test]
fn kebab_cases_pascal_and_snake_names() {
assert_eq!(kebab_case("MeetingPrep"), "meeting-prep");
assert_eq!(kebab_case("hubspot_sync"), "hubspot-sync");
assert_eq!(kebab_case("Already-Kebab"), "already-kebab");
}
#[test]
fn renders_agl_source_that_reparses() {
let parsed = parse(SAMPLE).unwrap();
let rendered = render_agl_source(&parsed.spec);
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn cache_blocks_round_trip_through_render_agl_source() {
let src = r#"
spec CallPrep {
in: customer: str
out: y: bool
cache slack-lookups {
customer: str, int_channel: str
}
cache call-prep-timestamps {
customer: str, last_call_date: str
}
flow {
state A -> evaluate(customer) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let rendered = render_agl_source(&parsed.spec);
assert!(rendered.contains("cache slack-lookups"));
assert!(rendered.contains("cache call-prep-timestamps"));
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn fan_and_watch_round_trip_through_render_agl_source() {
let src = r#"
spec DealMonitor {
in: targets: str
out: y: bool
flow {
state SCAN -> fan(WorkflowDeal, targets) -> next
state RETRY -> fan(InvestigateHypothesis, "5") -> next
state BUILD -> watch(ci status for kazam release) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let rendered = render_agl_source(&parsed.spec);
assert!(rendered.contains("fan(WorkflowDeal, targets)"));
assert!(rendered.contains(r#"fan(InvestigateHypothesis, "5")"#));
assert!(rendered.contains("watch(ci status for kazam release)"));
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn referenced_fan_specs_returns_every_distinct_spec_name() {
let src = r#"
spec DealMonitor {
in: targets: str
out: y: bool
flow {
state A -> fan(WorkflowDeal, targets) -> next
state B -> fan(WorkflowDeal, "3") -> next
state C -> evaluate(targets) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let names = referenced_fan_specs(&parsed.spec);
assert_eq!(names, vec!["WorkflowDeal".to_string()]);
}
#[test]
fn compiled_skill_includes_a_cache_section_per_block() {
let src = r#"
spec CallPrep {
in: customer: str
out: y: bool
cache slack-lookups {
customer: str, int_channel: str
}
flow {
state A -> evaluate(customer) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(doc.contains("## Cache"));
assert!(doc.contains("### slack-lookups"));
assert!(doc.contains("~/.kazam/agl/cache/slack-lookups.jsonl"));
assert!(doc.contains("- customer: str"));
assert!(doc.contains("- int_channel: str"));
}
#[test]
fn compiled_skill_omits_cache_section_when_spec_declares_none() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(!doc.contains("## Cache"));
}
#[test]
fn referenced_template_names_returns_every_word_in_every_evaluate() {
let src = r#"spec Foo {
in: x: str
out: y: str
flow {
state A -> evaluate(activity_summary_draft vs activity-summary) -> next
state B -> evaluate(x) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let names = referenced_template_names(&parsed.spec);
assert!(names.contains(&"activity-summary".to_string()));
assert!(names.contains(&"activity_summary_draft".to_string()));
assert!(names.contains(&"vs".to_string()));
assert!(names.contains(&"x".to_string()));
}
#[test]
fn compiled_skill_omits_templates_section_when_none_resolved() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(!doc.contains("## Templates"));
}
#[test]
fn compiled_skill_embeds_a_resolved_template_split_on_samples_marker() {
let parsed = parse(SAMPLE).unwrap();
let templates = vec![(
"activity-summary".to_string(),
"<!--spec-->\n## {Customer}\n\n- **Lead-in**: summary\n\
<!--samples-->\n## Halcyon\n\n- **Sentiment**: stayed Medium"
.to_string(),
)];
let doc = render(&parsed.spec, Target::Claude, &templates);
assert!(doc.contains("## Templates"));
assert!(doc.contains("### activity-summary"));
assert!(doc.contains("- **Lead-in**: summary"));
assert!(doc.contains("**Known-good examples:**"));
assert!(doc.contains("## Halcyon"));
assert!(!doc.contains("<!--spec-->"));
assert!(!doc.contains("<!--samples-->"));
}
#[test]
fn a_string_literal_call_arg_survives_the_compile_round_trip() {
let src = r#"
spec Foo {
in: customer: str
out: y: bool
flow {
state HIT_API -> call(Bash, customer, "https://example.com/enrich") -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(
doc.contains(r#"call(Bash, customer, "https://example.com/enrich")"#),
"doc: {doc}"
);
let rendered = render_agl_source(&parsed.spec);
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn claude_target_has_frontmatter_and_primer() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(doc.starts_with("---\n"));
assert!(doc.contains("name: meeting-prep"));
assert!(doc.contains("description: \"Runs the meeting-prep AGL graph\""));
assert!(doc.contains("Execute this graph exactly as written"));
assert!(doc.contains("FETCH_CALENDAR"));
assert!(doc.contains("```agl"));
}
#[test]
fn cursor_target_has_no_frontmatter() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Cursor, &[]);
assert!(!doc.starts_with("---\n"));
assert!(doc.contains("Execute this graph exactly as written"));
assert!(doc.contains("FETCH_CALENDAR"));
}
#[test]
fn codex_target_has_heading() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Codex, &[]);
assert!(doc.starts_with("## MeetingPrep\n"));
assert!(doc.contains("Execute this graph exactly as written"));
assert!(doc.contains("FETCH_CALENDAR"));
}
const SAMPLE_WITH_REQUIRES: &str = r#"
spec MeetingPrep {
in: calendar_event: str, slack_channels: list[str]
out: agenda_update: str
requires: GoogleCalendar.get, GoogleCalendar.update
invariant {
deny: write(calendar) without gate(human_approval)
}
flow {
state FETCH_CALENDAR -> call(GoogleCalendar.get, calendar_event) -> next
state PROPOSE_UPDATE -> gate(human_approval) -> EXECUTE_WRITE
state EXECUTE_WRITE -> call(GoogleCalendar.update, agenda) -> TERMINATE("Done")
}
}
"#;
#[test]
fn renders_agl_source_with_requires_that_reparses() {
let parsed = parse(SAMPLE_WITH_REQUIRES).unwrap();
let rendered = render_agl_source(&parsed.spec);
assert!(rendered.contains("requires: GoogleCalendar.get, GoogleCalendar.update"));
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn skill_with_requires_includes_preflight_section() {
let parsed = parse(SAMPLE_WITH_REQUIRES).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(doc.contains("## Preflight"));
assert!(doc.contains("- GoogleCalendar.get"));
assert!(doc.contains("- GoogleCalendar.update"));
assert!(doc.contains("stop immediately"));
}
#[test]
fn skill_with_requires_declares_allowed_tools_in_frontmatter() {
let parsed = parse(SAMPLE_WITH_REQUIRES).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
let front = doc.split("---").nth(1).expect("frontmatter");
assert!(front.contains("allowed-tools: GoogleCalendar.get, GoogleCalendar.update"));
}
#[test]
fn skill_without_requires_omits_allowed_tools_line() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(!doc.contains("allowed-tools:"));
}
#[test]
fn skill_without_requires_omits_preflight_section() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(!doc.contains("## Preflight"));
}
#[test]
fn skill_always_includes_flow_diagram_and_run_order() {
let parsed = parse(SAMPLE).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
assert!(doc.contains("## Before you start"));
assert!(doc.contains("## Flow"));
assert!(doc.contains("Do not wait for approval to start"));
}
#[test]
fn sections_appear_in_order_preflight_then_run_order_then_flow_then_source() {
let parsed = parse(SAMPLE_WITH_REQUIRES).unwrap();
let doc = render(&parsed.spec, Target::Claude, &[]);
let preflight = doc.find("## Preflight").expect("preflight section");
let run_order = doc.find("## Before you start").expect("run-order section");
let flow = doc.find("## Flow").expect("flow section");
let source = doc.find("```agl").expect("agl source block");
assert!(preflight < run_order);
assert!(run_order < flow);
assert!(flow < source);
}
#[test]
fn ascii_flow_shows_states_transitions_and_branch_cases() {
let src = r#"spec Foo {
in: x: str
out: y: str
flow {
state A -> evaluate(x) -> branch
branch A {
if cond_one -> B
if cond_two -> TERMINATE("done")
}
state B -> call(Server.method, x) -> TERMINATE("also done")
}
}"#;
let parsed = parse(src).unwrap();
let diagram = render_ascii_flow(&parsed.spec);
assert!(diagram.contains("A evaluate(x)"));
assert!(diagram.contains("if cond_one -> B"));
assert!(diagram.contains("if cond_two -> TERMINATE(\"done\")"));
assert!(diagram.contains("B call(Server.method, x)"));
}
#[test]
fn resolved_skill_name_defaults_to_kebab_case_of_the_graph_name() {
let parsed = parse(SAMPLE).unwrap();
assert_eq!(resolved_skill_name(&parsed.spec), "meeting-prep");
}
#[test]
fn resolved_skill_name_honors_an_explicit_skill_line() {
let src = r#"spec Foo {
in: x: str
out: y: str
skill: org-chart-sync
flow {
state A -> evaluate(x) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
assert_eq!(resolved_skill_name(&parsed.spec), "org-chart-sync");
}
#[test]
fn skill_line_round_trips_through_render_agl_source() {
let src = r#"spec Foo {
in: x: str
out: y: str
skill: org-chart-sync
flow {
state A -> evaluate(x) -> TERMINATE("done")
}
}"#;
let parsed = parse(src).unwrap();
let rendered = render_agl_source(&parsed.spec);
assert!(rendered.contains("skill: org-chart-sync"));
let reparsed = parse(&rendered).expect("re-rendered source should reparse");
assert_eq!(reparsed.spec, parsed.spec);
}
#[test]
fn agent_file_has_frontmatter_tools_from_requires_and_the_graph_body() {
let doc = render_agent_file(&parse(SAMPLE_WITH_REQUIRES).unwrap().spec, &[]);
assert!(doc.starts_with("---\n"));
assert!(doc.contains("name: meeting-prep"));
assert!(doc.contains("tools: GoogleCalendar.get, GoogleCalendar.update"));
assert!(doc.contains("model: sonnet"));
assert!(doc.contains("## Preflight"));
assert!(doc.contains("FETCH_CALENDAR"));
}
#[test]
fn agent_file_omits_tools_line_when_requires_is_empty() {
let doc = render_agent_file(&parse(SAMPLE).unwrap().spec, &[]);
assert!(!doc.contains("tools:"));
}
#[test]
fn skill_dispatcher_routes_to_the_subagent_and_does_not_inline_the_graph() {
let doc = render_skill_dispatcher(&parse(SAMPLE).unwrap().spec);
assert!(doc.starts_with("---\n"));
assert!(doc.contains("name: meeting-prep"));
assert!(doc.contains("subagent_type: \"meeting-prep\""));
assert!(!doc.contains("## Flow"));
assert!(!doc.contains("```agl"));
}
}