//! Patched pi-durable input placement, hold boundaries, and headless channel lifecycle. use sausage::{ cred::Cred, harness::{ input::{self, Input}, run_module, run_module_with_input, }, world::World, }; use std::{ process::{Command, Stdio}, sync::Arc, time::{Duration, Instant}, }; const BUNDLE: &str = include_str!("../../js/foundation-test-bundle.js"); #[test] fn bundle_has_patched_hooks() { for bundle in [BUNDLE, include_str!("../../js/bundle.js")] { assert!(bundle.contains("handlers.beforeInput")); assert!(bundle.contains("input: true")); assert!(bundle.contains("#held")); } assert!(!include_str!("../../js/patches/pi-durable-1.5.0.patch").contains("dist/env/node")); } async fn probe(case: &str) -> anyhow::Result<()> { let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let exec = sausage::process::exec(World::open(dir.path())?)?; run_module( world, exec, &format!("globalThis.foundationCase = {case:?};\n{BUNDLE}"), ) .await } #[tokio::test(flavor = "current_thread")] async fn input_placement() -> anyhow::Result<()> { probe("placement").await } #[tokio::test(flavor = "current_thread")] async fn followups_start_one_run() -> anyhow::Result<()> { probe("followups").await } #[tokio::test(flavor = "current_thread")] async fn response_thinking_level() -> anyhow::Result<()> { probe("thinking").await } #[tokio::test(flavor = "current_thread")] async fn hold_parks_at_phase_boundary() -> anyhow::Result<()> { probe("hold").await } #[tokio::test(flavor = "current_thread")] async fn headless_child() -> anyhow::Result<()> { if std::env::var_os("HEADLESS_CHILD").is_none() { return Ok(()); } let dir = tempfile::tempdir()?; let world = World::open(dir.path())?; let cred = Arc::new(Cred::open(&world.host_dir)?); let exec = sausage::process::exec(World::open(dir.path())?)?; let (sender, receiver) = input::channel(); sender.send(Input::Submit { conversation: 1, content: serde_json::json!("channel input"), request_id: "channel-request".into(), })?; sender.send(Input::Stop)?; tokio::time::timeout( Duration::from_secs(30), run_module_with_input( world, exec, cred, receiver, &format!("globalThis.foundationCase = 'headless';\n{BUNDLE}"), ), ) .await??; Ok(()) } #[test] fn channel_with_closed_stdin() -> anyhow::Result<()> { let mut child = Command::new(std::env::current_exe()?) .args([ "--exact", "harness_foundation::headless_child", "--nocapture", ]) .env("HEADLESS_CHILD", "1") .stdin(Stdio::null()) .spawn()?; let deadline = Instant::now() + Duration::from_secs(40); loop { if let Some(status) = child.try_wait()? { assert!(status.success()); break; } if Instant::now() > deadline { child.kill()?; child.wait()?; anyhow::bail!("Headless channel did not stop"); } std::thread::yield_now(); } Ok(()) } #[test] fn cli_stays_up_after_stdin_eof() -> anyhow::Result<()> { let dir = tempfile::tempdir()?; World::open(dir.path())?; let disk = dir.path().join("world"); let target = crate::common::build_git(); std::fs::create_dir_all(disk.join("bin"))?; for binary in ["git", "tar"] { std::fs::copy( target.join(format!("wasm32-wasip2/release/{binary}.wasm")), disk.join(format!("bin/{binary}.wasm")), )?; } let repo = disk.join("src/sausage"); let source = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../extensions"); for extension in ["core", "models", "subagents"] { crate::common::copy_tree( &source.join(extension), &repo.join("extensions").join(extension), )?; } crate::common::write_config( dir.path(), "console", serde_json::json!([ {"name":"console","description":"MainAgent console","model":"openai/gpt-6.1-sol","prompt":"Keep the user conversation"} ]), )?; for args in [ vec!["init", "-b", "main"], vec!["add", "."], vec!["commit", "--no-gpg-sign", "-m", "EOF fixture"], ] { assert!( Command::new("git") .args(args) .current_dir(&repo) .env("GIT_AUTHOR_NAME", "Test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "Test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .status()? .success() ); } let mut child = Command::new(env!("CARGO_BIN_EXE_sausage")) .arg("run") .arg(dir.path()) .env_remove("SAUSAGE_USERLAND") .stdin(Stdio::null()) .spawn()?; let deadline = Instant::now() + Duration::from_secs(30); loop { if let Some(status) = child.try_wait()? { anyhow::bail!("CLI exited at EOF: {status}"); } if let Ok(db) = rusqlite::Connection::open(dir.path().join("host/harness.sqlite")) && db .query_row("SELECT count(*) FROM conversations", [], |r| { r.get::<_, i64>(0) }) .unwrap_or(0) > 1 { break; } if Instant::now() > deadline { child.kill()?; child.wait()?; anyhow::bail!("CLI did not initialize"); } std::thread::yield_now(); } // EOF shutdown previously took one credential tick. Check beyond that tick. let alive_until = Instant::now() + Duration::from_secs(2); while Instant::now() < alive_until { if let Some(status) = child.try_wait()? { anyhow::bail!("CLI exited after EOF: {status}"); } std::thread::yield_now(); } assert!( Command::new("kill") .args(["-TERM", &child.id().to_string()]) .status()? .success() ); let deadline = Instant::now() + Duration::from_secs(10); loop { if let Some(status) = child.try_wait()? { assert!(status.success()); break; } if Instant::now() > deadline { child.kill()?; child.wait()?; anyhow::bail!("CLI did not stop cleanly"); } std::thread::yield_now(); } Ok(()) }