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;
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)
}
}
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 = HashMap::new();
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)
}
#[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);
}
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"
);
}
}