use std::path::{Path, PathBuf};
use std::time::Duration;
use serde_json::Value;
pub mod claude;
pub mod codex;
pub mod omp;
pub mod opencode;
pub mod sim;
pub mod wiring;
pub trait Harness: Sync {
fn name(&self) -> &'static str;
fn caps(&self) -> Caps;
fn wiring(&self) -> Wiring;
fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf;
fn probe(&self) -> Probe;
fn hidden(&self) -> bool {
false
}
fn normalise(&self, raw: Value) -> Value {
raw
}
fn checks(&self, _target: &Path) -> Vec<(Level, String)> {
Vec::new()
}
fn install_notes(&self) -> Vec<String> {
Vec::new()
}
fn sub_runs(&self, _home: &Path, _session: Option<&str>, _since: Option<i64>) -> Vec<Run> {
Vec::new()
}
fn budgets(&self) -> Option<Budgets> {
None
}
}
#[derive(Debug, Clone, Default)]
pub struct SubAgent {
pub id: String,
pub parent: String,
pub label: String,
pub phase: String,
pub kind: String,
pub started: i64,
pub cwd: String,
pub prompt: String,
pub work: String,
pub final_text: String,
pub output: Option<Value>,
}
impl SubAgent {
pub fn called(&mut self, input: &Value) {
self.work.push_str(&format!("{input}\n"));
}
pub fn said(&mut self, t: &str) {
self.work.push_str(&format!("{t}\n"));
self.final_text = t.to_string();
}
}
#[derive(Debug, Clone, Default)]
pub struct Run {
pub id: String,
pub session: String,
pub name: String,
pub started: i64,
pub loose: bool,
pub agents: Vec<SubAgent>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Caps {
pub prompt_context: bool,
pub tool_context: bool,
pub early_delivery: bool,
pub question_dialog: bool,
pub transcript: Transcript,
pub deny_capture: bool,
pub sends_pid: bool,
pub stop_budget: Option<Duration>,
pub context_chars: usize,
pub session_env: Option<&'static str>,
pub pid_env: Option<&'static str>,
pub drone_wake: DroneWake,
}
impl Caps {
#[must_use]
pub const fn context_chars(self) -> usize {
self.context_chars
}
}
pub const CONTEXT_GUARD: usize = if claude::CONTEXT_CHARS < codex::CONTEXT_CHARS { claude::CONTEXT_CHARS } else { codex::CONTEXT_CHARS };
pub const STOP_TIMEOUT_S: u64 = 90;
pub const OUTPUT_TAIL: usize = 4000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Budgets {
pub prompt: Duration,
pub pre_tool: Duration,
pub post_tool: Duration,
pub stop: Duration,
pub end: Duration,
}
impl Budgets {
#[must_use]
pub fn json(self) -> Value {
let ms = |d: Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
serde_json::json!({
"prompt_ms": ms(self.prompt),
"pre_ms": ms(self.pre_tool),
"post_ms": ms(self.post_tool),
"stop_ms": ms(self.stop),
"end_ms": ms(self.end),
"tail_chars": OUTPUT_TAIL,
})
}
}
pub fn with_budgets(h: &dyn Harness, input: &Value, out: Option<Value>) -> Option<Value> {
let Some(b) = h.budgets().filter(|_| input.get("budgets") == Some(&Value::Bool(true))) else { return out };
let mut o = out.filter(Value::is_object).unwrap_or_else(|| serde_json::json!({}));
o["budgets"] = b.json();
Some(o)
}
pub fn longest_stop() -> Duration {
let stop_timeout = |h: &dyn Harness| match h.wiring() {
Wiring::CommandHooks { timeouts, .. } => timeouts.iter().filter(|(e, _)| *e == "stop").map(|(_, s)| Duration::from_secs(*s)).max(),
Wiring::AdapterFile { .. } => None,
};
ALL.iter().flat_map(|h| [Some(h.caps().stop_budget.unwrap_or(DEFAULT_STOP_BUDGET)), stop_timeout(*h)]).flatten().max().unwrap_or(DEFAULT_STOP_BUDGET)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DroneWake {
Model,
Plugin,
Queue,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transcript {
Claude,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Wiring {
CommandHooks {
events: &'static [(&'static str, &'static str)],
timeouts: &'static [(&'static str, u64)],
retired: &'static [(&'static str, &'static str)],
through_links: bool,
},
AdapterFile { source: &'static str },
}
impl Wiring {
pub fn adapter(self) -> &'static str {
match self {
Wiring::AdapterFile { source } => source,
Wiring::CommandHooks { .. } => "",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Probe {
pub cli: &'static str,
pub tested: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Level {
Ok,
Warn,
Fail,
}
pub const DEFAULT_STOP_BUDGET: Duration = Duration::from_secs(60);
pub const TEST_ENV: &str = "BORG_TEST_HARNESS";
pub static ALL: &[&dyn Harness] = &[&claude::Claude, &omp::Omp, &opencode::Opencode, &codex::Codex, &sim::Sim];
pub fn by_name(name: &str) -> Option<&'static dyn Harness> {
let hidden_too = std::env::var_os(TEST_ENV).is_some();
ALL.iter().copied().find(|h| h.name() == name && (hidden_too || !h.hidden()))
}
pub fn visible() -> impl Iterator<Item = &'static dyn Harness> {
ALL.iter().copied().filter(|h| !h.hidden())
}
pub fn names(sep: &str) -> String {
visible().map(|h| h.name()).collect::<Vec<_>>().join(sep)
}
pub fn current() -> &'static dyn Harness {
named_current().unwrap_or(ALL[0])
}
pub fn named_current() -> Option<&'static dyn Harness> {
std::env::var("BORG_HARNESS").ok().and_then(|n| by_name(&n))
}
pub fn session_vars() -> Vec<(&'static str, &'static str)> {
ALL.iter().filter_map(|h| h.caps().session_env.map(|v| (v, h.name()))).collect()
}
pub fn env_sessions() -> Vec<(String, &'static str)> {
let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
session_vars().into_iter().filter_map(|(k, h)| var(k).map(|v| (v, h))).collect()
}
pub fn env_pids() -> Vec<(u32, &'static str)> {
let pid = |k: &str| std::env::var(k).ok().and_then(|v| v.trim().parse::<u32>().ok());
ALL.iter().filter_map(|h| h.caps().pid_env.and_then(pid).map(|p| (p, h.name()))).collect()
}
pub fn transcript(caps: Caps, ev: &Value) -> Option<&str> {
match caps.transcript {
Transcript::Claude => ev.get("transcript_path").and_then(Value::as_str).filter(|t| !t.is_empty()),
Transcript::None => None,
}
}
pub fn work_so_far(caps: Caps, ev: &Value, turn_log: &Path, cap: usize) -> String {
if let Some(t) = transcript(caps, ev).filter(|t| std::fs::metadata(t).is_ok()) {
return claude::turn_so_far(t.as_ref(), cap);
}
let text = std::fs::read_to_string(turn_log).unwrap_or_default();
let n = text.chars().count();
text.chars().skip(n.saturating_sub(cap)).collect()
}
pub fn last_message(caps: Caps, ev: &Value) -> String {
match given(ev) {
Some(t) => t.to_string(),
None => claude::last_assistant_full(transcript(caps, ev).unwrap_or_default().as_ref()),
}
}
pub fn plan(caps: Caps, ev: &Value) -> String {
match given(ev) {
Some(t) => t.to_string(),
None => claude::last_assistant_text(transcript(caps, ev).unwrap_or_default().as_ref()),
}
}
fn given(ev: &Value) -> Option<&str> {
ev.get("last_assistant_message").and_then(Value::as_str).filter(|t| !t.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn caps_are_todays() {
let row = |n: &str| {
let c = by_name(n).unwrap().caps();
(c.prompt_context, c.tool_context, c.early_delivery, c.question_dialog, c.transcript, c.deny_capture, c.sends_pid)
};
assert_eq!(row("claude"), (true, true, true, true, Transcript::Claude, true, false));
for n in ["omp", "opencode"] {
assert_eq!(row(n), (true, true, false, false, Transcript::None, true, true), "{n}");
}
assert_eq!(row("codex"), (true, true, false, false, Transcript::None, false, false));
let budgets = [("claude", None), ("omp", Some(28)), ("opencode", Some(90)), ("codex", Some(90)), ("sim", None)];
let got: Vec<(&str, Option<u64>)> = ALL.iter().map(|h| (h.name(), h.caps().stop_budget.map(|d| d.as_secs()))).collect();
assert_eq!(got, budgets, "every harness's stop budget");
let Wiring::CommandHooks { timeouts, through_links, .. } = by_name("claude").unwrap().wiring() else { panic!("claude has command hooks") };
assert_eq!((timeouts, through_links), (&[("stop", 90)][..], true), "a timeout on the stop only");
let chars: Vec<(&str, usize)> = ALL.iter().map(|h| (h.name(), h.caps().context_chars())).collect();
assert_eq!(chars, [("claude", 10_000), ("omp", 10_000), ("opencode", 10_000), ("codex", 10_000), ("sim", 10_000)]);
}
#[test]
fn only_an_adapters_harness_has_budgets_and_its_stop_is_its_caps_budget() {
for h in ALL {
let adapter = matches!(h.wiring(), Wiring::AdapterFile { .. });
assert_eq!(h.budgets().is_some(), adapter, "{}", h.name());
if let Some(b) = h.budgets() {
assert_eq!(h.caps().stop_budget, Some(b.stop), "{}", h.name());
assert!(b.stop <= Duration::from_secs(STOP_TIMEOUT_S) && b.pre_tool <= b.prompt, "{}: {b:?}", h.name());
}
}
}
#[test]
fn budgets_join_an_answer_only_when_an_adapter_asked() {
let omp = by_name("omp").unwrap();
let asked = serde_json::json!({"session_id": "s", "budgets": true});
let b = omp.budgets().unwrap();
let want = serde_json::json!({"prompt_ms": 5000, "pre_ms": 2000, "post_ms": 5000, "stop_ms": 28_000, "end_ms": 1500, "tail_chars": 4000});
assert_eq!(b.json(), want);
assert_eq!(with_budgets(omp, &asked, None), Some(serde_json::json!({"budgets": want})), "an answer of its own when borg says nothing");
let block = serde_json::json!({"decision": "block", "reason": "r"});
let both = with_budgets(omp, &asked, Some(block.clone())).unwrap();
assert_eq!((&both["decision"], &both["reason"], &both["budgets"]), (&block["decision"], &block["reason"], &want), "added to the answer");
let plain = serde_json::json!({"session_id": "s"});
assert_eq!(with_budgets(omp, &plain, Some(block.clone())), Some(block.clone()), "not asked: as it was");
assert_eq!(with_budgets(omp, &plain, None), None);
assert_eq!(with_budgets(by_name("claude").unwrap(), &asked, None), None, "no adapter, no budgets");
}
#[test]
fn each_adapters_fallback_budgets_are_its_harness_budgets() {
for h in ALL {
let Some(b) = h.budgets() else { continue };
let src = h.wiring().adapter();
let line = src.lines().find(|l| l.starts_with("const FALLBACK")).unwrap_or_else(|| panic!("{}: no `const FALLBACK` line", h.name()));
let body = line.split_once('{').and_then(|(_, r)| r.split_once('}')).map(|(b, _)| b).unwrap_or_default();
let pairs = body
.split(',')
.filter_map(|kv| kv.split_once(':'))
.map(|(k, v)| (k.trim().to_string(), serde_json::json!(v.trim().replace('_', "").parse::<u64>().unwrap())));
assert_eq!(Value::Object(pairs.collect()), b.json(), "{}'s adapter", h.name());
}
}
#[test]
fn each_adapter_reads_every_budget_borg_sends_it() {
let mut adapters = 0;
for h in ALL {
let Some(b) = h.budgets() else { continue };
adapters += 1;
let code: String = h.wiring().adapter().lines().filter(|l| !["//", "/*", "*"].iter().any(|c| l.trim_start().starts_with(c))).collect();
let sent = b.json();
let keys: Vec<&String> = sent.as_object().map(|o| o.keys().collect()).unwrap_or_default();
assert!(!keys.is_empty(), "{sent}");
for k in keys {
assert!(code.contains(&format!("\"{k}\"")) || code.contains(&format!("budgets.{k}")), "{}'s adapter never reads {k}", h.name());
}
}
assert_eq!(adapters, 2, "omp and opencode");
}
#[test]
fn the_longest_stop_is_the_largest_budget_or_installed_timeout() {
assert_eq!(longest_stop(), Duration::from_secs(STOP_TIMEOUT_S));
for h in ALL {
assert!(h.caps().stop_budget.unwrap_or(DEFAULT_STOP_BUDGET) <= longest_stop(), "{}", h.name());
}
}
#[test]
fn names_are_unique_and_found_and_sim_is_hidden() {
for h in visible() {
assert_eq!(by_name(h.name()).map(|x| x.name()), Some(h.name()));
}
assert_eq!(ALL.iter().filter(|h| ALL.iter().filter(|x| x.name() == h.name()).count() > 1).count(), 0);
assert_eq!(names("|"), "claude|omp|opencode|codex");
assert!(ALL.iter().any(|h| h.name() == "sim" && h.hidden()));
assert_eq!(by_name("sim").is_some(), std::env::var_os(TEST_ENV).is_some());
assert!(by_name("nope").is_none());
}
#[test]
fn the_transcript_is_read_only_where_the_harness_writes_one() {
let ev = serde_json::json!({"transcript_path": "/nonexistent/t.jsonl", "last_assistant_message": ""});
assert_eq!(transcript(by_name("claude").unwrap().caps(), &ev), Some("/nonexistent/t.jsonl"));
assert_eq!(transcript(by_name("omp").unwrap().caps(), &ev), None);
let msg = serde_json::json!({"last_assistant_message": "done"});
assert_eq!(last_message(by_name("opencode").unwrap().caps(), &msg), "done");
}
#[test]
fn a_plan_from_a_transcript_is_its_tail_and_a_given_message_is_whole() {
let dir = std::env::temp_dir().join(format!("borg-harness-plan-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let t = dir.join("t.jsonl");
let long = "x".repeat(2500);
let line = serde_json::json!({"type": "assistant", "message": {"content": [{"type": "text", "text": long}]}});
std::fs::write(&t, format!("{line}\n")).unwrap();
let caps = by_name("claude").unwrap().caps();
let ev = serde_json::json!({"transcript_path": t, "last_assistant_message": ""});
assert_eq!((last_message(caps, &ev).len(), plan(caps, &ev).len()), (2500, 2000));
let given = serde_json::json!({"transcript_path": t, "last_assistant_message": long});
assert_eq!(plan(caps, &given).len(), 2500, "the event's own message is the plan, whole");
let _ = std::fs::remove_dir_all(dir);
}
}