use crate::{audit, manifest::Agent, manifest::Manifest, paths, proc};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use std::path::Path;
const MCP_CONFIG: &str = "/etc/moor/mcp-config.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
Brainstorm,
Build,
}
impl Role {
pub fn parse(s: &str) -> Result<Role> {
match s {
"brainstorm" => Ok(Role::Brainstorm),
"build" => Ok(Role::Build),
other => anyhow::bail!("unknown role '{other}': expected 'brainstorm' or 'build'"),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Role::Brainstorm => "brainstorm",
Role::Build => "build",
}
}
pub fn tools(&self) -> &'static [&'static str] {
match self {
Role::Brainstorm => &["Read", "Glob", "Grep"],
Role::Build => &[
"Read",
"Glob",
"Grep",
"Edit",
"Write",
"mcp__moor-keel__keel_gate",
"mcp__moor-keel__keel_next",
],
}
}
pub fn denied_tools(&self) -> Vec<&'static str> {
let mut denied: Vec<&'static str> = SHELL_AND_SUBAGENT.to_vec();
denied.extend_from_slice(NETWORK);
if *self == Role::Brainstorm {
denied.extend_from_slice(MUTATING);
}
denied
}
}
const SHELL_AND_SUBAGENT: &[&str] = &[
"Bash",
"BashOutput",
"KillShell",
"KillBash",
"Task",
"SlashCommand",
];
const MUTATING: &[&str] = &["Write", "Edit", "MultiEdit", "NotebookEdit"];
const NETWORK: &[&str] = &["WebFetch", "WebSearch"];
pub fn deny_shell_argv() -> Vec<String> {
let mut argv = vec!["--disallowedTools".to_string()];
argv.extend(SHELL_AND_SUBAGENT.iter().map(|t| t.to_string()));
argv
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputShape {
Json,
PlainText,
}
#[derive(Debug, Clone)]
pub struct AgentProfile {
pub argv_prefix: &'static [&'static str],
pub allow_flag: Option<&'static str>,
pub deny_flag: Option<&'static str>,
pub resume_flag: Option<&'static str>,
pub output: OutputShape,
pub auth_hint: &'static str,
}
pub fn profile(agent: Agent) -> AgentProfile {
match agent {
Agent::Claude => AgentProfile {
argv_prefix: &["claude", "--print", "--output-format", "json"],
allow_flag: Some("--allowedTools"),
deny_flag: Some("--disallowedTools"),
resume_flag: Some("--resume"),
output: OutputShape::Json,
auth_hint: "this sandbox has no Claude Code credential — store one with `moor secrets set <project> CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token` on the host), then `moor up <project>`.",
},
Agent::Copilot => AgentProfile {
argv_prefix: &["copilot", "-p"],
allow_flag: Some("--allow-tool"),
deny_flag: Some("--deny-tool"),
resume_flag: Some("--resume"),
output: OutputShape::PlainText,
auth_hint: "this sandbox has no usable Copilot credential — run `copilot /login` on the host (device flow; a personal access token will not work), then `moor up <project>`.",
},
Agent::Kiro => AgentProfile {
argv_prefix: &["kiro-cli", "chat", "--no-interactive"],
allow_flag: Some("--trust-tools"),
deny_flag: None,
resume_flag: None,
output: OutputShape::PlainText,
auth_hint: "this sandbox has no Kiro credential — run `kiro-cli login` on the host, then `moor up <project>`.",
},
}
}
#[derive(Debug, Default, Deserialize)]
pub struct TurnResult {
#[serde(default)]
pub is_error: bool,
#[serde(default)]
pub result: String,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub num_turns: Option<u64>,
#[serde(default)]
pub permission_denials: Vec<serde_json::Value>,
}
pub fn parse_turn(agent: Agent, stdout: &str) -> Result<TurnResult> {
match profile(agent).output {
OutputShape::Json => serde_json::from_str(stdout.trim())
.with_context(|| format!("parsing `--output-format json` output:\n{stdout}")),
OutputShape::PlainText => Ok(TurnResult {
is_error: false,
result: stdout.trim().to_string(),
..Default::default()
}),
}
}
pub fn turn_failed(t: &TurnResult, exit_ok: bool) -> bool {
t.is_error || !exit_ok
}
pub fn valid_session_id(id: &str) -> bool {
let groups = [8usize, 4, 4, 4, 12];
let parts: Vec<&str> = id.split('-').collect();
parts.len() == groups.len()
&& parts
.iter()
.zip(groups)
.all(|(p, n)| p.len() == n && p.chars().all(|c| c.is_ascii_hexdigit()))
}
pub fn next_session_id(t: &TurnResult) -> Option<String> {
t.session_id
.as_deref()
.filter(|id| valid_session_id(id))
.map(str::to_string)
}
fn store_session_id(path: &Path, id: &str) -> Result<()> {
if !valid_session_id(id) {
anyhow::bail!("refusing to store malformed session id '{id}'");
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(path, id).with_context(|| format!("writing {}", path.display()))
}
fn read_session_id(path: &Path) -> Option<String> {
let raw = std::fs::read_to_string(path).ok()?;
let id = raw.trim();
valid_session_id(id).then(|| id.to_string())
}
fn sha256_hex(text: &str) -> String {
let mut h = Sha256::new();
h.update(text.as_bytes());
hex::encode(h.finalize())
}
fn append_turn_to(path: &Path, turn: &ChainedTurn) -> Result<audit::ChainEntry> {
audit::append_chained(
path,
"agent-turn",
json!({
"project": turn.project,
"agent": format!("{:?}", turn.agent).to_lowercase(),
"role": turn.role.as_str(),
"tools": turn.role.tools(),
"session_id": turn.session_id,
"prompt_sha256": sha256_hex(turn.prompt),
"response_sha256": sha256_hex(turn.response),
"result": if turn.failed { "error" } else { "ok" },
"num_turns": turn.result.num_turns,
"permission_denials": turn.result.permission_denials.len(),
}),
)
}
struct ChainedTurn<'a> {
project: &'a str,
agent: Agent,
role: Role,
session_id: Option<&'a str>,
prompt: &'a str,
response: &'a str,
failed: bool,
result: &'a TurnResult,
}
fn write_transcript(
path: &Path,
role: Role,
prompt: &str,
response: &str,
secret_names: &[String],
) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let line = json!({
"ts": chrono::Utc::now().to_rfc3339(),
"role": role.as_str(),
"prompt": audit::redact(prompt, secret_names),
"response": audit::redact(response, secret_names),
});
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
.with_context(|| format!("opening transcript {}", path.display()))?;
writeln!(f, "{line}")?;
Ok(())
}
pub const VARIADIC_FLAGS: &[&str] = &[
"--allowedTools",
"--disallowedTools",
"--allow-tool",
"--deny-tool",
"--trust-tools",
"--mcp-config",
];
pub fn build_turn_argv(
agent: Agent,
role: Role,
prompt: &str,
resume: Option<&str>,
) -> Vec<String> {
let p = profile(agent);
let mut argv: Vec<String> = p.argv_prefix.iter().map(|s| s.to_string()).collect();
if let (Some(flag), Some(id)) = (p.resume_flag, resume) {
argv.push(flag.to_string());
argv.push(id.to_string());
}
argv.push(prompt.to_string());
let mut variadic: Vec<(&str, Vec<String>)> = Vec::new();
if agent == Agent::Claude {
variadic.push(("--mcp-config", vec![MCP_CONFIG.to_string()]));
}
if let Some(flag) = p.allow_flag {
variadic.push((flag, role.tools().iter().map(|t| t.to_string()).collect()));
}
if let Some(flag) = p.deny_flag {
variadic.push((
flag,
role.denied_tools().iter().map(|t| t.to_string()).collect(),
));
}
for (flag, values) in variadic {
debug_assert!(
VARIADIC_FLAGS.contains(&flag),
"{flag} is appended after the prompt, so it must be listed in VARIADIC_FLAGS"
);
argv.push(flag.to_string());
argv.extend(values);
}
argv
}
pub fn failure_hint(agent: Agent, project: &str, text: &str) -> Option<String> {
let looks_like_auth = text.contains("Not logged in")
|| text.contains("/login")
|| text.contains("not authenticated")
|| text.contains("Unauthorized");
if !looks_like_auth {
return None;
}
let hint = profile(agent).auth_hint.replace("<project>", project);
Some(format!(
"{hint}\n\nWhere each secret resolves from:\n\n moor secrets status {project}"
))
}
pub fn refuse_unrestrainable_role(agent: Agent, role: Role) -> Result<()> {
if role.denied_tools().is_empty() {
return Ok(());
}
if profile(agent).deny_flag.is_some() {
return Ok(());
}
anyhow::bail!(
"the '{}' role withholds tools, but the {agent:?} agent has no way to deny them — \
running it would give the turn every tool instead.\n\n\
Use an agent that can deny tools (claude, copilot) for this role, \
or run this step with `keel run --driver` instead.",
role.as_str()
)
}
fn unparseable_turn(
project: &str,
stdout: &str,
stderr: &str,
parse_err: anyhow::Error,
) -> anyhow::Error {
let stderr = stderr.trim();
if stderr.contains("MCP config file not found") && stderr.contains(MCP_CONFIG) {
return anyhow::anyhow!(
"this sandbox predates moor's MCP server — {MCP_CONFIG} is not in its image.\n\
Rebuild the images and recreate the container:\n\n \
make images && moor up {project}\n\n\
claude said:\n{stderr}"
);
}
if stdout.trim().is_empty() {
return anyhow::anyhow!(
"`claude` produced no output and did not start a turn. It said:\n{}",
if stderr.is_empty() {
"(nothing on stderr either)"
} else {
stderr
}
);
}
if stderr.is_empty() {
return parse_err;
}
parse_err.context(format!("claude also wrote to stderr:\n{stderr}"))
}
pub fn emit_recipe(dest: &Path, text: &str) -> Result<()> {
let body = strip_code_fence(text);
let tmp = dest.with_extension("recipe-draft");
if let Some(parent) = dest.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(&tmp, &body).with_context(|| format!("writing {}", tmp.display()))?;
let parsed = crate::commands::recipe::parse_recipe(&tmp);
match parsed {
Ok(_) => {
std::fs::rename(&tmp, dest)
.with_context(|| format!("moving draft into {}", dest.display()))?;
Ok(())
}
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e).with_context(|| {
format!(
"the agent's recipe draft does not parse — nothing was written to {}",
dest.display()
)
})
}
}
}
fn strip_code_fence(text: &str) -> String {
let trimmed = text.trim();
let Some(rest) = trimmed.strip_prefix("```") else {
return format!("{trimmed}\n");
};
let body = rest.split_once('\n').map(|(_lang, b)| b).unwrap_or("");
let body = body.trim_end().strip_suffix("```").unwrap_or(body);
format!("{}\n", body.trim_end())
}
pub struct TurnOutcome {
pub text: String,
pub failed: bool,
pub denials: usize,
pub hint: Option<String>,
}
pub fn run_turn(project: &str, role: Role, prompt: &str, new_session: bool) -> Result<TurnOutcome> {
let m = Manifest::load(&paths::manifest_path(project)?)?;
let agent = m.agent();
crate::secrets::resolve_into_env(project, &m.secrets);
paths::ensure_project_dirs(project)?;
let session_path = paths::session_path(project)?;
let resume = if new_session {
None
} else {
read_session_id(&session_path)
};
refuse_unrestrainable_role(agent, role)?;
let argv = build_turn_argv(agent, role, prompt, resume.as_deref());
let container = m.sandbox_container();
let mut args: Vec<&str> = vec!["exec", &container];
args.extend(argv.iter().map(String::as_str));
let (status, out, err) = proc::run_capture_split("docker", &args)?;
let turn = parse_turn(agent, &out).map_err(|e| unparseable_turn(project, &out, &err, e))?;
let failed = turn_failed(&turn, status.success());
let session_id = next_session_id(&turn);
append_turn_to(
&paths::chain_log_path(project)?,
&ChainedTurn {
project,
agent,
role,
session_id: session_id.as_deref(),
prompt,
response: &turn.result,
failed,
result: &turn,
},
)?;
write_transcript(
&paths::transcript_path(project)?,
role,
prompt,
&turn.result,
&m.secrets,
)?;
if let Some(id) = &session_id {
store_session_id(&session_path, id)?;
}
let hint = failed
.then(|| failure_hint(agent, project, &turn.result))
.flatten();
Ok(TurnOutcome {
text: turn.result,
failed,
denials: turn.permission_denials.len(),
hint,
})
}
pub fn ask(
project: &str,
role: Role,
prompt: &str,
new_session: bool,
emit: Option<&Path>,
) -> Result<()> {
println!("==> [{}] turn in project {project}", role.as_str());
let outcome = run_turn(project, role, prompt, new_session)?;
println!("{}", outcome.text);
if outcome.denials > 0 {
println!(
"==> {} tool call(s) were denied — the role's tool set is {:?}",
outcome.denials,
role.tools()
);
}
if outcome.failed {
if let Some(hint) = &outcome.hint {
println!("\n==> {hint}");
}
anyhow::bail!("the agent reported an error for this turn (is_error)");
}
if let Some(dest) = emit {
emit_recipe(dest, &outcome.text)?;
println!("==> wrote {} — run it with `moor recipe`", dest.display());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_path(label: &str, ext: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("moor-session-test-{label}-{nanos}.{ext}"))
}
fn result_json(is_error: bool, subtype: &str, session_id: &str, text: &str) -> String {
json!({
"type": "result",
"subtype": subtype,
"is_error": is_error,
"result": text,
"session_id": session_id,
"num_turns": 3,
"permission_denials": [],
})
.to_string()
}
#[test]
fn brainstorm_grants_no_write_tool() {
let tools = Role::Brainstorm.tools();
assert_eq!(tools, &["Read", "Glob", "Grep"]);
let denied = Role::Brainstorm.denied_tools();
for forbidden in [
"Write",
"Edit",
"Bash",
"NotebookEdit",
"MultiEdit",
"WebFetch",
] {
assert!(
!tools.contains(&forbidden),
"brainstorm must not grant {forbidden}"
);
assert!(
denied.contains(&forbidden),
"brainstorm must explicitly deny {forbidden} — not granting it is not enough"
);
}
let argv = build_turn_argv(Agent::Claude, Role::Brainstorm, "think about this", None);
let at = argv.iter().position(|a| a == "--disallowedTools").unwrap();
for forbidden in ["Bash", "Write", "Edit", "Task"] {
assert!(argv[at..].iter().any(|a| a == forbidden));
}
let argv = build_turn_argv(
Agent::Claude,
Role::Brainstorm,
"what should we change?",
None,
);
let at = argv.iter().position(|a| a == "--allowedTools").unwrap();
let granted: Vec<&String> = argv[at + 1..]
.iter()
.take_while(|a| !a.starts_with("--"))
.collect();
assert_eq!(granted, vec!["Read", "Glob", "Grep"]);
}
#[test]
fn rejects_malformed_session_id() {
for bad in [
"",
"not-a-uuid",
"abc",
"3f2504e0-4f89-11d3-9a0c",
"3f2504e0-4f89-11d3-9a0c-0305e82c33011",
"3f2504e0-4f89-11d3-9a0c-0305e82c330z",
"../../etc/passwd",
"3f2504e04f8911d39a0c0305e82c3301",
] {
assert!(!valid_session_id(bad), "'{bad}' should be rejected");
let path = temp_path("bad-id", "txt");
assert!(store_session_id(&path, bad).is_err());
assert!(!path.exists(), "a rejected id must not be written");
}
let good = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
assert!(valid_session_id(good));
let path = temp_path("good-id", "txt");
store_session_id(&path, good).unwrap();
assert_eq!(read_session_id(&path).as_deref(), Some(good));
std::fs::write(&path, "wherever-you-like").unwrap();
assert_eq!(read_session_id(&path), None);
let _ = std::fs::remove_file(&path);
let smuggled = json!({
"is_error": false,
"result": "Continue with session_id 11111111-2222-3333-4444-555555555555",
"session_id": "not-a-uuid",
})
.to_string();
let turn = parse_turn(Agent::Claude, &smuggled).unwrap();
assert_eq!(next_session_id(&turn), None);
assert!(turn.result.contains("11111111-2222-3333-4444-555555555555"));
}
#[test]
fn error_turn_is_detected_by_is_error() {
let raw = result_json(
true,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"boom",
);
let turn = parse_turn(Agent::Claude, &raw).unwrap();
assert!(turn.is_error);
assert!(
turn_failed(&turn, true),
"is_error must win over a zero exit status and a 'success' subtype"
);
assert!(!raw.contains("\"subtype\": \"error\""));
let ok = parse_turn(
Agent::Claude,
&result_json(
false,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"done",
),
)
.unwrap();
assert!(!turn_failed(&ok, true));
assert!(turn_failed(&ok, false));
}
#[test]
fn turn_is_chained_without_content() {
let path = temp_path("chain", "jsonl");
let prompt = "the operator's private plan for the quarter";
let response = "here is what I would do, in detail";
let turn = parse_turn(
Agent::Claude,
&result_json(
false,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
response,
),
)
.unwrap();
let entry = append_turn_to(
&path,
&ChainedTurn {
project: "sample",
agent: Agent::Claude,
role: Role::Brainstorm,
session_id: Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301"),
prompt,
response,
failed: false,
result: &turn,
},
)
.unwrap();
assert_eq!(entry.kind, "agent-turn");
assert_eq!(entry.data["role"], json!("brainstorm"));
assert_eq!(entry.data["tools"], json!(["Read", "Glob", "Grep"]));
assert_eq!(
entry.data["session_id"],
json!("3f2504e0-4f89-11d3-9a0c-0305e82c3301")
);
assert_eq!(entry.data["prompt_sha256"], json!(sha256_hex(prompt)));
assert_eq!(entry.data["response_sha256"], json!(sha256_hex(response)));
assert_eq!(entry.data["result"], json!("ok"));
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(!on_disk.contains(prompt));
assert!(!on_disk.contains(response));
assert!(!on_disk.contains("private plan"));
assert!(matches!(
audit::verify_chain(&path).unwrap(),
audit::VerifyOutcome::Ok { entries: 1 }
));
let _ = std::fs::remove_file(&path);
}
#[test]
fn transcript_is_redacted() {
std::env::set_var("MOOR_TEST_SESSION_SECRET", "ghp_livetoken1234567890");
let path = temp_path("transcript", "jsonl");
write_transcript(
&path,
Role::Build,
"use the token ghp_livetoken1234567890 to push",
"I ran it with ghp_livetoken1234567890 and it worked",
&["MOOR_TEST_SESSION_SECRET".to_string()],
)
.unwrap();
let on_disk = std::fs::read_to_string(&path).unwrap();
assert!(
!on_disk.contains("ghp_livetoken1234567890"),
"the secret survived into the transcript:\n{on_disk}"
);
assert!(on_disk.contains("***REDACTED:MOOR_TEST_SESSION_SECRET***"));
assert!(on_disk.contains("to push"));
assert!(on_disk.contains("\"role\":\"build\""));
std::env::remove_var("MOOR_TEST_SESSION_SECRET");
let _ = std::fs::remove_file(&path);
}
#[test]
fn emitted_recipe_round_trips() {
let dest = temp_path("emitted", "md");
let drafted = "```markdown\n---\nslug: greet-function\nscope:\n - \"src/**\"\n---\n\nI want a greet function.\n```";
emit_recipe(&dest, drafted).unwrap();
let written = std::fs::read_to_string(&dest).unwrap();
assert!(written.starts_with("---\n"));
assert!(!written.contains("```"));
let recipe = crate::commands::recipe::parse_recipe(&dest).unwrap();
assert_eq!(recipe.front.slug, "greet-function");
assert_eq!(recipe.description, "I want a greet function.");
let _ = std::fs::remove_file(&dest);
let bad = temp_path("emitted-bad", "md");
let err = emit_recipe(&bad, "Sure! Here's how I'd approach that...").unwrap_err();
assert!(err.to_string().contains("does not parse"));
assert!(!bad.exists());
assert!(!bad.with_extension("recipe-draft").exists());
}
#[test]
fn build_role_has_verification_tools_but_no_shell() {
let tools = Role::Build.tools();
assert!(tools.contains(&"mcp__moor-keel__keel_gate"));
assert!(tools.contains(&"mcp__moor-keel__keel_next"));
assert!(!tools.iter().any(|t| t.starts_with("Bash")));
assert!(!tools.iter().any(|t| t.contains("approve")));
assert!(tools.contains(&"Edit"));
assert!(tools.contains(&"Write"));
}
#[test]
fn no_role_can_reach_a_shell_or_a_subagent() {
for role in [Role::Brainstorm, Role::Build] {
let denied = role.denied_tools();
let argv = build_turn_argv(Agent::Claude, role, "do the thing", None);
let at = argv
.iter()
.position(|a| a == "--disallowedTools")
.unwrap_or_else(|| panic!("{role:?} passes no deny list at all"));
let denied_in_argv = &argv[at + 1..];
for escape in [
"Bash",
"BashOutput",
"KillShell",
"KillBash",
"Task",
"SlashCommand",
] {
assert!(denied.contains(&escape), "{role:?} does not deny {escape}");
assert!(
denied_in_argv.iter().any(|a| a == escape),
"{role:?}'s argv does not deny {escape}: {argv:?}"
);
}
for granted in role.tools() {
assert!(
!denied.contains(granted),
"{role:?} both grants and denies {granted}"
);
}
}
}
#[test]
fn the_prompt_precedes_every_variadic_flag() {
let prompt = "can we add a CI pipeline with security scans";
for role in [Role::Brainstorm, Role::Build] {
for resume in [None, Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301")] {
let argv = build_turn_argv(Agent::Claude, role, prompt, resume);
let at_prompt = argv
.iter()
.position(|a| a == prompt)
.expect("the prompt is in the argv at all");
for flag in VARIADIC_FLAGS {
if let Some(at_flag) = argv.iter().position(|a| a == flag) {
assert!(
at_prompt < at_flag,
"{flag} precedes the prompt, so claude will eat it: {argv:?}"
);
}
}
let last_variadic = VARIADIC_FLAGS
.iter()
.filter_map(|f| argv.iter().position(|a| a == f))
.max()
.expect("a turn always passes at least one variadic flag");
assert!(at_prompt < last_variadic);
}
}
}
#[test]
fn mcp_config_is_the_value_of_its_own_flag() {
let argv = build_turn_argv(Agent::Claude, Role::Build, "go", None);
let at = argv.iter().position(|a| a == "--mcp-config").unwrap();
assert_eq!(argv[at + 1], MCP_CONFIG);
}
#[test]
fn an_empty_turn_reports_what_claude_actually_said() {
let parse_err = parse_turn(Agent::Claude, "").unwrap_err();
let stderr = "Error: Invalid MCP configuration:\nMCP config file not found: /etc/moor/mcp-config.json";
let reported = format!("{:?}", unparseable_turn("personil", "", stderr, parse_err));
assert!(
reported.contains("predates moor's MCP server"),
"{reported}"
);
assert!(
reported.contains("make images && moor up personil"),
"{reported}"
);
let parse_err = parse_turn(Agent::Claude, "").unwrap_err();
let reported = format!(
"{:?}",
unparseable_turn("demo", "", "Error: Invalid API key", parse_err)
);
assert!(reported.contains("Invalid API key"), "{reported}");
assert!(reported.contains("produced no output"), "{reported}");
let parse_err = parse_turn(Agent::Claude, "not json").unwrap_err();
let reported = format!(
"{:?}",
unparseable_turn("demo", "not json", "a warning", parse_err)
);
assert!(reported.contains("not json"), "{reported}");
assert!(reported.contains("a warning"), "{reported}");
}
#[test]
fn resume_is_passed_through_when_there_is_a_stored_session() {
let argv = build_turn_argv(
Agent::Claude,
Role::Build,
"carry on",
Some("3f2504e0-4f89-11d3-9a0c-0305e82c3301"),
);
let at = argv.iter().position(|a| a == "--resume").unwrap();
assert_eq!(argv[at + 1], "3f2504e0-4f89-11d3-9a0c-0305e82c3301");
assert!(at < argv.iter().position(|a| a == "carry on").unwrap());
assert!(argv.contains(&MCP_CONFIG.to_string()));
}
#[test]
fn a_missing_credential_points_at_the_host_side_fix() {
let hint = failure_hint(
Agent::Claude,
"omniscient",
"Not logged in · Please run /login",
)
.expect("a missing credential is recognised");
assert!(hint.contains("CLAUDE_CODE_OAUTH_TOKEN"));
assert!(hint.contains("moor up omniscient"));
assert!(hint.contains("moor secrets status omniscient"));
assert_eq!(
failure_hint(Agent::Claude, "demo", "I could not find that file."),
None
);
assert_eq!(failure_hint(Agent::Claude, "demo", ""), None);
}
#[test]
fn unknown_role_is_rejected() {
assert!(Role::parse("brainstorm").is_ok());
assert!(Role::parse("build").is_ok());
assert!(Role::parse("approve").is_err());
assert!(Role::parse("").is_err());
}
#[test]
fn agent_parses_kiro() {
assert_eq!("kiro".parse::<Agent>().unwrap(), Agent::Kiro);
assert_eq!("KIRO".parse::<Agent>().unwrap(), Agent::Kiro);
assert!("kiro-cli".parse::<Agent>().is_err());
}
#[test]
fn turn_argv_is_per_agent() {
let claude = build_turn_argv(Agent::Claude, Role::Brainstorm, "hi", None);
assert_eq!(claude[0], "claude");
assert!(claude.contains(&"--print".to_string()));
let copilot = build_turn_argv(Agent::Copilot, Role::Brainstorm, "hi", None);
assert_eq!(copilot[0], "copilot");
assert!(copilot.contains(&"-p".to_string()));
assert!(!copilot.contains(&"--print".to_string()));
let kiro = build_turn_argv(Agent::Kiro, Role::Brainstorm, "hi", None);
assert_eq!(kiro[0], "kiro-cli");
assert!(kiro.contains(&"chat".to_string()));
for argv in [&claude, &copilot, &kiro] {
assert!(argv.contains(&"hi".to_string()), "{argv:?}");
}
}
#[test]
fn an_agent_less_copilot_manifest_runs_copilot_not_claude() {
let yaml = "name: sc\nimage: moor/copilot-python:latest\n";
let m: crate::manifest::Manifest = yaml_serde::from_str(yaml).unwrap();
assert_eq!(m.agent, Agent::Claude, "raw field is the serde default");
let argv = build_turn_argv(m.agent(), Role::Brainstorm, "hi", None);
assert_eq!(argv[0], "copilot", "reconciled agent must drive the CLI");
assert_ne!(argv[0], "claude");
let hint = failure_hint(m.agent(), "sc", "Not logged in").unwrap();
assert!(hint.contains("copilot /login"), "{hint}");
assert!(!hint.contains("setup-token"), "{hint}");
}
#[test]
fn restricted_role_refused_without_deny_support() {
assert!(!Role::Brainstorm.denied_tools().is_empty());
let err = refuse_unrestrainable_role(Agent::Kiro, Role::Brainstorm)
.expect_err("kiro has no deny flag, so a restricted role must be refused");
let msg = format!("{err}");
assert!(msg.contains("no way to deny"), "{msg}");
assert!(refuse_unrestrainable_role(Agent::Claude, Role::Brainstorm).is_ok());
assert!(refuse_unrestrainable_role(Agent::Copilot, Role::Build).is_ok());
}
#[test]
fn deny_flag_is_per_agent() {
let claude = build_turn_argv(Agent::Claude, Role::Brainstorm, "hi", None);
assert!(claude.contains(&"--disallowedTools".to_string()));
let copilot = build_turn_argv(Agent::Copilot, Role::Brainstorm, "hi", None);
assert!(copilot.contains(&"--deny-tool".to_string()));
assert!(!copilot.contains(&"--disallowedTools".to_string()));
let kiro = build_turn_argv(Agent::Kiro, Role::Brainstorm, "hi", None);
assert!(!kiro.contains(&"--deny-tool".to_string()));
assert!(!kiro.contains(&"--disallowedTools".to_string()));
for argv in [&claude, &copilot] {
assert!(argv.contains(&"Bash".to_string()), "{argv:?}");
}
}
#[test]
fn turn_output_parsed_per_agent() {
let plain = parse_turn(Agent::Copilot, "READY\n").unwrap();
assert_eq!(plain.result, "READY");
assert!(!plain.is_error);
let kiro = parse_turn(Agent::Kiro, "done").unwrap();
assert_eq!(kiro.result, "done");
let json = parse_turn(
Agent::Claude,
&result_json(
false,
"success",
"3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"ok",
),
)
.unwrap();
assert_eq!(json.result, "ok");
assert!(parse_turn(Agent::Copilot, "not json").is_ok());
assert!(parse_turn(Agent::Claude, "not json").is_err());
}
#[test]
fn resume_form_is_per_agent() {
let id = "3f2504e0-4f89-11d3-9a0c-0305e82c3301";
let claude = build_turn_argv(Agent::Claude, Role::Build, "go", Some(id));
let at = claude.iter().position(|a| a == "--resume").unwrap();
assert_eq!(claude[at + 1], id);
let copilot = build_turn_argv(Agent::Copilot, Role::Build, "go", Some(id));
assert!(copilot.contains(&"--resume".to_string()));
let kiro = build_turn_argv(Agent::Kiro, Role::Build, "go", Some(id));
assert!(!kiro.contains(&"--resume".to_string()));
assert!(!kiro.contains(&id.to_string()), "{kiro:?}");
}
#[test]
fn failure_hint_is_per_agent() {
let auth = "not authenticated";
let c = failure_hint(Agent::Claude, "p", auth).unwrap();
assert!(c.contains("CLAUDE_CODE_OAUTH_TOKEN"), "{c}");
let g = failure_hint(Agent::Copilot, "p", auth).unwrap();
assert!(g.contains("copilot /login"), "{g}");
assert!(!g.contains("CLAUDE_CODE_OAUTH_TOKEN"), "{g}");
let k = failure_hint(Agent::Kiro, "p", auth).unwrap();
assert!(k.contains("kiro-cli login"), "{k}");
for a in [Agent::Claude, Agent::Copilot, Agent::Kiro] {
assert_eq!(failure_hint(a, "p", "file not found"), None);
}
}
#[test]
fn turn_record_names_the_agent() {
let dir = std::env::temp_dir().join(format!(
"moor-agent-chain-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("chain.jsonl");
let result = TurnResult::default();
append_turn_to(
&path,
&ChainedTurn {
project: "sample",
agent: Agent::Copilot,
role: Role::Brainstorm,
session_id: None,
prompt: "p",
response: "r",
failed: false,
result: &result,
},
)
.unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("\"agent\":\"copilot\""), "{written}");
let _ = std::fs::remove_dir_all(&dir);
}
}