Skip to main content

borg/harness/
mod.rs

1//! The coding agents borg runs in ("harnesses"), each one module behind one trait.
2//!
3//! Every harness already speaks one canonical, Claude-shaped hook event and gets a Claude-shaped answer
4//! back (hook.rs `dispatch`): a TS adapter translates for omp and opencode, Claude Code's command hooks need
5//! none, and Codex's need only its patch tool turned into an edit (`Harness::normalise`).
6//! So the core differs per harness only in data: how `borg install` wires it ([`Wiring`], `target`),
7//! what reaches its model and when ([`Caps`]), where the turn's work and the drone's last message come
8//! from ([`Transcript`]), and the stop's time budget. The hook (hook.rs), install (install.rs) and
9//! every other module ask these, never the harness's name.
10//!
11//! Adding a harness: one module here, a line in [`ALL`], and an adapter where the harness has no command
12//! hooks (docs/harnesses.md has the checklist; the conformance suites prove the basics).
13
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17use serde_json::Value;
18
19pub mod claude;
20pub mod codex;
21pub mod omp;
22pub mod opencode;
23pub mod sim;
24pub mod wiring;
25
26/// One coding agent borg can run in.
27pub trait Harness: Sync {
28    /// The CLI argument (`borg hook <name>`, `borg install <name>`), `BORG_HARNESS` and presence's `harness`.
29    fn name(&self) -> &'static str;
30    fn caps(&self) -> Caps;
31    /// What `borg install` writes.
32    fn wiring(&self) -> Wiring;
33    /// The config file `borg install` writes, under `home`; `var` reads the harness's own config-dir
34    /// variables (`--home` gives none, so nothing outside `home` is reached).
35    fn target(&self, home: &Path, var: &dyn Fn(&str) -> Option<PathBuf>) -> PathBuf;
36    /// What `borg doctor` asks of the harness's CLI.
37    fn probe(&self) -> Probe;
38    /// Left out of help, completions, `all`, status and doctor (the simulated harness).
39    fn hidden(&self) -> bool {
40        false
41    }
42    /// The native hook payload as the canonical event. Default: it is one already.
43    fn normalise(&self, raw: Value) -> Value {
44        raw
45    }
46    /// What `borg doctor` checks beyond the CLI's version line ([`Probe`]), e.g. that the harness trusts
47    /// borg's hooks; `target` is the config `borg install` writes. Default: nothing more.
48    fn checks(&self, _target: &Path) -> Vec<(Level, String)> {
49        Vec::new()
50    }
51    /// What `borg install` tells the engineer to do in the harness itself (e.g. trust the new hooks).
52    fn install_notes(&self) -> Vec<String> {
53        Vec::new()
54    }
55    /// The groups of sub-agents its sessions ran under `home` (review_fix.rs), oldest first: of
56    /// `session` only (as its end hook names it), else of every session, from runs that started at
57    /// or after `since` (epoch ms). Default: none.
58    fn sub_runs(&self, _home: &Path, _session: Option<&str>, _since: Option<i64>) -> Vec<Run> {
59        Vec::new()
60    }
61    /// The file its own transcript of main session `session` is in, which the turn-end sweep reads
62    /// (sweep.rs): under `home` (`$HOME`, its config-dir variable honoured), or as the hook's `input`
63    /// names it. Default: none (opencode keeps no file borg reads; its CLI exports a session at its end).
64    fn session_log(&self, _home: &Path, _session: &str, _input: &Value) -> Option<PathBuf> {
65        None
66    }
67    /// What its adapter waits for each answer ([`Budgets`]); None where the harness runs borg's hooks
68    /// itself, under the `timeout` borg installs or its own default (Claude Code, Codex). Default: None.
69    fn budgets(&self) -> Option<Budgets> {
70        None
71    }
72}
73
74/// One sub-agent of a [`Run`], as its transcript shows it.
75#[derive(Debug, Clone, Default)]
76pub struct SubAgent {
77    /// Its own id (a Claude Code agent id, a Codex thread id); empty where the harness gives none.
78    pub id: String,
79    /// The id of the sub-agent that started it; empty when the session's own agent did.
80    pub parent: String,
81    /// What the run calls it (`review:rust`, `MappingReview`, a task's description).
82    pub label: String,
83    /// Its workflow phase (`Review`, `Fix`); empty where the harness has none.
84    pub phase: String,
85    /// Its agent type (`reviewer`, `task`, `explore`); empty where the harness has none.
86    pub kind: String,
87    /// Epoch ms.
88    pub started: i64,
89    pub cwd: String,
90    pub prompt: String,
91    /// Every tool input and text it produced.
92    pub work: String,
93    /// Its last text to whoever started it: its answer (where a harness has no structured output, the
94    /// only place findings can be).
95    pub final_text: String,
96    /// Its structured output; None where it gave text only.
97    pub output: Option<Value>,
98}
99
100impl SubAgent {
101    /// A tool call's input, part of its work.
102    pub fn called(&mut self, input: &Value) {
103        self.work.push_str(&format!("{input}\n"));
104    }
105
106    /// A text it said: part of its work, and its final text until it says another.
107    pub fn said(&mut self, t: &str) {
108        self.work.push_str(&format!("{t}\n"));
109        self.final_text = t.to_string();
110    }
111}
112
113/// The sub-agents one session ran together: a Claude Code workflow run or a session's Agent-tool
114/// sub-agents, an omp session's task agents, an opencode session's subagents, a Codex session's
115/// spawned agents.
116#[derive(Debug, Clone, Default)]
117pub struct Run {
118    pub id: String,
119    pub session: String,
120    /// The workflow's or the session's name; may be empty.
121    pub name: String,
122    pub started: i64,
123    /// Its agents are the ones a plain session started one by one (Claude Code's Agent tool, Codex's
124    /// spawns), not one orchestrated run: the session's own agent built, so no sub-agent is the
125    /// builder, and they finish in any order, so a finding is named by its agent.
126    pub loose: bool,
127    /// In start order.
128    pub agents: Vec<SubAgent>,
129}
130
131/// What a harness can do with borg's answers, and what it sends. Each flag gates one behaviour of the
132/// core; a flag changes only with an evaluation behind it (docs/harnesses.md).
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Caps {
135    /// Context returned at a prompt reaches the model.
136    pub prompt_context: bool,
137    /// Context returned after a tool call reaches the model before its next step (live mode's asks).
138    pub tool_context: bool,
139    /// Early delivery: a prompt or touch naming a lesson's scope gets that scope's lessons at once (hook.rs `early`).
140    pub early_delivery: bool,
141    /// A queen question is asked with a dialog (AskUserQuestion), else inline (queen.rs `render_delivery`).
142    pub question_dialog: bool,
143    pub transcript: Transcript,
144    /// `post_tool_failure` carries the engineer's denials, which become episodes (hook.rs
145    /// `capture_rejection`); without it the event is ignored.
146    pub deny_capture: bool,
147    /// Events carry the harness's pid, so a gone process reads as ended (presence.rs `state`); without
148    /// it an event's `pid` is dropped (hook.rs `main`).
149    pub sends_pid: bool,
150    /// The stop's budget when the event names none (`BORG_STOP_BUDGET_MS`, which an adapter sets to its
151    /// [`Budgets::stop`]): the live wait, the stop's model call and the expert drone keep inside it
152    /// (expert_route.rs `stop_budget`). None: the expert drone keeps inside [`DEFAULT_STOP_BUDGET`], the
153    /// model call inside the model's own limit.
154    pub stop_budget: Option<Duration>,
155    /// The characters of context one hook answer may inject into the harness's model and have it
156    /// arrive whole, shared by everything delivered in that event (messages, lessons). Derived: the
157    /// harness's own limit where it has one (cited in its module), else [`CONTEXT_GUARD`].
158    pub context_chars: usize,
159    /// The variable the harness sets to its session's id in every command its model runs; None: the
160    /// harness gives its shell no id (opencode).
161    pub session_env: Option<&'static str>,
162    /// The variable the harness sets to its own pid in every command its model runs, for a harness
163    /// whose shell has no session id: its events carry that pid (`sends_pid`), so presence names the
164    /// session (opencode's `OPENCODE_PID`, set by its CLI).
165    pub pid_env: Option<&'static str>,
166    /// How an idle session of the harness can be woken with a message.
167    pub drone_wake: DroneWake,
168}
169
170impl Caps {
171    /// [`Caps::context_chars`], for callers that read it as a method.
172    #[must_use]
173    pub const fn context_chars(self) -> usize {
174        self.context_chars
175    }
176}
177
178/// Guard: the context one hook answer injects into a harness that sets no limit of its own (omp,
179/// opencode), so one event (a flood of messages, a long pool of lessons) cannot fill the model's
180/// context. Derived: the strictest limit a harness does set ([`claude::CONTEXT_CHARS`],
181/// [`codex::CONTEXT_CHARS`]), so every harness gets at most what any of them would take whole.
182pub const CONTEXT_GUARD: usize = if claude::CONTEXT_CHARS < codex::CONTEXT_CHARS { claude::CONTEXT_CHARS } else { codex::CONTEXT_CHARS };
183
184/// Guard: the seconds borg gives a stop where it sets the limit itself (Claude Code's and Codex's Stop
185/// hook `timeout`, opencode's adapter budget, which opencode does not limit): it bounds how long a stuck
186/// stop can hold the engineer's turn, and leaves room for the live wait (live.rs `stop_wait`), the
187/// model call (`BORG_PREDICT_TIMEOUT_S`, 45 s) and an expert drone. Changing it makes every Codex
188/// engineer trust borg's hooks again (codex.rs `TIMEOUTS`).
189pub const STOP_TIMEOUT_S: u64 = 90;
190
191/// Guard: the characters of a shell call's output an adapter sends borg (its end, where a test run's
192/// summary is, which is what borg reads it for: hook.rs `capture_test`), so a build log of megabytes
193/// is never piped through every hook.
194pub const OUTPUT_TAIL: usize = 4000;
195
196/// How long an adapter waits for borg's answer at each event before it fails open (borg is killed and
197/// the harness carries on as if it said nothing), for a harness whose hooks are an adapter's. borg's
198/// answer carries them to an adapter that asks ([`with_budgets`]), so this table is their one
199/// definition: the adapter's own copy is only its fallback until such an answer came (a test here keeps
200/// the two equal).
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub struct Budgets {
203    /// The engineer's prompt: how long it waits for borg's context.
204    pub prompt: Duration,
205    /// Before every tool call (only steering reads there: a stat when nobody steers).
206    pub pre_tool: Duration,
207    /// After a tool call: how long its result waits for borg's context.
208    pub post_tool: Duration,
209    /// The stop, the one hook that may call a model: also its [`Caps::stop_budget`].
210    pub stop: Duration,
211    /// The session's end.
212    pub end: Duration,
213}
214
215impl Budgets {
216    /// As the answer carries them: milliseconds, and the output tail ([`OUTPUT_TAIL`]) in characters.
217    #[must_use]
218    pub fn json(self) -> Value {
219        let ms = |d: Duration| u64::try_from(d.as_millis()).unwrap_or(u64::MAX);
220        serde_json::json!({
221            "prompt_ms": ms(self.prompt),
222            "pre_ms": ms(self.pre_tool),
223            "post_ms": ms(self.post_tool),
224            "stop_ms": ms(self.stop),
225            "end_ms": ms(self.end),
226            "tail_chars": OUTPUT_TAIL,
227        })
228    }
229}
230
231/// `out` with `"budgets"` ([`Budgets::json`]) when the event asked for them (`"budgets": true`, which an
232/// adapter sends until an answer carried them) and `h`'s hooks are an adapter's; anything else as it
233/// was. hook.rs `main` passes every answer it prints through this, so an adapter learns its budgets
234/// from its first answer.
235pub fn with_budgets(h: &dyn Harness, input: &Value, out: Option<Value>) -> Option<Value> {
236    let Some(b) = h.budgets().filter(|_| input.get("budgets") == Some(&Value::Bool(true))) else { return out };
237    let mut o = out.filter(Value::is_object).unwrap_or_else(|| serde_json::json!({}));
238    o["budgets"] = b.json();
239    Some(o)
240}
241
242/// The longest a stop hook of any harness may run: the largest stop budget borg keeps inside
243/// ([`Caps::stop_budget`], else [`DEFAULT_STOP_BUDGET`]) and the largest stop `timeout` it installs
244/// ([`Wiring::CommandHooks`]).
245pub fn longest_stop() -> Duration {
246    let stop_timeout = |h: &dyn Harness| match h.wiring() {
247        Wiring::CommandHooks { timeouts, .. } => timeouts.iter().filter(|(e, _)| *e == "stop").map(|(_, s)| Duration::from_secs(*s)).max(),
248        Wiring::AdapterFile { .. } => None,
249    };
250    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)
251}
252
253/// How an idle session of a harness is woken with a message.
254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
255pub enum DroneWake {
256    /// Its stop asks the model to run a waiter in the background, whose end starts the next turn
257    /// (Claude Code).
258    Model,
259    /// Its adapter keeps a waiter for the session's life and wakes the session by the harness's own
260    /// path (omp `pi.sendMessage`, opencode `session.promptAsync`/`prompt`).
261    Plugin,
262    /// No hook can wake an idle session: its stop starts a waiter detached, which wakes the session
263    /// through the harness's CLI (codex `codex queue --thread`). Its hooks run as children of the harness
264    /// process, which sends no pid of its own.
265    Queue,
266}
267
268/// Where the turn's work and the drone's last message come from.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum Transcript {
271    /// The event's `transcript_path` is a Claude Code JSONL transcript.
272    Claude,
273    /// None: the event's own `last_assistant_message`, and the tool calls the hook logs (`.turn`).
274    None,
275}
276
277/// How `borg install` wires a harness.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum Wiring {
280    /// Command hooks merged into a JSON file: one hook group per native event (native name, canonical
281    /// event), each running `<borg> hook <name> <event>`; `timeouts` gives a canonical event's hook a
282    /// `timeout` in seconds (the others get the harness's own default). `retired`: the hooks an older
283    /// borg installed and this one does not, which install and uninstall remove. `through_links`: the
284    /// file is often a symlink into a dotfiles repo, written through.
285    CommandHooks {
286        events: &'static [(&'static str, &'static str)],
287        timeouts: &'static [(&'static str, u64)],
288        retired: &'static [(&'static str, &'static str)],
289        through_links: bool,
290    },
291    /// A compiled-in adapter copied whole; a symlink is replaced, never written through, and uninstall
292    /// removes only a file with exactly this content.
293    AdapterFile { source: &'static str },
294}
295
296impl Wiring {
297    /// The adapter `borg install` copies whole ("" for command hooks).
298    pub fn adapter(self) -> &'static str {
299        match self {
300            Wiring::AdapterFile { source } => source,
301            Wiring::CommandHooks { .. } => "",
302        }
303    }
304}
305
306/// What `borg doctor` asks: the CLI whose `--version` it prints, and the versions borg was verified on.
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub struct Probe {
309    pub cli: &'static str,
310    /// Appended to the version line, e.g. " (adapter tested with …)"; empty for none.
311    pub tested: &'static str,
312}
313
314/// How bad a `borg doctor` finding is.
315#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
316pub enum Level {
317    Ok,
318    Warn,
319    Fail,
320}
321
322/// Guard: the stop's budget assumed where a harness names none: 60 s, Claude Code's default command-hook
323/// timeout before it became 600 s (claude.rs), so a hook installed with no `timeout` on an older Claude
324/// Code is never cut off. A safe floor, not a measured limit: `borg install` gives the Stop hook
325/// [`STOP_TIMEOUT_S`].
326pub const DEFAULT_STOP_BUDGET: Duration = Duration::from_secs(60);
327
328/// Set (to anything) by the conformance suites: only then are the hidden harnesses found by name, so
329/// `borg install sim` and `borg hook sim …` are unknown to everyone else.
330pub const TEST_ENV: &str = "BORG_TEST_HARNESS";
331
332/// Every harness, in the order help, status and `all` list them. Adding one is a line here.
333pub static ALL: &[&dyn Harness] = &[&claude::Claude, &omp::Omp, &opencode::Opencode, &codex::Codex, &sim::Sim];
334
335/// The harness called `name`; a hidden one only under [`TEST_ENV`].
336pub fn by_name(name: &str) -> Option<&'static dyn Harness> {
337    let hidden_too = std::env::var_os(TEST_ENV).is_some();
338    ALL.iter().copied().find(|h| h.name() == name && (hidden_too || !h.hidden()))
339}
340
341/// The harnesses people see (help, completions, `all`, status, doctor).
342pub fn visible() -> impl Iterator<Item = &'static dyn Harness> {
343    ALL.iter().copied().filter(|h| !h.hidden())
344}
345
346/// `claude|omp|opencode|codex`: the visible names, for usage lines.
347pub fn names(sep: &str) -> String {
348    visible().map(|h| h.name()).collect::<Vec<_>>().join(sep)
349}
350
351/// The drone's harness in a process its hook started (`BORG_HARNESS`, `borg check`), else the first.
352pub fn current() -> &'static dyn Harness {
353    named_current().unwrap_or(ALL[0])
354}
355
356/// The drone's harness when its hook named it (`BORG_HARNESS`), else None.
357pub fn named_current() -> Option<&'static dyn Harness> {
358    std::env::var("BORG_HARNESS").ok().and_then(|n| by_name(&n))
359}
360
361/// The variables harnesses set to their session's id in every command their model runs and every hook
362/// they run (`Caps::session_env`), each with its harness, in [`ALL`]'s order: Claude Code's
363/// (`CLAUDE_CODE_SESSION_ID`) equals the hook input's `session_id` and follows `/clear`, but under
364/// `--continue` or `--resume` without an id it may be the startup id instead (its env-vars docs); omp's
365/// (`PI_SESSION_ID`) is its session manager's id, the one its extension sends; codex's
366/// (`CODEX_THREAD_ID`, 0.153.2, also inside its sandbox) is the thread id its hooks carry as
367/// `session_id` and `codex queue --thread` takes. opencode gives its shell no session id.
368pub fn session_vars() -> Vec<(&'static str, &'static str)> {
369    ALL.iter().filter_map(|h| h.caps().session_env.map(|v| (v, h.name()))).collect()
370}
371
372/// Every session id set in this process's environment (`session_vars`, trimmed, non-empty), each with
373/// its harness, in `session_vars`' order. More than one when a harness runs inside another's shell (omp
374/// in a Claude Code session): it inherits the outer one's variable too.
375pub fn env_sessions() -> Vec<(String, &'static str)> {
376    let var = |k: &str| std::env::var(k).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
377    session_vars().into_iter().filter_map(|(k, h)| var(k).map(|v| (v, h))).collect()
378}
379
380/// Every harness pid set in this process's environment (`Caps::pid_env`), each with its harness, in
381/// [`ALL`]'s order.
382pub fn env_pids() -> Vec<(u32, &'static str)> {
383    let pid = |k: &str| std::env::var(k).ok().and_then(|v| v.trim().parse::<u32>().ok());
384    ALL.iter().filter_map(|h| h.caps().pid_env.and_then(pid).map(|p| (p, h.name()))).collect()
385}
386
387/// A directory a harness's own variable names (`CLAUDE_CONFIG_DIR`, `PI_CODING_AGENT_DIR`, `CODEX_HOME`,
388/// `XDG_DATA_HOME`); None when it is unset or empty. Each harness module's transcript locations start here.
389pub(crate) fn env_dir(var: &str) -> Option<PathBuf> {
390    std::env::var_os(var).filter(|v| !v.is_empty()).map(PathBuf::from)
391}
392
393/// The event's transcript, when the harness writes one and the event names it.
394pub fn transcript(caps: Caps, ev: &Value) -> Option<&str> {
395    match caps.transcript {
396        Transcript::Claude => ev.get("transcript_path").and_then(Value::as_str).filter(|t| !t.is_empty()),
397        Transcript::None => None,
398    }
399}
400
401/// Live mode: this turn's work so far, its last `cap` characters: from the transcript where the harness
402/// writes one ([`Transcript`]), else the turn's tool calls the hook logged in `turn_log`.
403pub fn work_so_far(caps: Caps, ev: &Value, turn_log: &Path, cap: usize) -> String {
404    if let Some(t) = transcript(caps, ev).filter(|t| std::fs::metadata(t).is_ok()) {
405        return claude::turn_so_far(t.as_ref(), cap);
406    }
407    let text = std::fs::read_to_string(turn_log).unwrap_or_default();
408    let n = text.chars().count();
409    text.chars().skip(n.saturating_sub(cap)).collect()
410}
411
412/// The drone's last message, whole: the event's `last_assistant_message`, else the transcript's last
413/// assistant text.
414pub fn last_message(caps: Caps, ev: &Value) -> String {
415    match given(ev) {
416        Some(t) => t.to_string(),
417        None => claude::last_assistant_full(transcript(caps, ev).unwrap_or_default().as_ref()),
418    }
419}
420
421/// The drone's plan, as the queen and the experts read it: [`last_message`], but a transcript's text
422/// only its last 2000 characters (`claude::last_assistant_text`).
423pub fn plan(caps: Caps, ev: &Value) -> String {
424    match given(ev) {
425        Some(t) => t.to_string(),
426        None => claude::last_assistant_text(transcript(caps, ev).unwrap_or_default().as_ref()),
427    }
428}
429
430/// The event's own non-blank `last_assistant_message`.
431fn given(ev: &Value) -> Option<&str> {
432    ev.get("last_assistant_message").and_then(Value::as_str).filter(|t| !t.trim().is_empty())
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    /// Today's flags, pinned: changing one is a product decision with an evaluation behind it, never a
440    /// side effect of a refactor.
441    #[test]
442    fn caps_are_todays() {
443        let row = |n: &str| {
444            let c = by_name(n).unwrap().caps();
445            (c.prompt_context, c.tool_context, c.early_delivery, c.question_dialog, c.transcript, c.deny_capture, c.sends_pid)
446        };
447        assert_eq!(row("claude"), (true, true, true, true, Transcript::Claude, true, false));
448        for n in ["omp", "opencode"] {
449            assert_eq!(row(n), (true, true, false, false, Transcript::None, true, true), "{n}");
450        }
451        assert_eq!(row("codex"), (true, true, false, false, Transcript::None, false, false));
452        // None: the expert drone keeps inside Claude Code's 60 s, the model call its own limit; omp: its
453        // 30 s handler limit less the adapter's margin; opencode and codex: the stop timeout borg sets.
454        let budgets = [("claude", None), ("omp", Some(28)), ("opencode", Some(90)), ("codex", Some(90)), ("sim", None)];
455        let got: Vec<(&str, Option<u64>)> = ALL.iter().map(|h| (h.name(), h.caps().stop_budget.map(|d| d.as_secs()))).collect();
456        assert_eq!(got, budgets, "every harness's stop budget");
457        let Wiring::CommandHooks { timeouts, through_links, .. } = by_name("claude").unwrap().wiring() else { panic!("claude has command hooks") };
458        assert_eq!((timeouts, through_links), (&[("stop", 90)][..], true), "a timeout on the stop only");
459        // Claude Code's and Codex's own limit; omp and opencode set none: the guard, the strictest of those.
460        let chars: Vec<(&str, usize)> = ALL.iter().map(|h| (h.name(), h.caps().context_chars())).collect();
461        assert_eq!(chars, [("claude", 10_000), ("omp", 10_000), ("opencode", 10_000), ("codex", 10_000), ("sim", 10_000)]);
462    }
463
464    /// An adapter's stop budget is the one its budgets name, and only adapters have budgets.
465    #[test]
466    fn only_an_adapters_harness_has_budgets_and_its_stop_is_its_caps_budget() {
467        for h in ALL {
468            let adapter = matches!(h.wiring(), Wiring::AdapterFile { .. });
469            assert_eq!(h.budgets().is_some(), adapter, "{}", h.name());
470            if let Some(b) = h.budgets() {
471                assert_eq!(h.caps().stop_budget, Some(b.stop), "{}", h.name());
472                assert!(b.stop <= Duration::from_secs(STOP_TIMEOUT_S) && b.pre_tool <= b.prompt, "{}: {b:?}", h.name());
473            }
474        }
475    }
476
477    #[test]
478    fn budgets_join_an_answer_only_when_an_adapter_asked() {
479        let omp = by_name("omp").unwrap();
480        let asked = serde_json::json!({"session_id": "s", "budgets": true});
481        let b = omp.budgets().unwrap();
482        let want = serde_json::json!({"prompt_ms": 5000, "pre_ms": 2000, "post_ms": 5000, "stop_ms": 28_000, "end_ms": 1500, "tail_chars": 4000});
483        assert_eq!(b.json(), want);
484        assert_eq!(with_budgets(omp, &asked, None), Some(serde_json::json!({"budgets": want})), "an answer of its own when borg says nothing");
485        let block = serde_json::json!({"decision": "block", "reason": "r"});
486        let both = with_budgets(omp, &asked, Some(block.clone())).unwrap();
487        assert_eq!((&both["decision"], &both["reason"], &both["budgets"]), (&block["decision"], &block["reason"], &want), "added to the answer");
488        let plain = serde_json::json!({"session_id": "s"});
489        assert_eq!(with_budgets(omp, &plain, Some(block.clone())), Some(block.clone()), "not asked: as it was");
490        assert_eq!(with_budgets(omp, &plain, None), None);
491        assert_eq!(with_budgets(by_name("claude").unwrap(), &asked, None), None, "no adapter, no budgets");
492    }
493
494    /// The adapter's budgets before borg's first answer (its `FALLBACK`) are the ones that answer carries.
495    #[test]
496    fn each_adapters_fallback_budgets_are_its_harness_budgets() {
497        for h in ALL {
498            let Some(b) = h.budgets() else { continue };
499            let src = h.wiring().adapter();
500            let line = src.lines().find(|l| l.starts_with("const FALLBACK")).unwrap_or_else(|| panic!("{}: no `const FALLBACK` line", h.name()));
501            let body = line.split_once('{').and_then(|(_, r)| r.split_once('}')).map(|(b, _)| b).unwrap_or_default();
502            let pairs = body
503                .split(',')
504                .filter_map(|kv| kv.split_once(':'))
505                .map(|(k, v)| (k.trim().to_string(), serde_json::json!(v.trim().replace('_', "").parse::<u64>().unwrap())));
506            assert_eq!(Value::Object(pairs.collect()), b.json(), "{}'s adapter", h.name());
507        }
508    }
509
510    /// No budget an answer carries is dead: each adapter's code (comments aside) reads every one, as the
511    /// `within` of a call (`"end_ms"`) or off its budgets (`budgets.tail_chars`).
512    #[test]
513    fn each_adapter_reads_every_budget_borg_sends_it() {
514        let mut adapters = 0;
515        for h in ALL {
516            let Some(b) = h.budgets() else { continue };
517            adapters += 1;
518            let code: String = h.wiring().adapter().lines().filter(|l| !["//", "/*", "*"].iter().any(|c| l.trim_start().starts_with(c))).collect();
519            let sent = b.json();
520            let keys: Vec<&String> = sent.as_object().map(|o| o.keys().collect()).unwrap_or_default();
521            assert!(!keys.is_empty(), "{sent}");
522            for k in keys {
523                assert!(code.contains(&format!("\"{k}\"")) || code.contains(&format!("budgets.{k}")), "{}'s adapter never reads {k}", h.name());
524            }
525        }
526        assert_eq!(adapters, 2, "omp and opencode");
527    }
528
529    /// The stop mark of a listening session (listen.rs) outlives every stop: it is sized from this.
530    #[test]
531    fn the_longest_stop_is_the_largest_budget_or_installed_timeout() {
532        assert_eq!(longest_stop(), Duration::from_secs(STOP_TIMEOUT_S));
533        for h in ALL {
534            assert!(h.caps().stop_budget.unwrap_or(DEFAULT_STOP_BUDGET) <= longest_stop(), "{}", h.name());
535        }
536    }
537
538    #[test]
539    fn names_are_unique_and_found_and_sim_is_hidden() {
540        for h in visible() {
541            assert_eq!(by_name(h.name()).map(|x| x.name()), Some(h.name()));
542        }
543        assert_eq!(ALL.iter().filter(|h| ALL.iter().filter(|x| x.name() == h.name()).count() > 1).count(), 0);
544        assert_eq!(names("|"), "claude|omp|opencode|codex");
545        assert!(ALL.iter().any(|h| h.name() == "sim" && h.hidden()));
546        // Known by name only under TEST_ENV (conformance_cli.rs runs `borg` with and without it).
547        assert_eq!(by_name("sim").is_some(), std::env::var_os(TEST_ENV).is_some());
548        assert!(by_name("nope").is_none());
549    }
550
551    #[test]
552    fn the_transcript_is_read_only_where_the_harness_writes_one() {
553        let ev = serde_json::json!({"transcript_path": "/nonexistent/t.jsonl", "last_assistant_message": ""});
554        assert_eq!(transcript(by_name("claude").unwrap().caps(), &ev), Some("/nonexistent/t.jsonl"));
555        assert_eq!(transcript(by_name("omp").unwrap().caps(), &ev), None);
556        let msg = serde_json::json!({"last_assistant_message": "done"});
557        assert_eq!(last_message(by_name("opencode").unwrap().caps(), &msg), "done");
558    }
559
560    #[test]
561    fn a_plan_from_a_transcript_is_its_tail_and_a_given_message_is_whole() {
562        let dir = std::env::temp_dir().join(format!("borg-harness-plan-{}", std::process::id()));
563        std::fs::create_dir_all(&dir).unwrap();
564        let t = dir.join("t.jsonl");
565        let long = "x".repeat(2500);
566        let line = serde_json::json!({"type": "assistant", "message": {"content": [{"type": "text", "text": long}]}});
567        std::fs::write(&t, format!("{line}\n")).unwrap();
568        let caps = by_name("claude").unwrap().caps();
569        let ev = serde_json::json!({"transcript_path": t, "last_assistant_message": ""});
570        assert_eq!((last_message(caps, &ev).len(), plan(caps, &ev).len()), (2500, 2000));
571        let given = serde_json::json!({"transcript_path": t, "last_assistant_message": long});
572        assert_eq!(plan(caps, &given).len(), 2500, "the event's own message is the plan, whole");
573        let _ = std::fs::remove_dir_all(dir);
574    }
575}