diff --git a/src/graph/dependencies.rs b/src/graph/dependencies.rs index c0d30ab..0e442f1 100644 --- a/src/graph/dependencies.rs +++ b/src/graph/dependencies.rs @@ -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"); + } } diff --git a/src/graph/queries.rs b/src/graph/queries.rs index 7508e45..c7b513f 100644 --- a/src/graph/queries.rs +++ b/src/graph/queries.rs @@ -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"); + } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index f5db09b..c1755f0 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -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 '@'"); + } }