From 0016fd746f4c67cb095676942a40e5f547b2f3b8 Mon Sep 17 00:00:00 2001 From: s-brez Date: Wed, 3 Jun 2026 20:42:00 +1000 Subject: [PATCH 1/2] Support variable reasoning effort per-fragment --- src/lib.rs | 1 + src/render.rs | 3 +- src/render/engine.rs | 47 +- src/render/fragments.rs | 2 + src/render/model.rs | 8 + src/render/types.rs | 17 + src/run/command.rs | 82 ++- src/run/harnesses/codex.rs | 251 ++++++++- src/run/harnesses/generic.rs | 19 + src/run/harnesses/mod.rs | 57 +- src/turn_settings.rs | 119 ++++ tests/run.rs | 4 + tests/run/codex_harness.rs | 984 ++++++++++++++++++++++++++++++++++ tests/run/reasoning_effort.rs | 469 ++++++++++++++++ tests/run/runner_modes.rs | 983 --------------------------------- 15 files changed, 2020 insertions(+), 1026 deletions(-) create mode 100644 src/turn_settings.rs create mode 100644 tests/run/codex_harness.rs create mode 100644 tests/run/reasoning_effort.rs diff --git a/src/lib.rs b/src/lib.rs index b376890..46cce33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod runner; pub mod sequence; pub mod store; mod trust; +mod turn_settings; mod user_config; pub mod yaml; diff --git a/src/render.rs b/src/render.rs index d6bc9e8..0be9321 100644 --- a/src/render.rs +++ b/src/render.rs @@ -17,11 +17,12 @@ const SEQUENCE_ID_PREFIX: &str = "seq_"; const INCLUDE_PREFIX: &str = "pseq.fragment."; pub use commands::{render, render_turns}; -pub(crate) use engine::render_sequence_turns; +pub(crate) use engine::render_sequence_runtime_turns; pub(crate) use load::load_current_sequence; pub use types::{ RenderOptions, RenderOutput, RenderTurnsOptions, RenderedSequenceTurns, RenderedTurn, RenderedTurnFragment, SavedRenderSummary, }; +pub(crate) use types::{RenderedRuntimeTurn, RenderedSequenceRuntimeTurns}; pub use validation::validate_saved_renders; pub(crate) use variables::{load_variables, validate_variable_name}; diff --git a/src/render/engine.rs b/src/render/engine.rs index 7d6dab0..4e5deea 100644 --- a/src/render/engine.rs +++ b/src/render/engine.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use crate::error::AppError; +use crate::turn_settings; use super::INCLUDE_PREFIX; use super::fragments::resolve_render_fragment; @@ -29,11 +30,7 @@ pub(crate) fn render_sequence_turns( .map(|(index, fragment)| { Ok(RenderedTurn { index: index + 1, - fragment: RenderedTurnFragment { - id: fragment.id.clone(), - name: fragment.name.clone(), - path: fragment.path.clone(), - }, + fragment: rendered_turn_fragment(fragment), text: render_fragment_body(fragment, &sequence.catalog, variables)?, }) }) @@ -47,6 +44,38 @@ pub(crate) fn render_sequence_turns( }) } +pub(crate) fn render_sequence_runtime_turns( + sequence: &RenderSequence, + variables: &BTreeMap, +) -> Result { + let turns = sequence + .fragments + .iter() + .enumerate() + .map(|(index, fragment)| { + let rendered_fragment = rendered_turn_fragment(fragment); + let settings = turn_settings::fragment_turn_settings( + fragment.pseq_metadata.as_ref(), + fragment.dotted_reasoning_effort.as_ref(), + &rendered_fragment, + )?; + Ok(RenderedRuntimeTurn { + index: index + 1, + fragment: rendered_fragment, + settings, + text: render_fragment_body(fragment, &sequence.catalog, variables)?, + }) + }) + .collect::, AppError>>()?; + + Ok(RenderedSequenceRuntimeTurns { + id: sequence.id.clone(), + name: sequence.name.clone(), + path: sequence.path.clone(), + turns, + }) +} + pub(super) fn render_text( fragments: &[RenderFragment], catalog: &[RenderFragment], @@ -175,6 +204,14 @@ fn fragment_include_frame(fragment: &RenderFragment) -> FragmentIncludeFrame { } } +fn rendered_turn_fragment(fragment: &RenderFragment) -> RenderedTurnFragment { + RenderedTurnFragment { + id: fragment.id.clone(), + name: fragment.name.clone(), + path: fragment.path.clone(), + } +} + fn fragment_label(fragment: &RenderFragment) -> String { format!("{} ({})", fragment.name, fragment.path) } diff --git a/src/render/fragments.rs b/src/render/fragments.rs index 2e6154c..38df034 100644 --- a/src/render/fragments.rs +++ b/src/render/fragments.rs @@ -139,6 +139,8 @@ fn parse_render_fragment( id: metadata.id, name: metadata.name, path, + pseq_metadata: metadata.pseq, + dotted_reasoning_effort: metadata.dotted_reasoning_effort, body: body.to_owned(), }) } diff --git a/src/render/model.rs b/src/render/model.rs index dd9b05b..44ead34 100644 --- a/src/render/model.rs +++ b/src/render/model.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::yaml; + #[derive(Debug)] pub(crate) struct RenderSequence { pub(super) id: String, @@ -14,6 +16,8 @@ pub(super) struct RenderFragment { pub(super) id: String, pub(super) name: String, pub(super) path: String, + pub(super) pseq_metadata: Option, + pub(super) dotted_reasoning_effort: Option, pub(super) body: String, } @@ -52,6 +56,10 @@ pub(super) struct HistoricalSequenceRecord { pub(super) struct RenderFragmentFrontmatter { pub(super) id: String, pub(super) name: String, + #[serde(default)] + pub(super) pseq: Option, + #[serde(default, rename = "pseq.run.reasoning_effort")] + pub(super) dotted_reasoning_effort: Option, } #[derive(Debug)] diff --git a/src/render/types.rs b/src/render/types.rs index 952071a..081482b 100644 --- a/src/render/types.rs +++ b/src/render/types.rs @@ -2,6 +2,7 @@ use serde::Serialize; use std::path::Path; use crate::commit::CommitMode; +use crate::turn_settings::TurnRuntimeSettings; #[derive(Debug)] pub struct RenderOptions<'a> { @@ -61,6 +62,22 @@ pub struct RenderedTurnFragment { pub path: String, } +#[derive(Debug, Clone)] +pub(crate) struct RenderedSequenceRuntimeTurns { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) path: String, + pub(crate) turns: Vec, +} + +#[derive(Debug, Clone)] +pub(crate) struct RenderedRuntimeTurn { + pub(crate) index: usize, + pub(crate) fragment: RenderedTurnFragment, + pub(crate) settings: TurnRuntimeSettings, + pub(crate) text: String, +} + #[derive(Debug, Serialize)] pub struct SavedRenderSummary { pub id: String, diff --git a/src/run/command.rs b/src/run/command.rs index 48fbaf7..b03384d 100644 --- a/src/run/command.rs +++ b/src/run/command.rs @@ -9,13 +9,18 @@ use super::diagnostics::{ should_write_diagnostics, write_runner_failure_diagnostic, write_runner_retry_diagnostic, write_turn_diagnostic, }; -use super::harnesses::{RunnerHarnessSession, prepare_runner_command}; +use super::harnesses::{ + HarnessTurnRequest, RunnerHarnessSession, prepare_runner_command, + runner_command_supports_turn_settings, validate_active_codex_turn_settings, + validate_command_turn_settings, +}; use super::model::{OutputMode, ProcessTermination, ProcessTurnOutput}; use super::options::{ RunSettings, feedback_variable, load_base_variables, load_feedback_seed, output_mode, resolve_run_settings, resolve_runner, validate_options, }; use super::types::*; +use crate::runner::ResolvedRunner; pub fn run_sequence( store_path: &Path, @@ -37,12 +42,13 @@ pub fn run_sequence( first_iteration_variables.insert(variable.clone(), previous_feedback.clone()); } let first_sequence = - render::render_sequence_turns(&render_sequence, &first_iteration_variables)?; + render::render_sequence_runtime_turns(&render_sequence, &first_iteration_variables)?; if options.feedback_from.is_some() && first_sequence.turns.is_empty() { return Err(AppError::InvalidRunInvocation { message: "feedback requires a sequence with at least one turn".to_owned(), }); } + preflight_turn_settings_support(&first_sequence, &runner, &options)?; let sequence_summary = RunSequenceSummary { id: first_sequence.id.clone(), @@ -69,7 +75,7 @@ pub fn run_sequence( if let Some(variable) = &feedback_variable { variables.insert(variable.clone(), previous_feedback.clone()); } - render::render_sequence_turns(&render_sequence, &variables)? + render::render_sequence_runtime_turns(&render_sequence, &variables)? }; let mut iteration_feedback = None; @@ -104,6 +110,8 @@ pub fn run_sequence( RunAttemptRequest { command: &command, prompt: &turn.text, + fragment: &turn.fragment, + turn_settings: turn.settings, output_mode, max_captured_output: options.max_captured_output, has_later_turn, @@ -189,6 +197,54 @@ pub fn run_sequence( )) } +fn preflight_turn_settings_support( + sequence: &render::RenderedSequenceRuntimeTurns, + runner: &ResolvedRunner, + options: &RunOptions<'_>, +) -> Result<(), AppError> { + let turns_per_iteration = sequence.turns.len(); + let total_turns = turns_per_iteration * options.iterations; + let mut run_scope_codex_session = false; + + for iteration in 1..=options.iterations { + let mut iteration_scope_codex_session = false; + for turn in &sequence.turns { + let global_turn_index = (iteration - 1) * turns_per_iteration + turn.index; + let scoped_turn_index = match options.session_scope { + SessionScope::Run => global_turn_index, + SessionScope::Iteration => turn.index, + }; + let has_later_turn = match options.session_scope { + SessionScope::Run => global_turn_index < total_turns, + SessionScope::Iteration => turn.index < turns_per_iteration, + }; + let command = runner.command_for_turn(scoped_turn_index); + let active_codex_session = match options.session_scope { + SessionScope::Run => run_scope_codex_session, + SessionScope::Iteration => iteration_scope_codex_session, + }; + + if active_codex_session { + validate_active_codex_turn_settings(turn.settings, &turn.fragment)?; + } else { + validate_command_turn_settings(command, turn.settings, &turn.fragment)?; + } + + if !active_codex_session + && has_later_turn + && runner_command_supports_turn_settings(command) + { + match options.session_scope { + SessionScope::Run => run_scope_codex_session = true, + SessionScope::Iteration => iteration_scope_codex_session = true, + } + } + } + } + + Ok(()) +} + #[derive(Debug)] struct RunAttemptRecord { attempt: usize, @@ -207,6 +263,8 @@ struct RetryDiagnosticContext { struct RunAttemptRequest<'a> { command: &'a [String], prompt: &'a str, + fragment: &'a render::RenderedTurnFragment, + turn_settings: crate::turn_settings::TurnRuntimeSettings, output_mode: OutputMode, max_captured_output: usize, has_later_turn: bool, @@ -223,13 +281,15 @@ fn run_turn_attempts( let mut attempts = Vec::new(); for attempt in 1..=max_attempts { - let (attempt_command, process) = runner_session.run_turn( - request.command, - request.prompt, - request.output_mode, - request.max_captured_output, - request.has_later_turn, - )?; + let (attempt_command, process) = runner_session.run_turn(HarnessTurnRequest { + argv: request.command, + prompt: request.prompt, + fragment: request.fragment, + settings: request.turn_settings, + output_mode: request.output_mode, + max_captured_output: request.max_captured_output, + needs_continuation: request.has_later_turn, + })?; let retryable = is_retryable_runner_failure(&process); let success = process.success; attempts.push(RunAttemptRecord { @@ -272,7 +332,7 @@ fn is_retryable_runner_failure(process: &ProcessTurnOutput) -> bool { fn run_turn_output( iterations: usize, iteration: usize, - turn: &render::RenderedTurn, + turn: &render::RenderedRuntimeTurn, command: &[String], process: &ProcessTurnOutput, include_output: bool, diff --git a/src/run/harnesses/codex.rs b/src/run/harnesses/codex.rs index e02165b..3488911 100644 --- a/src/run/harnesses/codex.rs +++ b/src/run/harnesses/codex.rs @@ -7,12 +7,17 @@ use serde_json::Value; use uuid::Uuid; use crate::error::AppError; +use crate::turn_settings::{ + REASONING_EFFORT_KEY, ReasoningEffort, TurnRuntimeSettings, fragment_setting_label, +}; use super::super::model::{OutputMode, ProcessTurnOutput}; use super::super::process::run_turn_command; use super::{HarnessTurnOutcome, HarnessTurnRequest, RunnerHarness}; const INTERNAL_CODEX_JSON_CAPTURE_LIMIT: usize = 8 * 1024 * 1024; +const CODEX_REASONING_EFFORT_CONFIG_KEY: &str = "model_reasoning_effort"; +const CODEX_REASONING_EFFORT_VALUES: &[&str] = &["minimal", "low", "medium", "high", "xhigh"]; #[derive(Debug)] pub(super) struct CodexSession { @@ -51,6 +56,16 @@ pub(super) fn prepare_command(argv: &[String], current_dir: &Path) -> Vec Result<(), AppError> { + if let Some(reasoning_effort) = settings.reasoning_effort { + codex_reasoning_effort_config(reasoning_effort, fragment)?; + } + Ok(()) +} + pub(super) fn run_turn( session: Option<&CodexSession>, request: &HarnessTurnRequest<'_>, @@ -61,10 +76,17 @@ pub(super) fn run_turn( &session.executable, &session.resume_prefix_args, &session.id, + request.settings, + request.fragment, &output_path, - ) + )? } else { - codex_first_turn_command(request.argv, &output_path) + codex_first_turn_command( + request.argv, + request.settings, + request.fragment, + &output_path, + )? }; let internal_capture_limit = INTERNAL_CODEX_JSON_CAPTURE_LIMIT.max(request.max_captured_output); @@ -113,26 +135,36 @@ struct CodexFinalizedTurnOutput { internal_stdout: Option, } -fn codex_first_turn_command(argv: &[String], output_path: &Path) -> Vec { +fn codex_first_turn_command( + argv: &[String], + settings: TurnRuntimeSettings, + fragment: &crate::render::RenderedTurnFragment, + output_path: &Path, +) -> Result, AppError> { let mut command = argv.to_vec(); + insert_codex_turn_settings_args(&mut command, 2, settings, fragment)?; insert_codex_json_output_args(&mut command, 2, output_path); - command + Ok(command) } fn codex_resume_command( executable: &str, resume_prefix_args: &[String], session_id: &str, + settings: TurnRuntimeSettings, + fragment: &crate::render::RenderedTurnFragment, output_path: &Path, -) -> Vec { +) -> Result, AppError> { let mut command = vec![executable.to_owned(), "exec".to_owned()]; command.extend_from_slice(resume_prefix_args); + let insert_at = command.len(); + insert_codex_turn_settings_args(&mut command, insert_at, settings, fragment)?; command.push("resume".to_owned()); let insert_at = command.len(); insert_codex_json_output_args(&mut command, insert_at, output_path); command.push(session_id.to_owned()); command.push("-".to_owned()); - command + Ok(command) } fn insert_codex_json_output_args(command: &mut Vec, insert_at: usize, output_path: &Path) { @@ -146,6 +178,43 @@ fn insert_codex_json_output_args(command: &mut Vec, insert_at: usize, ou command.splice(insert_at..insert_at, args); } +fn insert_codex_turn_settings_args( + command: &mut Vec, + insert_at: usize, + settings: TurnRuntimeSettings, + fragment: &crate::render::RenderedTurnFragment, +) -> Result<(), AppError> { + let Some(reasoning_effort) = settings.reasoning_effort else { + return Ok(()); + }; + + let config = codex_reasoning_effort_config(reasoning_effort, fragment)?; + remove_codex_reasoning_effort_args(command); + let insert_at = insert_at.min(command.len()); + command.splice(insert_at..insert_at, ["-c".to_owned(), config]); + Ok(()) +} + +fn codex_reasoning_effort_config( + effort: ReasoningEffort, + fragment: &crate::render::RenderedTurnFragment, +) -> Result { + if effort == ReasoningEffort::Max { + return Err(AppError::InvalidRunInvocation { + message: format!( + "{} declares {REASONING_EFFORT_KEY}=max, but Codex supports only {}", + fragment_setting_label(fragment), + CODEX_REASONING_EFFORT_VALUES.join(", ") + ), + }); + } + + Ok(format!( + "{CODEX_REASONING_EFFORT_CONFIG_KEY}=\"{}\"", + effort.as_str() + )) +} + fn finalize_codex_turn_output( process: Result, command: &[String], @@ -516,6 +585,43 @@ fn remove_codex_output_last_message_args(command: &mut Vec) { } } +fn remove_codex_reasoning_effort_args(command: &mut Vec) { + let mut index = 0; + while index < command.len() { + let arg = &command[index]; + if arg == "-c" || arg == "--config" { + if let Some(value) = command.get(index + 1) + && is_codex_reasoning_effort_config(value) + { + let end = (index + 2).min(command.len()); + command.drain(index..end); + continue; + } + index += if index + 1 < command.len() { 2 } else { 1 }; + continue; + } + if let Some(value) = arg + .strip_prefix("--config=") + .or_else(|| arg.strip_prefix("-c=")) + && is_codex_reasoning_effort_config(value) + { + command.remove(index); + continue; + } + index += 1; + } +} + +fn is_codex_reasoning_effort_config(value: &str) -> bool { + let Some(rest) = value + .trim_start() + .strip_prefix(CODEX_REASONING_EFFORT_CONFIG_KEY) + else { + return false; + }; + rest.trim_start().starts_with('=') +} + fn temporary_output_path(prefix: &str) -> PathBuf { std::env::temp_dir().join(format!("{prefix}-{}.txt", Uuid::new_v4())) } @@ -549,7 +655,10 @@ fn write_to_stderr(text: &str) -> Result<(), AppError> { #[cfg(test)] mod tests { + use std::path::Path; + use super::*; + use crate::render::RenderedTurnFragment; #[test] fn codex_session_id_from_jsonl_stdout_rejects_empty_and_option_like_ids() { @@ -596,7 +705,137 @@ mod tests { assert!(manages_codex_exec_session(&prompt)); } + #[test] + fn codex_first_turn_reasoning_effort_overrides_existing_config() { + let command = codex_first_turn_command( + &argv(&[ + "codex", + "exec", + "-c", + "model_reasoning_effort=\"medium\"", + "--config", + "sandbox_mode=\"workspace-write\"", + "-", + ]), + turn_settings(ReasoningEffort::High), + &test_fragment(), + Path::new("last-message.txt"), + ) + .unwrap(); + + assert!(has_arg_pair( + &command, + "-c", + "model_reasoning_effort=\"high\"" + )); + assert!( + !command + .iter() + .any(|arg| arg == "model_reasoning_effort=\"medium\"") + ); + assert!(has_arg_pair( + &command, + "--config", + "sandbox_mode=\"workspace-write\"" + )); + assert!(has_arg_pair( + &command, + "--output-last-message", + "last-message.txt" + )); + } + + #[test] + fn codex_resume_reasoning_effort_does_not_mutate_saved_prefix_args() { + let resume_prefix_args = + argv(&["--color", "never", "-c", "model_reasoning_effort=\"low\""]); + let command = codex_resume_command( + "codex", + &resume_prefix_args, + "session-123", + turn_settings(ReasoningEffort::XHigh), + &test_fragment(), + Path::new("last-message.txt"), + ) + .unwrap(); + + assert_eq!( + resume_prefix_args, + argv(&["--color", "never", "-c", "model_reasoning_effort=\"low\""]) + ); + assert!(has_arg_pair( + &command, + "-c", + "model_reasoning_effort=\"xhigh\"" + )); + assert!( + !command + .iter() + .any(|arg| arg == "model_reasoning_effort=\"low\"") + ); + assert!(command.iter().any(|arg| arg == "resume")); + assert!(command.iter().any(|arg| arg == "session-123")); + } + + #[test] + fn codex_omitted_reasoning_effort_preserves_runner_config() { + let command = codex_first_turn_command( + &argv(&[ + "codex", + "exec", + "-c", + "model_reasoning_effort=\"medium\"", + "-", + ]), + TurnRuntimeSettings::default(), + &test_fragment(), + Path::new("last-message.txt"), + ) + .unwrap(); + + assert!(has_arg_pair( + &command, + "-c", + "model_reasoning_effort=\"medium\"" + )); + } + + #[test] + fn codex_rejects_reasoning_effort_max() { + let error = codex_first_turn_command( + &argv(&["codex", "exec", "-"]), + turn_settings(ReasoningEffort::Max), + &test_fragment(), + Path::new("last-message.txt"), + ) + .unwrap_err(); + + let message = error.to_string(); + assert!(message.contains("pseq.run.reasoning_effort=max")); + assert!(message.contains("Codex supports only minimal, low, medium, high, xhigh")); + } + fn argv(args: &[&str]) -> Vec { args.iter().map(|arg| (*arg).to_owned()).collect() } + + fn turn_settings(reasoning_effort: ReasoningEffort) -> TurnRuntimeSettings { + TurnRuntimeSettings { + reasoning_effort: Some(reasoning_effort), + } + } + + fn test_fragment() -> RenderedTurnFragment { + RenderedTurnFragment { + id: "frg_test".to_owned(), + name: "Turn".to_owned(), + path: "fragments/turn.md".to_owned(), + } + } + + fn has_arg_pair(command: &[String], key: &str, value: &str) -> bool { + command + .windows(2) + .any(|pair| pair[0] == key && pair[1] == value) + } } diff --git a/src/run/harnesses/generic.rs b/src/run/harnesses/generic.rs index 9eaaa19..7ce2efc 100644 --- a/src/run/harnesses/generic.rs +++ b/src/run/harnesses/generic.rs @@ -1,4 +1,5 @@ use crate::error::AppError; +use crate::turn_settings::{REASONING_EFFORT_KEY, fragment_setting_label}; use super::super::process::run_turn_command; use super::{HarnessTurnOutcome, HarnessTurnRequest}; @@ -7,7 +8,25 @@ pub(super) fn prepare_command(argv: &[String]) -> Vec { argv.to_vec() } +pub(super) fn validate_turn_settings( + settings: crate::turn_settings::TurnRuntimeSettings, + fragment: &crate::render::RenderedTurnFragment, + argv: &[String], +) -> Result<(), AppError> { + if !settings.is_empty() { + return Err(AppError::InvalidRunInvocation { + message: format!( + "{} declares {REASONING_EFFORT_KEY}, but runner command {:?} is not a recognized adapter that supports it", + fragment_setting_label(fragment), + argv + ), + }); + } + Ok(()) +} + pub(super) fn run_turn(request: &HarnessTurnRequest<'_>) -> Result { + validate_turn_settings(request.settings, request.fragment, request.argv)?; let process = run_turn_command( request.argv, request.prompt, diff --git a/src/run/harnesses/mod.rs b/src/run/harnesses/mod.rs index 02f5f7d..91b4aeb 100644 --- a/src/run/harnesses/mod.rs +++ b/src/run/harnesses/mod.rs @@ -5,6 +5,8 @@ use std::env; use std::path::Path; use crate::error::AppError; +use crate::render::RenderedTurnFragment; +use crate::turn_settings::TurnRuntimeSettings; use super::model::{OutputMode, ProcessTurnOutput}; @@ -48,6 +50,30 @@ pub(super) fn prepare_runner_command(argv: &[String]) -> Result, App Ok(RunnerHarnessKind::detect(argv).prepare_command(argv, ¤t_dir)) } +pub(super) fn runner_command_supports_turn_settings(argv: &[String]) -> bool { + RunnerHarnessKind::detect(argv) == RunnerHarnessKind::Codex + && codex::manages_codex_exec_session(argv) +} + +pub(super) fn validate_command_turn_settings( + argv: &[String], + settings: TurnRuntimeSettings, + fragment: &RenderedTurnFragment, +) -> Result<(), AppError> { + if runner_command_supports_turn_settings(argv) { + codex::validate_turn_settings(settings, fragment) + } else { + generic::validate_turn_settings(settings, fragment, argv) + } +} + +pub(super) fn validate_active_codex_turn_settings( + settings: TurnRuntimeSettings, + fragment: &RenderedTurnFragment, +) -> Result<(), AppError> { + codex::validate_turn_settings(settings, fragment) +} + impl RunnerHarnessSession { pub(super) fn new() -> Self { Self::default() @@ -55,25 +81,14 @@ impl RunnerHarnessSession { pub(super) fn run_turn( &mut self, - argv: &[String], - prompt: &str, - output_mode: OutputMode, - max_captured_output: usize, - needs_continuation: bool, + request: HarnessTurnRequest<'_>, ) -> Result<(Vec, ProcessTurnOutput), AppError> { - let request = HarnessTurnRequest { - argv, - prompt, - output_mode, - max_captured_output, - needs_continuation, - }; - let harness_kind = RunnerHarnessKind::detect(argv); + let harness_kind = RunnerHarnessKind::detect(request.argv); let outcome = match &self.active { RunnerHarness::Codex(session) => codex::run_turn(Some(session), &request), RunnerHarness::Generic if harness_kind == RunnerHarnessKind::Codex - && codex::manages_codex_exec_session(argv) => + && codex::manages_codex_exec_session(request.argv) => { codex::run_turn(None, &request) } @@ -90,12 +105,14 @@ impl RunnerHarnessSession { } } -struct HarnessTurnRequest<'a> { - argv: &'a [String], - prompt: &'a str, - output_mode: OutputMode, - max_captured_output: usize, - needs_continuation: bool, +pub(super) struct HarnessTurnRequest<'a> { + pub(super) argv: &'a [String], + pub(super) prompt: &'a str, + pub(super) fragment: &'a RenderedTurnFragment, + pub(super) settings: TurnRuntimeSettings, + pub(super) output_mode: OutputMode, + pub(super) max_captured_output: usize, + pub(super) needs_continuation: bool, } struct HarnessTurnOutcome { diff --git a/src/turn_settings.rs b/src/turn_settings.rs new file mode 100644 index 0000000..d0c8176 --- /dev/null +++ b/src/turn_settings.rs @@ -0,0 +1,119 @@ +use crate::error::AppError; +use crate::render::RenderedTurnFragment; +use crate::yaml::Value; + +pub(crate) const REASONING_EFFORT_KEY: &str = "pseq.run.reasoning_effort"; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub(crate) struct TurnRuntimeSettings { + pub(crate) reasoning_effort: Option, +} + +impl TurnRuntimeSettings { + pub(crate) fn is_empty(self) -> bool { + self.reasoning_effort.is_none() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReasoningEffort { + Minimal, + Low, + Medium, + High, + XHigh, + Max, +} + +impl ReasoningEffort { + pub(crate) const VALUES: &'static [&'static str] = + &["minimal", "low", "medium", "high", "xhigh", "max"]; + + pub(crate) fn parse(value: &str) -> Option { + match value { + "minimal" => Some(Self::Minimal), + "low" => Some(Self::Low), + "medium" => Some(Self::Medium), + "high" => Some(Self::High), + "xhigh" => Some(Self::XHigh), + "max" => Some(Self::Max), + _ => None, + } + } + + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Minimal => "minimal", + Self::Low => "low", + Self::Medium => "medium", + Self::High => "high", + Self::XHigh => "xhigh", + Self::Max => "max", + } + } +} + +pub(crate) fn fragment_turn_settings( + pseq_metadata: Option<&Value>, + dotted_reasoning_effort: Option<&Value>, + fragment: &RenderedTurnFragment, +) -> Result { + if dotted_reasoning_effort.is_some() { + return Err(invalid_setting( + fragment, + format!( + "literal frontmatter key {REASONING_EFFORT_KEY:?} is not supported; use nested keys pseq -> run -> reasoning_effort" + ), + )); + } + + let Some(pseq_metadata) = pseq_metadata else { + return Ok(TurnRuntimeSettings::default()); + }; + let Some(pseq_mapping) = pseq_metadata.as_mapping() else { + return Ok(TurnRuntimeSettings::default()); + }; + let Some(run_metadata) = pseq_mapping.get("run") else { + return Ok(TurnRuntimeSettings::default()); + }; + let Some(run_mapping) = run_metadata.as_mapping() else { + return Ok(TurnRuntimeSettings::default()); + }; + let Some(reasoning_effort) = run_mapping.get("reasoning_effort") else { + return Ok(TurnRuntimeSettings::default()); + }; + + let effort = reasoning_effort.as_str().ok_or_else(|| { + invalid_setting( + fragment, + format!("{REASONING_EFFORT_KEY} must be a string value"), + ) + })?; + let effort = ReasoningEffort::parse(effort).ok_or_else(|| { + invalid_setting( + fragment, + format!( + "{REASONING_EFFORT_KEY} has unsupported value {effort:?}; expected one of {}", + ReasoningEffort::VALUES.join(", ") + ), + ) + })?; + + Ok(TurnRuntimeSettings { + reasoning_effort: Some(effort), + }) +} + +pub(crate) fn fragment_setting_label(fragment: &RenderedTurnFragment) -> String { + format!( + "fragment {:?} ({})", + fragment.name, + fragment.path.replace('\\', "/") + ) +} + +fn invalid_setting(fragment: &RenderedTurnFragment, message: String) -> AppError { + AppError::InvalidRunInvocation { + message: format!("{}: {message}", fragment_setting_label(fragment)), + } +} diff --git a/tests/run.rs b/tests/run.rs index 0e2a689..4c7ee55 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -12,10 +12,14 @@ use common::{pseq_command_in_own_process_group, pseq_in_own_process_group}; #[path = "run/capture_output.rs"] mod capture_output; +#[path = "run/codex_harness.rs"] +mod codex_harness; #[path = "run/failures.rs"] mod failures; #[path = "run/feedback.rs"] mod feedback; +#[path = "run/reasoning_effort.rs"] +mod reasoning_effort; #[path = "run/runner_modes.rs"] mod runner_modes; diff --git a/tests/run/codex_harness.rs b/tests/run/codex_harness.rs new file mode 100644 index 0000000..d30928e --- /dev/null +++ b/tests/run/codex_harness.rs @@ -0,0 +1,984 @@ +use super::*; + +#[cfg(unix)] +#[test] +fn run_adds_git_metadata_writable_roots_for_sandboxed_agent_runner() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-agent-git-roots"); + let workspace = TestStore::initialized("run-agent-git-roots-workspace"); + let bin_dir = TestStore::new("run-agent-git-roots-bin"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +args="$*" +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + esac + shift +done +if [ -n "$out" ]; then + printf '%s\n' "$args" > "$out" +else + printf '%s\n' "$args" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "-", + ], + workspace.path(), + &[("PATH", &path)], + ); + assert_success(&output); + + let json = stdout_json(&output); + let command = json["turns"][0]["command"].as_array().unwrap(); + let git_dir_path = workspace.path().join(".git"); + let git_dir = path_str(&git_dir_path); + assert!( + command + .windows(2) + .any(|args| args[0] == "--add-dir" && args[1] == git_dir), + "expected command to add git metadata as writable root, got {command:?}" + ); + let stdout = json["turns"][0]["stdout"].as_str().unwrap(); + assert!( + stdout.contains(git_dir), + "fake runner should receive the prepared command argv, got {stdout:?}" + ); +} + +#[cfg(unix)] +#[test] +fn run_does_not_session_wrap_codex_exec_review_after_prepared_options() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-review-subcommand-store"); + let bin_dir = TestStore::new("run-codex-review-subcommand-bin"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +cat >/dev/null +printf '%s\n' "$*" +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "review", + ], + store.path(), + &[("PATH", &path)], + ); + assert_success(&output); + + let json = stdout_json(&output); + let command = json["turns"][0]["command"].as_array().unwrap(); + let git_dir_path = store.path().join(".git"); + let git_dir = path_str(&git_dir_path); + assert!( + command + .windows(2) + .any(|args| args[0] == "--add-dir" && args[1] == git_dir), + "expected prepared command to keep writable Git metadata root, got {command:?}" + ); + assert!( + command.iter().any(|arg| arg == "review"), + "expected Codex review subcommand to remain in command, got {command:?}" + ); + assert!( + !command.iter().any(|arg| arg == "--output-last-message"), + "expected Codex review subcommand not to be session wrapped, got {command:?}" + ); + assert!( + json["turns"][0]["stdout"] + .as_str() + .unwrap() + .contains("review"), + "expected generic fake Codex stdout to be captured" + ); +} + +#[cfg(unix)] +#[test] +fn run_resumes_exact_codex_session_for_later_sequence_turns() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-session-fake-store"); + let bin_dir = TestStore::new("run-codex-session-fake-bin"); + let log_path = bin_dir.path().join("codex.log"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +args="$*" +out="" +resume=0 +session="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + resume) + resume=1 + ;; + --json|--color|--sandbox|-m|-s) + if [ "$1" != "--json" ]; then + shift + fi + ;; + --*) + ;; + -) + ;; + *) + if [ "$resume" = "1" ] && [ -z "$session" ]; then + session="$1" + fi + ;; + esac + shift +done +input=$(cat) +if [ "$resume" = "1" ]; then + printf 'resume session=%s input=%s\n' "$session" "$input" >> "$PSEQ_FAKE_CODEX_LOG" + printf 'resumed %s\n' "$session" > "$out" +else + printf 'first input=%s args=%s\n' "$input" "$args" >> "$PSEQ_FAKE_CODEX_LOG" + printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' + printf 'started fake-session-123\n' > "$out" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments( + &store, + "Workflow", + &[("First", "first prompt\n"), ("Second", "second prompt\n")], + ); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let log_path_arg = path_str(&log_path); + let ignored_output_path = bin_dir.path().join("ignored-output.txt"); + let ignored_output_path_arg = path_str(&ignored_output_path); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "--output-last-message", + ignored_output_path_arg, + "-", + ], + store.path(), + &[ + ("PATH", path.as_str()), + ("PSEQ_FAKE_CODEX_LOG", log_path_arg), + ], + ); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 2); + assert_eq!(json["turns"][0]["stdout"], "started fake-session-123\n"); + assert_eq!(json["turns"][1]["stdout"], "resumed fake-session-123\n"); + + let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); + let turn_1_command = json["turns"][0]["command"].as_array().unwrap(); + assert!( + !turn_1_command + .iter() + .any(|arg| arg == ignored_output_path_arg), + "expected pseq to own Codex output capture path, got {turn_1_command:?}" + ); + assert!( + turn_2_command.iter().any(|arg| arg == "resume"), + "expected second turn command to use Codex resume, got {turn_2_command:?}" + ); + assert!( + turn_2_command.iter().any(|arg| arg == "fake-session-123"), + "expected second turn command to resume exact session id, got {turn_2_command:?}" + ); + assert!( + !turn_2_command.iter().any(|arg| arg == "--last"), + "expected exact session resume, got {turn_2_command:?}" + ); + let git_dir_path = store.path().join(".git"); + let git_dir = path_str(&git_dir_path); + assert!( + turn_2_command + .windows(2) + .any(|args| args[0] == "--add-dir" && args[1] == git_dir), + "expected resumed Codex command to preserve writable Git metadata roots, got {turn_2_command:?}" + ); + + let log = fs::read_to_string(log_path).unwrap(); + assert!(log.contains("first input=first prompt")); + assert!(log.contains("resume session=fake-session-123 input=second prompt")); +} + +#[cfg(unix)] +#[test] +fn run_retries_codex_resume_turn_in_same_session() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-retry-same-session-store"); + let bin_dir = TestStore::new("run-codex-retry-same-session-bin"); + let log_path = bin_dir.path().join("codex.log"); + let retry_count_path = bin_dir.path().join("resume-attempts.txt"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +out="" +resume=0 +session="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + resume) + resume=1 + ;; + --json|--color|--sandbox|-m|-s) + if [ "$1" != "--json" ]; then + shift + fi + ;; + --*) + ;; + -) + ;; + *) + if [ "$resume" = "1" ] && [ -z "$session" ]; then + session="$1" + fi + ;; + esac + shift +done +input=$(cat) +if [ "$resume" = "1" ]; then + count=$(cat "$PSEQ_FAKE_CODEX_RETRY_COUNT" 2>/dev/null || printf 0) + count=$((count + 1)) + printf '%s\n' "$count" > "$PSEQ_FAKE_CODEX_RETRY_COUNT" + printf 'resume attempt=%s session=%s input=%s\n' "$count" "$session" "$input" >> "$PSEQ_FAKE_CODEX_LOG" + if [ "$count" -eq 1 ]; then + printf 'transient resume failure\n' >&2 + exit 23 + fi + printf 'resumed %s after retry\n' "$session" > "$out" +else + printf 'first input=%s\n' "$input" >> "$PSEQ_FAKE_CODEX_LOG" + printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' + printf 'started fake-session-123\n' > "$out" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments( + &store, + "Workflow", + &[("First", "first prompt\n"), ("Second", "second prompt\n")], + ); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let log_path_arg = path_str(&log_path); + let retry_count_path_arg = path_str(&retry_count_path); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--retry-delay-ms", + "0", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "-", + ], + store.path(), + &[ + ("PATH", path.as_str()), + ("PSEQ_FAKE_CODEX_LOG", log_path_arg), + ("PSEQ_FAKE_CODEX_RETRY_COUNT", retry_count_path_arg), + ], + ); + assert_success(&output); + + let json = stdout_json(&output); + let first_turn = &json["turns"][0]; + let second_turn = &json["turns"][1]; + assert!(first_turn.get("attempt_count").is_none()); + assert_eq!(second_turn["attempt_count"], 2); + assert_eq!( + second_turn["stdout"], + "resumed fake-session-123 after retry\n" + ); + let attempts = second_turn["attempts"].as_array().unwrap(); + assert_eq!(attempts.len(), 2); + for attempt in attempts { + let command = attempt["command"].as_array().unwrap(); + assert!( + command.iter().any(|arg| arg == "resume"), + "retry attempt should resume the established Codex session, got {command:?}" + ); + assert!( + command.iter().any(|arg| arg == "fake-session-123"), + "retry attempt should use the original session id, got {command:?}" + ); + } + + let log = fs::read_to_string(log_path).unwrap(); + assert!(log.contains("first input=first prompt")); + assert!(log.contains("resume attempt=1 session=fake-session-123 input=second prompt")); + assert!(log.contains("resume attempt=2 session=fake-session-123 input=second prompt")); +} + +#[cfg(unix)] +#[test] +fn run_can_reset_codex_session_per_iteration_while_carrying_feedback() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-session-scope-iteration-store"); + let bin_dir = TestStore::new("run-codex-session-scope-iteration-bin"); + let counter_path = bin_dir.path().join("counter.txt"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +out="" +resume=0 +session="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + resume) + resume=1 + ;; + --json|--color|--sandbox|-m|-s) + if [ "$1" != "--json" ]; then + shift + fi + ;; + --*) + ;; + -) + ;; + *) + if [ "$resume" = "1" ] && [ -z "$session" ]; then + session="$1" + fi + ;; + esac + shift +done +input=$(cat) +if [ "$resume" = "1" ]; then + printf 'resumed %s\n' "$session" > "$out" +else + count=0 + if [ -f "$PSEQ_FAKE_CODEX_COUNTER" ]; then + count=$(cat "$PSEQ_FAKE_CODEX_COUNTER") + fi + count=$((count + 1)) + printf '%s\n' "$count" > "$PSEQ_FAKE_CODEX_COUNTER" + session="iteration-session-$count" + saw=none + case "$input" in + *PSEQ-SEED*) saw=seed ;; + *"resumed iteration-session-1"*) saw=feedback ;; + esac + printf '{"type":"thread.started","thread_id":"%s"}\n' "$session" + printf 'started %s saw=%s\n' "$session" "$saw" > "$out" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments( + &store, + "Workflow", + &[ + ("First", "feedback={{loop_feedback}}\n"), + ("Final", "final prompt\n"), + ], + ); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let counter_path_arg = path_str(&counter_path); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--iterations", + "2", + "--session-scope", + "iteration", + "--feedback-from", + "final-stdout", + "--feedback-var", + "loop_feedback", + "--feedback-seed", + "PSEQ-SEED", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "-", + ], + store.path(), + &[ + ("PATH", &path), + ("PSEQ_FAKE_CODEX_COUNTER", counter_path_arg), + ], + ); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 4); + assert_eq!( + json["turns"][0]["stdout"], + "started iteration-session-1 saw=seed\n" + ); + assert_eq!(json["turns"][1]["stdout"], "resumed iteration-session-1\n"); + assert_eq!( + json["turns"][2]["stdout"], + "started iteration-session-2 saw=feedback\n" + ); + assert_eq!(json["turns"][3]["stdout"], "resumed iteration-session-2\n"); + + let iteration_1_final = json["turns"][1]["command"].as_array().unwrap(); + let iteration_2_first = json["turns"][2]["command"].as_array().unwrap(); + let iteration_2_final = json["turns"][3]["command"].as_array().unwrap(); + assert!( + iteration_1_final.iter().any(|arg| arg == "resume") + && iteration_1_final + .iter() + .any(|arg| arg == "iteration-session-1"), + "expected first iteration final turn to resume iteration-session-1, got {iteration_1_final:?}" + ); + assert!( + !iteration_2_first.iter().any(|arg| arg == "resume"), + "expected second iteration first turn to start a fresh session, got {iteration_2_first:?}" + ); + assert!( + iteration_2_final.iter().any(|arg| arg == "resume") + && iteration_2_final + .iter() + .any(|arg| arg == "iteration-session-2"), + "expected second iteration final turn to resume iteration-session-2, got {iteration_2_final:?}" + ); +} + +#[cfg(unix)] +#[test] +fn run_named_codex_runner_uses_session_continuation_instead_of_configured_next() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-named-codex-session-store"); + let bin_dir = TestStore::new("run-named-codex-session-bin"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +out="" +resume=0 +session="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + resume) + resume=1 + ;; + --json|--color) + ;; + -) + ;; + *) + if [ "$resume" = "1" ] && [ -z "$session" ]; then + session="$1" + fi + ;; + esac + shift +done +cat >/dev/null +if [ "$resume" = "1" ]; then + printf 'resumed %s\n' "$session" > "$out" +else + printf '{"type":"thread.started","thread_id":"named-session-456"}\n' + printf 'started named-session-456\n' > "$out" +fi +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments( + &store, + "Workflow", + &[("First", "first prompt\n"), ("Second", "second prompt\n")], + ); + + assert_success(&pseq(&[ + "--store", + path_str(store.path()), + "runner", + "set", + "codex", + "first", + "--", + "codex", + "exec", + "--color", + "never", + "-", + ])); + assert_success(&pseq(&[ + "--store", + path_str(store.path()), + "runner", + "set", + "codex", + "next", + "--", + pseq_bin(), + "--version", + ])); + assert_success(&pseq(&[ + "--store", + path_str(store.path()), + "runner", + "default", + "codex", + ])); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + ], + store.path(), + &[("PATH", &path)], + ); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 2); + assert_eq!(json["turns"][1]["stdout"], "resumed named-session-456\n"); + let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); + assert!( + turn_2_command.iter().any(|arg| arg == "resume"), + "expected named Codex runner to use session continuation, got {turn_2_command:?}" + ); + assert!( + turn_2_command.iter().any(|arg| arg == "named-session-456"), + "expected named Codex runner to resume exact session id, got {turn_2_command:?}" + ); + assert!( + !turn_2_command + .iter() + .any(|arg| arg.as_str() == Some(pseq_bin())), + "expected configured next command to be ignored for active Codex session, got {turn_2_command:?}" + ); +} + +#[cfg(unix)] +#[test] +fn run_fails_when_codex_session_id_is_missing_before_later_turns() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-missing-session-store"); + let bin_dir = TestStore::new("run-codex-missing-session-bin"); + let log_path = bin_dir.path().join("codex.log"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + --output-last-message|-o) + shift + out="$1" + ;; + esac + shift +done +input=$(cat) +printf '%s\n' "$input" >> "$PSEQ_FAKE_CODEX_LOG" +printf 'no session id\n' > "$out" +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments( + &store, + "Workflow", + &[("First", "first prompt\n"), ("Second", "second prompt\n")], + ); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let log_path_arg = path_str(&log_path); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "codex", + "exec", + "--sandbox", + "read-only", + "--color", + "never", + "-", + ], + store.path(), + &[ + ("PATH", path.as_str()), + ("PSEQ_FAKE_CODEX_LOG", log_path_arg), + ], + ); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + stderr_json(&output)["error"]["code"], + "invalid_run_invocation" + ); + assert_eq!(fs::read_to_string(log_path).unwrap(), "first prompt\n"); +} + +#[cfg(unix)] +#[test] +fn run_fails_when_successful_codex_does_not_write_final_message() { + use std::os::unix::fs::PermissionsExt; + + let store = TestStore::initialized("run-codex-missing-final-message-store"); + let bin_dir = TestStore::new("run-codex-missing-final-message-bin"); + fs::create_dir_all(bin_dir.path()).unwrap(); + let fake_codex = bin_dir.path().join("codex"); + fs::write( + &fake_codex, + r#"#!/bin/sh +cat >/dev/null +printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' +"#, + ) + .unwrap(); + fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + + let path = format!( + "{}:{}", + path_str(bin_dir.path()), + std::env::var("PATH").unwrap_or_default() + ); + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "codex", + "exec", + "--color", + "never", + "-", + ], + store.path(), + &[("PATH", &path)], + ); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!( + stderr_json(&output)["error"]["code"], + "runner_read_output_failed" + ); +} + +#[ignore = "boots the real Codex CLI and spends model/tool time"] +#[test] +fn run_with_real_codex_can_commit_inside_workspace_write_sandbox() { + const MARKER_FILE: &str = "pseq-real-codex-git-marker.txt"; + const MARKER_TEXT: &str = "pseq real codex git metadata write check\n"; + const COMMIT_MESSAGE: &str = "pseq real codex git write check"; + + assert_success( + &std::process::Command::new("codex") + .arg("--version") + .output() + .expect("real codex CLI should be installed for this ignored test"), + ); + + let store = TestStore::initialized("run-real-codex-git-store"); + let workspace = TestStore::new("run-real-codex-git-workspace"); + fs::create_dir_all(workspace.path()).unwrap(); + assert_success(&git(workspace.path(), &["init", "--quiet"])); + create_sequence_with_fragments( + &store, + "Workflow", + &[( + "Only", + &format!( + "\ +Automated pseq integration test. + +Do exactly this in the current Git repository: +1. Write the file `{MARKER_FILE}` with exactly this single line: +{} +2. Run: +git add {MARKER_FILE} +git -c user.name=pseq-real-codex-test -c user.email=pseq-real-codex-test@example.invalid commit -m {COMMIT_MESSAGE:?} +3. Do not modify any other files. +4. Final response: committed {COMMIT_MESSAGE} +", + MARKER_TEXT.trim_end() + ), + )], + ); + + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--max-captured-output", + "2000000", + "--", + "codex", + "exec", + "--sandbox", + "workspace-write", + "--color", + "never", + "-", + ], + workspace.path(), + &[], + ); + assert_success(&output); + + let json = stdout_json(&output); + let command = json["turns"][0]["command"].as_array().unwrap(); + let git_dir_path = workspace.path().join(".git"); + let git_dir = path_str(&git_dir_path); + assert!( + command + .windows(2) + .any(|args| args[0] == "--add-dir" && args[1] == git_dir), + "expected pseq to add git metadata as a writable root, got {command:?}" + ); + + let committed_file = git(workspace.path(), &["show", &format!("HEAD:{MARKER_FILE}")]); + assert_success(&committed_file); + assert_eq!( + String::from_utf8(committed_file.stdout).unwrap(), + MARKER_TEXT + ); + + let commit_subject = git(workspace.path(), &["log", "-1", "--pretty=%s"]); + assert_success(&commit_subject); + assert_eq!( + String::from_utf8(commit_subject.stdout).unwrap().trim(), + COMMIT_MESSAGE + ); + assert_git_clean(workspace.path()); +} + +#[ignore = "boots the real Codex CLI and spends model/tool time"] +#[test] +fn run_with_real_codex_keeps_sequence_turns_in_one_session() { + const TOKEN: &str = "PSEQ-CODEX-SESSION-CONTINUITY-1779625000"; + + assert_success( + &std::process::Command::new("codex") + .arg("--version") + .output() + .expect("real codex CLI should be installed for this ignored test"), + ); + + let store = TestStore::initialized("run-real-codex-session-store"); + create_sequence_with_fragments( + &store, + "Workflow", + &[ + ( + "Remember", + &format!( + "\ +Remember this exact continuity token for the next prompt: +{TOKEN} + +Reply exactly: +stored +" + ), + ), + ( + "Recall", + "\ +Without reading files, running shell commands, or using external state, reply with the exact continuity token I asked you to remember in the previous prompt. +Do not include anything except the token. +", + ), + ], + ); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--max-captured-output", + "2000000", + "--", + "codex", + "exec", + "-m", + "gpt-5.4-mini", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--color", + "never", + "-", + ]); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 2); + let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); + assert!( + turn_2_command.iter().any(|arg| arg == "resume"), + "expected second Codex turn to resume the first session, got {turn_2_command:?}" + ); + assert!( + !turn_2_command.iter().any(|arg| arg == "--last"), + "expected pseq to resume an exact Codex session id, got {turn_2_command:?}" + ); + let turn_2_stdout = json["turns"][1]["stdout"].as_str().unwrap(); + assert!( + turn_2_stdout.contains(TOKEN), + "expected second turn to recall {TOKEN}, got {turn_2_stdout:?}" + ); +} diff --git a/tests/run/reasoning_effort.rs b/tests/run/reasoning_effort.rs new file mode 100644 index 0000000..d32189e --- /dev/null +++ b/tests/run/reasoning_effort.rs @@ -0,0 +1,469 @@ +use super::*; + +#[test] +fn run_rejects_reasoning_effort_for_unrecognized_runner_before_execution() { + let store = TestStore::initialized("run-reasoning-generic-reject"); + let scratch = TestStore::new("run-reasoning-generic-reject-scratch"); + fs::create_dir_all(scratch.path()).unwrap(); + let marker = scratch.path().join("runner-started"); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + add_fragment_pseq_run_reasoning_effort(&store, "Only", "high"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "printf started > \"$1\"", + "sh", + path_str(&marker), + ]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!marker.exists(), "runner command should not be spawned"); + + let error = stderr_json(&output); + assert_eq!(error["error"]["code"], "invalid_run_invocation"); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("fragment \"Only\"")); + assert!(message.contains("pseq.run.reasoning_effort")); + assert!(message.contains("not a recognized adapter")); +} + +#[test] +fn run_rejects_later_reasoning_effort_for_unrecognized_runner_before_any_execution() { + let store = TestStore::initialized("run-reasoning-later-generic-reject"); + let scratch = TestStore::new("run-reasoning-later-generic-reject-scratch"); + fs::create_dir_all(scratch.path()).unwrap(); + let marker = scratch.path().join("runner-started"); + create_sequence_with_fragments( + &store, + "Workflow", + &[("First", "first\n"), ("Second", "second\n")], + ); + add_fragment_pseq_run_reasoning_effort(&store, "Second", "high"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "printf started > \"$1\"", + "sh", + path_str(&marker), + ]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!marker.exists(), "no runner turn should be spawned"); + + let error = stderr_json(&output); + assert_eq!(error["error"]["code"], "invalid_run_invocation"); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("fragment \"Second\"")); + assert!(message.contains("pseq.run.reasoning_effort")); + assert!(message.contains("not a recognized adapter")); +} + +#[test] +fn run_rejects_invalid_reasoning_effort_before_execution() { + let store = TestStore::initialized("run-reasoning-invalid-value"); + let scratch = TestStore::new("run-reasoning-invalid-value-scratch"); + fs::create_dir_all(scratch.path()).unwrap(); + let marker = scratch.path().join("runner-started"); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + add_fragment_pseq_run_reasoning_effort(&store, "Only", "turbo"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "printf started > \"$1\"", + "sh", + path_str(&marker), + ]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!marker.exists(), "runner command should not be spawned"); + + let error = stderr_json(&output); + assert_eq!(error["error"]["code"], "invalid_run_invocation"); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("fragment \"Only\"")); + assert!(message.contains("pseq.run.reasoning_effort")); + assert!(message.contains("unsupported value \"turbo\"")); + assert!(message.contains("minimal, low, medium, high, xhigh, max")); +} + +#[test] +fn run_rejects_non_string_reasoning_effort_before_execution() { + let store = TestStore::initialized("run-reasoning-non-string-value"); + let scratch = TestStore::new("run-reasoning-non-string-value-scratch"); + fs::create_dir_all(scratch.path()).unwrap(); + let marker = scratch.path().join("runner-started"); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + add_fragment_frontmatter_block(&store, "Only", "pseq:\n run:\n reasoning_effort: 7\n"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "printf started > \"$1\"", + "sh", + path_str(&marker), + ]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!marker.exists(), "runner command should not be spawned"); + + let error = stderr_json(&output); + assert_eq!(error["error"]["code"], "invalid_run_invocation"); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("fragment \"Only\"")); + assert!(message.contains("pseq.run.reasoning_effort must be a string value")); +} + +#[test] +fn run_rejects_literal_dotted_reasoning_effort_key_before_execution() { + let store = TestStore::initialized("run-reasoning-literal-dotted-key"); + let scratch = TestStore::new("run-reasoning-literal-dotted-key-scratch"); + fs::create_dir_all(scratch.path()).unwrap(); + let marker = scratch.path().join("runner-started"); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + add_fragment_frontmatter_block(&store, "Only", "pseq.run.reasoning_effort: high\n"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "printf started > \"$1\"", + "sh", + path_str(&marker), + ]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!(!marker.exists(), "runner command should not be spawned"); + + let error = stderr_json(&output); + assert_eq!(error["error"]["code"], "invalid_run_invocation"); + let message = error["error"]["message"].as_str().unwrap(); + assert!(message.contains("fragment \"Only\"")); + assert!(message.contains("literal frontmatter key \"pseq.run.reasoning_effort\"")); + assert!(message.contains("pseq -> run -> reasoning_effort")); +} + +#[test] +fn run_ignores_unrelated_pseq_frontmatter_metadata() { + let store = TestStore::initialized("run-reasoning-unrelated-pseq"); + create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); + add_fragment_frontmatter_block(&store, "Only", "pseq: note\n"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "cat", + ]); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 1); + assert_eq!(json["turns"][0]["stdout"], "body\n"); +} + +#[test] +fn run_ignores_reasoning_effort_on_included_fragments() { + let store = TestStore::initialized("run-reasoning-include-ignored"); + assert_success(&pseq_with_stdin( + &[ + "--store", + path_str(store.path()), + "fragment", + "new", + "Top", + "--stdin", + ], + "{{pseq.fragment.Reusable}}", + )); + assert_success(&pseq_with_stdin( + &[ + "--store", + path_str(store.path()), + "fragment", + "new", + "Reusable", + "--stdin", + ], + "included\n", + )); + assert_success(&pseq(&[ + "--store", + path_str(store.path()), + "sequence", + "new", + "Workflow", + ])); + assert_success(&pseq(&[ + "--store", + path_str(store.path()), + "sequence", + "add", + "Workflow", + "Top", + ])); + add_fragment_pseq_run_reasoning_effort(&store, "Reusable", "high"); + + let output = pseq(&[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--", + "sh", + "-c", + "cat", + ]); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 1); + assert_eq!(json["turns"][0]["stdout"], "included\n"); +} + +#[ignore = "boots the real Codex CLI and spends model/tool time"] +#[test] +fn run_with_real_codex_applies_fragment_reasoning_effort_per_turn() { + assert_success( + &std::process::Command::new("codex") + .arg("--version") + .output() + .expect("real codex CLI should be installed for this ignored test"), + ); + + let store = TestStore::initialized("run-real-codex-reasoning-store"); + let codex_home = isolated_codex_home("run-real-codex-reasoning-codex-home"); + create_sequence_with_fragments( + &store, + "Workflow", + &[ + ( + "Low", + "Reply exactly with this token and no other text: PSEQ-REASONING-LOW\n", + ), + ( + "Default", + "Reply exactly with this token and no other text: PSEQ-REASONING-DEFAULT\n", + ), + ( + "High", + "Reply exactly with this token and no other text: PSEQ-REASONING-HIGH\n", + ), + ], + ); + add_fragment_pseq_run_reasoning_effort(&store, "Low", "low"); + add_fragment_pseq_run_reasoning_effort(&store, "High", "high"); + + let output = pseq_in_dir_with_env( + &[ + "--store", + path_str(store.path()), + "--json", + "run", + "Workflow", + "--max-captured-output", + "2000000", + "--", + "codex", + "exec", + "-m", + "gpt-5.4-mini", + "-c", + "model_reasoning_effort=\"medium\"", + "--ignore-user-config", + "--skip-git-repo-check", + "--sandbox", + "read-only", + "--color", + "never", + "-", + ], + store.path(), + &[("CODEX_HOME", path_str(codex_home.path()))], + ); + assert_success(&output); + + let json = stdout_json(&output); + assert_eq!(json["completed_turns"], 3); + assert_codex_turn_reasoning_effort(&json, 0, "low"); + assert_codex_turn_reasoning_effort(&json, 1, "medium"); + assert_codex_turn_reasoning_effort(&json, 2, "high"); + assert_eq!( + codex_session_turn_efforts(&codex_home), + vec!["low".to_owned(), "medium".to_owned(), "high".to_owned()] + ); + + for turn_index in 1..=2 { + let command = json["turns"][turn_index]["command"].as_array().unwrap(); + assert!( + command.iter().any(|arg| arg == "resume"), + "expected later Codex turn to resume the active session, got {command:?}" + ); + } + + assert!( + json["turns"][0]["stdout"] + .as_str() + .unwrap() + .contains("PSEQ-REASONING-LOW") + ); + assert!( + json["turns"][1]["stdout"] + .as_str() + .unwrap() + .contains("PSEQ-REASONING-DEFAULT") + ); + assert!( + json["turns"][2]["stdout"] + .as_str() + .unwrap() + .contains("PSEQ-REASONING-HIGH") + ); +} + +fn add_fragment_pseq_run_reasoning_effort(store: &TestStore, name: &str, effort: &str) { + add_fragment_frontmatter_block( + store, + name, + &format!("pseq:\n run:\n reasoning_effort: {effort}\n"), + ); +} + +fn add_fragment_frontmatter_block(store: &TestStore, name: &str, block: &str) { + let path = fragment_path_by_name(store, name); + let content = fs::read_to_string(&path).unwrap(); + let rest = content + .strip_prefix("---\n") + .expect("fragment should start with YAML frontmatter"); + let delimiter = rest + .find("\n---\n") + .expect("fragment should close YAML frontmatter"); + let frontmatter = &rest[..delimiter]; + let body = &rest[delimiter + "\n---\n".len()..]; + fs::write(&path, format!("---\n{frontmatter}\n{block}---\n{body}")).unwrap(); +} + +fn fragment_path_by_name(store: &TestStore, name: &str) -> std::path::PathBuf { + let expected_line = format!("name: {name}"); + let mut matches = Vec::new(); + for entry in fs::read_dir(store.path().join("fragments")).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|value| value.to_str()) != Some("md") { + continue; + } + let content = fs::read_to_string(&path).unwrap(); + if content.lines().any(|line| line == expected_line) { + matches.push(path); + } + } + assert_eq!(matches.len(), 1, "expected one fragment named {name:?}"); + matches.pop().unwrap() +} + +fn isolated_codex_home(name: &str) -> TestStore { + let codex_home = TestStore::new(name); + fs::create_dir_all(codex_home.path()).unwrap(); + fs::copy( + default_codex_home().join("auth.json"), + codex_home.path().join("auth.json"), + ) + .expect("real Codex integration tests require Codex auth"); + codex_home +} + +fn default_codex_home() -> std::path::PathBuf { + if let Some(path) = std::env::var_os("CODEX_HOME") { + return path.into(); + } + if let Some(home) = std::env::var_os("HOME") { + return std::path::PathBuf::from(home).join(".codex"); + } + if let Some(userprofile) = std::env::var_os("USERPROFILE") { + return std::path::PathBuf::from(userprofile).join(".codex"); + } + panic!("real Codex integration tests require CODEX_HOME, HOME, or USERPROFILE"); +} + +fn codex_session_turn_efforts(codex_home: &TestStore) -> Vec { + let mut records = Vec::new(); + collect_codex_session_turn_efforts(&codex_home.path().join("sessions"), &mut records); + records.sort_by(|left, right| left.0.cmp(&right.0)); + records + .into_iter() + .map(|(_, effort)| effort) + .collect::>() +} + +fn collect_codex_session_turn_efforts(path: &std::path::Path, records: &mut Vec<(String, String)>) { + let Ok(entries) = fs::read_dir(path) else { + return; + }; + for entry in entries { + let path = entry.unwrap().path(); + if path.is_dir() { + collect_codex_session_turn_efforts(&path, records); + continue; + } + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + for line in fs::read_to_string(&path).unwrap().lines() { + let value: serde_json::Value = serde_json::from_str(line).unwrap(); + if value["type"] == "turn_context" { + records.push(( + value["timestamp"].as_str().unwrap().to_owned(), + value["payload"]["effort"].as_str().unwrap().to_owned(), + )); + } + } + } +} + +fn assert_codex_turn_reasoning_effort(json: &serde_json::Value, turn_index: usize, effort: &str) { + let command = json["turns"][turn_index]["command"].as_array().unwrap(); + let expected = format!("model_reasoning_effort=\"{effort}\""); + assert!( + command + .windows(2) + .any(|args| args[0] == "-c" && args[1] == expected), + "expected turn {} Codex command to set reasoning effort to {effort:?}, got {command:?}", + turn_index + 1 + ); +} diff --git a/tests/run/runner_modes.rs b/tests/run/runner_modes.rs index ecadab9..0b3bfeb 100644 --- a/tests/run/runner_modes.rs +++ b/tests/run/runner_modes.rs @@ -33,989 +33,6 @@ fn run_with_ad_hoc_command_feeds_each_fragment_as_one_turn() { assert_git_clean(sink.path()); } -#[cfg(unix)] -#[test] -fn run_adds_git_metadata_writable_roots_for_sandboxed_agent_runner() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-agent-git-roots"); - let workspace = TestStore::initialized("run-agent-git-roots-workspace"); - let bin_dir = TestStore::new("run-agent-git-roots-bin"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -args="$*" -out="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - esac - shift -done -if [ -n "$out" ]; then - printf '%s\n' "$args" > "$out" -else - printf '%s\n' "$args" -fi -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "--color", - "never", - "-", - ], - workspace.path(), - &[("PATH", &path)], - ); - assert_success(&output); - - let json = stdout_json(&output); - let command = json["turns"][0]["command"].as_array().unwrap(); - let git_dir_path = workspace.path().join(".git"); - let git_dir = path_str(&git_dir_path); - assert!( - command - .windows(2) - .any(|args| args[0] == "--add-dir" && args[1] == git_dir), - "expected command to add git metadata as writable root, got {command:?}" - ); - let stdout = json["turns"][0]["stdout"].as_str().unwrap(); - assert!( - stdout.contains(git_dir), - "fake runner should receive the prepared command argv, got {stdout:?}" - ); -} - -#[cfg(unix)] -#[test] -fn run_does_not_session_wrap_codex_exec_review_after_prepared_options() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-review-subcommand-store"); - let bin_dir = TestStore::new("run-codex-review-subcommand-bin"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -cat >/dev/null -printf '%s\n' "$*" -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "review", - ], - store.path(), - &[("PATH", &path)], - ); - assert_success(&output); - - let json = stdout_json(&output); - let command = json["turns"][0]["command"].as_array().unwrap(); - let git_dir_path = store.path().join(".git"); - let git_dir = path_str(&git_dir_path); - assert!( - command - .windows(2) - .any(|args| args[0] == "--add-dir" && args[1] == git_dir), - "expected prepared command to keep writable Git metadata root, got {command:?}" - ); - assert!( - command.iter().any(|arg| arg == "review"), - "expected Codex review subcommand to remain in command, got {command:?}" - ); - assert!( - !command.iter().any(|arg| arg == "--output-last-message"), - "expected Codex review subcommand not to be session wrapped, got {command:?}" - ); - assert!( - json["turns"][0]["stdout"] - .as_str() - .unwrap() - .contains("review"), - "expected generic fake Codex stdout to be captured" - ); -} - -#[cfg(unix)] -#[test] -fn run_resumes_exact_codex_session_for_later_sequence_turns() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-session-fake-store"); - let bin_dir = TestStore::new("run-codex-session-fake-bin"); - let log_path = bin_dir.path().join("codex.log"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -args="$*" -out="" -resume=0 -session="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - resume) - resume=1 - ;; - --json|--color|--sandbox|-m|-s) - if [ "$1" != "--json" ]; then - shift - fi - ;; - --*) - ;; - -) - ;; - *) - if [ "$resume" = "1" ] && [ -z "$session" ]; then - session="$1" - fi - ;; - esac - shift -done -input=$(cat) -if [ "$resume" = "1" ]; then - printf 'resume session=%s input=%s\n' "$session" "$input" >> "$PSEQ_FAKE_CODEX_LOG" - printf 'resumed %s\n' "$session" > "$out" -else - printf 'first input=%s args=%s\n' "$input" "$args" >> "$PSEQ_FAKE_CODEX_LOG" - printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' - printf 'started fake-session-123\n' > "$out" -fi -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments( - &store, - "Workflow", - &[("First", "first prompt\n"), ("Second", "second prompt\n")], - ); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let log_path_arg = path_str(&log_path); - let ignored_output_path = bin_dir.path().join("ignored-output.txt"); - let ignored_output_path_arg = path_str(&ignored_output_path); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "--color", - "never", - "--output-last-message", - ignored_output_path_arg, - "-", - ], - store.path(), - &[ - ("PATH", path.as_str()), - ("PSEQ_FAKE_CODEX_LOG", log_path_arg), - ], - ); - assert_success(&output); - - let json = stdout_json(&output); - assert_eq!(json["completed_turns"], 2); - assert_eq!(json["turns"][0]["stdout"], "started fake-session-123\n"); - assert_eq!(json["turns"][1]["stdout"], "resumed fake-session-123\n"); - - let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); - let turn_1_command = json["turns"][0]["command"].as_array().unwrap(); - assert!( - !turn_1_command - .iter() - .any(|arg| arg == ignored_output_path_arg), - "expected pseq to own Codex output capture path, got {turn_1_command:?}" - ); - assert!( - turn_2_command.iter().any(|arg| arg == "resume"), - "expected second turn command to use Codex resume, got {turn_2_command:?}" - ); - assert!( - turn_2_command.iter().any(|arg| arg == "fake-session-123"), - "expected second turn command to resume exact session id, got {turn_2_command:?}" - ); - assert!( - !turn_2_command.iter().any(|arg| arg == "--last"), - "expected exact session resume, got {turn_2_command:?}" - ); - let git_dir_path = store.path().join(".git"); - let git_dir = path_str(&git_dir_path); - assert!( - turn_2_command - .windows(2) - .any(|args| args[0] == "--add-dir" && args[1] == git_dir), - "expected resumed Codex command to preserve writable Git metadata roots, got {turn_2_command:?}" - ); - - let log = fs::read_to_string(log_path).unwrap(); - assert!(log.contains("first input=first prompt")); - assert!(log.contains("resume session=fake-session-123 input=second prompt")); -} - -#[cfg(unix)] -#[test] -fn run_retries_codex_resume_turn_in_same_session() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-retry-same-session-store"); - let bin_dir = TestStore::new("run-codex-retry-same-session-bin"); - let log_path = bin_dir.path().join("codex.log"); - let retry_count_path = bin_dir.path().join("resume-attempts.txt"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -out="" -resume=0 -session="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - resume) - resume=1 - ;; - --json|--color|--sandbox|-m|-s) - if [ "$1" != "--json" ]; then - shift - fi - ;; - --*) - ;; - -) - ;; - *) - if [ "$resume" = "1" ] && [ -z "$session" ]; then - session="$1" - fi - ;; - esac - shift -done -input=$(cat) -if [ "$resume" = "1" ]; then - count=$(cat "$PSEQ_FAKE_CODEX_RETRY_COUNT" 2>/dev/null || printf 0) - count=$((count + 1)) - printf '%s\n' "$count" > "$PSEQ_FAKE_CODEX_RETRY_COUNT" - printf 'resume attempt=%s session=%s input=%s\n' "$count" "$session" "$input" >> "$PSEQ_FAKE_CODEX_LOG" - if [ "$count" -eq 1 ]; then - printf 'transient resume failure\n' >&2 - exit 23 - fi - printf 'resumed %s after retry\n' "$session" > "$out" -else - printf 'first input=%s\n' "$input" >> "$PSEQ_FAKE_CODEX_LOG" - printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' - printf 'started fake-session-123\n' > "$out" -fi -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments( - &store, - "Workflow", - &[("First", "first prompt\n"), ("Second", "second prompt\n")], - ); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let log_path_arg = path_str(&log_path); - let retry_count_path_arg = path_str(&retry_count_path); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--retry-delay-ms", - "0", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "--color", - "never", - "-", - ], - store.path(), - &[ - ("PATH", path.as_str()), - ("PSEQ_FAKE_CODEX_LOG", log_path_arg), - ("PSEQ_FAKE_CODEX_RETRY_COUNT", retry_count_path_arg), - ], - ); - assert_success(&output); - - let json = stdout_json(&output); - let first_turn = &json["turns"][0]; - let second_turn = &json["turns"][1]; - assert!(first_turn.get("attempt_count").is_none()); - assert_eq!(second_turn["attempt_count"], 2); - assert_eq!( - second_turn["stdout"], - "resumed fake-session-123 after retry\n" - ); - let attempts = second_turn["attempts"].as_array().unwrap(); - assert_eq!(attempts.len(), 2); - for attempt in attempts { - let command = attempt["command"].as_array().unwrap(); - assert!( - command.iter().any(|arg| arg == "resume"), - "retry attempt should resume the established Codex session, got {command:?}" - ); - assert!( - command.iter().any(|arg| arg == "fake-session-123"), - "retry attempt should use the original session id, got {command:?}" - ); - } - - let log = fs::read_to_string(log_path).unwrap(); - assert!(log.contains("first input=first prompt")); - assert!(log.contains("resume attempt=1 session=fake-session-123 input=second prompt")); - assert!(log.contains("resume attempt=2 session=fake-session-123 input=second prompt")); -} - -#[cfg(unix)] -#[test] -fn run_can_reset_codex_session_per_iteration_while_carrying_feedback() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-session-scope-iteration-store"); - let bin_dir = TestStore::new("run-codex-session-scope-iteration-bin"); - let counter_path = bin_dir.path().join("counter.txt"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -out="" -resume=0 -session="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - resume) - resume=1 - ;; - --json|--color|--sandbox|-m|-s) - if [ "$1" != "--json" ]; then - shift - fi - ;; - --*) - ;; - -) - ;; - *) - if [ "$resume" = "1" ] && [ -z "$session" ]; then - session="$1" - fi - ;; - esac - shift -done -input=$(cat) -if [ "$resume" = "1" ]; then - printf 'resumed %s\n' "$session" > "$out" -else - count=0 - if [ -f "$PSEQ_FAKE_CODEX_COUNTER" ]; then - count=$(cat "$PSEQ_FAKE_CODEX_COUNTER") - fi - count=$((count + 1)) - printf '%s\n' "$count" > "$PSEQ_FAKE_CODEX_COUNTER" - session="iteration-session-$count" - saw=none - case "$input" in - *PSEQ-SEED*) saw=seed ;; - *"resumed iteration-session-1"*) saw=feedback ;; - esac - printf '{"type":"thread.started","thread_id":"%s"}\n' "$session" - printf 'started %s saw=%s\n' "$session" "$saw" > "$out" -fi -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments( - &store, - "Workflow", - &[ - ("First", "feedback={{loop_feedback}}\n"), - ("Final", "final prompt\n"), - ], - ); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let counter_path_arg = path_str(&counter_path); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--iterations", - "2", - "--session-scope", - "iteration", - "--feedback-from", - "final-stdout", - "--feedback-var", - "loop_feedback", - "--feedback-seed", - "PSEQ-SEED", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "--color", - "never", - "-", - ], - store.path(), - &[ - ("PATH", &path), - ("PSEQ_FAKE_CODEX_COUNTER", counter_path_arg), - ], - ); - assert_success(&output); - - let json = stdout_json(&output); - assert_eq!(json["completed_turns"], 4); - assert_eq!( - json["turns"][0]["stdout"], - "started iteration-session-1 saw=seed\n" - ); - assert_eq!(json["turns"][1]["stdout"], "resumed iteration-session-1\n"); - assert_eq!( - json["turns"][2]["stdout"], - "started iteration-session-2 saw=feedback\n" - ); - assert_eq!(json["turns"][3]["stdout"], "resumed iteration-session-2\n"); - - let iteration_1_final = json["turns"][1]["command"].as_array().unwrap(); - let iteration_2_first = json["turns"][2]["command"].as_array().unwrap(); - let iteration_2_final = json["turns"][3]["command"].as_array().unwrap(); - assert!( - iteration_1_final.iter().any(|arg| arg == "resume") - && iteration_1_final - .iter() - .any(|arg| arg == "iteration-session-1"), - "expected first iteration final turn to resume iteration-session-1, got {iteration_1_final:?}" - ); - assert!( - !iteration_2_first.iter().any(|arg| arg == "resume"), - "expected second iteration first turn to start a fresh session, got {iteration_2_first:?}" - ); - assert!( - iteration_2_final.iter().any(|arg| arg == "resume") - && iteration_2_final - .iter() - .any(|arg| arg == "iteration-session-2"), - "expected second iteration final turn to resume iteration-session-2, got {iteration_2_final:?}" - ); -} - -#[cfg(unix)] -#[test] -fn run_named_codex_runner_uses_session_continuation_instead_of_configured_next() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-named-codex-session-store"); - let bin_dir = TestStore::new("run-named-codex-session-bin"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -out="" -resume=0 -session="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - resume) - resume=1 - ;; - --json|--color) - ;; - -) - ;; - *) - if [ "$resume" = "1" ] && [ -z "$session" ]; then - session="$1" - fi - ;; - esac - shift -done -cat >/dev/null -if [ "$resume" = "1" ]; then - printf 'resumed %s\n' "$session" > "$out" -else - printf '{"type":"thread.started","thread_id":"named-session-456"}\n' - printf 'started named-session-456\n' > "$out" -fi -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments( - &store, - "Workflow", - &[("First", "first prompt\n"), ("Second", "second prompt\n")], - ); - - assert_success(&pseq(&[ - "--store", - path_str(store.path()), - "runner", - "set", - "codex", - "first", - "--", - "codex", - "exec", - "--color", - "never", - "-", - ])); - assert_success(&pseq(&[ - "--store", - path_str(store.path()), - "runner", - "set", - "codex", - "next", - "--", - pseq_bin(), - "--version", - ])); - assert_success(&pseq(&[ - "--store", - path_str(store.path()), - "runner", - "default", - "codex", - ])); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - ], - store.path(), - &[("PATH", &path)], - ); - assert_success(&output); - - let json = stdout_json(&output); - assert_eq!(json["completed_turns"], 2); - assert_eq!(json["turns"][1]["stdout"], "resumed named-session-456\n"); - let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); - assert!( - turn_2_command.iter().any(|arg| arg == "resume"), - "expected named Codex runner to use session continuation, got {turn_2_command:?}" - ); - assert!( - turn_2_command.iter().any(|arg| arg == "named-session-456"), - "expected named Codex runner to resume exact session id, got {turn_2_command:?}" - ); - assert!( - !turn_2_command - .iter() - .any(|arg| arg.as_str() == Some(pseq_bin())), - "expected configured next command to be ignored for active Codex session, got {turn_2_command:?}" - ); -} - -#[cfg(unix)] -#[test] -fn run_fails_when_codex_session_id_is_missing_before_later_turns() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-missing-session-store"); - let bin_dir = TestStore::new("run-codex-missing-session-bin"); - let log_path = bin_dir.path().join("codex.log"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -out="" -while [ "$#" -gt 0 ]; do - case "$1" in - --output-last-message|-o) - shift - out="$1" - ;; - esac - shift -done -input=$(cat) -printf '%s\n' "$input" >> "$PSEQ_FAKE_CODEX_LOG" -printf 'no session id\n' > "$out" -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments( - &store, - "Workflow", - &[("First", "first prompt\n"), ("Second", "second prompt\n")], - ); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let log_path_arg = path_str(&log_path); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--", - "codex", - "exec", - "--sandbox", - "read-only", - "--color", - "never", - "-", - ], - store.path(), - &[ - ("PATH", path.as_str()), - ("PSEQ_FAKE_CODEX_LOG", log_path_arg), - ], - ); - - assert_eq!(output.status.code(), Some(1)); - assert_eq!( - stderr_json(&output)["error"]["code"], - "invalid_run_invocation" - ); - assert_eq!(fs::read_to_string(log_path).unwrap(), "first prompt\n"); -} - -#[cfg(unix)] -#[test] -fn run_fails_when_successful_codex_does_not_write_final_message() { - use std::os::unix::fs::PermissionsExt; - - let store = TestStore::initialized("run-codex-missing-final-message-store"); - let bin_dir = TestStore::new("run-codex-missing-final-message-bin"); - fs::create_dir_all(bin_dir.path()).unwrap(); - let fake_codex = bin_dir.path().join("codex"); - fs::write( - &fake_codex, - r#"#!/bin/sh -cat >/dev/null -printf '{"type":"thread.started","thread_id":"fake-session-123"}\n' -"#, - ) - .unwrap(); - fs::set_permissions(&fake_codex, fs::Permissions::from_mode(0o755)).unwrap(); - create_sequence_with_fragments(&store, "Workflow", &[("Only", "body\n")]); - - let path = format!( - "{}:{}", - path_str(bin_dir.path()), - std::env::var("PATH").unwrap_or_default() - ); - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--", - "codex", - "exec", - "--color", - "never", - "-", - ], - store.path(), - &[("PATH", &path)], - ); - - assert_eq!(output.status.code(), Some(1)); - assert_eq!( - stderr_json(&output)["error"]["code"], - "runner_read_output_failed" - ); -} - -#[ignore = "boots the real Codex CLI and spends model/tool time"] -#[test] -fn run_with_real_codex_can_commit_inside_workspace_write_sandbox() { - const MARKER_FILE: &str = "pseq-real-codex-git-marker.txt"; - const MARKER_TEXT: &str = "pseq real codex git metadata write check\n"; - const COMMIT_MESSAGE: &str = "pseq real codex git write check"; - - assert_success( - &std::process::Command::new("codex") - .arg("--version") - .output() - .expect("real codex CLI should be installed for this ignored test"), - ); - - let store = TestStore::initialized("run-real-codex-git-store"); - let workspace = TestStore::new("run-real-codex-git-workspace"); - fs::create_dir_all(workspace.path()).unwrap(); - assert_success(&git(workspace.path(), &["init", "--quiet"])); - create_sequence_with_fragments( - &store, - "Workflow", - &[( - "Only", - &format!( - "\ -Automated pseq integration test. - -Do exactly this in the current Git repository: -1. Write the file `{MARKER_FILE}` with exactly this single line: -{} -2. Run: -git add {MARKER_FILE} -git -c user.name=pseq-real-codex-test -c user.email=pseq-real-codex-test@example.invalid commit -m {COMMIT_MESSAGE:?} -3. Do not modify any other files. -4. Final response: committed {COMMIT_MESSAGE} -", - MARKER_TEXT.trim_end() - ), - )], - ); - - let output = pseq_in_dir_with_env( - &[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--max-captured-output", - "2000000", - "--", - "codex", - "exec", - "--sandbox", - "workspace-write", - "--color", - "never", - "-", - ], - workspace.path(), - &[], - ); - assert_success(&output); - - let json = stdout_json(&output); - let command = json["turns"][0]["command"].as_array().unwrap(); - let git_dir_path = workspace.path().join(".git"); - let git_dir = path_str(&git_dir_path); - assert!( - command - .windows(2) - .any(|args| args[0] == "--add-dir" && args[1] == git_dir), - "expected pseq to add git metadata as a writable root, got {command:?}" - ); - - let committed_file = git(workspace.path(), &["show", &format!("HEAD:{MARKER_FILE}")]); - assert_success(&committed_file); - assert_eq!( - String::from_utf8(committed_file.stdout).unwrap(), - MARKER_TEXT - ); - - let commit_subject = git(workspace.path(), &["log", "-1", "--pretty=%s"]); - assert_success(&commit_subject); - assert_eq!( - String::from_utf8(commit_subject.stdout).unwrap().trim(), - COMMIT_MESSAGE - ); - assert_git_clean(workspace.path()); -} - -#[ignore = "boots the real Codex CLI and spends model/tool time"] -#[test] -fn run_with_real_codex_keeps_sequence_turns_in_one_session() { - const TOKEN: &str = "PSEQ-CODEX-SESSION-CONTINUITY-1779625000"; - - assert_success( - &std::process::Command::new("codex") - .arg("--version") - .output() - .expect("real codex CLI should be installed for this ignored test"), - ); - - let store = TestStore::initialized("run-real-codex-session-store"); - create_sequence_with_fragments( - &store, - "Workflow", - &[ - ( - "Remember", - &format!( - "\ -Remember this exact continuity token for the next prompt: -{TOKEN} - -Reply exactly: -stored -" - ), - ), - ( - "Recall", - "\ -Without reading files, running shell commands, or using external state, reply with the exact continuity token I asked you to remember in the previous prompt. -Do not include anything except the token. -", - ), - ], - ); - - let output = pseq(&[ - "--store", - path_str(store.path()), - "--json", - "run", - "Workflow", - "--max-captured-output", - "2000000", - "--", - "codex", - "exec", - "-m", - "gpt-5.4-mini", - "--skip-git-repo-check", - "--sandbox", - "read-only", - "--color", - "never", - "-", - ]); - assert_success(&output); - - let json = stdout_json(&output); - assert_eq!(json["completed_turns"], 2); - let turn_2_command = json["turns"][1]["command"].as_array().unwrap(); - assert!( - turn_2_command.iter().any(|arg| arg == "resume"), - "expected second Codex turn to resume the first session, got {turn_2_command:?}" - ); - assert!( - !turn_2_command.iter().any(|arg| arg == "--last"), - "expected pseq to resume an exact Codex session id, got {turn_2_command:?}" - ); - let turn_2_stdout = json["turns"][1]["stdout"].as_str().unwrap(); - assert!( - turn_2_stdout.contains(TOKEN), - "expected second turn to recall {TOKEN}, got {turn_2_stdout:?}" - ); -} - #[test] fn run_uses_named_generic_runner_first_then_next_commands() { let store = TestStore::initialized("run-named"); From bf3345a252727bf97d34d0aa97e782086aeb26b2 Mon Sep 17 00:00:00 2001 From: s-brez Date: Wed, 3 Jun 2026 20:47:53 +1000 Subject: [PATCH 2/2] Prepare v0.0.4 --- Cargo.lock | 2 +- Cargo.toml | 2 +- npm/pseq/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41c51ac..bf04259 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,7 +325,7 @@ dependencies = [ [[package]] name = "pseq" -version = "0.0.3" +version = "0.0.4" dependencies = [ "clap", "nix", diff --git a/Cargo.toml b/Cargo.toml index 31822a2..54ad6b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pseq" -version = "0.0.3" +version = "0.0.4" edition = "2024" license = "MIT" description = "Simple prompt and command sequencer for CLI agent automation." diff --git a/npm/pseq/package.json b/npm/pseq/package.json index 0a00a49..f9c5dc8 100644 --- a/npm/pseq/package.json +++ b/npm/pseq/package.json @@ -1,6 +1,6 @@ { "name": "@s-brez/pseq", - "version": "0.0.3", + "version": "0.0.4", "description": "Simple prompt and command sequencer for CLI agent automation.", "main": "bin/pseq.js", "scripts": {