use std::path::{Path, PathBuf};
use mermaid_cli::domain::{ToolCallId, ToolStatus, TurnId};
use mermaid_cli::providers::ExecContext;
use mermaid_cli::providers::ProviderFactory;
use mermaid_cli::providers::ctx::test_exec_context_with_config;
use mermaid_cli::providers::tool::ToolExecutor;
use mermaid_cli::providers::tool::subagent::{SubagentSpawner, SubagentTool};
use mermaid_cli::providers::tool::web::WebCapabilities;
use mermaid_cli::runtime::SafetyMode;
use mermaid_cli::runtime::git::git;
use std::sync::Arc;
const MODEL: &str = "meta/muse-spark-1.2-contributor";
fn have_key() -> bool {
std::env::var("MODEL_API_KEY").is_ok_and(|k| !k.trim().is_empty())
}
fn project(tag: &str) -> Option<PathBuf> {
let dir = std::env::temp_dir().join(format!("mermaid_wt_e2e_{tag}_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
if git(&dir).args(["init", "-q"]).run().is_err() {
return None;
}
std::fs::write(dir.join("greeting.txt"), "hello\n").unwrap();
git(&dir).args(["add", "-A"]).run().unwrap();
git(&dir).args(["commit", "-qm", "init"]).run().unwrap();
Some(dir)
}
fn tool_and_ctx(workdir: &Path) -> (SubagentTool, ExecContext) {
let mut config = mermaid_cli::app::Config::default();
config.safety.mode = SafetyMode::FullAccess;
config.safety.checkpoint_on_mutation = false;
let providers = Arc::new(ProviderFactory::new(config.clone()));
let web = Arc::new(WebCapabilities::resolve(&config.web));
let tool = SubagentTool::new(Arc::new(SubagentSpawner::new(providers, web)));
let (ctx, _rx) =
test_exec_context_with_config(TurnId(1), ToolCallId(1), workdir.to_path_buf(), config);
(tool, ctx)
}
fn read(path: &Path) -> String {
std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
}
#[tokio::test]
#[ignore = "costs a real API call; runs in the integration CI job"]
async fn an_isolated_child_writes_in_its_checkout_and_the_work_lands() {
if !have_key() {
eprintln!("skipping: MODEL_API_KEY is not set");
return;
}
let Some(project) = project("lands") else {
eprintln!("skipping: git is not available");
return;
};
let (tool, ctx) = tool_and_ctx(&project);
let outcome = tool
.execute(
serde_json::json!({
"prompt": "Replace the entire contents of greeting.txt with exactly \
the single line: goodbye. Then stop and report what you did.",
"description": "rewrite greeting",
"isolation": "worktree",
"model": MODEL,
}),
ctx,
)
.await;
let report = outcome.model_content.clone();
assert_eq!(
outcome.status,
ToolStatus::Success,
"child should have succeeded and merged: {report}"
);
assert!(
report.contains("isolated worktree"),
"the parent must be told its child ran isolated: {report}"
);
assert!(
report.contains("applied to the project"),
"the merge must be reported: {report}"
);
assert_eq!(
read(&project.join("greeting.txt")).trim(),
"goodbye",
"the child's work must have landed in the project"
);
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
#[ignore = "costs a real API call; runs in the integration CI job"]
async fn a_shared_child_and_an_isolated_child_differ_in_what_the_project_sees() {
if !have_key() {
eprintln!("skipping: MODEL_API_KEY is not set");
return;
}
let Some(project) = project("visibility") else {
eprintln!("skipping: git is not available");
return;
};
let (tool, ctx) = tool_and_ctx(&project);
let shared = tool
.execute(
serde_json::json!({
"prompt": "Create a file named shared.txt containing exactly: shared. \
Then stop and report what you did.",
"description": "shared write",
"isolation": "shared",
"model": MODEL,
}),
ctx,
)
.await;
assert_eq!(
shared.status,
ToolStatus::Success,
"{}",
shared.model_content
);
assert!(
!shared.model_content.contains("isolated worktree"),
"a shared child must not claim isolation: {}",
shared.model_content
);
assert!(
project.join("shared.txt").exists(),
"a shared child writes directly into the project"
);
let _ = std::fs::remove_dir_all(&project);
}
#[tokio::test]
#[ignore = "costs a real API call; runs in the integration CI job"]
async fn parallel_isolated_children_do_not_collide() {
if !have_key() {
eprintln!("skipping: MODEL_API_KEY is not set");
return;
}
let Some(project) = project("parallel") else {
eprintln!("skipping: git is not available");
return;
};
let mut config = mermaid_cli::app::Config::default();
config.safety.mode = SafetyMode::FullAccess;
config.safety.checkpoint_on_mutation = false;
let providers = Arc::new(ProviderFactory::new(config.clone()));
let web = Arc::new(WebCapabilities::resolve(&config.web));
let spawner = Arc::new(SubagentSpawner::new(providers, web));
let names = ["alpha", "beta", "gamma"];
let mut handles = Vec::new();
for (i, name) in names.iter().enumerate() {
let tool = SubagentTool::new(spawner.clone());
let (ctx, _rx) = test_exec_context_with_config(
TurnId(1),
ToolCallId(i as u64 + 1),
project.clone(),
config.clone(),
);
let name = name.to_string();
handles.push(tokio::spawn(async move {
tool.execute(
serde_json::json!({
"prompt": format!(
"Create a file named {name}.txt containing exactly: {name}. \
Touch no other file. Then stop and report what you did."
),
"description": format!("write {name}"),
"isolation": "worktree",
"model": MODEL,
}),
ctx,
)
.await
}));
}
for handle in handles {
let outcome = handle.await.unwrap();
assert_eq!(
outcome.status,
ToolStatus::Success,
"every isolated child should land cleanly: {}",
outcome.model_content
);
}
for name in names {
let path = project.join(format!("{name}.txt"));
assert!(path.exists(), "{name}.txt should have landed");
assert_eq!(read(&path).trim(), name);
}
assert_eq!(
read(&project.join("greeting.txt")).trim(),
"hello",
"an untouched file must survive three concurrent merges"
);
let _ = std::fs::remove_dir_all(&project);
}