#![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, SessionId, open_realm_persistence_in};
use meerkat_store::RealmOrigin;
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 skip_if_no_prereqs() -> bool {
if cfg!(windows) {
eprintln!("Skipping: the slow-MCP stub uses /bin/sh");
return true;
}
if rkat_binary_path().is_none() {
eprintln!("Skipping: rkat binary not found (build with `cargo build -p meerkat-cli`)");
return true;
}
false
}
const CHECKPOINT_WEDGE_MARKERS: &[&str] = &[
"Internal error",
"failed to prepare the preflighted head-canonical intra-turn checkpoint route",
"live actor reload required",
"does not retain the committed boundary row prefix",
];
fn assert_clean_cli_output(step: &str, output: &std::process::Output) {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"{step} failed (exit {:?})\nstdout:\n{stdout}\nstderr:\n{stderr}",
output.status.code()
);
for marker in CHECKPOINT_WEDGE_MARKERS {
assert!(
!stdout.contains(marker) && !stderr.contains(marker),
"{step} surfaced the checkpoint wedge marker '{marker}'\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
}
}
async fn write_slow_mcp_config(
project_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let stub_path = project_dir.join("slow-mcp-server.sh");
tokio::fs::write(&stub_path, "#!/bin/sh\nexec 2>/dev/null\nsleep 60\n").await?;
let mcp_toml = format!(
"[[servers]]\nname = \"king-search\"\ncommand = \"/bin/sh\"\nargs = [\"{}\"]\nconnect_timeout_secs = 2\n",
stub_path.display()
);
tokio::fs::write(project_dir.join(".rkat/mcp.toml"), mcp_toml).await?;
Ok(())
}
#[tokio::test]
#[ignore = "lane:e2e-system"]
async fn integration_real_cli_mcp_pending_resume_journey() -> Result<(), Box<dyn std::error::Error>>
{
if skip_if_no_prereqs() {
return Ok(());
}
if std::env::var("RUN_TEST_CLI_MCP_PENDING_INNER").is_ok() {
return inner_test_cli_mcp_pending_resume_journey().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("integration_real_cli_mcp_pending_resume_journey")
.arg("--ignored")
.env("RUN_TEST_CLI_MCP_PENDING_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_test_cli_mcp_pending_resume_journey() -> 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")?);
let home_dir = PathBuf::from(std::env::var("HOME")?);
std::env::set_current_dir(&project_dir)?;
let mut config = Config::default();
config.agent.max_tokens_per_turn = Some(128);
let config_toml = toml::to_string_pretty(&config)?;
tokio::fs::write(project_dir.join(".rkat/config.toml"), config_toml).await?;
write_slow_mcp_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)
.env("RKAT_TEST_CLIENT", "1")
.args([
"run",
"-m",
"claude-sonnet-4-5",
"hello",
"--yolo",
"--output",
"json",
])
.output(),
)
.await??;
assert_clean_cli_output("turn one (run with pending MCP server)", &output);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|error| format!("failed to parse turn-one JSON output: {error}\n{stdout}"))?;
let session_id = parsed["session_id"]
.as_str()
.ok_or("session_id missing in turn-one response")?
.to_string();
let session_ref = parsed["session_ref"]
.as_str()
.ok_or("session_ref missing in turn-one response")?;
let realm_id = session_ref
.split_once(':')
.map(|(realm_id, _)| realm_id)
.ok_or("session_ref missing realm prefix")?
.to_string();
let realm_show = Command::new(&rkat)
.current_dir(&project_dir)
.env("HOME", &home_dir)
.env("XDG_DATA_HOME", &data_dir)
.args(["realm", "show", &realm_id])
.output()
.await?;
assert_clean_cli_output("realm show after turn one", &realm_show);
let realm_show_stdout = String::from_utf8_lossy(&realm_show.stdout);
let realms_root = realm_show_stdout
.lines()
.find_map(|line| line.strip_prefix("state_root: "))
.map(PathBuf::from)
.ok_or_else(|| format!("state_root missing in realm show output: {realm_show_stdout}"))?;
let (_manifest, persistence) =
open_realm_persistence_in(&realms_root, &realm_id, None, Some(RealmOrigin::Workspace))
.await?;
let store = persistence.session_store();
let session = store
.load(&SessionId::parse(&session_id)?)
.await?
.ok_or("session not found after turn one")?;
let turn_one_len = session.messages().len();
let pending_notices = session
.messages()
.iter()
.filter(|message| {
matches!(
message,
meerkat_core::Message::SystemNotice(notice)
if notice.kind == meerkat_core::SystemNoticeKind::McpPending
)
})
.count();
assert!(
pending_notices >= 1,
"turn one must commit the [MCP_PENDING] notice into the boundary prefix; \
the slow-MCP stub did not register as pending (transcript rows: {turn_one_len})"
);
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.env("RKAT_TEST_CLIENT", "1")
.args(["run", "--resume", &session_id, "--yolo", "Say ok again."])
.output(),
)
.await??;
assert_clean_cli_output("turn two (resume with server still pending)", &output);
let session = store
.load(&SessionId::parse(&session_id)?)
.await?
.ok_or("session not found after turn two: realm did not reopen")?;
let turn_two_len = session.messages().len();
assert!(
turn_two_len > turn_one_len,
"turn two must extend the reopened session ({turn_one_len} -> {turn_two_len})"
);
assert!(
session.messages().iter().any(|message| {
matches!(
message,
meerkat_core::Message::User(user)
if user.text_content().contains("Say ok again")
)
}),
"turn two's user prompt must be visible in the persisted session"
);
tokio::fs::remove_file(project_dir.join(".rkat/mcp.toml")).await?;
let output = timeout(
Duration::from_secs(120),
Command::new(&rkat)
.current_dir(&project_dir)
.env("RKAT_TEST_CLIENT", "1")
.args(["run", "--resume", &session_id, "--yolo", "And once more."])
.output(),
)
.await??;
assert_clean_cli_output("turn three (resume with server removed)", &output);
let session = store
.load(&SessionId::parse(&session_id)?)
.await?
.ok_or("session not found after turn three")?;
assert!(
session.messages().len() > turn_two_len,
"turn three must extend the session ({turn_two_len} -> {})",
session.messages().len()
);
assert!(
session.messages().iter().any(|message| {
matches!(
message,
meerkat_core::Message::SystemNotice(notice)
if notice.kind == meerkat_core::SystemNoticeKind::McpPending
)
}),
"the committed [MCP_PENDING] notice must remain in the transcript after both resumes"
);
Ok(())
}