#![cfg(feature = "integration-real-tests")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::PathBuf;
use tokio::process::Command;
use tokio::time::{Duration, timeout};
use meerkat::Config;
use tempfile::TempDir;
fn rkat_binary_path() -> Option<PathBuf> {
if let Some(path) = std::env::var_os("CARGO_BIN_EXE_rkat") {
let path = PathBuf::from(path);
if path.exists() {
return Some(path.canonicalize().unwrap_or(path));
}
}
if let Some(target_dir) = std::env::var_os("CARGO_TARGET_DIR") {
let target_dir = PathBuf::from(target_dir);
let debug = target_dir.join("debug/rkat");
if debug.exists() {
return Some(debug);
}
let release = target_dir.join("release/rkat");
if release.exists() {
return Some(release);
}
}
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest_dir.parent()?;
let codex_debug = workspace_root.join("target-codex/debug/rkat");
if codex_debug.exists() {
return Some(codex_debug);
}
let codex_release = workspace_root.join("target-codex/release/rkat");
if codex_release.exists() {
return Some(codex_release);
}
let debug = workspace_root.join("target/debug/rkat");
if debug.exists() {
return Some(debug);
}
let release = workspace_root.join("target/release/rkat");
if release.exists() {
return Some(release);
}
None
}
fn anthropic_api_key() -> Option<String> {
first_env(&["RKAT_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"])
}
fn openai_api_key() -> Option<String> {
first_env(&["RKAT_OPENAI_API_KEY", "OPENAI_API_KEY"])
}
fn first_env(vars: &[&str]) -> Option<String> {
for name in vars {
if let Ok(value) = std::env::var(name)
&& !value.is_empty()
{
return Some(value);
}
}
None
}
fn smoke_model() -> String {
std::env::var("SMOKE_MODEL").unwrap_or_else(|_| "claude-sonnet-4-5".to_string())
}
async fn read_jsonl_files_under(
root: &std::path::Path,
) -> Result<String, Box<dyn std::error::Error>> {
let mut stack = vec![root.to_path_buf()];
let mut combined = String::new();
while let Some(dir) = stack.pop() {
let mut entries = match tokio::fs::read_dir(&dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
Err(err) => return Err(Box::new(err)),
};
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let file_type = entry.file_type().await?;
if file_type.is_dir() {
stack.push(path);
} else if path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext == "jsonl")
{
combined.push_str(&tokio::fs::read_to_string(path).await?);
combined.push('\n');
}
}
}
Ok(combined)
}
fn skip_if_no_prereqs() -> bool {
if rkat_binary_path().is_none() {
eprintln!("Skipping: rkat binary not found (build with `cargo build -p meerkat-cli`)");
return true;
}
false
}
fn skip_if_no_api_prereqs() -> bool {
if skip_if_no_prereqs() {
return true;
}
if anthropic_api_key().is_none() {
eprintln!(
"Skipping: no Anthropic API key (set ANTHROPIC_API_KEY or RKAT_ANTHROPIC_API_KEY)"
);
return true;
}
false
}
fn skip_if_no_openai_api_prereqs() -> bool {
if skip_if_no_prereqs() {
return true;
}
if openai_api_key().is_none() {
eprintln!("Skipping: no OpenAI API key (set OPENAI_API_KEY or RKAT_OPENAI_API_KEY)");
return true;
}
false
}
async fn write_smoke_config(
project_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let rkat_dir = project_dir.join(".rkat");
tokio::fs::create_dir_all(&rkat_dir).await?;
let mut config = Config::default();
config.agent.max_tokens_per_turn = 256;
config.agent.model = smoke_model();
let config_toml = toml::to_string_pretty(&config)?;
tokio::fs::write(rkat_dir.join("config.toml"), config_toml).await?;
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_scenario_26_cli_run_resume_persistence() -> Result<(), Box<dyn std::error::Error>> {
if skip_if_no_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_SMOKE_11_INNER").is_ok() {
return inner_e2e_cli_run_resume_persistence().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_scenario_26_cli_run_resume_persistence")
.arg("--ignored")
.env("RUN_TEST_E2E_SMOKE_11_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_cli_run_resume_persistence() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
let _data_dir = PathBuf::from(std::env::var("TEST_DATA_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"My name is RkatBot. Remember my name.",
"--tools",
"safe",
"--output",
"json",
])
.output(),
)
.await??;
if !output.status.success() {
return Err(format!(
"rkat run failed (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;
let session_id = parsed["session_id"]
.as_str()
.ok_or("session_id missing in initial run response")?
.to_string();
assert!(!session_id.is_empty(), "session_id should be non-empty");
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"--resume",
&session_id,
"What is my name? Reply with just the name.",
])
.output(),
)
.await??;
if !output.status.success() {
return Err(format!(
"rkat run --resume failed (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
)
.into());
}
let resume_stdout = String::from_utf8_lossy(&output.stdout);
let resume_text = resume_stdout.trim().to_lowercase();
assert!(
resume_text.contains("rkatbot"),
"Resume response should mention 'RkatBot', got: {resume_stdout}"
);
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_scenario_27_cli_shell_and_structured_output() -> Result<(), Box<dyn std::error::Error>>
{
if skip_if_no_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_SMOKE_12_INNER").is_ok() {
return inner_e2e_cli_shell_tool().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_scenario_27_cli_shell_and_structured_output")
.arg("--ignored")
.env("RUN_TEST_E2E_SMOKE_12_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_cli_shell_tool() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"Use the shell to run 'echo SMOKE_OK_42' and tell me the output",
"--tools",
"workspace",
"--output",
"json",
])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat run with shell failed (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;
let tool_calls = parsed["tool_calls"].as_u64().unwrap_or(0);
assert!(
tool_calls > 0,
"Expected at least one tool call, got {tool_calls}"
);
let text = parsed["text"].as_str().unwrap_or("");
assert!(
text.contains("SMOKE_OK_42"),
"Response text should contain 'SMOKE_OK_42', got: {text}"
);
let schema =
r#"{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"]}"#;
let structured_output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"What is 6 times 7? Give just the number.",
"--schema",
schema,
"--output",
"json",
])
.output(),
)
.await??;
assert!(
structured_output.status.success(),
"rkat structured output run failed (exit {:?}): {}",
structured_output.status.code(),
String::from_utf8_lossy(&structured_output.stderr)
);
let structured_stdout = String::from_utf8_lossy(&structured_output.stdout);
let structured: serde_json::Value =
serde_json::from_str(structured_stdout.trim()).map_err(|e| {
format!("Failed to parse structured output JSON: {e}\nstdout: {structured_stdout}")
})?;
assert!(
structured["structured_output"]["answer"].is_number(),
"structured output should contain a numeric answer, got: {structured}"
);
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_scenario_73_cli_generate_image_openai_default()
-> Result<(), Box<dyn std::error::Error>> {
if skip_if_no_openai_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_SMOKE_73_INNER").is_ok() {
return inner_e2e_cli_generate_image_openai_default().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_scenario_73_cli_generate_image_openai_default")
.arg("--ignored")
.env("RUN_TEST_E2E_SMOKE_73_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_cli_generate_image_openai_default() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
let rkat_dir = project_dir.join(".rkat");
tokio::fs::create_dir_all(&rkat_dir).await?;
let mut config = Config::default();
config.agent.max_tokens_per_turn = 4096;
config.agent.model = smoke_model();
let config_toml = toml::to_string_pretty(&config)?;
tokio::fs::write(rkat_dir.join("config.toml"), config_toml).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let output = timeout(
Duration::from_secs(600),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"Check today's news and generate an infographic image with the top news. Save it to top-news-infographic.png",
"--yolo",
"-m",
"gpt-5.5",
])
.output(),
)
.await??;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
assert!(
output.status.success(),
"rkat generate_image run failed (exit {:?}): {combined}",
output.status.code()
);
for failure in [
"unsupported_target",
"guard rejected",
"invalid_arguments",
"execution_failed",
"internal image operation state error",
"currently unavailable",
"don't currently have access",
"isn't currently available",
"couldn't create",
"failed in this session",
"\"terminal\":\"denied\"",
"\"terminal\": \"denied\"",
] {
assert!(
!combined.to_ascii_lowercase().contains(failure),
"CLI generate_image smoke hit failure marker {failure:?}: {combined}"
);
}
let saved_path = project_dir.join("top-news-infographic.png");
let saved = tokio::fs::read(&saved_path)
.await
.map_err(|err| format!("expected saved PNG at '{}': {err}", saved_path.display()))?;
assert!(
!saved.is_empty(),
"saved infographic should be nonempty at '{}'",
saved_path.display()
);
assert!(
saved.len() >= 8,
"saved infographic should be long enough to include a PNG signature"
);
assert_eq!(
&saved[..8],
&[0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A],
"saved infographic should be a PNG file; output was: {combined}"
);
let temp_root = project_dir
.parent()
.ok_or("project dir should have a parent temp root")?;
let events = read_jsonl_files_under(temp_root).await?;
assert!(
events.contains(r#""name":"generate_image""#),
"smoke should exercise generate_image, not just create a PNG by other means; output was: {combined}"
);
assert!(
events.contains(r#""name":"blob_save_file""#),
"smoke should exercise blob_save_file to materialize the generated blob; output was: {combined}"
);
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-system"]
async fn e2e_scenario_28_cli_capabilities_and_config() -> Result<(), Box<dyn std::error::Error>> {
if skip_if_no_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_SMOKE_13_INNER").is_ok() {
return inner_e2e_cli_capabilities_and_config().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_scenario_28_cli_capabilities_and_config")
.arg("--ignored")
.env("RUN_TEST_E2E_SMOKE_13_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_cli_capabilities_and_config() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let output = timeout(
Duration::from_secs(30),
Command::new(&rkat)
.current_dir(&project_dir)
.args(["capabilities"])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat capabilities failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let caps: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse capabilities JSON: {e}\nstdout: {stdout}"))?;
assert!(
caps.get("contract_version").is_some(),
"capabilities response should have contract_version, got: {caps}"
);
let contract_version = &caps["contract_version"];
assert!(
contract_version.get("major").is_some(),
"contract_version should have major field"
);
let capabilities = caps["capabilities"]
.as_array()
.ok_or("capabilities should be an array")?;
let has_sessions = capabilities
.iter()
.any(|c| c["id"].as_str() == Some("sessions"));
assert!(
has_sessions,
"capabilities should include 'sessions', got: {:?}",
capabilities
.iter()
.filter_map(|c| c["id"].as_str())
.collect::<Vec<_>>()
);
let output = timeout(
Duration::from_secs(30),
Command::new(&rkat)
.current_dir(&project_dir)
.args(["config", "get"])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat config get failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let config_stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!config_stdout.trim().is_empty(),
"config get output should be non-empty"
);
assert!(
config_stdout.contains('[') || config_stdout.contains('='),
"config get output should look like TOML, got: {config_stdout}"
);
let output = timeout(
Duration::from_secs(30),
Command::new(&rkat)
.current_dir(&project_dir)
.args(["config", "get", "--format", "json", "--with-generation"])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat config get --with-generation failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let envelope_stdout = String::from_utf8_lossy(&output.stdout);
let envelope: serde_json::Value =
serde_json::from_str(envelope_stdout.trim()).map_err(|e| {
format!("Failed to parse config envelope JSON: {e}\nstdout: {envelope_stdout}")
})?;
let baseline_generation = envelope["generation"]
.as_u64()
.ok_or("generation missing in config envelope")?;
assert!(
envelope.get("config").is_some(),
"config envelope should include config field"
);
let output = timeout(
Duration::from_secs(30),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"config",
"patch",
"--json",
r#"{"agent":{"max_tokens_per_turn":333}}"#,
"--expected-generation",
&baseline_generation.to_string(),
])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat config patch with expected-generation failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let patch_stdout = String::from_utf8_lossy(&output.stdout);
assert!(
patch_stdout.contains("generation="),
"config patch output should contain generation marker, got: {patch_stdout}"
);
let output = timeout(
Duration::from_secs(30),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"config",
"set",
"--toml",
"[agent]\nmodel = \"claude-opus-4-6\"\nmax_tokens_per_turn = 256\nbudget_warning_threshold = 0.8\n",
"--expected-generation",
&baseline_generation.to_string(),
])
.output(),
)
.await??;
assert!(
!output.status.success(),
"stale expected-generation write should fail"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.to_lowercase().contains("generation") || stderr.to_lowercase().contains("expected"),
"stale generation failure should mention generation mismatch, got: {stderr}"
);
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-live"]
async fn e2e_cli_structured_output() -> Result<(), Box<dyn std::error::Error>> {
if skip_if_no_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_SMOKE_14_INNER").is_ok() {
return inner_e2e_cli_structured_output().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_cli_structured_output")
.arg("--ignored")
.env("RUN_TEST_E2E_SMOKE_14_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_cli_structured_output() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let schema =
r#"{"type":"object","properties":{"answer":{"type":"integer"}},"required":["answer"]}"#;
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"What is 6 times 7? Give just the number.",
"--schema",
schema,
"--output",
"json",
])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat run with structured output failed (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("Failed to parse JSON output: {e}\nstdout: {stdout}"))?;
let structured_output = &parsed["structured_output"];
assert!(
!structured_output.is_null(),
"structured_output should be present in response, got: {parsed}"
);
let answer = structured_output.get("answer");
assert!(
answer.is_some(),
"structured_output should have 'answer' field, got: {structured_output}"
);
let answer_val = answer.unwrap();
assert!(
answer_val.is_number(),
"answer should be a number, got: {answer_val}"
);
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_001_background_job_active_turn_completion() -> Result<(), Box<dyn std::error::Error>> {
if skip_if_no_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_BG_001_INNER").is_ok() {
return inner_e2e_001_bg_active_turn().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_001_background_job_active_turn_completion")
.arg("--ignored")
.env("RUN_TEST_E2E_BG_001_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_001_bg_active_turn() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"Run `echo bg_done` in the background using the shell tool with background=true. \
Then call shell_job_status to check on it. \
Report the final status including the job_id.",
"--tools",
"full",
"--output",
"json",
"--stream",
])
.output(),
)
.await??;
assert!(
output.status.success(),
"rkat run failed (exit {:?}): {}",
output.status.code(),
String::from_utf8_lossy(&output.stderr),
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}\n{stderr}");
let has_bg_notice =
combined.contains("[BG_JOB]") || combined.contains("background_job_completed");
let has_job_id = combined.contains("job_id") || combined.contains("j_");
let has_bg_done = combined.contains("bg_done");
assert!(
has_bg_notice || has_bg_done,
"E2E-001: expected background job completion notice or output in transcript.\n\
stdout: {stdout}\nstderr: {stderr}"
);
if has_bg_notice {
assert!(
has_job_id,
"E2E-001: background notice should reference job_id (CONTRACT-002)"
);
}
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-smoke"]
async fn e2e_002_background_job_idle_keepalive_completion() -> Result<(), Box<dyn std::error::Error>>
{
if skip_if_no_api_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_E2E_BG_002_INNER").is_ok() {
return inner_e2e_002_bg_keepalive().await;
}
let temp_dir = TempDir::new()?;
let project_dir = temp_dir.path().join("project");
tokio::fs::create_dir_all(project_dir.join(".rkat")).await?;
let data_dir = temp_dir.path().join("data");
tokio::fs::create_dir_all(&data_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let status = Command::new(std::env::current_exe()?)
.arg("e2e_002_background_job_idle_keepalive_completion")
.arg("--ignored")
.env("RUN_TEST_E2E_BG_002_INNER", "1")
.env("CARGO_BIN_EXE_rkat", &rkat)
.env("HOME", temp_dir.path())
.env("XDG_DATA_HOME", &data_dir)
.env("TEST_PROJECT_DIR", &project_dir)
.env("TEST_DATA_DIR", &data_dir)
.status()
.await?;
assert!(status.success(), "inner test failed");
Ok(())
}
async fn inner_e2e_002_bg_keepalive() -> Result<(), Box<dyn std::error::Error>> {
let project_dir = PathBuf::from(std::env::var("TEST_PROJECT_DIR")?);
std::env::set_current_dir(&project_dir)?;
write_smoke_config(&project_dir).await?;
let rkat = rkat_binary_path().ok_or("rkat binary not found")?;
let stdout_file = project_dir.join("e2e_002_stdout.txt");
let stderr_file = project_dir.join("e2e_002_stderr.txt");
let stdout_f = std::fs::File::create(&stdout_file)?;
let stderr_f = std::fs::File::create(&stderr_file)?;
let mut child = Command::new(&rkat)
.current_dir(&project_dir)
.args([
"run",
"Run `sleep 2 && echo keepalive_bg_done` in the background using shell with background=true. \
Then say 'Waiting for background job.' and stop.",
"--tools",
"full",
"--keep-alive",
"--stream",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::from(stdout_f))
.stderr(std::process::Stdio::from(stderr_f))
.spawn()?;
tokio::time::sleep(Duration::from_secs(20)).await;
child.kill().await.ok();
let _ = child.wait().await;
let stdout = tokio::fs::read_to_string(&stdout_file)
.await
.unwrap_or_default();
let stderr = tokio::fs::read_to_string(&stderr_file)
.await
.unwrap_or_default();
let combined = format!("{stdout}\n{stderr}");
let has_bg_notice =
combined.contains("[BG_JOB]") || combined.contains("background_job_completed");
let has_bg_done = combined.contains("keepalive_bg_done");
assert!(
has_bg_notice || has_bg_done,
"E2E-002: expected background job completion surfaced during idle keep-alive.\n\
The runtime should have woken the session without user input (REQ-002).\n\
stdout: {stdout}\nstderr: {stderr}"
);
Ok(())
}