diff --git a/AGENTS.md b/AGENTS.md index 011591f7..c4a2c493 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,6 +172,11 @@ network: # optional network policy (standalone target only permissions: # optional ADO access token configuration read: my-read-arm-connection # ARM service connection for read-only ADO access (Stage 1 agent) write: my-write-arm-connection # ARM service connection for write ADO access (Stage 2 executor only) +parameters: # optional ADO runtime parameters (surfaced in UI when queuing a run) + - name: clearMemory + displayName: "Clear agent memory" + type: boolean + default: false --- @@ -304,6 +309,48 @@ The `timeout-minutes` field sets a wall-clock limit (in minutes) for the entire When omitted, Azure DevOps uses its default job timeout (60 minutes). When set, the compiler emits `timeoutInMinutes: ` on the agentic job. +### Runtime Parameters + +The `parameters` field defines Azure DevOps [runtime parameters](https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runtime-parameters) that are surfaced in the ADO UI when manually queuing a pipeline run. Parameters are emitted as a top-level `parameters:` block in the generated pipeline YAML. + +```yaml +parameters: + - name: verbose + displayName: "Verbose output" + type: boolean + default: false + - name: region + displayName: "Target region" + type: string + default: "us-east" + values: + - us-east + - eu-west + - ap-south +``` + +#### Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Parameter identifier (valid ADO identifier) | +| `displayName` | string | No | Human-readable label in the ADO UI | +| `type` | string | No | ADO parameter type: `boolean`, `string`, `number`, `object` | +| `default` | any | No | Default value when not specified at queue time | +| `values` | list | No | Allowed values (for `string`/`number` parameters) | + +Parameters can be referenced in custom steps using `${{ parameters.paramName }}`. + +#### Auto-injected `clearMemory` Parameter + +When `safe-outputs.memory` is configured, the compiler automatically injects a `clearMemory` boolean parameter (default: `false`) at the beginning of the parameters list. This parameter: + +- Is surfaced in the ADO UI when manually queuing a run +- When set to `true`, skips downloading the previous agent memory artifact +- Creates an empty memory directory so the agent starts fresh + +If you define your own `clearMemory` parameter in the front matter, the auto-injected one is suppressed — your definition takes precedence. + ### Tools Configuration The `tools` field controls which tools are available to the agent. Both sub-fields are optional and have sensible defaults. @@ -387,6 +434,25 @@ The compiler transforms the input into valid Azure DevOps pipeline YAML based on Explicit markings are embedded in these templates that the compiler is allowed to replace e.g. `{{ copilot_params }}` denotes parameters which are passed to the copilot command line tool. The compiler should not replace sections denoted by `${{ some content }}`. What follows is a mapping of markings to responsibilities (primarily for the standalone template). +## {{ parameters }} + +Should be replaced with the top-level `parameters:` block generated from the `parameters` front matter field. If no parameters are defined (and no auto-injected parameters apply), this marker is replaced with an empty string. + +When `safe-outputs.memory` is configured, the compiler auto-injects a `clearMemory` boolean parameter (default: `false`) unless one is already user-defined. + +Example output: +```yaml +parameters: +- name: clearMemory + displayName: Clear agent memory + type: boolean + default: false +- name: verbose + displayName: Verbose output + type: boolean + default: false +``` + ## {{ repositories }} For each additional repository specified in the front matter append: diff --git a/src/compile/common.rs b/src/compile/common.rs index 84555975..69249a42 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; -use super::types::{FrontMatter, Repository, TriggerConfig}; +use super::types::{FrontMatter, PipelineParameter, Repository, TriggerConfig}; use crate::compile::types::McpConfig; use crate::fuzzy_schedule; @@ -82,6 +82,127 @@ pub fn replace_with_indent(template: &str, placeholder: &str, replacement: &str) /// Generate a schedule YAML block from a ScheduleConfig. /// When no explicit schedule branches are configured, defaults to `main`. +/// Generate the top-level `parameters:` YAML block from front matter parameters. +/// +/// Returns a YAML block like: +/// ```yaml +/// parameters: +/// - name: clearMemory +/// displayName: "Clear agent memory" +/// type: boolean +/// default: false +/// ``` +/// +/// Returns an empty string if the parameters list is empty. +/// Returns an error if any parameter name is not a valid ADO identifier. +pub fn generate_parameters(parameters: &[PipelineParameter]) -> Result { + if parameters.is_empty() { + return Ok(String::new()); + } + + // Validate parameter names — must be valid ADO identifiers to prevent + // YAML injection or template expression injection. + for p in parameters { + if !is_valid_parameter_name(&p.name) { + anyhow::bail!( + "Invalid parameter name '{}': must match [A-Za-z_][A-Za-z0-9_]* (ADO identifier)", + p.name + ); + } + // Reject ADO expressions in string fields to prevent template expression injection. + // Parameter definitions should only contain literal values. + if let Some(ref display_name) = p.display_name { + reject_ado_expressions(display_name, &p.name, "displayName")?; + } + if let Some(ref default) = p.default { + reject_ado_expressions_in_value(default, &p.name, "default")?; + } + if let Some(ref values) = p.values { + for v in values { + reject_ado_expressions_in_value(v, &p.name, "values")?; + } + } + } + + let yaml = serde_yaml::to_string(&serde_yaml::Value::Sequence( + parameters + .iter() + .map(|p| serde_yaml::to_value(p).context("Failed to serialize pipeline parameter")) + .collect::>>()?, + )) + .context("Failed to serialize parameters to YAML")?; + + // serde_yaml outputs the sequence without a key; we need to wrap it under `parameters:` + Ok(format!("parameters:\n{}", yaml)) +} + +/// Validate that a string is a valid ADO pipeline parameter name (`[A-Za-z_][A-Za-z0-9_]*`). +fn is_valid_parameter_name(name: &str) -> bool { + let mut chars = name.chars(); + chars + .next() + .map_or(false, |c| c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Reject ADO template expressions (`${{`) and macro expressions (`$(`) in a string value. +/// Parameter definitions should only contain literal values — expressions could enable +/// information disclosure or logic manipulation in the generated pipeline. +fn reject_ado_expressions(value: &str, param_name: &str, field_name: &str) -> Result<()> { + if value.contains("${{") || value.contains("$(") { + anyhow::bail!( + "Parameter '{}' field '{}' contains an ADO expression ('${{{{' or '$(') which is not \ + allowed in parameter definitions. Use literal values only.", + param_name, + field_name, + ); + } + Ok(()) +} + +/// Reject ADO expressions in a serde_yaml::Value, recursing into strings within sequences. +fn reject_ado_expressions_in_value( + value: &serde_yaml::Value, + param_name: &str, + field_name: &str, +) -> Result<()> { + match value { + serde_yaml::Value::String(s) => reject_ado_expressions(s, param_name, field_name), + serde_yaml::Value::Sequence(seq) => { + for item in seq { + reject_ado_expressions_in_value(item, param_name, field_name)?; + } + Ok(()) + } + // Booleans, numbers, null — safe, no injection risk + _ => Ok(()), + } +} + +/// Build the final parameters list by combining user-defined parameters +/// with auto-injected parameters (e.g., `clearMemory` when memory is enabled). +pub fn build_parameters(user_params: &[PipelineParameter], has_memory: bool) -> Vec { + let mut params = user_params.to_vec(); + + // Auto-inject clearMemory parameter when memory is configured, + // unless the user already defined one with the same name. + if has_memory && !params.iter().any(|p| p.name == "clearMemory") { + params.insert( + 0, + PipelineParameter { + name: "clearMemory".to_string(), + display_name: Some("Clear agent memory".to_string()), + param_type: Some("boolean".to_string()), + default: Some(serde_yaml::Value::Bool(false)), + values: None, + }, + ); + } + + params +} + +/// Generate a schedule YAML block from a fuzzy schedule expression. pub fn generate_schedule(name: &str, config: &super::types::ScheduleConfig) -> Result { let branches = config.branches(); let fallback; @@ -1787,6 +1908,123 @@ mod tests { assert!(args.is_empty(), "memory-only safe-outputs should produce no args (all tools available)"); } + // ─── parameter name validation ────────────────────────────────────────── + + #[test] + fn test_is_valid_parameter_name() { + assert!(is_valid_parameter_name("clearMemory")); + assert!(is_valid_parameter_name("myParam")); + assert!(is_valid_parameter_name("_private")); + assert!(is_valid_parameter_name("param123")); + assert!(!is_valid_parameter_name("")); + assert!(!is_valid_parameter_name("has space")); + assert!(!is_valid_parameter_name("has-dash")); + assert!(!is_valid_parameter_name("${{inject}}")); + assert!(!is_valid_parameter_name("123startsWithDigit")); + } + + #[test] + fn test_generate_parameters_rejects_invalid_name() { + let params = vec![PipelineParameter { + name: "${{evil}}".to_string(), + display_name: None, + param_type: None, + default: None, + values: None, + }]; + let result = generate_parameters(¶ms); + assert!(result.is_err(), "Should reject invalid parameter name"); + assert!( + result.unwrap_err().to_string().contains("Invalid parameter name"), + "Error should mention invalid parameter name" + ); + } + + #[test] + fn test_build_parameters_auto_injects_clear_memory() { + let params = build_parameters(&[], true); + assert_eq!(params.len(), 1); + assert_eq!(params[0].name, "clearMemory"); + } + + #[test] + fn test_build_parameters_no_inject_without_memory() { + let params = build_parameters(&[], false); + assert!(params.is_empty()); + } + + #[test] + fn test_build_parameters_no_duplicate_clear_memory() { + let user = vec![PipelineParameter { + name: "clearMemory".to_string(), + display_name: Some("Custom".to_string()), + param_type: Some("boolean".to_string()), + default: Some(serde_yaml::Value::Bool(true)), + values: None, + }]; + let params = build_parameters(&user, true); + assert_eq!(params.len(), 1, "Should not duplicate clearMemory"); + assert_eq!(params[0].display_name.as_deref(), Some("Custom"), "Should keep user's definition"); + } + + #[test] + fn test_generate_parameters_rejects_expression_in_display_name() { + let params = vec![PipelineParameter { + name: "myParam".to_string(), + display_name: Some("Test ${{ variables.evil }}".to_string()), + param_type: None, + default: None, + values: None, + }]; + let result = generate_parameters(¶ms); + assert!(result.is_err(), "Should reject ADO expression in displayName"); + } + + #[test] + fn test_generate_parameters_rejects_expression_in_default() { + let params = vec![PipelineParameter { + name: "myParam".to_string(), + display_name: None, + param_type: None, + default: Some(serde_yaml::Value::String("$(secretVar)".to_string())), + values: None, + }]; + let result = generate_parameters(¶ms); + assert!(result.is_err(), "Should reject ADO macro expression in default"); + } + + #[test] + fn test_generate_parameters_rejects_expression_in_values() { + let params = vec![PipelineParameter { + name: "myParam".to_string(), + display_name: None, + param_type: None, + default: None, + values: Some(vec![ + serde_yaml::Value::String("safe".to_string()), + serde_yaml::Value::String("${{ parameters.inject }}".to_string()), + ]), + }]; + let result = generate_parameters(¶ms); + assert!(result.is_err(), "Should reject ADO expression in values"); + } + + #[test] + fn test_generate_parameters_allows_literal_values() { + let params = vec![PipelineParameter { + name: "region".to_string(), + display_name: Some("Target Region".to_string()), + param_type: Some("string".to_string()), + default: Some(serde_yaml::Value::String("us-east".to_string())), + values: Some(vec![ + serde_yaml::Value::String("us-east".to_string()), + serde_yaml::Value::String("eu-west".to_string()), + ]), + }]; + let result = generate_parameters(¶ms); + assert!(result.is_ok(), "Should accept literal values"); + } + // ─── replace_with_indent ───────────────────────────────────────────────── #[test] diff --git a/src/compile/onees.rs b/src/compile/onees.rs index c4d2456a..ce846ca5 100644 --- a/src/compile/onees.rs +++ b/src/compile/onees.rs @@ -17,15 +17,16 @@ use std::path::Path; use super::Compiler; use super::common::{ - self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, compute_effective_workspace, - generate_acquire_ado_token, generate_checkout_self, generate_checkout_steps, - generate_ci_trigger, generate_copilot_ado_env, generate_copilot_params, - generate_executor_ado_env, generate_header_comment, generate_job_timeout, - generate_pipeline_path, generate_pipeline_resources, generate_pr_trigger, - generate_repositories, generate_schedule, generate_source_path, generate_working_directory, - is_custom_mcp, replace_with_indent, validate_comment_target, - validate_resolve_pr_thread_statuses, validate_submit_pr_review_events, - validate_update_pr_votes, validate_update_work_item_target, validate_write_permissions, + self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, build_parameters, + compute_effective_workspace, generate_acquire_ado_token, generate_checkout_self, + generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env, + generate_copilot_params, generate_executor_ado_env, generate_header_comment, + generate_job_timeout, generate_parameters, generate_pipeline_path, + generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule, + generate_source_path, generate_working_directory, is_custom_mcp, replace_with_indent, + validate_comment_target, validate_resolve_pr_thread_statuses, + validate_submit_pr_review_events, validate_update_pr_votes, + validate_update_work_item_target, validate_write_permissions, }; use super::types::{FrontMatter, McpConfig}; @@ -61,6 +62,9 @@ impl Compiler for OneESCompiler { let checkout_steps = generate_checkout_steps(&front_matter.checkout); let checkout_self = generate_checkout_self(); let copilot_params = generate_copilot_params(front_matter); + let has_memory = front_matter.safe_outputs.contains_key("memory"); + let parameters = build_parameters(&front_matter.parameters, has_memory); + let parameters_yaml = generate_parameters(¶meters)?; let effective_workspace = compute_effective_workspace( &front_matter.workspace, @@ -168,6 +172,7 @@ displayName: "Finalize""#, // Replace all template markers let compiler_version = env!("CARGO_PKG_VERSION"); let replacements: Vec<(&str, &str)> = vec![ + ("{{ parameters }}", ¶meters_yaml), ("{{ compiler_version }}", compiler_version), // No-op for 1ES (template doesn't use AWF), but included for forward-compatibility ("{{ firewall_version }}", AWF_VERSION), @@ -430,7 +435,7 @@ mod tests { mcps.insert( "my-tool".to_string(), McpConfig::WithOptions(McpOptions { - command: Some("node".to_string()), + container: Some("node:20-slim".to_string()), ..Default::default() }), ); diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index f38761c1..defd25da 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -15,15 +15,15 @@ use std::path::Path; use super::Compiler; use super::common::{ self, AWF_VERSION, COPILOT_CLI_VERSION, DEFAULT_POOL, MCPG_PORT, MCPG_VERSION, - compute_effective_workspace, generate_acquire_ado_token, generate_cancel_previous_builds, - generate_checkout_self, generate_checkout_steps, generate_ci_trigger, generate_copilot_ado_env, - generate_copilot_params, generate_enabled_tools_args, generate_executor_ado_env, - generate_header_comment, generate_job_timeout, generate_pipeline_path, - generate_pipeline_resources, generate_pr_trigger, generate_repositories, generate_schedule, - generate_source_path, generate_working_directory, replace_with_indent, sanitize_filename, - validate_comment_target, validate_resolve_pr_thread_statuses, - validate_submit_pr_review_events, validate_update_pr_votes, - validate_update_work_item_target, validate_write_permissions, + build_parameters, compute_effective_workspace, generate_acquire_ado_token, + generate_cancel_previous_builds, generate_checkout_self, generate_checkout_steps, + generate_ci_trigger, generate_copilot_ado_env, generate_copilot_params, + generate_enabled_tools_args, generate_executor_ado_env, generate_header_comment, + generate_job_timeout, generate_parameters, generate_pipeline_path, generate_pipeline_resources, + generate_pr_trigger, generate_repositories, generate_schedule, generate_source_path, + generate_working_directory, replace_with_indent, sanitize_filename, validate_comment_target, + validate_resolve_pr_thread_statuses, validate_submit_pr_review_events, + validate_update_pr_votes, validate_update_work_item_target, validate_write_permissions, }; use super::types::{FrontMatter, McpConfig}; use crate::allowed_hosts::{CORE_ALLOWED_HOSTS, mcp_required_hosts}; @@ -100,6 +100,11 @@ impl Compiler for StandaloneCompiler { let setup_job = generate_setup_job(&front_matter.setup, &front_matter.name, &pool); let teardown_job = generate_teardown_job(&front_matter.teardown, &front_matter.name, &pool); let has_memory = front_matter.safe_outputs.contains_key("memory"); + + // Build parameters list: user-defined + auto-injected clearMemory for memory + let parameters = build_parameters(&front_matter.parameters, has_memory); + let parameters_yaml = generate_parameters(¶meters)?; + let prepare_steps = generate_prepare_steps(&front_matter.steps, has_memory); let finalize_steps = generate_finalize_steps(&front_matter.post_steps); let agentic_depends_on = generate_agentic_depends_on(&front_matter.setup); @@ -159,6 +164,7 @@ impl Compiler for StandaloneCompiler { // Replace template markers let compiler_version = env!("CARGO_PKG_VERSION"); let replacements: Vec<(&str, &str)> = vec![ + ("{{ parameters }}", ¶meters_yaml), ("{{ compiler_version }}", compiler_version), ("{{ firewall_version }}", AWF_VERSION), ("{{ mcpg_version }}", MCPG_VERSION), @@ -857,9 +863,13 @@ pub fn generate_mcpg_docker_env(front_matter: &FrontMatter) -> String { /// Generate the steps to download agent memory from the previous successful run /// and restore it to the staging directory. +/// +/// When the `clearMemory` parameter is true, the download step is skipped +/// and only an empty memory directory is created. fn generate_memory_download() -> String { r#"- task: DownloadPipelineArtifact@2 displayName: "Download previous agent memory" + condition: eq(${{ parameters.clearMemory }}, false) continueOnError: true inputs: source: "specific" @@ -881,7 +891,14 @@ fn generate_memory_download() -> String { echo "No previous agent memory found - empty memory directory created" fi displayName: "Restore previous agent memory" - continueOnError: true"# + condition: eq(${{ parameters.clearMemory }}, false) + continueOnError: true + +- bash: | + mkdir -p /tmp/awf-tools/staging/agent_memory + echo "Memory cleared by pipeline parameter - starting fresh" + displayName: "Initialize empty agent memory (clearMemory=true)" + condition: eq(${{ parameters.clearMemory }}, true)"# .to_string() } diff --git a/src/compile/types.rs b/src/compile/types.rs index 145772d9..5af5764d 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2,7 +2,7 @@ //! //! This module defines the front matter grammar that is shared across all compile targets. -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// Target platform for compiled pipeline @@ -200,6 +200,41 @@ pub struct ToolsConfig { pub edit: Option, } +/// Azure DevOps runtime parameter definition. +/// +/// These are emitted as top-level `parameters:` in the generated pipeline YAML, +/// surfaced in the ADO UI when manually queuing a run. +/// +/// Example front matter: +/// ```yaml +/// parameters: +/// - name: debugLevel +/// displayName: "Debug verbosity" +/// type: string +/// default: "info" +/// values: +/// - info +/// - debug +/// - trace +/// ``` +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] +pub struct PipelineParameter { + /// Parameter name (must be a valid ADO identifier) + pub name: String, + /// Human-readable label shown in the ADO UI + #[serde(rename = "displayName", skip_serializing_if = "Option::is_none")] + pub display_name: Option, + /// ADO parameter type: boolean, string, number, object, etc. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub param_type: Option, + /// Default value for the parameter + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + /// Allowed values (for string/number parameters) + #[serde(skip_serializing_if = "Option::is_none")] + pub values: Option>, +} + /// Front matter configuration from the input markdown file #[derive(Debug, Deserialize)] pub struct FrontMatter { @@ -267,6 +302,9 @@ pub struct FrontMatter { /// Workflow-level environment variables #[serde(default)] pub env: HashMap, + /// Runtime parameters for the pipeline (surfaced in ADO UI when queuing a run) + #[serde(default)] + pub parameters: Vec, } fn default_model() -> String { diff --git a/templates/1es-base.yml b/templates/1es-base.yml index a3ba4c1e..fe49a8c5 100644 --- a/templates/1es-base.yml +++ b/templates/1es-base.yml @@ -3,7 +3,7 @@ # for the main agent task, while adding custom jobs for safe output analysis and processing. name: {{ agent_name }}-$(BuildID) - +{{ parameters }} {{ schedule }} {{ pr_trigger }} {{ ci_trigger }} diff --git a/templates/base.yml b/templates/base.yml index aed99232..b9411714 100644 --- a/templates/base.yml +++ b/templates/base.yml @@ -1,5 +1,6 @@ name: {{ agent_name }}-$(BuildID) +{{ parameters }} resources: repositories: - repository: self diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index efa0d6b9..d11a4931 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -2297,3 +2297,228 @@ fn test_mcpg_docker_env_passthrough() { let _ = fs::remove_dir_all(&temp_dir); } + +/// Test that user-defined parameters are emitted in the compiled pipeline YAML +#[test] +fn test_parameters_in_compiled_output() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-params-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let input = r#"--- +name: "Params Agent" +description: "Tests parameters feature" +parameters: + - name: verbose + displayName: "Verbose output" + type: boolean + default: false + - name: region + displayName: "Target region" + type: string + default: "us-east" + values: + - us-east + - eu-west +--- + +## Test + +Do the thing. +"#; + + let input_path = temp_dir.join("params-agent.md"); + let output_path = temp_dir.join("params-agent.yml"); + fs::write(&input_path, input).unwrap(); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .output() + .expect("Failed to run compiler"); + + assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + + let compiled = fs::read_to_string(&output_path).unwrap(); + + // Verify parameters block is present + assert!(compiled.contains("parameters:"), "Should contain parameters: block"); + assert!(compiled.contains("name: verbose"), "Should contain verbose parameter"); + assert!(compiled.contains("name: region"), "Should contain region parameter"); + assert!(compiled.contains("displayName: Verbose output"), "Should contain displayName"); + assert!(compiled.contains("default: false"), "Should contain default for verbose"); + assert!(compiled.contains("default: us-east"), "Should contain default for region"); + assert!(compiled.contains("- us-east"), "Should contain values for region"); + assert!(compiled.contains("- eu-west"), "Should contain values for region"); + + // No clearMemory should be injected (no memory configured) + assert!(!compiled.contains("clearMemory"), "Should NOT contain clearMemory without memory"); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that clearMemory is auto-injected when memory is enabled +#[test] +fn test_parameters_clear_memory_auto_injected() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-clear-memory-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let input = r#"--- +name: "Memory Agent" +description: "Tests clearMemory auto-injection" +safe-outputs: + memory: + allowed-extensions: + - .md +--- + +## Test + +Do the thing. +"#; + + let input_path = temp_dir.join("memory-agent.md"); + let output_path = temp_dir.join("memory-agent.yml"); + fs::write(&input_path, input).unwrap(); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .output() + .expect("Failed to run compiler"); + + assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + + let compiled = fs::read_to_string(&output_path).unwrap(); + + // Verify clearMemory parameter is auto-injected + assert!(compiled.contains("name: clearMemory"), "Should auto-inject clearMemory parameter"); + assert!(compiled.contains("displayName: Clear agent memory"), "Should have displayName"); + assert!(compiled.contains("type: boolean"), "Should be boolean type"); + + // Verify memory download has condition + assert!( + compiled.contains("condition: eq(${{ parameters.clearMemory }}, false)"), + "Memory download should be conditional on clearMemory=false" + ); + assert!( + compiled.contains("condition: eq(${{ parameters.clearMemory }}, true)"), + "Clear memory step should run when clearMemory=true" + ); + assert!( + compiled.contains("Initialize empty agent memory (clearMemory=true)"), + "Should have the clear memory initialization step" + ); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that user-defined clearMemory is not duplicated +#[test] +fn test_parameters_user_defined_clear_memory_not_duplicated() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-user-clear-memory-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let input = r#"--- +name: "Custom Memory Agent" +description: "Tests user-defined clearMemory not duplicated" +parameters: + - name: clearMemory + displayName: "Reset memory" + type: boolean + default: true +safe-outputs: + memory: + allowed-extensions: + - .md +--- + +## Test + +Do the thing. +"#; + + let input_path = temp_dir.join("custom-memory.md"); + let output_path = temp_dir.join("custom-memory.yml"); + fs::write(&input_path, input).unwrap(); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .output() + .expect("Failed to run compiler"); + + assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + + let compiled = fs::read_to_string(&output_path).unwrap(); + + // Verify user's clearMemory is present (with their custom displayName and default) + assert!(compiled.contains("displayName: Reset memory"), "Should use user's displayName"); + assert!(compiled.contains("default: true"), "Should use user's default value"); + + // Verify clearMemory only appears once (not duplicated) + let count = compiled.matches("name: clearMemory").count(); + assert_eq!(count, 1, "clearMemory should appear exactly once, not duplicated"); + + let _ = fs::remove_dir_all(&temp_dir); +} + +/// Test that parameters block has no unreplaced markers +#[test] +fn test_parameters_no_unreplaced_markers() { + let temp_dir = std::env::temp_dir().join(format!( + "agentic-pipeline-params-markers-{}", + std::process::id() + )); + fs::create_dir_all(&temp_dir).expect("Failed to create temp directory"); + + let input = r#"--- +name: "Markers Agent" +description: "Tests no unreplaced markers with parameters" +parameters: + - name: myParam + type: string + default: "hello" +safe-outputs: + memory: + allowed-extensions: + - .md +--- + +## Test +"#; + + let input_path = temp_dir.join("markers-agent.md"); + let output_path = temp_dir.join("markers-agent.yml"); + fs::write(&input_path, input).unwrap(); + + let binary_path = PathBuf::from(env!("CARGO_BIN_EXE_ado-aw")); + let output = std::process::Command::new(&binary_path) + .args(["compile", input_path.to_str().unwrap(), "-o", output_path.to_str().unwrap()]) + .output() + .expect("Failed to run compiler"); + + assert!(output.status.success(), "Compiler should succeed: {}", String::from_utf8_lossy(&output.stderr)); + + let compiled = fs::read_to_string(&output_path).unwrap(); + + // Verify no unreplaced {{ markers }} remain (excluding ${{ }} which are ADO expressions) + for line in compiled.lines() { + let stripped = line.replace("${{", ""); + assert!( + !stripped.contains("{{ "), + "Should not contain unreplaced marker: {}", + line.trim() + ); + } + + let _ = fs::remove_dir_all(&temp_dir); +}