From e26543f21a74063c85301744d93bdf3d9a0d182e Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 10 Sep 2026 12:46:53 -0600 Subject: [PATCH] test(graph): cover agent node inputs end to end and at the runtime guards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two offline chain tests materialize an echo-inputs graph agent whose script reports the width it sees: a parent with inputs.width gets the parent's value back, a parent without inputs gets the child's YAML default. Two agents.rs tests pin the exact runtime guard texts for a config-only target and a reserved initial_prompt key. Task T5 of plans/map-subgraph-branches-design.md ยง3.12. --- src/function/agents.rs | 82 ++++++++++++++++++++++++++++++++ src/graph/map.rs | 105 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/src/function/agents.rs b/src/function/agents.rs index d8a98ba..da31122 100644 --- a/src/function/agents.rs +++ b/src/function/agents.rs @@ -1943,6 +1943,88 @@ mod tests { assert_eq!(effective_max_agent_depth(&ctx), 5); } + fn unique_agent_name(prefix: &str) -> String { + format!( + "{prefix}_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + ) + } + + fn write_config_only_agent(agent_name: &str) { + let agent_dir = paths::agent_data_dir(agent_name); + create_dir_all(&agent_dir).unwrap(); + write( + agent_dir.join("config.yaml"), + format!("name: {agent_name}\ninstructions: hi\n"), + ) + .unwrap(); + } + + fn write_graph_agent(agent_name: &str) { + let agent_dir = paths::agent_data_dir(agent_name); + create_dir_all(&agent_dir).unwrap(); + write( + agent_dir.join("graph.yaml"), + format!( + "name: {agent_name}\nstart: done\nnodes:\n done:\n type: end\n output: done\n" + ), + ) + .unwrap(); + } + + fn run_agent_for_graph_error( + agent_name: &str, + graph_inputs: Option>, + ) -> String { + let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd); + let err = run_async(run_agent_for_graph( + &mut ctx, + agent_name, + "hi", + graph_inputs, + )) + .expect_err("run_agent_for_graph should reject the inputs"); + format!("{err:#}") + } + + #[test] + #[serial] + fn run_agent_for_graph_rejects_inputs_for_config_only_agent() { + let _guard = TestConfigDirGuard::new(); + let agent_name = unique_agent_name("test_inputs_plain_agent"); + write_config_only_agent(&agent_name); + + let chain = run_agent_for_graph_error(&agent_name, Some(HashMap::new())); + + assert_eq!( + chain, + format!( + "agent '{agent_name}' has no graph.yaml; `inputs:` is only valid on agent nodes that target a graph agent" + ) + ); + } + + #[test] + #[serial] + fn run_agent_for_graph_rejects_initial_prompt_input_on_graph_agent() { + let _guard = TestConfigDirGuard::new(); + let agent_name = unique_agent_name("test_inputs_graph_agent"); + write_graph_agent(&agent_name); + let inputs = HashMap::from([("initial_prompt".to_string(), json!("x"))]); + + let chain = run_agent_for_graph_error(&agent_name, Some(inputs)); + + assert_eq!( + chain, + format!( + "agent '{agent_name}': `inputs.initial_prompt` is reserved (the dispatcher seeds it from `prompt:`)" + ) + ); + } + fn auto_continue_ctx() -> RequestContext { let app = AppState { config: Arc::new(AppConfig { diff --git a/src/graph/map.rs b/src/graph/map.rs index e069b3c..965b801 100644 --- a/src/graph/map.rs +++ b/src/graph/map.rs @@ -1484,6 +1484,46 @@ nodes: .unwrap(); } + const ECHO_INPUTS_AGENT: &str = "echo-inputs"; + + /// Materializes a graph agent whose only script echoes the `width` it + /// finds in `GRAPH_STATE` back as `output`. Its YAML default is + /// `width: 1`, so a parent that seeds `width` through `inputs:` is + /// observable against a parent that does not. + fn materialize_echo_inputs_agent() { + let graph_path = paths::agent_graph_file(ECHO_INPUTS_AGENT); + let agent_dir = graph_path.parent().unwrap(); + fs::create_dir_all(agent_dir).unwrap(); + fs::write( + agent_dir.join("echo.py"), + "#!/usr/bin/env python3\nimport os, json\n\ + state = json.loads(os.environ.get(\"GRAPH_STATE\", \"{}\"))\n\ + print(json.dumps({\"output\": str(state[\"width\"])}))\n", + ) + .unwrap(); + fs::write( + &graph_path, + format!( + r#" +name: {ECHO_INPUTS_AGENT} +start: echo +initial_state: + width: 1 +nodes: + echo: + type: script + script: echo.py + timeout: 30 + next: done + done: + type: end + output: "{{{{output}}}}" +"# + ), + ) + .unwrap(); + } + const DOUBLER_GRAPH: &str = r#" name: t start: doubler @@ -1784,6 +1824,71 @@ nodes: assert!(peers.0.is_finished(&peers.1.0)); } + fn echo_inputs_parent_graph(inputs_block: &str) -> String { + format!( + r#" +name: t +start: worker +nodes: + worker: + type: agent + agent: {ECHO_INPUTS_AGENT} + prompt: "p" +{inputs_block} state_updates: + output: "{{{{output}}}}" +"# + ) + } + + async fn run_echo_inputs_chain(parent_yaml: &str) -> StateManager { + let h = Harness::new(parent_yaml); + let mut state = StateManager::new(HashMap::from([("n".to_string(), json!(3))])); + let mut ctx = silent_ctx(); + + h.run("worker", None, &mut state, &mut ctx) + .await + .unwrap_or_else(|e| panic!("chain failed: {e:#}")); + + state + } + + /// The child's `width` default is overlaid by the parent's `inputs` + /// before the child graph starts, so its script sees the parent's value. + #[tokio::test] + #[serial] + async fn agent_node_inputs_seed_the_child_graph_state() { + if !cmd_available("python3") { + eprintln!("skipping: python3 not available"); + return; + } + let _guard = TestConfigDirGuard::new(); + materialize_echo_inputs_agent(); + let parent = echo_inputs_parent_graph(" inputs:\n width: \"{{n}}\"\n"); + + let state = run_echo_inputs_chain(&parent).await; + + assert_eq!(state.state().get("output"), Some(&json!("3"))); + } + + /// Control for the test above: the same child with no `inputs` on the + /// parent node keeps its YAML default, so an override in the sibling + /// test cannot be the child ignoring its own `initial_state`. + #[tokio::test] + #[serial] + async fn agent_node_without_inputs_leaves_the_child_graph_defaults() { + if !cmd_available("python3") { + eprintln!("skipping: python3 not available"); + return; + } + let _guard = TestConfigDirGuard::new(); + materialize_echo_inputs_agent(); + let parent = echo_inputs_parent_graph(""); + + let state = run_echo_inputs_chain(&parent).await; + + assert_eq!(state.state().get("output"), Some(&json!("1"))); + } + #[tokio::test] async fn run_item_chain_reaches_flagged_agent_after_script_step() { if !cmd_available("python3") {