use httpmock::Method::POST;
use httpmock::MockServer;
use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};
use tempfile::TempDir;
fn lernie_bin() -> std::path::PathBuf {
crate::test_support::lernie_binary()
}
const INHERITED_GIT_ENV: &[&str] = &[
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_OBJECT_DIRECTORY",
"GIT_PREFIX",
"GIT_COMMON_DIR",
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
];
fn git_command(dest: &Path, args: &[&str]) -> Command {
let mut cmd = Command::new("git");
for var in INHERITED_GIT_ENV {
cmd.env_remove(var);
}
cmd.arg("-C").arg(dest).args(args);
cmd
}
fn git_capture(dest: &Path, args: &[&str]) -> String {
let out = git_command(dest, args).output().expect("spawn git");
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
pub fn write_global_models(harness: &Path) {
let yaml = "\
models:
claude-sonnet-5:
provider: test
model_id: claude-sonnet-5
capabilities: [tool_use_native]
context_window: 200000
claude-haiku-4-5:
provider: test
model_id: claude-haiku-4-5
capabilities: [tool_use_native]
context_window: 200000
";
fs::write(harness.join("models.yaml"), yaml).unwrap();
}
pub fn write_brazen_config(dir: &Path, endpoint: &str) -> std::path::PathBuf {
let toml = format!(
"timeout = 10\n\
[[provider]]\nname = \"test\"\nbase_url = \"{endpoint}\"\n\
protocol = \"anthropic_messages\"\nauth = \"none\"\n\
body_defaults = {{ max_tokens = 64 }}\n"
);
let path = dir.join("brazen.toml");
fs::write(&path, toml).unwrap();
path
}
fn write_per_repo_roles(dest: &Path) {
let yaml = "\
roles:
worker:
provider: test
model: claude-sonnet-5
tools: [bash, read_file]
compactor:
provider: test
model: claude-haiku-4-5
";
crate::template::authoring::author(
dest,
&dest.join(".no-pools"),
"default",
crate::template::authoring::Origin::Advance,
|dir| fs::write(dir.join("providers.yaml"), yaml),
&crate::template::RealGit::new(),
)
.unwrap();
}
pub fn scaffold_repo(dest: &Path, harness: &Path) {
let out = Command::new(lernie_bin())
.arg("new")
.arg(dest)
.env("LERNIE_HOME", harness)
.output()
.expect("spawn lernie new");
assert!(
out.status.success(),
"lernie new: {}",
String::from_utf8_lossy(&out.stderr)
);
write_per_repo_roles(dest);
}
pub const HAPPY_SSE: &str = concat!(
"event: message_start\n",
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_e2e\",\"model\":\"claude-sonnet-5\",\"stop_reason\":null,\"content\":[],\"usage\":{\"input_tokens\":2,\"output_tokens\":0}}}\n\n",
"event: content_block_start\n",
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\n",
"event: content_block_delta\n",
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"pong\"}}\n\n",
"event: content_block_stop\n",
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
"event: message_delta\n",
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":1}}\n\n",
"event: message_stop\n",
"data: {\"type\":\"message_stop\"}\n\n",
);
#[test]
fn prompt_subcommand_persists_conversation_without_terminal_compaction() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(POST).path("/v1/messages");
then.status(200)
.header("content-type", "text/event-stream")
.body(HAPPY_SSE);
});
let holder = TempDir::new().unwrap();
let harness = holder.path().join("harness");
fs::create_dir_all(&harness).unwrap();
write_global_models(&harness);
let brazen_config = write_brazen_config(holder.path(), &server.base_url());
let dest = holder.path().join("conv");
scaffold_repo(&dest, &harness);
let bare = dest.join("repo.git");
let config_head_before = git_capture(&bare, &["rev-parse", "config/default"]);
let prompt_out = Command::new(lernie_bin())
.arg("prompt")
.arg(&dest)
.arg("ping")
.env("LERNIE_HOME", &harness)
.env("BRAZEN_CONFIG", &brazen_config)
.stderr(Stdio::piped())
.output()
.expect("spawn lernie prompt");
assert!(
prompt_out.status.success(),
"lernie prompt: {}",
String::from_utf8_lossy(&prompt_out.stderr)
);
let conv_id = String::from_utf8(prompt_out.stdout)
.unwrap()
.trim()
.to_string();
assert!(!conv_id.contains('/'), "stdout carries the id: {conv_id:?}");
assert_eq!(conv_id.len(), 25, "got {conv_id:?}");
let branch_ref = format!("agents/{conv_id}");
let config_head_after = git_capture(&bare, &["rev-parse", "config/default"]);
assert_eq!(
config_head_before, config_head_after,
"config/default must not advance"
);
let control_on_branch = git_command(&bare, &["show", &format!("{branch_ref}:providers.yaml")])
.output()
.expect("spawn git show");
assert!(
!control_on_branch.status.success(),
"control files must leave the agent tree (§2.2)"
);
let conv_parents = git_capture(&bare, &["log", "-1", "--pretty=%P", &branch_ref]);
assert_eq!(
conv_parents.split_whitespace().count(),
1,
"conv tip is an ordinary commit, not a terminal-compaction merge (§2.7)"
);
let summary_on_branch = git_command(&bare, &["show", &format!("{branch_ref}:summary/001.md")])
.output()
.expect("spawn git show");
assert!(
!summary_on_branch.status.success(),
"no terminal-compaction summary lands on a final response (§2.7)"
);
let step_dir = dest.join(format!("steps/{conv_id}/001"));
let request: serde_json::Value =
serde_json::from_slice(&fs::read(step_dir.join("request.json")).unwrap()).unwrap();
let user_text = request["messages"][0]["content"][0]["text"]
.as_str()
.unwrap();
assert!(
user_text.starts_with("---\nfrom: user\n"),
"got {user_text:?}"
);
assert!(user_text.contains("\ndeposited_at: "), "got {user_text:?}");
assert!(user_text.ends_with("\n---\nping"), "got {user_text:?}");
assert!(
request["system"][0]["text"]
.as_str()
.unwrap()
.starts_with("<goal>\nping\n</goal>"),
);
assert!(request["stream"].is_null());
let lines: Vec<serde_json::Value> = fs::read(step_dir.join("response.json"))
.unwrap()
.split(|b| *b == b'\n')
.filter(|l| !l.is_empty())
.map(|l| serde_json::from_slice(l).expect("valid JSON line"))
.collect();
assert_eq!(lines.first().unwrap()["type"], "message_start");
assert_eq!(lines.last().unwrap()["type"], "end");
let text = lines.iter().find(|e| e["type"] == "content_delta").unwrap();
assert_eq!(text["delta"]["text_delta"], "pong");
assert!(
git_capture(&bare, &["ls-tree", "-r", "--name-only", &branch_ref])
.lines()
.all(|l| !l.starts_with("steps/"))
);
assert!(dest.join("agents").join(&conv_id).exists());
assert!(git_capture(&bare, &["branch", "--list", &branch_ref]).contains(&conv_id));
}
#[test]
fn prompt_subcommand_surfaces_missing_repo() {
let holder = TempDir::new().unwrap();
let harness = holder.path().join("harness");
fs::create_dir_all(&harness).unwrap();
write_global_models(&harness);
let out = Command::new(lernie_bin())
.arg("prompt")
.arg(holder.path().join("does-not-exist"))
.arg("hi")
.env("LERNIE_HOME", &harness)
.stderr(Stdio::piped())
.output()
.expect("spawn lernie prompt");
assert!(!out.status.success(), "expected failure on missing repo");
assert!(String::from_utf8_lossy(&out.stderr).contains("lernie prompt"));
}