use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use console::style;
use crate::ask_handler::AskHandler;
use crate::cancellation::CancellationToken;
use crate::config::WorkflowConfig;
use crate::error::Result;
use crate::executor::{Executor, PromptRun};
use crate::step::prompt::{PromptResult, StreamCallbacks};
use crate::variable::VariableStore;
const PI_HTTP_REQUEST_TIMEOUT_SECS: &str = "PI_HTTP_REQUEST_TIMEOUT_SECS";
const DEFAULT_PI_PLANNING_REQUEST_TIMEOUT_SECS: &str = "1800";
pub const PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/plan.md");
pub const FIX_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/fix-plan.md");
pub const ASK_PLAN_PROMPT_TEMPLATE: &str = include_str!("../prompts/ask-plan.md");
pub const PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/plan-sdk.md");
pub const FIX_PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/fix-plan-sdk.md");
pub const ASK_PLAN_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/ask-plan-sdk.md");
pub const PLAN_GRILL_PROMPT_TEMPLATE_SDK: &str = include_str!("../prompts/plan-grill-sdk.md");
const PLAN_LANGUAGE_VAR: &str = "plan.language";
#[must_use]
pub fn setup_plan_vars(
session_input: String,
plan_path: PathBuf,
config: &WorkflowConfig,
) -> VariableStore {
let mut vars = VariableStore::new(session_input);
vars.set_named_file(crate::session::PLAN_VAR, plan_path);
vars.set_named_value(PLAN_LANGUAGE_VAR, config.effective_plan_language());
vars
}
#[must_use]
pub fn sdk_plan_tools_enabled(config: &WorkflowConfig) -> bool {
config.sdk.is_some() && config.interactive_planning
}
#[must_use]
pub fn plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
PLAN_PROMPT_TEMPLATE_SDK
} else {
PLAN_PROMPT_TEMPLATE
}
}
#[must_use]
pub fn initial_plan_template(config: &WorkflowConfig, grill: bool) -> &'static str {
if grill && sdk_plan_tools_enabled(config) {
PLAN_GRILL_PROMPT_TEMPLATE_SDK
} else {
plan_template(config)
}
}
#[must_use]
pub fn fix_plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
FIX_PLAN_PROMPT_TEMPLATE_SDK
} else {
FIX_PLAN_PROMPT_TEMPLATE
}
}
#[must_use]
pub fn ask_plan_template(config: &WorkflowConfig) -> &'static str {
if sdk_plan_tools_enabled(config) {
ASK_PLAN_PROMPT_TEMPLATE_SDK
} else {
ASK_PLAN_PROMPT_TEMPLATE
}
}
pub struct PlanPromptCtx<'a> {
pub config: &'a WorkflowConfig,
pub ask: Arc<dyn AskHandler>,
pub plan_path: &'a Path,
pub interactive: bool,
pub rate_limit_retries: usize,
pub working_dir: Option<&'a Path>,
pub grill: bool,
pub cancel_token: Option<&'a CancellationToken>,
}
impl PlanPromptCtx<'_> {
#[must_use]
fn executor(&self) -> Executor {
Executor::new(self.config.sdk.as_deref(), &self.config.command)
}
}
fn resolve_planning_env(
config: &WorkflowConfig,
vars: &VariableStore,
) -> Result<HashMap<String, String>> {
let mut env = crate::engine::resolve_env(&config.env, &HashMap::new(), vars)?;
if !env.contains_key(PI_HTTP_REQUEST_TIMEOUT_SECS)
&& std::env::var_os(PI_HTTP_REQUEST_TIMEOUT_SECS).is_none()
{
env.insert(
PI_HTTP_REQUEST_TIMEOUT_SECS.to_string(),
DEFAULT_PI_PLANNING_REQUEST_TIMEOUT_SECS.to_string(),
);
}
Ok(env)
}
pub async fn run_plan_prompt_template(
ctx: &PlanPromptCtx<'_>,
vars: &mut VariableStore,
template: &str,
label: &str,
stream_callbacks: Option<&StreamCallbacks<'_>>,
resume: &mut Option<String>,
register_plan_tools: bool,
) -> Result<PromptResult> {
let prompt = vars.resolve(template)?;
let executor = ctx.executor();
let model_or_mode = executor.plan_model_or_mode(
ctx.config.plan_model.as_deref(),
ctx.config.model.as_deref(),
);
let plan_tools_enabled = sdk_plan_tools_enabled(ctx.config);
let tools = if plan_tools_enabled && register_plan_tools {
crate::sdk_tools::planning_tools(
ctx.plan_path.to_path_buf(),
Arc::clone(&ctx.ask),
ctx.interactive,
)
} else {
Vec::new()
};
let env = resolve_planning_env(ctx.config, vars)?;
eprintln!("\n{} {}", style("â–¶").cyan().bold(), style(label).bold());
let spinner = (!executor.is_sdk()).then(|| crate::spinner::Spinner::start("Cruising..."));
let on_retry = move |msg: &str| eprintln!("{msg}");
let outcome = executor
.run(PromptRun {
prompt: &prompt,
model_or_mode: model_or_mode.as_deref(),
max_retries: ctx.rate_limit_retries,
env: &env,
on_retry: Some(&on_retry),
cancel_token: ctx.cancel_token,
working_dir: ctx.working_dir,
stream: stream_callbacks,
tools,
resume: resume.clone(),
})
.await;
drop(spinner);
let outcome = outcome?;
if plan_tools_enabled && outcome.session_id.is_some() {
*resume = outcome.session_id;
}
Ok(outcome.result)
}
pub fn write_input_as_plan(plan_path: &Path, input: &str) -> Result<String> {
let content = input.trim().to_string();
if content.is_empty() {
return Err(crate::error::CruiseError::Other(
"cannot use empty input as plan".to_string(),
));
}
std::fs::write(plan_path, &content)
.map_err(|e| crate::error::CruiseError::Other(format!("failed to write plan: {e}")))?;
Ok(content)
}
#[must_use]
pub fn extract_terminal_error_from_transcript(jsonl: &str) -> Option<String> {
let mut last_error = None;
for line in jsonl.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
continue;
};
let Some(message) = value.get("message") else {
continue;
};
let Some(stop_reason) = message.get("stopReason").and_then(|v| v.as_str()) else {
continue;
};
if stop_reason != "error" {
continue;
}
let Some(error_message) = message.get("errorMessage").and_then(|v| v.as_str()) else {
continue;
};
if !error_message.is_empty() {
last_error = Some(error_message.to_string());
}
}
last_error
}
pub fn resolve_generated_plan_content(
plan_path: &Path,
stdout: &str,
stderr: &str,
transcript: Option<&str>,
) -> Result<String> {
match crate::metadata::resolve_plan_content(plan_path, stdout, stderr) {
Ok(content) => Ok(content),
Err(original_err) => {
if let Some(jsonl) = transcript
&& let Some(backend_error) = extract_terminal_error_from_transcript(jsonl)
{
return Err(crate::error::CruiseError::Other(format!(
"planning backend failed after producing no plan output: {backend_error}"
)));
}
Err(original_err)
}
}
}
#[must_use]
pub fn read_sdk_transcript(working_dir: Option<&Path>, session_id: &str) -> Option<String> {
let transcript_path = seher::sdk::pi_session_path(working_dir, session_id);
std::fs::read_to_string(&transcript_path).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn make_temp_dir() -> TempDir {
TempDir::new().unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn write_input_as_plan_writes_trimmed_content_to_file() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
let content = write_input_as_plan(&plan_path, " implement feature X ")
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(content, "implement feature X");
assert_eq!(
std::fs::read_to_string(&plan_path).unwrap_or_else(|e| panic!("{e:?}")),
"implement feature X"
);
}
#[test]
fn write_input_as_plan_returns_err_for_empty_input() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
assert!(write_input_as_plan(&plan_path, "").is_err());
assert!(!plan_path.exists());
}
#[test]
fn write_input_as_plan_returns_err_for_whitespace_only_input() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
assert!(write_input_as_plan(&plan_path, " \n\t ").is_err());
}
#[test]
fn write_input_as_plan_preserves_multiline_markdown() {
let dir = make_temp_dir();
let plan_path = dir.path().join("plan.md");
let input = "# Plan\n\n- step 1\n- step 2";
let content = write_input_as_plan(&plan_path, input).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(content, "# Plan\n\n- step 1\n- step 2");
assert_eq!(
std::fs::read_to_string(&plan_path).unwrap_or_else(|e| panic!("{e:?}")),
content
);
}
#[test]
fn write_input_as_plan_returns_err_on_invalid_path() {
let plan_path = std::path::Path::new("/nonexistent/dir/plan.md");
assert!(write_input_as_plan(plan_path, "some content").is_err());
}
fn config_with(sdk: Option<&str>, command: Option<&str>) -> WorkflowConfig {
let mut yaml = String::new();
if let Some(s) = sdk {
yaml.push_str("sdk: ");
yaml.push_str(s);
yaml.push('\n');
}
if let Some(c) = command {
yaml.push_str("command: [");
yaml.push_str(c);
yaml.push_str("]\n");
}
yaml.push_str("steps:\n s1:\n prompt: hi\n");
WorkflowConfig::from_yaml(&yaml).unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn templates_select_command_variants_without_sdk() {
let config = config_with(None, Some("echo"));
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE);
}
#[test]
fn templates_select_sdk_variants_with_sdk() {
let config = config_with(Some("seher"), None);
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE_SDK);
}
#[test]
fn templates_select_sdk_variants_with_pi() {
let config = config_with(Some("pi"), None);
assert!(sdk_plan_tools_enabled(&config));
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE_SDK);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE_SDK);
}
fn sdk_config_no_interactive() -> WorkflowConfig {
WorkflowConfig::from_yaml(
"sdk: seher\ninteractive_planning: false\nsteps:\n s1:\n prompt: hi\n",
)
.unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn interactive_planning_defaults_to_true_for_sdk() {
let config = config_with(Some("seher"), None);
assert!(config.interactive_planning);
assert!(sdk_plan_tools_enabled(&config));
}
#[test]
fn sdk_plan_tools_disabled_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert!(!sdk_plan_tools_enabled(&config));
}
#[test]
fn templates_fall_back_to_command_variants_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert_eq!(plan_template(&config), PLAN_PROMPT_TEMPLATE);
assert_eq!(fix_plan_template(&config), FIX_PLAN_PROMPT_TEMPLATE);
assert_eq!(ask_plan_template(&config), ASK_PLAN_PROMPT_TEMPLATE);
}
#[test]
fn grill_ignored_when_interactive_planning_off() {
let config = sdk_config_no_interactive();
assert_eq!(initial_plan_template(&config, true), PLAN_PROMPT_TEMPLATE);
}
#[test]
fn sdk_and_command_templates_differ() {
assert_ne!(PLAN_PROMPT_TEMPLATE, PLAN_PROMPT_TEMPLATE_SDK);
assert_ne!(FIX_PLAN_PROMPT_TEMPLATE, FIX_PLAN_PROMPT_TEMPLATE_SDK);
assert_ne!(ASK_PLAN_PROMPT_TEMPLATE, ASK_PLAN_PROMPT_TEMPLATE_SDK);
}
#[test]
fn initial_plan_template_uses_grill_variant_for_sdk_when_enabled() {
let config = config_with(Some("seher"), None);
assert_eq!(
initial_plan_template(&config, true),
PLAN_GRILL_PROMPT_TEMPLATE_SDK
);
}
#[test]
fn initial_plan_template_uses_standard_sdk_variant_when_grill_off() {
let config = config_with(Some("seher"), None);
assert_eq!(
initial_plan_template(&config, false),
PLAN_PROMPT_TEMPLATE_SDK
);
}
#[test]
fn initial_plan_template_ignores_grill_without_sdk() {
let config = config_with(None, Some("echo"));
assert_eq!(initial_plan_template(&config, true), PLAN_PROMPT_TEMPLATE);
}
#[test]
fn grill_template_differs_from_standard_sdk_plan() {
assert_ne!(PLAN_GRILL_PROMPT_TEMPLATE_SDK, PLAN_PROMPT_TEMPLATE_SDK);
}
use crate::ask_handler::NoninteractiveAskHandler;
use crate::cancellation::CancellationToken;
use crate::error::CruiseError;
use crate::variable::VariableStore;
use std::sync::Arc;
fn make_ctx_no_token<'a>(config: &'a WorkflowConfig, plan_path: &'a Path) -> PlanPromptCtx<'a> {
PlanPromptCtx {
config,
ask: Arc::new(NoninteractiveAskHandler),
plan_path,
interactive: false,
rate_limit_retries: 0,
working_dir: None,
grill: false,
cancel_token: None,
}
}
#[test]
fn plan_prompt_ctx_cancel_token_is_none_when_not_set() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let config = config_with(None, Some("\"echo\""));
let ctx = make_ctx_no_token(&config, &plan_path);
assert!(ctx.cancel_token.is_none());
}
#[test]
fn plan_prompt_ctx_cancel_token_stored_when_provided() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let config = config_with(None, Some("\"echo\""));
let token = CancellationToken::new();
let ctx = PlanPromptCtx {
config: &config,
ask: Arc::new(NoninteractiveAskHandler),
plan_path: &plan_path,
interactive: false,
rate_limit_retries: 0,
working_dir: None,
grill: false,
cancel_token: Some(&token),
};
assert!(ctx.cancel_token.is_some());
token.cancel();
assert!(
ctx.cancel_token
.unwrap_or_else(|| panic!("cancel_token was set above"))
.is_cancelled()
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_plan_prompt_template_with_no_cancel_token_completes() {
let _guard = crate::test_support::lock_process();
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
std::fs::write(&plan_path, "").unwrap_or_else(|e| panic!("{e:?}"));
let config = config_with(None, Some("\"cat\""));
let ctx = make_ctx_no_token(&config, &plan_path);
let mut vars = VariableStore::new("test input".to_string());
let mut resume = None;
let result =
run_plan_prompt_template(&ctx, &mut vars, "hello", "test", None, &mut resume, false)
.await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
}
#[cfg(unix)]
#[tokio::test]
async fn run_plan_prompt_template_pre_cancelled_token_returns_interrupted() {
let _guard = crate::test_support::lock_process();
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
std::fs::write(&plan_path, "").unwrap_or_else(|e| panic!("{e:?}"));
let config = config_with(None, Some("\"sleep\", \"100\""));
let token = CancellationToken::new();
token.cancel();
let ctx = PlanPromptCtx {
config: &config,
ask: Arc::new(NoninteractiveAskHandler),
plan_path: &plan_path,
interactive: false,
rate_limit_retries: 0,
working_dir: None,
grill: false,
cancel_token: Some(&token),
};
let mut vars = VariableStore::new("test input".to_string());
let mut resume = None;
let timed = tokio::time::timeout(
std::time::Duration::from_secs(5),
run_plan_prompt_template(&ctx, &mut vars, "hello", "test", None, &mut resume, false),
)
.await;
assert!(
timed.is_ok(),
"timed out — cancel_token is not forwarded to PromptRun"
);
assert!(
matches!(
timed.unwrap_or_else(|e| panic!("{e:?}")),
Err(CruiseError::Interrupted)
),
"expected CruiseError::Interrupted"
);
}
#[test]
fn extract_terminal_error_returns_error_from_valid_jsonl() {
let jsonl = r#"{"message":{"stopReason":"ok","content":"hello"}}
{"message":{"stopReason":"error","errorMessage":"context_length_exceeded: token limit 100000 exceeded"}}"#;
let result = extract_terminal_error_from_transcript(jsonl);
assert_eq!(
result,
Some("context_length_exceeded: token limit 100000 exceeded".to_string())
);
}
#[test]
fn extract_terminal_error_returns_last_error_when_multiple_exist() {
let jsonl = r#"{"message":{"stopReason":"error","errorMessage":"first error"}}
{"message":{"stopReason":"ok","content":"some output"}}
{"message":{"stopReason":"error","errorMessage":"final context_length_exceeded error"}}"#;
let result = extract_terminal_error_from_transcript(jsonl);
assert_eq!(
result,
Some("final context_length_exceeded error".to_string())
);
}
#[test]
fn extract_terminal_error_returns_none_for_empty_input() {
assert_eq!(extract_terminal_error_from_transcript(""), None);
}
#[test]
fn extract_terminal_error_returns_none_for_no_error_lines() {
let jsonl = r#"{"message":{"stopReason":"ok","content":"hello"}}
{"message":{"stopReason":"ok","content":"world"}}"#;
assert_eq!(extract_terminal_error_from_transcript(jsonl), None);
}
#[test]
fn extract_terminal_error_returns_none_for_malformed_json() {
let jsonl = r#"not valid json
{"message":{"stopReason":"error","errorMessage":"this is valid but after bad line"}}"#;
let result = extract_terminal_error_from_transcript(jsonl);
assert_eq!(result, Some("this is valid but after bad line".to_string()));
}
#[test]
fn extract_terminal_error_returns_none_when_error_message_missing() {
let jsonl = r#"{"message":{"stopReason":"error"}}"#;
assert_eq!(extract_terminal_error_from_transcript(jsonl), None);
}
#[test]
fn extract_terminal_error_returns_none_when_error_message_empty() {
let jsonl = r#"{"message":{"stopReason":"error","errorMessage":""}}"#;
assert_eq!(extract_terminal_error_from_transcript(jsonl), None);
}
#[test]
fn extract_terminal_error_returns_none_when_stop_reason_not_error() {
let jsonl = r#"{"message":{"stopReason":"max_tokens","errorMessage":"truncated"}}"#;
assert_eq!(extract_terminal_error_from_transcript(jsonl), None);
}
#[test]
fn extract_terminal_error_ignores_non_message_lines() {
let jsonl = r#"{"type":"start","session":"abc123"}
{"message":{"stopReason":"error","errorMessage":"API error: context_length_exceeded"}}"#;
let result = extract_terminal_error_from_transcript(jsonl);
assert_eq!(
result,
Some("API error: context_length_exceeded".to_string())
);
}
#[test]
fn resolve_generated_plan_content_returns_content_when_plan_file_exists() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
std::fs::write(&plan_path, "# Existing Plan\n\nSteps here.")
.unwrap_or_else(|e| panic!("{e:?}"));
let result = resolve_generated_plan_content(&plan_path, "", "", None)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "# Existing Plan\n\nSteps here.");
}
#[test]
fn resolve_generated_plan_content_returns_stdout_when_nonempty() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let result = resolve_generated_plan_content(&plan_path, "# Plan from stdout", "", None)
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "# Plan from stdout");
}
#[test]
fn resolve_generated_plan_content_falls_back_to_transcript_error_when_all_empty() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let transcript = r#"{"message":{"stopReason":"error","errorMessage":"API error: context_length_exceeded: token limit 200000 exceeded"}}"#;
let result = resolve_generated_plan_content(&plan_path, "", "", Some(transcript));
assert!(result.is_err(), "expected Err, got: {result:?}");
let Err(err) = result else {
panic!("expected Err, got: {result:?}")
};
let err_msg = err.to_string();
assert!(
err_msg.contains("context_length_exceeded"),
"error should mention context_length_exceeded: {err_msg}"
);
assert!(
err_msg.contains("planning backend failed"),
"error should identify the source: {err_msg}"
);
}
#[test]
fn resolve_generated_plan_content_preserves_original_error_when_no_transcript() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let result = resolve_generated_plan_content(&plan_path, "", "", None);
assert!(result.is_err(), "expected Err, got: {result:?}");
let Err(err) = result else {
panic!("expected Err, got: {result:?}")
};
let err_msg = err.to_string();
assert!(
err_msg.contains("plan generation produced no output"),
"should keep original error when no transcript: {err_msg}"
);
}
#[test]
fn resolve_generated_plan_content_preserves_original_error_when_transcript_has_no_error() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let transcript = r#"{"message":{"stopReason":"ok","content":"some output"}}"#;
let result = resolve_generated_plan_content(&plan_path, "", "", Some(transcript));
assert!(result.is_err(), "expected Err, got: {result:?}");
let Err(err) = result else {
panic!("expected Err, got: {result:?}")
};
let err_msg = err.to_string();
assert!(
err_msg.contains("plan generation produced no output"),
"should keep original error when transcript has no error: {err_msg}"
);
}
#[test]
fn resolve_generated_plan_content_ignores_transcript_when_content_available() {
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
let transcript =
r#"{"message":{"stopReason":"error","errorMessage":"context_length_exceeded"}}"#;
let result =
resolve_generated_plan_content(&plan_path, "# Plan from stdout", "", Some(transcript))
.unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(result, "# Plan from stdout");
}
use crate::test_support::{EnvGuard, lock_process};
use std::fmt::Write as _;
fn config_with_env(env_entries: &[(&str, &str)]) -> WorkflowConfig {
let mut yaml = String::from("command: [\"echo\"]\n");
if !env_entries.is_empty() {
yaml.push_str("env:\n");
for (k, v) in env_entries {
let _ = writeln!(yaml, " {k}: \"{v}\"");
}
}
yaml.push_str("steps:\n s1:\n prompt: hi\n");
WorkflowConfig::from_yaml(&yaml).unwrap_or_else(|e| panic!("{e:?}"))
}
fn config_with_env_template(key: &str, template: &str) -> WorkflowConfig {
let yaml = format!(
"command: [\"echo\"]\nenv:\n {key}: \"{template}\"\nsteps:\n s1:\n prompt: hi\n"
);
WorkflowConfig::from_yaml(&yaml).unwrap_or_else(|e| panic!("{e:?}"))
}
#[test]
fn resolve_planning_env_inserts_default_when_no_env() {
let _guard = lock_process();
let _env_guard = EnvGuard::remove(PI_HTTP_REQUEST_TIMEOUT_SECS);
let config = config_with_env(&[]);
let vars = VariableStore::new("test".to_string());
let env = resolve_planning_env(&config, &vars).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
env.get(PI_HTTP_REQUEST_TIMEOUT_SECS)
.map(std::string::String::as_str),
Some(DEFAULT_PI_PLANNING_REQUEST_TIMEOUT_SECS),
"should insert default timeout when no env is set"
);
}
#[test]
fn resolve_planning_env_preserves_workflow_env_override() {
let _guard = lock_process();
let _env_guard = EnvGuard::remove(PI_HTTP_REQUEST_TIMEOUT_SECS);
let config = config_with_env(&[(PI_HTTP_REQUEST_TIMEOUT_SECS, "900")]);
let vars = VariableStore::new("test".to_string());
let env = resolve_planning_env(&config, &vars).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
env.get(PI_HTTP_REQUEST_TIMEOUT_SECS)
.map(std::string::String::as_str),
Some("900"),
"workflow env should override the default"
);
}
#[test]
fn resolve_planning_env_does_not_override_ambient_env() {
let _guard = lock_process();
let _env_guard = EnvGuard::set(PI_HTTP_REQUEST_TIMEOUT_SECS, "600");
let config = config_with_env(&[]);
let vars = VariableStore::new("test".to_string());
let env = resolve_planning_env(&config, &vars).unwrap_or_else(|e| panic!("{e:?}"));
assert!(
!env.contains_key(PI_HTTP_REQUEST_TIMEOUT_SECS),
"should NOT insert default when ambient env is set; pi reads it directly"
);
}
#[test]
fn resolve_planning_env_preserves_unrelated_workflow_env() {
let _guard = lock_process();
let _env_guard = EnvGuard::remove(PI_HTTP_REQUEST_TIMEOUT_SECS);
let config = config_with_env_template("MY_VAR", "{input}");
let vars = VariableStore::new("hello world".to_string());
let env = resolve_planning_env(&config, &vars).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
env.get("MY_VAR").map(std::string::String::as_str),
Some("hello world"),
"unrelated workflow env should be preserved and template resolved"
);
assert_eq!(
env.get(PI_HTTP_REQUEST_TIMEOUT_SECS)
.map(std::string::String::as_str),
Some(DEFAULT_PI_PLANNING_REQUEST_TIMEOUT_SECS),
"default timeout should be inserted alongside unrelated env"
);
}
#[test]
fn resolve_planning_env_preserves_override_with_other_env() {
let _guard = lock_process();
let _env_guard = EnvGuard::remove(PI_HTTP_REQUEST_TIMEOUT_SECS);
let config = config_with_env(&[
(PI_HTTP_REQUEST_TIMEOUT_SECS, "1200"),
("OTHER_KEY", "value"),
]);
let vars = VariableStore::new("test".to_string());
let env = resolve_planning_env(&config, &vars).unwrap_or_else(|e| panic!("{e:?}"));
assert_eq!(
env.get(PI_HTTP_REQUEST_TIMEOUT_SECS)
.map(std::string::String::as_str),
Some("1200"),
"workflow timeout override should be preserved"
);
assert_eq!(
env.get("OTHER_KEY").map(std::string::String::as_str),
Some("value"),
"other workflow env should be preserved"
);
}
#[cfg(unix)]
#[tokio::test]
async fn run_plan_prompt_template_forwards_default_env_to_command_backend() {
let _guard = lock_process();
let _env_guard = EnvGuard::remove(PI_HTTP_REQUEST_TIMEOUT_SECS);
let tmp = make_temp_dir();
let plan_path = tmp.path().join("plan.md");
std::fs::write(&plan_path, "").unwrap_or_else(|e| panic!("{e:?}"));
let config = config_with(
None,
Some("\"sh\", \"-c\", \"printf %s $PI_HTTP_REQUEST_TIMEOUT_SECS\""),
);
let ctx = make_ctx_no_token(&config, &plan_path);
let mut vars = VariableStore::new("test input".to_string());
let mut resume = None;
let result =
run_plan_prompt_template(&ctx, &mut vars, "hello", "test", None, &mut resume, false)
.await;
assert!(result.is_ok(), "expected Ok, got: {result:?}");
let prompt_result = result.unwrap_or_else(|e| panic!("{e:?}"));
assert!(
prompt_result.output.contains("1800"),
"expected output to contain '1800', got: {:?}",
prompt_result.output
);
}
}