Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/graph/dependencies.rs
Original file line number Diff line number Diff line change
Expand Up @@ -417,4 +417,71 @@ mod tests {
// Verify we can get a summary
assert!(report.unique_count() > 0);
}

#[test]
fn test_extract_flow_and_apex_method_dependencies() {
// An agent with a flow:// action and an apex://Class.method action should
// populate the flows and apex_classes sets correctly, and be queryable via
// get_by_type and get_by_topic.
let source = r#"config:
agent_name: "Test"

topic main:
description: "Main topic"

actions:
fetch_order:
description: "Fetch an order via a Flow"
inputs:
id: string
description: "Order ID"
outputs:
status: string
description: "Order status"
target: "flow://FetchOrder"

classify_order:
description: "Classify the order via Apex"
inputs:
id: string
description: "Order ID"
outputs:
label: string
description: "Classification label"
target: "apex://OrderClassifier.classify"

reasoning:
instructions: "Help the user"
"#;
let ast = crate::parse(source).expect("Failed to parse source");
let report = extract_dependencies(&ast);

// Flow dependency is extracted correctly
assert!(report.uses_flow("FetchOrder"), "FetchOrder should be in flows");
assert!(report.flows.contains("FetchOrder"), "FetchOrder should be in the flows set");

// Apex method dependency extracts the class name
assert!(
report.uses_apex_class("OrderClassifier"),
"OrderClassifier should be in apex_classes"
);
assert!(
report.apex_classes.contains("OrderClassifier"),
"OrderClassifier should be in the apex_classes set"
);

// get_by_type grouping must include both flow and apex_method categories
let flow_deps = report.get_by_type("flow");
assert_eq!(flow_deps.len(), 1, "Expected 1 flow dependency");

let apex_deps = report.get_by_type("apex_method");
assert_eq!(apex_deps.len(), 1, "Expected 1 apex_method dependency");

// get_by_topic groups all action dependencies under "main"
let main_deps = report.get_by_topic("main");
assert_eq!(main_deps.len(), 2, "Expected 2 total dependencies in topic main");

// unique_count reflects all distinct dependency entries
assert!(report.unique_count() >= 2, "Expected at least 2 unique dependencies");
}
}
177 changes: 177 additions & 0 deletions src/graph/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,181 @@ topic main:
// At least one edge should exist (the Routes edge from start_agent → main)
assert!(graph.edge_count() > 0, "Expected at least one edge in the graph");
}

#[test]
fn test_find_variable_readers_returns_reasoning_action() {
// A reasoning action that binds @variables.order_id via a with clause
// must show up as a reader of the order_id variable.
let source = r#"config:
agent_name: "Test"

variables:
order_id: mutable string = ""
description: "Order ID used throughout the topic"

topic main:
description: "Main topic"

actions:
lookup_order:
description: "Look up an order"
inputs:
id: string
description: "The order identifier"
outputs:
status: string
description: "Order status"
target: "flow://LookupOrder"

reasoning:
instructions: "Help the user"
actions:
do_lookup: @actions.lookup_order
description: "Perform the lookup"
with id=@variables.order_id
"#;
let graph = parse_and_build(source);
let var_idx = graph.get_variable("order_id").expect("order_id variable not found");
let readers = graph.find_variable_readers(var_idx);
assert!(!readers.is_empty(), "Expected at least one reader of order_id");
// The reasoning action 'do_lookup' is the only reader
assert_eq!(readers.len(), 1, "Expected exactly one reader of order_id");
}

#[test]
fn test_find_variable_writers_returns_reasoning_action() {
// A reasoning action with `set @variables.status = @outputs.result` must
// show up as a writer of the status variable.
let source = r#"config:
agent_name: "Test"

variables:
status: mutable string = ""
description: "Computed status written by a reasoning action"

topic main:
description: "Main topic"

actions:
check:
description: "Run a status check"
inputs:
id: string
description: "Record ID"
outputs:
result: string
description: "Check result"
target: "flow://Check"

reasoning:
instructions: "Help the user"
actions:
do_check: @actions.check
description: "Run a status check"
with id=...
set @variables.status = @outputs.result
"#;
let graph = parse_and_build(source);
let var_idx = graph.get_variable("status").expect("status variable not found");
let writers = graph.find_variable_writers(var_idx);
assert!(!writers.is_empty(), "Expected at least one writer of status");
assert_eq!(writers.len(), 1, "Expected exactly one writer of status");
}

#[test]
fn test_find_action_invokers_returns_reasoning_action() {
// A reasoning action that references @actions.get_data is an invoker
// of the get_data action definition; find_action_invokers should return it.
let source = r#"config:
agent_name: "Test"

topic main:
description: "Main topic"

actions:
get_data:
description: "Fetch a record"
inputs:
id: string
description: "Record ID"
outputs:
result: string
description: "Fetched result"
target: "flow://GetData"

reasoning:
instructions: "Help the user"
actions:
fetch: @actions.get_data
description: "Fetch the record"
"#;
let graph = parse_and_build(source);
let action_def_idx =
graph.get_action_def("main", "get_data").expect("get_data action def not found");
let invokers = graph.find_action_invokers(action_def_idx);
assert!(!invokers.is_empty(), "Expected at least one invoker of get_data");
// Only one reasoning action references get_data
assert_eq!(invokers.len(), 1, "Expected exactly one invoker of get_data");
}

#[test]
fn test_get_topic_reasoning_actions_returns_all_actions_in_topic() {
// get_topic_reasoning_actions("main") must return every reasoning action
// declared inside topic main's reasoning block.
let source = r#"config:
agent_name: "Test"

topic main:
description: "Main topic"
reasoning:
instructions: "Help the user"
actions:
escalate: @utils.escalate
description: "Escalate to a human agent"
go_other: @utils.transition to @topic.other
description: "Switch to the other topic"

topic other:
description: "Other topic"
reasoning:
instructions: "Handle other"
"#;
let graph = parse_and_build(source);
let reasoning_actions = graph.get_topic_reasoning_actions("main");
// topic main has two reasoning actions: escalate and go_other
assert_eq!(reasoning_actions.len(), 2, "Expected 2 reasoning actions in topic main");
// topic other has no reasoning actions — it should return an empty list
assert_eq!(
graph.get_topic_reasoning_actions("other").len(),
0,
"topic other has no reasoning actions; its count must not bleed into main"
);
}

#[test]
fn test_get_topic_action_defs_returns_all_defs_in_topic() {
// get_topic_action_defs("main") must return every action definition
// declared inside topic main's actions block.
let source = r#"config:
agent_name: "Test"

topic main:
description: "Main topic"

actions:
first_action:
description: "First action"
target: "flow://First"
second_action:
description: "Second action"
target: "flow://Second"

reasoning:
instructions: "Help the user"
"#;
let graph = parse_and_build(source);
let action_defs = graph.get_topic_action_defs("main");
// topic main has exactly two action definitions
assert_eq!(action_defs.len(), 2, "Expected 2 action defs in topic main");
}
}
28 changes: 28 additions & 0 deletions tests/integration_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,32 @@ topic main:
.collect();
assert!(tokens.iter().any(|t| matches!(t, lexer::Token::Config)));
}

#[test]
fn test_parse_source_returns_error_for_unknown_top_level_keyword() {
// A word that is not a valid AgentScript top-level keyword (e.g. "foobar")
// cannot match any block parser. The parser's error-recovery strategy
// (skip_then_retry_until) generates a parse error for the skipped tokens,
// so parse_source must return Err.
let result = parse_source("foobar_keyword: \"value\"\n");
assert!(
result.is_err(),
"Expected Err for unknown top-level keyword, got Ok"
);
let errors = result.unwrap_err();
assert!(!errors.is_empty(), "Expected at least one error message in the Err result");
}

#[test]
fn test_parse_source_returns_error_for_unpaired_at_sign() {
// An `@` symbol at the top level with no following namespace is not valid
// AgentScript syntax. It should produce at least one parse error.
let result = parse_source("@\n");
assert!(
result.is_err(),
"Expected Err for bare '@' at the top level, got Ok"
);
let errors = result.unwrap_err();
assert!(!errors.is_empty(), "Expected at least one error message for bare '@'");
}
}