'(in 4d 11h)' and '(already past)' were computed at render time and as frozen as the read age. The absolute reset time stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1740 lines
88 KiB
Rust
1740 lines
88 KiB
Rust
//! The actuator's tests: a real mind over a temporary directory holds her run,
|
|
//! a scripted `Child` plays the stream and a settable clock plays time. The
|
|
//! stream lines are built from Claude Code 2.1.268's own records (the two
|
|
//! fixture files beside this one), varying only ids and usage numbers. The real
|
|
//! `ClaudeProcess` is driven through a fake `claude` script on Unix.
|
|
|
|
use std::cell::{Cell, RefCell};
|
|
use std::collections::VecDeque;
|
|
use std::rc::Rc;
|
|
|
|
use chrono::Duration as ChronoDuration;
|
|
use eureka_pipeline::{PipelineDocument, PipelineKind as K, PipelineRef, RunOperator, RunTurn, Slug};
|
|
use huginn_mind::{PipelineAdmissionOutcome, PipelineSelectionPage};
|
|
|
|
use super::*;
|
|
use cultnet_rs::Selection;
|
|
use huginn_mind::PipelinePageItems;
|
|
use crate::lease_client::token_holding;
|
|
use crate::testkit::*;
|
|
|
|
/// A cap no constant of the library equals, so that a request of it came from the argument.
|
|
const TEST_CAP_S: u64 = 5_400;
|
|
|
|
const RECORDS: &str = include_str!("claude-2.1.268-assistant-usage-records.jsonl");
|
|
const PROBE: &str = include_str!("claude-2.1.268-stream-json-probe.jsonl");
|
|
|
|
// Pinned against Python's uuid.uuid5(uuid.NAMESPACE_OID, <run id>), not against the code under test.
|
|
const SESSION_OF_MIND_RUN: &str = "00cb82a4-c0ac-5ad4-83e2-8e685589fb67";
|
|
const SESSION_OF_RUN_A: &str = "d6db8da1-224c-5430-91b5-17aa4df8ae7a";
|
|
const SESSION_OF_RUN_B: &str = "546bec88-f7a9-5fa0-bf07-0fada54f071b";
|
|
|
|
fn foreign_run(id: &str) -> PipelineRef {
|
|
PipelineRef { kind: K::Run, id: id.into() }
|
|
}
|
|
|
|
/// The first real assistant record with its id and usage replaced.
|
|
fn request(id: &str, input: u64, read: u64, write: u64) -> String {
|
|
let mut event: Value = serde_json::from_str(RECORDS.lines().next().unwrap()).unwrap();
|
|
event["message"]["id"] = json!(id);
|
|
event["message"]["usage"]["input_tokens"] = json!(input);
|
|
event["message"]["usage"]["cache_read_input_tokens"] = json!(read);
|
|
event["message"]["usage"]["cache_creation_input_tokens"] = json!(write);
|
|
event.to_string()
|
|
}
|
|
|
|
/// A request whose whole prefix is `prefix`, under a message id of its own.
|
|
fn at(id: &str, prefix: u64) -> String {
|
|
request(id, 0, prefix, 0)
|
|
}
|
|
|
|
fn subagent(id: &str, prefix: u64) -> String {
|
|
let mut event: Value = serde_json::from_str(&at(id, prefix)).unwrap();
|
|
event["parent_tool_use_id"] = json!("toolu_0001");
|
|
event.to_string()
|
|
}
|
|
|
|
fn result_line() -> String {
|
|
PROBE.lines().nth(2).unwrap().to_string()
|
|
}
|
|
|
|
/// The replay echo of a user line the CLI took into a request.
|
|
fn echo() -> String {
|
|
json!({"type": "user", "isReplay": true, "uuid": "00000000-0000-4000-8000-000000000001", "message": {"role": "user", "content": "a line"}}).to_string()
|
|
}
|
|
|
|
/// A turn that ended well, with an answer.
|
|
fn ok_result() -> String {
|
|
json!({"type": "result", "subtype": "success", "is_error": false, "terminal_reason": "completed", "result": "The answer."}).to_string()
|
|
}
|
|
|
|
fn init_line() -> String {
|
|
PROBE.lines().next().unwrap().to_string()
|
|
}
|
|
|
|
fn self_run(label: &str, budget: &str) -> PipelineDocument {
|
|
let PipelineDocument::Run(mut document) = run(label, RunTurn::SelfRun, RunOperator::Mind, &[]) else { unreachable!() };
|
|
document.budget_usd = budget.into();
|
|
PipelineDocument::Run(document)
|
|
}
|
|
|
|
use Step::{After, Eof, Exit, Idle, Line as Out};
|
|
|
|
/// The mind as the actuator sees it, over a `TestMind`: another instance's name,
|
|
/// admissions counted, and optionally refused as a mind that cannot be reached would.
|
|
struct Seen<'a> {
|
|
inner: &'a TestMind,
|
|
instance: Slug,
|
|
doubled: bool,
|
|
admit_fails: bool,
|
|
/// A query for this key fails.
|
|
query_fails_on: Option<String>,
|
|
admits: Cell<usize>,
|
|
}
|
|
|
|
impl<'a> Seen<'a> {
|
|
fn of(inner: &'a TestMind) -> Self {
|
|
Self { inner, instance: inner.instance().clone(), doubled: false, admit_fails: false, query_fails_on: None, admits: Cell::new(0) }
|
|
}
|
|
}
|
|
|
|
impl MindPort for Seen<'_> {
|
|
fn instance(&self) -> &Slug {
|
|
&self.instance
|
|
}
|
|
|
|
fn query(&self, selection: &Selection) -> Result<PipelineSelectionPage> {
|
|
if let (Some(key), Some(keys)) = (&self.query_fails_on, &selection.keys)
|
|
&& keys.contains(key)
|
|
{
|
|
return Err(anyhow!("mind-down-canary"));
|
|
}
|
|
let mut page = self.inner.query(selection)?;
|
|
if self.doubled
|
|
&& let PipelinePageItems::Headers(items) = &mut page.items
|
|
{
|
|
let again = items.clone();
|
|
items.extend(again);
|
|
}
|
|
Ok(page)
|
|
}
|
|
|
|
fn admit(&self, agent: &str, session: &str, documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
|
self.admits.set(self.admits.get() + 1);
|
|
if self.admit_fails {
|
|
return Err(anyhow!("down"));
|
|
}
|
|
self.inner.admit(agent, session, documents)
|
|
}
|
|
}
|
|
|
|
struct Ran {
|
|
outcome: Result<SelfRunOutcome>,
|
|
out: String,
|
|
events: Vec<Event>,
|
|
/// Every event, the lease's included.
|
|
all: Vec<Event>,
|
|
spawns: Vec<Vec<String>>,
|
|
admits: usize,
|
|
asked: Vec<LeaseRequest>,
|
|
}
|
|
|
|
impl Ran {
|
|
fn ended(&self) -> (Cause, Option<i32>, u64, u64) {
|
|
match self.outcome.as_ref().unwrap() {
|
|
SelfRunOutcome::Ended { cause, code, requests, prefix } => (*cause, *code, *requests, *prefix),
|
|
other => panic!("not ended: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// The text of every user line written to the child, in order.
|
|
fn written(&self) -> Vec<String> {
|
|
self.events
|
|
.iter()
|
|
.filter_map(|event| match event {
|
|
Event::Wrote(line) => {
|
|
let line: Value = serde_json::from_str(line).unwrap();
|
|
assert_eq!((line["type"].as_str(), line["message"]["role"].as_str()), (Some("user"), Some("user")), "{line}");
|
|
Some(line["message"]["content"].as_str().unwrap().to_string())
|
|
}
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// What the unit's stdout holds apart from the decisions the actuator journals.
|
|
fn lines(&self) -> String {
|
|
self.out.lines().filter(|line| !line.starts_with("decision ")).map(|line| format!("{line}\n")).collect()
|
|
}
|
|
|
|
fn count(&self, wanted: &Event) -> usize {
|
|
self.events.iter().filter(|event| *event == wanted).count()
|
|
}
|
|
|
|
fn asked(&self) -> Vec<u64> {
|
|
self.events
|
|
.iter()
|
|
.filter_map(|event| match event {
|
|
Event::Asked(timeout) => Some(timeout.as_secs()),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
/// `dies_on_interrupt`: the child ends at the first signal, else it ignores all.
|
|
fn carry_with(seen: &Seen, steps: Vec<Step>, code: Option<i32>, dies_on_interrupt: bool) -> Ran {
|
|
carry_signalled(seen, steps, code, dies_on_interrupt.then_some(Stop::Interrupt), &[])
|
|
}
|
|
|
|
fn carry_signalled(seen: &Seen, steps: Vec<Step>, code: Option<i32>, dies_on: Option<Stop>, refuses: &[Stop]) -> Ran {
|
|
carry_scripted(seen, steps, code, dies_on, refuses, false)
|
|
}
|
|
|
|
fn carry_scripted(seen: &Seen, steps: Vec<Step>, code: Option<i32>, dies_on: Option<Stop>, refuses: &[Stop], hangs: bool) -> Ran {
|
|
carry_one(seen, steps, (code, dies_on, refuses, hangs))
|
|
}
|
|
|
|
fn carry_one(seen: &Seen, steps: Vec<Step>, (code, dies_on, refuses, hangs): (Option<i32>, Option<Stop>, &[Stop], bool)) -> Ran {
|
|
let log = Rc::new(RefCell::new(Vec::new()));
|
|
let clock = Rc::new(Fixed(Cell::new(at_start())));
|
|
let fake = Fake::with(Some(Scripted { steps: steps.into(), log: log.clone(), clock: clock.clone(), code, dies_on, refuses: refuses.to_vec(), hangs, exited: false, eof: false, asked: 0 }), false);
|
|
let lender = FakeLender::logging(log.clone(), Answer::Grant);
|
|
let mut out = Vec::new();
|
|
let outcome = carry(seen, &*clock, &lender, &fake, TEST_CAP_S, &mut out);
|
|
let all = log.borrow().clone();
|
|
// The child's own events; the lease's are in `all`, in order with them.
|
|
let events = all.iter().filter(|event| !matches!(event, Event::Lent | Event::Spawned | Event::Abandoned)).cloned().collect();
|
|
let spawns = fake.spawns.borrow().clone();
|
|
Ran { outcome, out: String::from_utf8(out).unwrap(), events, all, spawns, admits: seen.admits.get(), asked: lender.asked.borrow().clone() }
|
|
}
|
|
|
|
fn at_start() -> chrono::DateTime<chrono::Utc> {
|
|
crate::testkit::at(0)
|
|
}
|
|
|
|
/// A mind holding her one open Self run `selfrun-a` with budget 5.
|
|
fn with_run() -> TestMind {
|
|
let mind = TestMind::seeded();
|
|
mind.committed(vec![self_run("selfrun-a", "5")]);
|
|
mind
|
|
}
|
|
|
|
fn go(steps: Vec<Step>) -> Ran {
|
|
let mind = with_run();
|
|
carry_with(&Seen::of(&mind), steps, Some(0), false)
|
|
}
|
|
|
|
type Seed = Box<dyn Fn(&TestMind)>;
|
|
|
|
fn flushes(ran: &Ran) -> usize {
|
|
ran.written().iter().filter(|text| text.starts_with(FLUSH_MESSAGE)).count()
|
|
}
|
|
|
|
// ---- who is carried -----------------------------------------------------
|
|
|
|
#[test]
|
|
fn without_exactly_one_open_self_run_of_hers_it_is_no_run_and_no_child_is_spawned() {
|
|
let cases: Vec<(&str, Seed)> = vec![
|
|
("no run at all", Box::new(|_| {})),
|
|
("a Self run the operator operates", Box::new(|mind| mind.committed(vec![run("op", RunTurn::SelfRun, RunOperator::Operator, &[])]))),
|
|
("her Persona turn", Box::new(|mind| mind.committed(vec![run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]))),
|
|
(
|
|
"a Self run of hers that is closed",
|
|
Box::new(|mind| {
|
|
mind.committed(vec![self_run("selfrun-a", "5")]);
|
|
mind.committed(vec![close(run_ref("selfrun-a"), recorded())]);
|
|
}),
|
|
),
|
|
];
|
|
for (name, seed) in cases {
|
|
let mind = TestMind::seeded();
|
|
seed(&mind);
|
|
let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false);
|
|
assert_eq!(ran.outcome.unwrap(), SelfRunOutcome::NoRun, "{name}");
|
|
assert!(ran.spawns.is_empty() && ran.events.is_empty() && ran.out.is_empty(), "{name}");
|
|
}
|
|
|
|
// Another instance's mind holds none of this instance's runs.
|
|
let mind = with_run();
|
|
let mut other = Seen::of(&mind);
|
|
other.instance = Slug("another".into());
|
|
assert_eq!(carry_with(&other, vec![], Some(0), false).outcome.unwrap(), SelfRunOutcome::NoRun);
|
|
|
|
// Two open runs are not one: nothing is spawned for either.
|
|
let mut two = Seen::of(&mind);
|
|
two.doubled = true;
|
|
let ran = carry_with(&two, vec![], Some(0), false);
|
|
assert_eq!(ran.outcome.unwrap(), SelfRunOutcome::NoRun);
|
|
assert!(ran.spawns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_mind_that_cannot_be_read_or_a_child_that_cannot_start_is_an_error_not_an_outcome() {
|
|
let mind = with_run();
|
|
let clock = Rc::new(Fixed(Cell::new(at_start())));
|
|
let refusing = Fake::with(None, true);
|
|
assert!(carry(&Seen::of(&mind), &*clock, &FakeLender::new(Answer::Grant), &refusing, TEST_CAP_S, &mut Vec::new()).is_err());
|
|
assert_eq!(refusing.spawns.borrow().len(), 1, "it tried exactly once");
|
|
|
|
struct Down;
|
|
impl MindPort for Down {
|
|
fn instance(&self) -> &Slug {
|
|
static INSTANCE: std::sync::OnceLock<Slug> = std::sync::OnceLock::new();
|
|
INSTANCE.get_or_init(|| Slug("yggdrasil".into()))
|
|
}
|
|
fn query(&self, _selection: &Selection) -> Result<PipelineSelectionPage> {
|
|
Err(anyhow!("down"))
|
|
}
|
|
fn admit(&self, _agent: &str, _session: &str, _documents: Vec<PipelineDocument>) -> Result<PipelineAdmissionOutcome> {
|
|
unreachable!()
|
|
}
|
|
}
|
|
let never = Fake::with(None, false);
|
|
assert!(carry(&Down, &*clock, &FakeLender::new(Answer::Grant), &never, TEST_CAP_S, &mut Vec::new()).is_err());
|
|
assert!(never.spawns.borrow().is_empty());
|
|
}
|
|
|
|
// ---- what is started ------------------------------------------------------
|
|
|
|
#[test]
|
|
fn claude_args_is_exactly_the_fixed_list_for_a_session() {
|
|
let session = Uuid::parse_str(SESSION_OF_RUN_A).unwrap();
|
|
let args = claude_args(session);
|
|
assert_eq!(
|
|
args,
|
|
[
|
|
"-p",
|
|
"--input-format",
|
|
"stream-json",
|
|
"--output-format",
|
|
"stream-json",
|
|
"--verbose",
|
|
"--session-id",
|
|
SESSION_OF_RUN_A,
|
|
"--max-turns",
|
|
"500",
|
|
"--permission-mode",
|
|
"bypassPermissions",
|
|
"--permission-prompts",
|
|
"none",
|
|
"--setting-sources",
|
|
"user",
|
|
"--replay-user-messages",
|
|
]
|
|
);
|
|
// Those belong to the run measure or to the unit's managed settings.
|
|
for forbidden in ["--resume", "--continue", "--fork-session", "--model", "--dangerously-skip-permissions", "--system-prompt", "--max-budget"] {
|
|
assert!(!args.iter().any(|arg| arg.starts_with(forbidden) || arg.contains("dangerously")), "{forbidden}");
|
|
}
|
|
// Print mode starts in Manual without a mode, and prompts none then denies.
|
|
let mode = args.iter().position(|arg| arg == "--permission-mode").expect("mode flag");
|
|
assert_eq!(args[mode + 1], "bypassPermissions");
|
|
assert_eq!(MAX_TURNS, 500);
|
|
}
|
|
|
|
#[test]
|
|
fn the_session_id_is_uuid_v5_of_the_run_id_and_differs_per_run() {
|
|
assert_eq!(session_id(&foreign_run("eureka:run:mind-20261022T120000000Z")).to_string(), SESSION_OF_MIND_RUN);
|
|
assert_eq!(session_id(&run_ref("selfrun-a")).to_string(), SESSION_OF_RUN_A);
|
|
assert_eq!(session_id(&run_ref("selfrun-b")).to_string(), SESSION_OF_RUN_B);
|
|
}
|
|
|
|
#[test]
|
|
fn the_child_is_started_for_her_run_with_the_fixed_list_whatever_the_run_record_says() {
|
|
// The run's budget field is a dead record; nothing in what is started depends on it.
|
|
for budget in ["0", "0.5", "10", "100"] {
|
|
let mind = TestMind::seeded();
|
|
mind.committed(vec![self_run("selfrun-b", budget)]);
|
|
let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false);
|
|
assert_eq!(ran.spawns.len(), 1, "{budget}");
|
|
assert_eq!(ran.spawns[0], claude_args(Uuid::parse_str(SESSION_OF_RUN_B).unwrap()), "{budget}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_start_message_is_the_first_thing_written_and_names_the_skill_the_instance_and_the_run() {
|
|
let ran = go(vec![Out(result_line())]);
|
|
assert!(matches!(&ran.events[0], Event::Wrote(_)), "the start precedes the first read: {:?}", ran.events[0]);
|
|
let written = ran.written();
|
|
for needle in ["Plan usage: five_hour 42.3% allowed, resets at 2026-10-29T20:50:00Z, read at 2026-10-29T20:33Z", "about 90% of the weekly allowance", "nothing enforces it"] {
|
|
assert!(written[0].contains(needle), "the lease grant plan windows reach the child: {needle}: {}", written[0]);
|
|
}
|
|
for needle in ["Use the eureka skill.", "yggdrasil", "yggdrasil:run:selfrun-a", AGENT] {
|
|
assert!(written[0].contains(needle), "{needle}: {}", written[0]);
|
|
}
|
|
assert_eq!(written.len(), 1, "no flush without the ceiling");
|
|
}
|
|
|
|
#[test]
|
|
fn the_start_message_opens_with_the_skill_the_seed_ships() {
|
|
let message = start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"), &[plan_window()], 1_793_306_100_000);
|
|
assert!(message.starts_with("Use the eureka skill. You are Self for the instance `yggdrasil`;"), "{message}");
|
|
assert!(!message.contains("mind-self skill"), "{message}");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
mod seed_links {
|
|
use super::*;
|
|
use std::os::unix::fs::symlink;
|
|
|
|
#[test]
|
|
fn a_relative_in_tree_link_is_copied_as_a_link_that_resolves() {
|
|
let seed = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir_all(seed.path().join("skills/eureka/agents")).unwrap();
|
|
std::fs::write(seed.path().join("skills/eureka/agents/hands.md"), "hands").unwrap();
|
|
symlink("skills/eureka/agents", seed.path().join("agents")).unwrap();
|
|
std::fs::create_dir(seed.path().join("sub")).unwrap();
|
|
symlink("../skills/eureka", seed.path().join("sub/eureka")).unwrap();
|
|
let out = tempfile::tempdir().unwrap();
|
|
copy_tree(seed.path(), out.path()).unwrap();
|
|
assert!(out.path().join("agents").symlink_metadata().unwrap().file_type().is_symlink());
|
|
assert_eq!(std::fs::read_link(out.path().join("agents")).unwrap(), Path::new("skills/eureka/agents"));
|
|
assert_eq!(std::fs::read_to_string(out.path().join("agents/hands.md")).unwrap(), "hands");
|
|
assert_eq!(std::fs::read_to_string(out.path().join("sub/eureka/agents/hands.md")).unwrap(), "hands");
|
|
}
|
|
|
|
#[test]
|
|
fn an_absolute_or_escaping_link_is_refused_with_the_typed_error() {
|
|
for target in ["/etc/passwd", "../outside", "a/../../outside", "missing"] {
|
|
let seed = tempfile::tempdir().unwrap();
|
|
symlink(target, seed.path().join("bad")).unwrap();
|
|
let out = tempfile::tempdir().unwrap();
|
|
let error = copy_tree(seed.path(), out.path()).unwrap_err();
|
|
let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<SeedLinkRefused>());
|
|
assert_eq!(refused.map(|r| r.target.as_path()), Some(Path::new(target)), "{target}: {error}");
|
|
assert!(out.path().join("bad").symlink_metadata().is_err(), "{target} was copied");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_through_another_in_tree_link_cannot_escape() {
|
|
let parent = tempfile::tempdir().unwrap();
|
|
let seed = parent.path().join("seed");
|
|
std::fs::create_dir(&seed).unwrap();
|
|
symlink(".", seed.join("c")).unwrap();
|
|
symlink("c/..", seed.join("a")).unwrap();
|
|
let out = tempfile::tempdir().unwrap();
|
|
let error = copy_tree(&seed, out.path()).unwrap_err();
|
|
let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<SeedLinkRefused>());
|
|
assert_eq!(refused.map(|r| r.target.as_path()), Some(Path::new("c/..")), "{error}");
|
|
}
|
|
|
|
/// A seed whose parent is not the config dir's parent, and a sibling of the
|
|
/// config dir named `seed` holding the same file name: the copy's link text
|
|
/// resolves there, though in the source it lands under the seed root.
|
|
fn refused_in_the_copy(plant: impl Fn(&Path), expected: &str) {
|
|
let source = tempfile::tempdir().unwrap();
|
|
let seed = source.path().join("seed");
|
|
std::fs::create_dir(&seed).unwrap();
|
|
std::fs::write(seed.join("f"), "seed").unwrap();
|
|
plant(&seed);
|
|
let runtime = tempfile::tempdir().unwrap();
|
|
std::fs::create_dir(runtime.path().join("seed")).unwrap();
|
|
std::fs::write(runtime.path().join("seed/f"), "planted").unwrap();
|
|
let out = runtime.path().join("claude-config");
|
|
std::fs::create_dir(&out).unwrap();
|
|
let error = copy_tree(&seed, &out).unwrap_err();
|
|
let refused = error.get_ref().and_then(|inner| inner.downcast_ref::<SeedLinkRefused>());
|
|
assert_eq!(refused.map(|r| r.target.as_path()), Some(Path::new(expected)), "{error}");
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_that_leaves_the_seed_and_returns_by_its_name_is_refused() {
|
|
refused_in_the_copy(|seed| symlink("../seed/f", seed.join("x")).unwrap(), "../seed/f");
|
|
}
|
|
|
|
#[test]
|
|
fn a_link_through_a_dot_link_to_the_seeds_name_is_refused() {
|
|
refused_in_the_copy(
|
|
|seed| {
|
|
symlink(".", seed.join("c")).unwrap();
|
|
symlink("c/../seed/f", seed.join("a")).unwrap();
|
|
},
|
|
"c/../seed/f",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_result_events_text_reaches_the_journal_with_its_subtype_and_error_flag() {
|
|
let line = json!({"type": "result", "subtype": "success", "is_error": false, "result": "all done"}).to_string();
|
|
let ran = go(vec![Out(line)]);
|
|
assert!(ran.out.contains("result subtype=success is_error=false: all done\n"), "{}", ran.out);
|
|
}
|
|
|
|
#[test]
|
|
fn a_long_result_is_cut_at_the_limit_on_a_character_boundary_and_marked() {
|
|
// One ASCII byte first, so the limit falls in the middle of a two-byte character.
|
|
let text = format!("a{}", "é".repeat(RESULT_TEXT_LIMIT));
|
|
let line = json!({"type": "result", "result": text}).to_string();
|
|
let ran = go(vec![Out(line)]);
|
|
let written = ran.out.lines().find(|l| l.starts_with("result")).unwrap();
|
|
assert_eq!(written, format!("result: a{}", "é".repeat(RESULT_TEXT_LIMIT / 2 - 1)));
|
|
assert!(ran.out.contains("\n[truncated]\n"), "{}", ran.out);
|
|
}
|
|
|
|
// ---- what is read -----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn read_usage_sums_input_cache_read_and_cache_write_and_reads_absent_fields_as_zero() {
|
|
assert_eq!(read_usage(&request("m1", 1, 20, 300)), Usage::Request { id: "m1".into(), prefix: 321 });
|
|
// Each term alone, so dropping or doubling any one is seen.
|
|
assert_eq!(read_usage(&request("m", 7, 0, 0)), Usage::Request { id: "m".into(), prefix: 7 });
|
|
assert_eq!(read_usage(&request("m", 0, 7, 0)), Usage::Request { id: "m".into(), prefix: 7 });
|
|
assert_eq!(read_usage(&request("m", 0, 0, 7)), Usage::Request { id: "m".into(), prefix: 7 });
|
|
for absent in ["input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"] {
|
|
let mut event: Value = serde_json::from_str(&request("m", 1, 20, 300)).unwrap();
|
|
event["message"]["usage"].as_object_mut().unwrap().remove(absent);
|
|
let want = 321 - [("input_tokens", 1), ("cache_read_input_tokens", 20), ("cache_creation_input_tokens", 300)].iter().find(|(name, _)| *name == absent).unwrap().1;
|
|
assert_eq!(read_usage(&event.to_string()), Usage::Request { id: "m".into(), prefix: want }, "{absent} absent");
|
|
}
|
|
let mut no_usage: Value = serde_json::from_str(&request("m", 1, 1, 1)).unwrap();
|
|
no_usage["message"].as_object_mut().unwrap().remove("usage");
|
|
assert_eq!(read_usage(&no_usage.to_string()), Usage::Request { id: "m".into(), prefix: 0 });
|
|
}
|
|
|
|
#[test]
|
|
fn read_usage_classifies_the_owners_real_records() {
|
|
let sums = [84210, 100751, 117174, 118336, 126746, 127250, 129853];
|
|
let mut seen = Vec::new();
|
|
for line in RECORDS.lines() {
|
|
let Usage::Request { id, prefix } = read_usage(line) else { panic!("an assistant record of the main thread is a request: {line}") };
|
|
if !seen.iter().any(|(known, _)| *known == id) {
|
|
seen.push((id, prefix));
|
|
}
|
|
}
|
|
assert_eq!(seen.iter().map(|(_, prefix)| *prefix).collect::<Vec<_>>(), sums);
|
|
assert_eq!(read_usage(&result_line()), Usage::Result { ok: false, blocked: false });
|
|
assert_eq!(read_usage(&ok_result()), Usage::Result { ok: true, blocked: false });
|
|
let blocked = json!({"type": "result", "subtype": "success", "is_error": true, "terminal_reason": "blocking_limit", "result": "Prompt is too long"}).to_string();
|
|
assert_eq!(read_usage(&blocked), Usage::Result { ok: false, blocked: true });
|
|
// An echo is a main-thread user line the CLI replayed, with string content; nothing else is.
|
|
assert_eq!(read_usage(&echo()), Usage::Echo);
|
|
let tool_result = json!({"type": "user", "message": {"role": "user", "content": [{"type": "tool_result", "tool_use_id": "t", "content": "x"}]}}).to_string();
|
|
assert_eq!(read_usage(&tool_result), Usage::Other, "a tool result is not one of our lines");
|
|
let not_replayed = json!({"type": "user", "isReplay": false, "message": {"role": "user", "content": "a line"}}).to_string();
|
|
assert_eq!(read_usage(¬_replayed), Usage::Other);
|
|
let subagent_echo = json!({"type": "user", "isReplay": true, "parent_tool_use_id": "toolu_1", "message": {"role": "user", "content": "a line"}}).to_string();
|
|
assert_eq!(read_usage(&subagent_echo), Usage::Other, "a subagent's line is not ours");
|
|
assert_eq!(read_usage(&init_line()), Usage::Other);
|
|
assert_eq!(read_usage(&subagent("m", 5)), Usage::Other, "a subagent's request is not the main thread's");
|
|
let mut no_id: Value = serde_json::from_str(&at("m", 5)).unwrap();
|
|
no_id["message"].as_object_mut().unwrap().remove("id");
|
|
assert_eq!(read_usage(&no_id.to_string()), Usage::Other);
|
|
let mut null_parent: Value = serde_json::from_str(&at("m", 5)).unwrap();
|
|
null_parent["parent_tool_use_id"] = Value::Null;
|
|
assert_eq!(read_usage(&null_parent.to_string()), Usage::Request { id: "m".into(), prefix: 5 });
|
|
let mut no_parent: Value = serde_json::from_str(&at("m", 5)).unwrap();
|
|
no_parent.as_object_mut().unwrap().remove("parent_tool_use_id");
|
|
assert_eq!(read_usage(&no_parent.to_string()), Usage::Request { id: "m".into(), prefix: 5 });
|
|
for unreadable in ["", "not json", "[1,2]", "\"assistant\"", "12", "null", "{\"type\":"] {
|
|
assert_eq!(read_usage(unreadable), Usage::Unreadable, "{unreadable:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn records_that_share_a_message_id_count_once_and_subagent_records_never_move_the_prefix() {
|
|
let mut steps: Vec<Step> = RECORDS.lines().map(|line| Out(line.to_string())).collect();
|
|
steps.insert(3, Out(subagent("msg_sub", 999_999)));
|
|
let ran = go(steps);
|
|
assert_eq!(
|
|
ran.out,
|
|
"request 1 prefix 84210
|
|
request 2 prefix 100751
|
|
request 3 prefix 117174
|
|
request 4 prefix 118336
|
|
request 5 prefix 126746
|
|
request 6 prefix 127250
|
|
request 7 prefix 129853
|
|
"
|
|
);
|
|
let (cause, _, requests, prefix) = ran.ended();
|
|
assert_eq!((cause, requests, prefix), (Cause::Own, 7, 129_853), "the newest prefix, 129853, not a subagent's 999999");
|
|
assert_eq!(flushes(&ran), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_prefix_is_the_newest_requests_not_the_largest() {
|
|
let ran = go(vec![Out(at("m1", 200_000)), Out(at("m2", 150_000))]);
|
|
assert_eq!(ran.ended().3, 150_000);
|
|
}
|
|
|
|
// ---- the flush --------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn below_the_ceiling_no_flush_is_written_and_at_it_exactly_one() {
|
|
let below = go(vec![Out(at("m1", CEILING_TOKENS - 1))]);
|
|
assert_eq!(flushes(&below), 0);
|
|
assert_eq!(below.ended().0, Cause::Own);
|
|
|
|
let at_it = go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS - 1)), Out(at("m2", CEILING_TOKENS)), Out(at("m3", CEILING_TOKENS + 10_000))]);
|
|
assert_eq!(flushes(&at_it), 1, "{:?}", at_it.written());
|
|
assert_eq!(at_it.written().len(), 2, "the start and the flush");
|
|
assert_eq!(at_it.ended().0, Cause::FlushRequested);
|
|
assert_eq!(CEILING_TOKENS, 300_000);
|
|
}
|
|
|
|
#[test]
|
|
fn a_closed_input_is_never_asked_for_a_flush_whatever_the_prefix_grows_to() {
|
|
// An error before any answer closes the input; a request that comes after it asks nothing.
|
|
let ran = go(vec![Out(echo()), Out(result_line()), Out(at("m2", CEILING_TOKENS + 1))]);
|
|
assert_eq!(ran.count(&Event::InputClosed), 1);
|
|
assert_eq!(flushes(&ran), 0);
|
|
assert_eq!(ran.ended().0, Cause::Own);
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn stdin_closes_once_at_the_result_after_the_flushs_echo_folded_or_queued_and_nothing_is_written_after() {
|
|
let writes_before_close = |ran: &Ran| {
|
|
assert_eq!(ran.count(&Event::InputClosed), 1, "{:?}", ran.events);
|
|
let closed_at = ran.events.iter().position(|event| *event == Event::InputClosed).unwrap();
|
|
assert_eq!(ran.events[..closed_at].iter().filter(|event| matches!(event, Event::Wrote(_))).count(), 2, "start and flush before the close");
|
|
assert!(ran.events[closed_at..].iter().all(|event| !matches!(event, Event::Wrote(_))));
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0, "never a grace interrupt");
|
|
};
|
|
// Folded: the flush lands in the running turn; its one result answers it.
|
|
writes_before_close(&go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(echo()), Out(at("m2", 11)), Out(ok_result())]));
|
|
// Queued: the first result ends the turn it was queued behind; the second is the flush's own.
|
|
writes_before_close(&go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(ok_result()), Out(echo()), Out(at("m2", 11)), Out(ok_result())]));
|
|
// The first result of a queued flush closes nothing: the flush is still to have its turn.
|
|
let one = go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(ok_result())]);
|
|
assert_eq!(one.count(&Event::InputClosed), 0, "{:?}", one.events);
|
|
|
|
let never = go(vec![Out(at("m1", 10))]);
|
|
assert_eq!(never.count(&Event::InputClosed), 0, "no result, no close");
|
|
}
|
|
|
|
// ---- the interrupt ------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn after_the_flush_the_grace_interrupts_exactly_once_and_not_a_second_early() {
|
|
let grace = FLUSH_GRACE_S;
|
|
// Just short of the grace: no interrupt, and the child ended on its own.
|
|
let early = go(vec![Out(at("m1", CEILING_TOKENS)), Idle(grace - 1)]);
|
|
assert_eq!(early.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
assert_eq!(early.ended().0, Cause::FlushRequested);
|
|
|
|
// At the grace, and long past it: one interrupt.
|
|
let on_time = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace), Idle(grace), Idle(grace)], Some(130), true);
|
|
assert_eq!(on_time.count(&Event::Stopped(Stop::Interrupt)), 1);
|
|
assert_eq!(on_time.ended().0, Cause::Signalled(Stop::Interrupt));
|
|
|
|
// Exactly at the grace is the grace: the interrupt comes then, not on the next wait.
|
|
let exact = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace)], Some(130), true);
|
|
assert_eq!(exact.ended().0, Cause::Signalled(Stop::Interrupt));
|
|
|
|
// A child that ignores it is interrupted once and still waited for.
|
|
let stubborn = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(grace), Idle(grace), Idle(grace)], Some(0), false);
|
|
assert_eq!(stubborn.count(&Event::Stopped(Stop::Interrupt)), 1);
|
|
assert_eq!(stubborn.ended().1, Some(0), "its own end is waited for");
|
|
assert_eq!(FLUSH_GRACE_S, 900);
|
|
}
|
|
|
|
#[test]
|
|
fn the_wait_for_the_child_never_passes_the_grace_deadline_and_never_spins_after_the_interrupt() {
|
|
let ran = go(vec![Out(at("m1", CEILING_TOKENS)), Idle(840), Idle(59), Idle(1)]);
|
|
// The inbox poll's 30 until the grace is near, the one second left, then the escalation's own wait, then the drain after the child's exit.
|
|
assert_eq!(ran.asked(), [30, 30, 30, 1, 29, 2], "{:?}", ran.asked());
|
|
}
|
|
|
|
#[test]
|
|
fn the_headroom_counts_the_requests_after_the_first_result_that_follows_the_flush_and_one_short_of_it_does_not_interrupt() {
|
|
let hard = CEILING_TOKENS + FLUSH_HEADROOM_TOKENS;
|
|
let short = go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(ok_result()), Out(echo()), Out(at("m2", hard - 1))]);
|
|
assert_eq!(short.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
assert_eq!(short.ended().0, Cause::FlushRequested);
|
|
|
|
let at_hard = carry_with(&Seen::of(&with_run()), vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(ok_result()), Out(echo()), Out(at("m2", hard)), Out(at("m3", hard + 1))], Some(130), true);
|
|
assert_eq!(at_hard.count(&Event::Stopped(Stop::Interrupt)), 1);
|
|
assert_eq!(at_hard.ended().0, Cause::Signalled(Stop::Interrupt));
|
|
assert_eq!(FLUSH_HEADROOM_TOKENS, 60_000);
|
|
|
|
// Before the flush's echo the running turn's own requests are not the flush's turn: however far they grow, no interrupt.
|
|
let same_turn = go(vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(at("m2", hard)), Out(at("m3", hard + 500_000))]);
|
|
assert_eq!(same_turn.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_jump_past_the_ceiling_plus_the_headroom_writes_the_flush_and_the_running_turn_cannot_use_up_the_flushs_turn() {
|
|
let jump = carry_with(
|
|
&Seen::of(&with_run()),
|
|
vec![Out(echo()), Out(at("m1", 290_000)), Out(at("m2", 400_000)), Out(at("m3", 470_000)), Out(ok_result()), Out(echo()), Out(at("m4", 459_999)), Out(at("m5", 460_000)), Out(at("m6", 470_000))],
|
|
Some(130),
|
|
true,
|
|
);
|
|
let kinds: Vec<&str> = jump
|
|
.events
|
|
.iter()
|
|
.filter_map(|event| match event {
|
|
Event::Wrote(line) if line.contains("context ceiling") => Some("flush"),
|
|
Event::Stopped(_) => Some("interrupt"),
|
|
Event::InputClosed => Some("closed"),
|
|
Event::Asked(_) => Some("read"),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
// Reads: the start echo, m1, m2 (the jump, which writes the flush), then m3 (the running turn's 470k, past the headroom: no interrupt).
|
|
assert_eq!(kinds[..6], ["read", "read", "read", "flush", "read", "read"], "{kinds:?}");
|
|
// The headroom is measured from the prefix the flush was asked at (400k) and counts the requests after the
|
|
// flush's echo only: 459_999 does not interrupt; 460_000 does, once, and m6 is never read.
|
|
assert_eq!(jump.count(&Event::Stopped(Stop::Interrupt)), 1, "{kinds:?}");
|
|
assert_eq!(jump.lines(), "request 1 prefix 290000
|
|
request 2 prefix 400000
|
|
request 3 prefix 470000
|
|
result subtype=success is_error=false: The answer.
|
|
request 4 prefix 459999
|
|
request 5 prefix 460000
|
|
");
|
|
assert_eq!(jump.ended().0, Cause::Signalled(Stop::Interrupt));
|
|
assert_eq!(flushes(&jump), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_that_ends_before_the_grace_is_never_interrupted() {
|
|
let ran = go(vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())]);
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
assert_eq!(ran.ended().0, Cause::FlushRequested);
|
|
}
|
|
|
|
#[test]
|
|
fn the_grace_is_the_full_grace_from_the_moment_the_flush_was_asked_whichever_request_that_was() {
|
|
let grace = FLUSH_GRACE_S;
|
|
// Five requests, time passing before the ceiling, then the flush on the sixth.
|
|
let before: Vec<Step> = vec![Out(echo()), Out(at("m1", 10)), Idle(500), Out(at("m2", 20)), Out(at("m3", 30)), Out(at("m4", 40)), Out(at("m5", 50)), Out(at("m6", CEILING_TOKENS))];
|
|
let early = carry_with(&Seen::of(&with_run()), [before.clone(), vec![Idle(grace - 1)]].concat(), Some(0), true);
|
|
assert_eq!((flushes(&early), early.count(&Event::Stopped(Stop::Interrupt))), (1, 0), "one second short of the grace, flush on request 6");
|
|
let on_time = carry_with(&Seen::of(&with_run()), [before, vec![Idle(grace - 1), Idle(1)]].concat(), Some(0), true);
|
|
assert_eq!(on_time.count(&Event::Stopped(Stop::Interrupt)), 1, "the grace is the same after six requests as after one");
|
|
}
|
|
|
|
#[test]
|
|
fn the_message_ids_of_a_long_run_are_all_remembered() {
|
|
let ids: Vec<String> = (1..=12).map(|n| format!("m{n}")).collect();
|
|
let mut steps: Vec<Step> = ids.iter().map(|id| Out(at(id, 1_000))).collect();
|
|
// Every earlier request seen again, the oldest and the newest among them: none counts twice.
|
|
steps.extend(ids.iter().map(|id| Out(at(id, 2_000))));
|
|
let ran = go(steps);
|
|
let (_, _, requests, prefix) = ran.ended();
|
|
assert_eq!((requests, prefix), (12, 1_000));
|
|
assert_eq!(ran.out.lines().filter(|line| line.starts_with("request ")).count(), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn a_usage_sum_that_overflows_is_at_or_over_the_ceiling_not_a_panic_or_a_wrap() {
|
|
let line = request("m", u64::MAX, 1, 0);
|
|
assert_eq!(read_usage(&line), Usage::Request { id: "m".into(), prefix: u64::MAX });
|
|
assert_eq!(read_usage(&request("m", u64::MAX, u64::MAX, u64::MAX)), Usage::Request { id: "m".into(), prefix: u64::MAX });
|
|
assert_eq!(read_usage(&request("m", 1, u64::MAX, 1)), Usage::Request { id: "m".into(), prefix: u64::MAX });
|
|
// Through the actuator: the flush is asked for, and the headroom beyond a saturated prefix does not overflow
|
|
// either, and the pass that wrote the flush does not interrupt.
|
|
let ran = go(vec![Out(echo()), Out(request("m1", u64::MAX, 7, 0)), Out(request("m2", 0, 0, 0))]);
|
|
assert_eq!(flushes(&ran), 1);
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn an_error_result_before_any_answer_closes_the_input_at_once_and_the_run_ends_with_a_typed_outcome() {
|
|
// The shape of an API or authentication failure: an error result before any answer. A flush would fail the same way.
|
|
let ran = carry_with(&Seen::of(&with_run()), vec![Out(echo()), Out(result_line())], Some(1), false);
|
|
assert_eq!(ran.count(&Event::InputClosed), 1);
|
|
assert_eq!(flushes(&ran), 0);
|
|
assert_eq!(ran.ended(), (Cause::Own, Some(1), 0, 0));
|
|
assert_eq!(ran.outcome.as_ref().unwrap().exit_status(), 1);
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn without_a_flush_nothing_is_ever_signalled_however_large_the_prefix_grows() {
|
|
// An error before any answer closed the input: a request far over the ceiling asks for no flush and nothing is signalled.
|
|
let ran = go(vec![Out(echo()), Out(result_line()), Out(at("m2", 900_000)), Idle(100_000)]);
|
|
assert_eq!(ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 0);
|
|
assert_eq!(ran.ended().0, Cause::Own);
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_that_ignores_a_signal_gets_the_next_one_after_the_bound_and_the_outcome_names_the_one_that_ended_it() {
|
|
let hung = |dies_on: Option<Stop>, refuses: &[Stop], rest: Vec<Step>| {
|
|
carry_signalled(&Seen::of(&with_run()), [vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], rest].concat(), Some(0), dies_on, refuses)
|
|
};
|
|
let stops = |ran: &Ran| -> Vec<Event> { ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).cloned().collect() };
|
|
let after = ESCALATE_AFTER_S;
|
|
|
|
// Ignores SIGINT and SIGTERM: each next signal waits out the bound, SIGKILL ends it.
|
|
let killed = hung(Some(Stop::Kill), &[], vec![Idle(after - 1), Idle(1), Idle(after - 1), Idle(1), Idle(after)]);
|
|
assert_eq!(stops(&killed), [Event::Stopped(Stop::Interrupt), Event::Stopped(Stop::Terminate), Event::Stopped(Stop::Kill)]);
|
|
assert_eq!(killed.ended().0, Cause::Signalled(Stop::Kill));
|
|
|
|
// One second short of the bound there is no second signal.
|
|
let waiting = hung(None, &[], vec![Idle(after - 1)]);
|
|
assert_eq!(stops(&waiting), [Event::Stopped(Stop::Interrupt)]);
|
|
|
|
// SIGTERM ends it: the outcome says terminated, and nothing stronger is sent.
|
|
let terminated = hung(Some(Stop::Terminate), &[], vec![Idle(after), Idle(after)]);
|
|
assert_eq!(stops(&terminated), [Event::Stopped(Stop::Interrupt), Event::Stopped(Stop::Terminate)]);
|
|
assert_eq!(terminated.outcome.as_ref().unwrap().to_string(), "ended: terminated code 0 requests 1 prefix 300000");
|
|
}
|
|
|
|
#[test]
|
|
fn a_signal_that_could_not_be_delivered_is_never_reported_as_sent() {
|
|
let hung = |refuses: &[Stop], rest: Vec<Step>| {
|
|
carry_signalled(&Seen::of(&with_run()), [vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], rest].concat(), Some(0), None, refuses)
|
|
};
|
|
// SIGINT is refused and the child ends by itself afterwards: the flush was requested, nothing interrupted it.
|
|
let refused = hung(&[Stop::Interrupt], vec![]);
|
|
assert_eq!(refused.count(&Event::Stopped(Stop::Interrupt)), 1, "it was attempted");
|
|
assert_eq!(refused.ended().0, Cause::FlushRequested);
|
|
|
|
// SIGINT delivered but ignored, SIGTERM refused, the child ends on its own: the strongest delivered is SIGINT.
|
|
let partly = hung(&[Stop::Terminate], vec![Idle(ESCALATE_AFTER_S)]);
|
|
assert_eq!(partly.ended().0, Cause::Signalled(Stop::Interrupt));
|
|
|
|
// Not even SIGKILL is delivered and the child never ends: after the bound the run is over without it, and the label says so.
|
|
let stuck = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], Some(0), None, &[Stop::Interrupt, Stop::Terminate, Stop::Kill], true);
|
|
assert_eq!(stuck.ended(), (Cause::Unstoppable, None, 1, CEILING_TOKENS));
|
|
assert_eq!(stuck.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 3);
|
|
assert_eq!(stuck.outcome.unwrap().exit_status(), 1);
|
|
}
|
|
|
|
/// A clock that counts its reads.
|
|
struct Counting(Fixed, Cell<u32>);
|
|
|
|
impl Clock for Counting {
|
|
fn now(&self) -> chrono::DateTime<chrono::Utc> {
|
|
self.1.set(self.1.get() + 1);
|
|
self.0.now()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_clock_is_read_once_per_event_so_a_step_of_the_wall_clock_cannot_split_a_pass() {
|
|
let mind = with_run();
|
|
let log = Rc::new(RefCell::new(Vec::new()));
|
|
let clock = Rc::new(Counting(Fixed(Cell::new(at_start())), Cell::new(0)));
|
|
// The child's reads move the clock by far more than the grace, as a stepped wall clock would.
|
|
struct Stepped(Rc<Counting>);
|
|
impl Clock for Stepped {
|
|
fn now(&self) -> chrono::DateTime<chrono::Utc> {
|
|
let now = self.0.now();
|
|
self.0.0.0.set(now + ChronoDuration::seconds(FLUSH_GRACE_S + 1));
|
|
now
|
|
}
|
|
}
|
|
let steps: VecDeque<Step> = vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(at("m2", CEILING_TOKENS + 1)), Idle(0)].into();
|
|
let child = Scripted { steps, log: log.clone(), clock: Rc::new(Fixed(Cell::new(at_start()))), code: Some(0), dies_on: None, refuses: vec![], hangs: false, exited: false, eof: false, asked: 0 };
|
|
let fake = Fake::with(Some(child), false);
|
|
let outcome = carry(&Seen::of(&mind), &Stepped(clock.clone()), &FakeLender::new(Answer::Grant), &fake, TEST_CAP_S, &mut Vec::new()).unwrap();
|
|
let events = log.borrow().clone();
|
|
let reads = events.iter().filter(|event| matches!(event, Event::Asked(_))).count() as u32;
|
|
// One read for the lease's expiry, one before the first wait, then one per fact heard, and one for the closure:
|
|
// never a second read inside a pass.
|
|
assert_eq!(clock.1.get(), 3 + reads, "{events:?}");
|
|
// The flush was written on the first request at time 0; the very next pass sees the stepped clock.
|
|
// The flush pass itself has no interrupt, whatever the step: the interrupt is on a later event.
|
|
let flush_at = events.iter().position(|event| matches!(event, Event::Wrote(line) if line.contains("context ceiling"))).unwrap();
|
|
let stop_at = events.iter().position(|event| matches!(event, Event::Stopped(_))).unwrap();
|
|
assert!(events[flush_at..stop_at].iter().any(|event| matches!(event, Event::Asked(_))), "a read between the flush and the interrupt: {events:?}");
|
|
assert_eq!(outcome.exit_status(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn a_deadline_that_came_during_a_line_is_answered_without_waiting_for_the_next_poll() {
|
|
// The result arrives after the grace has run out: the input closes, and the interrupt is due at once.
|
|
let ran = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Step::After(FLUSH_GRACE_S + 5, ok_result())], Some(130), Some(Stop::Interrupt), &[], true);
|
|
assert_eq!(ran.count(&Event::Stopped(Stop::Interrupt)), 1);
|
|
assert_eq!(ran.count(&Event::InputClosed), 0, "the flush is queued behind the turn that just ended: its echo has not come");
|
|
let stop_at = ran.events.iter().position(|event| matches!(event, Event::Stopped(_))).unwrap();
|
|
let reads_before = ran.events[..stop_at].iter().filter(|event| matches!(event, Event::Asked(_))).count();
|
|
assert_eq!(reads_before, 2, "the interrupt is in the pass that heard the result, not a poll away: {:?}", ran.events);
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_that_exited_is_never_signalled_however_long_its_stream_stays_open() {
|
|
// Flush asked for, the child exits, then the grace and more pass with its stream still open.
|
|
let ran = carry_scripted(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Exit(Some(0)), Idle(1)], Some(0), None, &[], true);
|
|
assert_eq!(ran.events.iter().filter(|event| matches!(event, Event::Stopped(_))).count(), 0);
|
|
assert_eq!(ran.ended(), (Cause::FlushRequested, Some(0), 1, CEILING_TOKENS));
|
|
// The stream ending first and the exit after: the same end.
|
|
let eof_first = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10)), Eof], Some(0), false);
|
|
assert_eq!(eof_first.ended(), (Cause::Own, Some(0), 1, 10));
|
|
}
|
|
|
|
// ---- how it ends --------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn the_outcome_names_how_it_ended_and_the_exit_status_follows_the_childs_code() {
|
|
let own = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10)), Out(result_line())], Some(0), false);
|
|
assert_eq!(own.outcome.as_ref().unwrap(), &SelfRunOutcome::Ended { cause: Cause::Own, code: Some(0), requests: 1, prefix: 10 });
|
|
assert_eq!(own.outcome.as_ref().unwrap().to_string(), "ended: own code 0 requests 1 prefix 10");
|
|
assert_eq!(own.outcome.unwrap().exit_status(), 0);
|
|
|
|
let flushed = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Out(result_line())], Some(0), false);
|
|
assert_eq!(flushed.outcome.as_ref().unwrap().to_string(), "ended: flush-requested code 0 requests 1 prefix 300000");
|
|
assert_eq!(flushed.outcome.unwrap().exit_status(), 0);
|
|
|
|
let nonzero = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", 10))], Some(2), false);
|
|
assert_eq!(nonzero.outcome.as_ref().unwrap().to_string(), "ended: own code 2 requests 1 prefix 10");
|
|
assert_eq!(nonzero.outcome.unwrap().exit_status(), 1);
|
|
|
|
let signalled = carry_with(&Seen::of(&with_run()), vec![Out(at("m1", CEILING_TOKENS)), Idle(FLUSH_GRACE_S)], None, true);
|
|
assert_eq!(signalled.outcome.as_ref().unwrap().to_string(), "ended: interrupted signal requests 1 prefix 300000");
|
|
assert_eq!(signalled.outcome.unwrap().exit_status(), 1, "a child killed by a signal has no code");
|
|
|
|
assert_eq!(SelfRunOutcome::NoRun.exit_status(), 3);
|
|
assert_eq!(SelfRunOutcome::NoRun.to_string(), "no run: she has no single open Self run");
|
|
}
|
|
|
|
|
|
// ---- the real child -----------------------------------------------------------
|
|
|
|
#[test]
|
|
fn unreadable_and_foreign_lines_are_skipped_and_never_printed() {
|
|
let canary = "canary-stream-line-8c41";
|
|
let mut foreign: Value = serde_json::from_str(&init_line()).unwrap();
|
|
foreign["cwd"] = json!(canary);
|
|
foreign["type"] = json!(canary);
|
|
let ran = go(vec![
|
|
Out(format!("not json {canary}")),
|
|
Out(foreign.to_string()),
|
|
Out(format!("[\"{canary}\"]")),
|
|
Out(at("m1", 10)),
|
|
Out(result_line()),
|
|
]);
|
|
assert_eq!(ran.lines(), "request 1 prefix 10
|
|
result subtype=success is_error=true: Failed to authenticate: OAuth session expired and could not be refreshed
|
|
");
|
|
let outcome = ran.outcome.as_ref().unwrap().to_string();
|
|
assert!(!ran.out.contains(canary) && !outcome.contains(canary));
|
|
assert!(!ran.written().iter().any(|text| text.contains(canary)), "nothing from the stream is written back");
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
mod process {
|
|
mod lease_child;
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
use std::path::PathBuf;
|
|
|
|
use super::*;
|
|
use crate::launch::SystemClock;
|
|
|
|
/// A fake `claude`: the shell script `body`, in a directory that outlives the child.
|
|
fn script(dir: &tempfile::TempDir, body: &str) -> PathBuf {
|
|
let path = dir.path().join("claude");
|
|
std::fs::write(&path, format!("#!/bin/bash\n{body}\n")).unwrap();
|
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
path
|
|
}
|
|
|
|
/// Spawns it, retrying the one error a freshly written executable can give
|
|
/// while another test's fork still holds its write handle.
|
|
fn start(program: &Path, args: &[String]) -> Box<dyn Child> {
|
|
start_with(program, args, &token_holding("tok-canary"))
|
|
}
|
|
|
|
/// What the unit would hold: what a shell needs, and credential-shaped variables
|
|
/// that live only here, never in the test process, so only the spawner can pass them on.
|
|
fn unit_environment() -> Vec<(std::ffi::OsString, std::ffi::OsString)> {
|
|
let mut unit: Vec<(std::ffi::OsString, std::ffi::OsString)> = vec![("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into())];
|
|
for name in CREDENTIAL_CANARIES {
|
|
unit.push((name.into(), format!("canary-{name}").into()));
|
|
}
|
|
unit
|
|
}
|
|
|
|
const CREDENTIAL_CANARIES: [&str; 6] = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_API_KEY_HELPER_TTL_MS", "UNLISTED_VARIABLE"];
|
|
|
|
fn start_with(program: &Path, args: &[String], token: &TokenFd) -> Box<dyn Child> {
|
|
for _ in 0..50 {
|
|
let config = FreshConfig::create(program.parent().unwrap(), None).unwrap();
|
|
match spawn_program(program, args, token, &unit_environment(), config) {
|
|
Ok(child) => return child,
|
|
Err(error) if format!("{error:#}").contains("busy") => std::thread::sleep(StdDuration::from_millis(100)),
|
|
Err(error) => panic!("{error:#}"),
|
|
}
|
|
}
|
|
panic!("the script stayed busy");
|
|
}
|
|
|
|
/// Everything the child gives until it has been reaped and its stdout has ended.
|
|
fn drain(child: &mut dyn Child) -> (Vec<String>, Option<i32>) {
|
|
let (mut lines, mut code, mut eof) = (Vec::new(), None, false);
|
|
loop {
|
|
match child.next(StdDuration::from_secs(20)) {
|
|
Read::Line(line) => lines.push(line),
|
|
Read::Eof => eof = true,
|
|
Read::Exited(status) => code = Some(status),
|
|
Read::Timeout => panic!("the fake claude hung"),
|
|
}
|
|
if let (true, Some(status)) = (eof, code) {
|
|
return (lines, status);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a process of this id still runs. A zombie is dead: with no init to reap
|
|
/// it (cargo as PID 1 in a one-command job) it stays in the table after it is killed.
|
|
fn alive(pid: &str) -> bool {
|
|
let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { return false };
|
|
stat.rsplit_once(')').is_some_and(|(_, rest)| !rest.trim_start().starts_with('Z'))
|
|
}
|
|
|
|
#[test]
|
|
fn the_child_gets_its_arguments_and_lines_in_order_and_ends_when_its_input_closes() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let program = script(&dir, "echo \"args: $*\"\nwhile read line; do echo \"got: $line\"; done\necho bye\nexit 7");
|
|
let mut child = start(&program, &["-p".to_string(), "--max-turns".to_string(), "500".to_string()]);
|
|
child.write_line("first");
|
|
child.write_line("second");
|
|
child.close_input();
|
|
assert_eq!(drain(&mut *child), (["args: -p --max-turns 500", "got: first", "got: second", "bye"].map(String::from).to_vec(), Some(7)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_silent_child_times_out_instead_of_blocking_and_a_signalled_child_has_no_code() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let program = script(&dir, "kill -9 $$");
|
|
let mut child = start(&program, &[]);
|
|
assert_eq!(drain(&mut *child), (Vec::new(), None));
|
|
|
|
let quiet = script(&dir, "exec sleep 5");
|
|
let mut child = start(&quiet, &[]);
|
|
assert_eq!(child.next(StdDuration::from_millis(100)), Read::Timeout);
|
|
child.stop(Stop::Interrupt);
|
|
assert_eq!(drain(&mut *child), (Vec::new(), None), "SIGINT ended the unprepared child");
|
|
}
|
|
|
|
#[test]
|
|
fn interrupt_is_sigint_the_childs_own_handler_can_catch() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let program = script(&dir, "trap 'echo interrupted; exit 130' INT\necho ready\nwhile :; do sleep 0.05; done");
|
|
let mut child = start(&program, &[]);
|
|
assert_eq!(child.next(StdDuration::from_secs(20)), Read::Line("ready".into()));
|
|
child.stop(Stop::Interrupt);
|
|
assert_eq!(drain(&mut *child), (vec!["interrupted".to_string()], Some(130)));
|
|
}
|
|
|
|
#[test]
|
|
fn a_program_that_cannot_start_is_an_error_that_names_no_argument() {
|
|
let canary = "canary-argument-6b0d";
|
|
let scratch = tempfile::tempdir().unwrap();
|
|
let config = FreshConfig::create(scratch.path(), None).unwrap();
|
|
let error = spawn_program(Path::new("/nonexistent/claude"), &[canary.to_string()], &token_holding("tok-canary"), &[], config).err().unwrap();
|
|
assert!(!format!("{error:#}").contains(canary) && !format!("{error:#}").contains("nonexistent"), "{error:#}");
|
|
}
|
|
|
|
/// A read that times out here means the stream stopped, never a wait worth 60 s:
|
|
/// fail at once instead of hanging the suite.
|
|
struct Prompt(Box<dyn Child>);
|
|
impl Child for Prompt {
|
|
fn write_line(&mut self, line: &str) {
|
|
self.0.write_line(line)
|
|
}
|
|
fn close_input(&mut self) {
|
|
self.0.close_input()
|
|
}
|
|
fn next(&mut self, timeout: StdDuration) -> Read {
|
|
match self.0.next(StdDuration::from_secs(20).min(timeout)) {
|
|
Read::Timeout => panic!("the fake claude went quiet"),
|
|
read => read,
|
|
}
|
|
}
|
|
fn stop(&mut self, how: Stop) -> bool {
|
|
self.0.stop(how)
|
|
}
|
|
}
|
|
|
|
struct Spawn(PathBuf);
|
|
impl Spawner for Spawn {
|
|
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
|
Ok(Box::new(Prompt(start_with(&self.0, args, token))))
|
|
}
|
|
}
|
|
|
|
/// The real child as it is: a read may time out, which is how the drain after an exit is waited.
|
|
/// A run that has not ended by 15 s is a failure, not a hang.
|
|
struct Bounded(Box<dyn Child>, std::time::Instant);
|
|
impl Child for Bounded {
|
|
fn write_line(&mut self, line: &str) {
|
|
self.0.write_line(line)
|
|
}
|
|
fn close_input(&mut self) {
|
|
self.0.close_input()
|
|
}
|
|
fn next(&mut self, timeout: StdDuration) -> Read {
|
|
assert!(self.1.elapsed() < StdDuration::from_secs(15), "the run did not end");
|
|
self.0.next(StdDuration::from_secs(1).min(timeout))
|
|
}
|
|
fn stop(&mut self, how: Stop) -> bool {
|
|
self.0.stop(how)
|
|
}
|
|
}
|
|
|
|
struct Patient(PathBuf);
|
|
impl Spawner for Patient {
|
|
fn spawn(&self, args: &[String], token: &TokenFd) -> Result<Box<dyn Child>> {
|
|
Ok(Box::new(Bounded(start_with(&self.0, args, token), std::time::Instant::now())))
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_whole_run_through_the_real_child_flushes_at_the_ceiling_and_ends_on_the_flushs_own_result() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let lines = [at("m1", 10), at("m2", CEILING_TOKENS)].join("
|
|
");
|
|
let (echo, result) = (echo(), ok_result());
|
|
// Prints two requests, waits for the start and the flush on stdin, echoes both, then answers, as a stand-in for a Self that obeys.
|
|
let body = format!("cat <<'EOF'
|
|
{lines}
|
|
EOF
|
|
read -t 5 start
|
|
read -t 5 flush
|
|
cat <<'EOF'
|
|
{echo}
|
|
{echo}
|
|
{result}
|
|
EOF
|
|
while read more; do :; done
|
|
exit 0");
|
|
let program = script(&dir, &body);
|
|
let mind = with_run();
|
|
let mut out = Vec::new();
|
|
let outcome = carry(&Seen::of(&mind), &SystemClock, &FakeLender::new(Answer::Grant), &Spawn(program), TEST_CAP_S, &mut out).unwrap();
|
|
assert_eq!(outcome, SelfRunOutcome::Ended { cause: Cause::FlushRequested, code: Some(0), requests: 2, prefix: 300_000 });
|
|
let printed: String = String::from_utf8(out).unwrap().lines().filter(|line| !line.starts_with("decision ")).map(|line| format!("{line}\n")).collect();
|
|
assert_eq!(printed, "request 1 prefix 10
|
|
request 2 prefix 300000
|
|
result subtype=success is_error=false: The answer.
|
|
");
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_that_ends_after_a_failure_result_ends_the_run_promptly_through_the_real_child() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let result = result_line();
|
|
// An authentication or API failure: the error result is the first thing it prints, then it exits 1.
|
|
let program = script(&dir, &format!("read -t 5 start\ncat <<'EOF'\n{result}\nEOF\nexit 1"));
|
|
let started = std::time::Instant::now();
|
|
let mut out = Vec::new();
|
|
let outcome = carry(&Seen::of(&with_run()), &SystemClock, &FakeLender::new(Answer::Grant), &Spawn(program), TEST_CAP_S, &mut out).unwrap();
|
|
assert_eq!(outcome, SelfRunOutcome::Ended { cause: Cause::Own, code: Some(1), requests: 0, prefix: 0 });
|
|
assert_eq!(outcome.exit_status(), 1);
|
|
assert!(!String::from_utf8_lossy(&out).contains("request "), "no request was heard");
|
|
assert!(started.elapsed() < StdDuration::from_secs(20), "it ended promptly");
|
|
}
|
|
|
|
/// The Yggdrasil probe shape: the child prints its result and exits, and a background
|
|
/// process it started still holds its stdout, so the stream never ends. The run is over
|
|
/// when the child is, and the actuator signals nothing: the child is gone.
|
|
#[test]
|
|
fn a_child_that_exits_while_a_descendant_holds_its_stdout_ends_the_run_without_a_signal() {
|
|
for flushed in [false, true] {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let pids = dir.path().join("holder.pid");
|
|
let (result, ceiling) = (result_line(), at("m1", CEILING_TOKENS));
|
|
let before = if flushed { format!("cat <<'EOF'\n{ceiling}\nEOF\nread -t 5 start\nread -t 5 flush\n") } else { "read -t 5 start\n".to_string() };
|
|
let program = script(&dir, &format!("{before}cat <<'EOF'\n{result}\nEOF\nsleep 30 &\necho $! > {}\nexit 0", pids.display()));
|
|
let started = std::time::Instant::now();
|
|
let mut out = Vec::new();
|
|
let outcome = carry(&Seen::of(&with_run()), &SystemClock, &FakeLender::new(Answer::Grant), &Patient(program), TEST_CAP_S, &mut out).unwrap();
|
|
let holder = std::fs::read_to_string(&pids).unwrap().trim().to_string();
|
|
let cause = if flushed { Cause::FlushRequested } else { Cause::Own };
|
|
let (requests, prefix) = if flushed { (1, CEILING_TOKENS) } else { (0, 0) };
|
|
assert_eq!(outcome, SelfRunOutcome::Ended { cause, code: Some(0), requests, prefix }, "flushed: {flushed}");
|
|
assert!(alive(&holder), "the actuator signals no one after the child is gone");
|
|
assert!(started.elapsed() < StdDuration::from_secs(20), "it ended on the child's exit: {:?}", started.elapsed());
|
|
Command::new("kill").arg(&holder).status().unwrap();
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_signal_reaches_a_child_that_ignores_the_weaker_ones_and_one_for_a_gone_child_is_not_delivered() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let program = script(&dir, "trap '' INT TERM\necho ready\nwhile :; do sleep 0.05; done");
|
|
let mut child = start(&program, &[]);
|
|
assert_eq!(child.next(StdDuration::from_secs(20)), Read::Line("ready".into()));
|
|
assert!(child.stop(Stop::Interrupt) && child.stop(Stop::Terminate), "delivered, and ignored");
|
|
assert_eq!(child.next(StdDuration::from_millis(300)), Read::Timeout, "still running");
|
|
assert!(child.stop(Stop::Kill));
|
|
assert_eq!(drain(&mut *child), (Vec::new(), None), "SIGKILL leaves no code");
|
|
|
|
let quick = script(&dir, "exit 0");
|
|
let mut gone = start(&quick, &[]);
|
|
assert_eq!(drain(&mut *gone), (Vec::new(), Some(0)));
|
|
assert!(!gone.stop(Stop::Interrupt), "no such process");
|
|
}
|
|
|
|
#[test]
|
|
fn a_signal_reaches_the_whole_process_group_the_child_started() {
|
|
// Not SIGINT: a background job of a non-interactive shell ignores it, which is the shell's rule, not ours.
|
|
for how in [Stop::Terminate, Stop::Kill] {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
// The child starts a descendant of its own and waits for it.
|
|
let program = script(&dir, "sleep 300 &\necho $!\nwait");
|
|
let mut child = start(&program, &[]);
|
|
let Read::Line(holder) = child.next(StdDuration::from_secs(20)) else { panic!("no pid") };
|
|
assert!(alive(&holder));
|
|
assert!(child.stop(how), "{how:?} delivered to the group");
|
|
drain(&mut *child);
|
|
let mut waited = 0;
|
|
while alive(&holder) && waited < 100 {
|
|
std::thread::sleep(StdDuration::from_millis(50));
|
|
waited += 1;
|
|
}
|
|
assert!(!alive(&holder), "{how:?} reached the descendant too");
|
|
}
|
|
}
|
|
}
|
|
|
|
mod lease;
|
|
mod closure;
|
|
|
|
// ---- the task in the start message ---------------------------------------
|
|
|
|
fn follow_up_saying(label: &str, item: &str) -> (PipelineDocument, PipelineRef) {
|
|
let PipelineDocument::FollowUp(mut document) = follow_up(label, &spec_ref("x")) else { unreachable!() };
|
|
document.item = item.into();
|
|
(PipelineDocument::FollowUp(document), reference(K::FollowUp, &id("follow_up", label)))
|
|
}
|
|
|
|
fn claiming(claims: &[PipelineRef], documents: Vec<PipelineDocument>) -> TestMind {
|
|
let mind = TestMind::seeded();
|
|
mind.committed(vec![spec("x")]);
|
|
if !documents.is_empty() {
|
|
mind.committed(documents);
|
|
}
|
|
let PipelineDocument::Run(mut document) = self_run("selfrun-a", "5") else { unreachable!() };
|
|
document.claims = claims.to_vec();
|
|
mind.committed(vec![PipelineDocument::Run(document)]);
|
|
mind
|
|
}
|
|
|
|
#[test]
|
|
fn the_start_message_carries_the_claimed_follow_ups_item_and_id_verbatim() {
|
|
let (brief, claim) = follow_up_saying("brief-a", "Fix the thing.\nThen report \u{e9}.");
|
|
let mind = claiming(std::slice::from_ref(&claim), vec![brief]);
|
|
let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false);
|
|
let first = &ran.written()[0];
|
|
assert!(first.starts_with(&start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"), &[plan_window()], at_start().timestamp_millis() as u64)), "{first}");
|
|
for needle in ["Fix the thing.\nThen report \u{e9}.", claim.id.0.as_str(), "Use the eureka skill."] {
|
|
assert!(first.contains(needle), "{needle}: {first}");
|
|
}
|
|
assert!(first.contains(&format!("(campaign `{}`)", follow_up_campaign())), "{first}");
|
|
}
|
|
|
|
fn follow_up_campaign() -> String {
|
|
let PipelineDocument::FollowUp(document) = follow_up("c", &spec_ref("x")) else { unreachable!() };
|
|
document.campaign.0
|
|
}
|
|
|
|
#[test]
|
|
fn a_claim_that_is_no_brief_is_named_not_dropped() {
|
|
// A claimed cut spec is the task: named with its id, to be read with huginn view.
|
|
let mind = claiming(&[spec_ref("x")], vec![]);
|
|
let ran = carry_with(&Seen::of(&mind), vec![Out(result_line())], Some(0), false);
|
|
let first = &ran.written()[0];
|
|
assert!(first.contains(&format!("claimed cut spec `{}`", spec_ref("x").id.0)) && first.contains("huginn view"), "{first}");
|
|
assert!(!first.contains("unknown"), "{first}");
|
|
// A claim the mind does not hold at all.
|
|
let absent = reference(K::FollowUp, &id("follow_up", "absent"));
|
|
let task = task_section(&mind, std::slice::from_ref(&absent)).unwrap();
|
|
assert!(task.contains(&absent.id.0) && task.contains("does not hold"), "{task}");
|
|
// A document of another kind is named by id and kind.
|
|
let other = run_ref("selfrun-a");
|
|
let task = task_section(&mind, std::slice::from_ref(&other)).unwrap();
|
|
assert!(task.contains(&other.id.0) && task.contains("no brief"), "{task}");
|
|
}
|
|
|
|
#[test]
|
|
fn every_claim_of_sixty_four_full_items_is_named_by_id() {
|
|
let big = "\u{e9}".repeat(500);
|
|
let docs: Vec<_> = (0..64).map(|n| follow_up_saying(&format!("big-{n}"), &big)).collect();
|
|
let claims: Vec<_> = docs.iter().map(|(_, claim)| claim.clone()).collect();
|
|
let mind = claiming(&claims, docs.into_iter().map(|(document, _)| document).collect());
|
|
let task = task_section(&mind, &claims).unwrap();
|
|
for claim in &claims {
|
|
assert!(task.contains(&claim.id.0), "{}", claim.id.0);
|
|
}
|
|
assert_eq!(task.matches(&big).count(), 64, "every item verbatim");
|
|
assert!(!task.contains("truncated"));
|
|
// Claim order is kept.
|
|
assert!(task.find(&claims[0].id.0) < task.find(&claims[63].id.0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_mind_query_failure_on_a_claim_fails_the_run_with_the_cause_and_starts_no_child() {
|
|
let (brief, claim) = follow_up_saying("brief-a", "Fix the thing.");
|
|
let mind = claiming(std::slice::from_ref(&claim), vec![brief]);
|
|
let mut seen = Seen::of(&mind);
|
|
seen.query_fails_on = Some(claim.id.0.clone());
|
|
let ran = carry_with(&seen, vec![Out(result_line())], Some(0), false);
|
|
let error = format!("{:#}", ran.outcome.as_ref().unwrap_err());
|
|
assert!(error.contains("mind-down-canary") && error.contains(&claim.id.0), "{error}");
|
|
assert!(ran.spawns.is_empty() && ran.events.is_empty() && !ran.all.iter().any(|event| matches!(event, Event::Lent)), "no lease, no child");
|
|
assert!(!ran.out.contains("unknown"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_run_without_claims_gets_the_identity_lines_alone() {
|
|
let ran = go(vec![Out(result_line())]);
|
|
assert_eq!(ran.written()[0], start_message(&Slug("yggdrasil".into()), &run_ref("selfrun-a"), &[plan_window()], at_start().timestamp_millis() as u64));
|
|
}
|
|
|
|
// ---- the live session -----------------------------------------------------
|
|
|
|
use eureka_pipeline::PipelineResolution;
|
|
|
|
fn live_mind() -> Rc<TestMind> {
|
|
let mind = Rc::new(with_run());
|
|
mind.committed(vec![spec("x"), run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
|
mind
|
|
}
|
|
|
|
/// The mind gains these documents as her Persona turn's admission: the provenance that makes a self-run follow-up a brief.
|
|
fn persona_commit(mind: &TestMind, documents: Vec<PipelineDocument>) {
|
|
let outcome = mind.admit(crate::persona::AGENT, "persona-a", documents).unwrap();
|
|
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
|
|
}
|
|
|
|
/// Her Persona admits these briefs, `seconds` into the run.
|
|
fn briefed(mind: &Rc<TestMind>, seconds: i64, documents: Vec<PipelineDocument>) -> Step {
|
|
let mind = mind.clone();
|
|
Step::Do(seconds, Rc::new(move || persona_commit(&mind, documents.clone())))
|
|
}
|
|
|
|
/// A brief from her Persona: a follow-up with owner `self-run`, sourced from the Persona turn's run.
|
|
fn brief(label: &str, item: &str) -> PipelineDocument {
|
|
let PipelineDocument::FollowUp(mut document) = follow_up(label, &run_ref("persona")) else { unreachable!() };
|
|
document.item = item.into();
|
|
document.owner = inbox::OWNER.into();
|
|
PipelineDocument::FollowUp(document)
|
|
}
|
|
|
|
fn brief_ref(label: &str) -> PipelineRef {
|
|
reference(K::FollowUp, &id("follow_up", label))
|
|
}
|
|
|
|
/// The mind gains these documents, `seconds` into the run.
|
|
fn admits(mind: &Rc<TestMind>, seconds: i64, documents: Vec<PipelineDocument>) -> Step {
|
|
let mind = mind.clone();
|
|
Step::Do(seconds, Rc::new(move || mind.committed(documents.clone())))
|
|
}
|
|
|
|
/// Her Self resolves the brief herself, through her mind tool.
|
|
fn resolves(mind: &Rc<TestMind>, label: &str) -> Step {
|
|
admits(mind, 0, vec![close(brief_ref(label), recorded())])
|
|
}
|
|
|
|
fn resolutions_of(mind: &TestMind, subject: &PipelineRef) -> Vec<PipelineResolution> {
|
|
mind.ids(K::Resolution)
|
|
.iter()
|
|
.filter_map(|resolution| match mind.view(K::Resolution, resolution)?.document {
|
|
PipelineDocument::Resolution(document) if document.subject == *subject => Some(document),
|
|
_ => None,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn five_minute_request(id: &str, prefix: u64) -> String {
|
|
let mut event: Value = serde_json::from_str(&at(id, prefix)).unwrap();
|
|
event["message"]["usage"]["cache_creation"] = json!({"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 100});
|
|
event.to_string()
|
|
}
|
|
|
|
fn tells(ran: &Ran) -> Vec<String> {
|
|
ran.written().into_iter().filter(|text| text.contains("A brief from your Persona")).collect()
|
|
}
|
|
|
|
fn mind_run(mind: &Rc<TestMind>, steps: Vec<Step>) -> Ran {
|
|
carry_with(&Seen::of(mind), steps, Some(0), false)
|
|
}
|
|
|
|
#[test]
|
|
fn a_result_never_ends_the_session_the_child_ends_it_or_the_cache_does() {
|
|
let ran = go(vec![Out(echo()), Out(at("m1", 10)), Out(ok_result())]);
|
|
assert_eq!(ran.count(&Event::InputClosed), 0, "a result is the end of a turn: {:?}", ran.events);
|
|
assert_eq!(ran.ended().0, Cause::Own);
|
|
}
|
|
|
|
#[test]
|
|
fn a_brief_derived_while_a_turn_runs_is_written_at_once_counted_at_its_echo_and_never_told_twice() {
|
|
let mind = live_mind();
|
|
let steps = vec![
|
|
Out(echo()),
|
|
Out(at("m1", 10)),
|
|
// The brief arrives while the first turn runs: the next poll writes it at once, mid-turn.
|
|
briefed(&mind, 31, vec![brief("b1", "Survey the noise functions.")]),
|
|
Out(echo()),
|
|
// Later polls do not tell it again.
|
|
Idle(31),
|
|
Idle(31),
|
|
Out(ok_result()),
|
|
];
|
|
let ran = mind_run(&mind, steps);
|
|
let told = tells(&ran);
|
|
assert_eq!(told.len(), 1, "{:?}", ran.written());
|
|
assert!(told[0].contains(&brief_ref("b1").id.0) && told[0].contains("Survey the noise functions."), "{}", told[0]);
|
|
assert!(told[0].contains("resolve follow-up") && told[0].contains("Recorded"), "she is told how to close it: {}", told[0]);
|
|
// The plan line is restated under every brief for the run's life, so it names the instant of the reading, never an age that stops being true.
|
|
assert!(told[0].contains("read at 2026-10-29T20:33Z") && !told[0].contains(" ago"), "{}", told[0]);
|
|
assert_eq!(ran.written().len(), 2, "the start message and one tell: {:?}", ran.written());
|
|
assert!(ran.out.contains("decision Write { tells: true, flush: None } prefix 10 ttl_s 0 idle_s working inbox true written 2 consumed 1\n"), "{}", ran.out);
|
|
assert_eq!(ran.count(&Event::InputClosed), 0);
|
|
// A result is not an answer: nothing closes the brief by its timing.
|
|
assert!(resolutions_of(&mind, &brief_ref("b1")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_brief_derived_while_the_session_is_idle_is_written_and_the_session_is_busy_until_its_echo() {
|
|
let mind = live_mind();
|
|
let steps = vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), briefed(&mind, 31, vec![brief("b2", "Wire the shader uniforms.")]), Out(echo()), Out(ok_result())];
|
|
let ran = mind_run(&mind, steps);
|
|
assert_eq!(tells(&ran).len(), 1, "{:?}", ran.written());
|
|
assert!(ran.out.contains("idle_s 31 inbox true written 2 consumed 1\n"), "idle when it was written: {}", ran.out);
|
|
assert_eq!(ran.count(&Event::InputClosed), 0);
|
|
assert!(resolutions_of(&mind, &brief_ref("b2")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_brief_open_at_start_is_in_the_start_message_with_how_to_resolve_it_and_is_not_told_again() {
|
|
let mind = live_mind();
|
|
persona_commit(&mind, vec![brief("p1", "Still open.")]);
|
|
let ran = mind_run(&mind, vec![Out(echo()), Idle(31), Idle(31)]);
|
|
let start = &ran.written()[0];
|
|
assert!(start.contains("Also open for you") && start.contains(&brief_ref("p1").id.0) && start.contains("Still open."), "{start}");
|
|
assert!(start.contains(&format!("resolve follow-up {} Recorded", brief_ref("p1").id.0)), "{start}");
|
|
assert_eq!(ran.written().len(), 1, "never told again: {:?}", ran.written());
|
|
}
|
|
|
|
#[test]
|
|
fn a_follow_up_her_self_filed_for_herself_is_never_told() {
|
|
let mind = live_mind();
|
|
let PipelineDocument::FollowUp(mut hers) = follow_up("hers", &run_ref("selfrun-a")) else { unreachable!() };
|
|
hers.owner = inbox::OWNER.into();
|
|
// Naming the Persona turn's run as the source does not make it hers to be told: the receipt records who admitted it.
|
|
let PipelineDocument::FollowUp(mut citing) = follow_up("citing", &run_ref("persona")) else { unreachable!() };
|
|
citing.owner = inbox::OWNER.into();
|
|
mind.committed(vec![PipelineDocument::FollowUp(hers), PipelineDocument::FollowUp(citing)]);
|
|
let ran = mind_run(&mind, vec![Out(echo()), Idle(31)]);
|
|
assert_eq!(ran.written().len(), 1, "{:?}", ran.written());
|
|
assert!(!ran.written()[0].contains("Also open"), "{}", ran.written()[0]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_idle_end_closes_no_brief_the_ones_she_left_open_stay_open_and_her_resolutions_stand() {
|
|
let mind = live_mind();
|
|
persona_commit(&mind, vec![brief("p1", "Left open."), brief("p2", "Answered by her.")]);
|
|
let steps = vec![Out(echo()), Out(at("m1", 10)), resolves(&mind, "p2"), Out(ok_result()), Idle(3539), Idle(1), Out(echo()), Out(ok_result())];
|
|
let ran = mind_run(&mind, steps);
|
|
assert_eq!(ran.count(&Event::InputClosed), 1, "{:?}", ran.events);
|
|
assert!(resolutions_of(&mind, &brief_ref("p1")).is_empty(), "the actuator resolved nothing she left open");
|
|
assert_eq!(resolutions_of(&mind, &brief_ref("p2")).len(), 1, "her own resolution stands alone");
|
|
}
|
|
|
|
#[test]
|
|
fn a_rotation_a_ceiling_an_error_or_a_crash_leaves_the_told_briefs_open_for_the_successor() {
|
|
let cap = i64::try_from(TEST_CAP_S).unwrap();
|
|
let ends: Vec<(&str, Vec<Step>)> = vec![
|
|
("rotation", vec![Out(echo()), After(cap - 1400, at("m1", 10)), Out(echo()), Out(ok_result())]),
|
|
("ceiling", vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), Out(echo()), Out(ok_result())]),
|
|
("error before an answer", vec![Out(echo()), Out(result_line())]),
|
|
("idle flush answered by an error", vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), Idle(3539), Idle(1), Out(echo()), Out(result_line())]),
|
|
("crash", vec![Out(echo()), Out(at("m1", 10)), Exit(Some(137))]),
|
|
];
|
|
for (name, steps) in ends {
|
|
let mind = live_mind();
|
|
persona_commit(&mind, vec![brief("p1", "Left open.")]);
|
|
let ran = mind_run(&mind, steps);
|
|
assert!(ran.outcome.is_ok(), "{name}");
|
|
assert!(resolutions_of(&mind, &brief_ref("p1")).is_empty(), "{name}: the brief stays open");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_brief_admitted_after_the_flush_is_written_stays_in_force_and_untold() {
|
|
let mind = live_mind();
|
|
let steps = vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), briefed(&mind, 31, vec![brief("late", "Too late.")]), Idle(31), Out(echo()), Out(ok_result())];
|
|
let ran = mind_run(&mind, steps);
|
|
assert!(tells(&ran).is_empty(), "{:?}", ran.written());
|
|
assert_eq!(ran.written().len(), 2, "the start and the flush; no line after the flush");
|
|
assert!(resolutions_of(&mind, &brief_ref("late")).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn the_session_ends_where_the_cache_would_be_evicted_with_a_flush_and_a_waiting_brief_keeps_it_open() {
|
|
// An hour's cache: warm at 3539 s, the flush at 3540, the input closed at the result after the flush's echo.
|
|
let hour = go(vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), Idle(3539), Idle(1), Out(echo()), Out(ok_result()), Idle(1)]);
|
|
assert_eq!(flushes(&hour), 1);
|
|
let flush_at = hour.events.iter().position(|event| matches!(event, Event::Wrote(line) if line.contains(FLUSH_MESSAGE))).unwrap();
|
|
let closed_at = hour.events.iter().position(|event| *event == Event::InputClosed).unwrap();
|
|
assert!(flush_at < closed_at && hour.count(&Event::InputClosed) == 1, "{:?}", hour.events);
|
|
assert!(hour.out.contains("decision Write { tells: false, flush: Some(Idle) } prefix 10 ttl_s 3600 idle_s 3540 inbox false written 2 consumed 1\n"), "{}", hour.out);
|
|
assert!(hour.written().iter().any(|text| text.contains("nothing is waiting and its cache would lapse") && text.contains("Resolve each brief you answered")), "{:?}", hour.written());
|
|
// Five minutes' cache: the flush at 240 s.
|
|
let short = go(vec![Out(echo()), Out(five_minute_request("m1", 10)), Out(ok_result()), Idle(239), Idle(1), Out(echo()), Out(ok_result()), Idle(1)]);
|
|
assert_eq!(flushes(&short), 1);
|
|
// Not a second early.
|
|
let early = go(vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), Idle(3539)]);
|
|
assert_eq!(flushes(&early), 0);
|
|
// A brief waiting at the cache's end is told, not flushed: the session stays open.
|
|
let mind = live_mind();
|
|
let steps = vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), briefed(&mind, 3539, vec![brief("b5", "One more.")]), Idle(1), Out(echo()), Out(ok_result()), Idle(1)];
|
|
let ran = mind_run(&mind, steps);
|
|
assert_eq!((flushes(&ran), tells(&ran).len(), ran.count(&Event::InputClosed)), (0, 1, 0), "{:?}
|
|
{}", ran.written(), ran.out);
|
|
}
|
|
|
|
#[test]
|
|
fn a_tell_that_is_not_yet_echoed_keeps_the_session_busy_so_the_cache_end_does_not_flush_over_it() {
|
|
let mind = live_mind();
|
|
let steps = vec![Out(echo()), Out(at("m1", 10)), Out(ok_result()), briefed(&mind, 31, vec![brief("b6", "Pending.")]), Idle(3600)];
|
|
let ran = mind_run(&mind, steps);
|
|
assert_eq!((tells(&ran).len(), flushes(&ran)), (1, 0), "{:?}", ran.written());
|
|
}
|
|
|
|
#[test]
|
|
fn near_the_cap_the_session_is_asked_for_its_flush_idle_or_working_and_closes_at_the_result_after_its_echo() {
|
|
let cap = i64::try_from(TEST_CAP_S).unwrap();
|
|
// Idle at cap - 1700 s: the flush at cap - 1500, not a second before.
|
|
let before = go(vec![Out(echo()), After(cap - 1700, at("m1", 10)), Out(ok_result()), Idle(199)]);
|
|
assert_eq!(flushes(&before), 0);
|
|
let idle = go(vec![Out(echo()), After(cap - 1700, at("m1", 10)), Out(ok_result()), Idle(199), Idle(1), Out(echo()), Out(ok_result())]);
|
|
assert_eq!((flushes(&idle), idle.count(&Event::InputClosed)), (1, 1), "{:?}", idle.events);
|
|
assert!(idle.written().iter().any(|text| text.contains("the unit's time is nearly up")), "{:?}", idle.written());
|
|
// Mid-turn: the flush at cap - 1500, once.
|
|
let working = go(vec![Out(echo()), After(cap - 1500, at("m1", 10))]);
|
|
assert_eq!(flushes(&working), 1);
|
|
let not_yet = go(vec![Out(echo()), After(cap - 1501, at("m1", 10))]);
|
|
assert_eq!(flushes(¬_yet), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn no_stdin_line_is_written_after_the_flush_and_nothing_but_the_writer_writes() {
|
|
let mind = live_mind();
|
|
persona_commit(&mind, vec![brief("b7", "Early.")]);
|
|
let steps = vec![Out(echo()), Out(at("m1", CEILING_TOKENS)), briefed(&mind, 31, vec![brief("b8", "After the flush.")]), Idle(31), Idle(31), Out(echo()), Out(at("m2", 11)), Out(ok_result())];
|
|
let ran = mind_run(&mind, steps);
|
|
let written = ran.written();
|
|
assert_eq!(written.len(), 2, "{written:?}");
|
|
assert!(written[1].starts_with(FLUSH_MESSAGE), "{}", written[1]);
|
|
assert_eq!(ran.spawns.len(), 1);
|
|
assert!(ran.spawns[0].iter().all(|arg| arg != "--settings"), "no hooks on argv: {:?}", ran.spawns[0]);
|
|
}
|
|
|
|
/// The committed 2.1.312 streams, replayed through the real parser and the decision.
|
|
mod live_session {
|
|
use crate::self_run::decide::{Action as Do, Event as Fact, Why};
|
|
use super::*;
|
|
|
|
fn flush(why: Why) -> Do {
|
|
Do::Write { tells: false, flush: Some(why) }
|
|
}
|
|
|
|
/// One recorded line as the loop would hear it: a label for the stream, the decision's action.
|
|
fn replay(jsonl: &str) -> Vec<(String, Do)> {
|
|
replay_state(jsonl).0
|
|
}
|
|
|
|
/// The same, with the state the decision ended in. A `want` entry (the probe asked its writer to send a line that waits) is a brief waiting; the flush is the decision's own.
|
|
fn replay_state(jsonl: &str) -> (Vec<(String, Do)>, State) {
|
|
let mut state = State::started(crate::testkit::at(0), 100_000);
|
|
let mut ttl_1h = false;
|
|
let mut trace = Vec::new();
|
|
for entry in jsonl.lines() {
|
|
let entry: Value = serde_json::from_str(entry).unwrap();
|
|
let at = crate::testkit::at((entry["t"].as_f64().unwrap() * 1000.0) as i64);
|
|
if let Some(text) = entry.get("want").and_then(Value::as_str) {
|
|
let (label, fact) = if text.starts_with(FLUSH_MESSAGE) { ("flush-asked", Fact::Tick) } else { ("want", Fact::Inbox { pending: true }) };
|
|
let (next, action) = decide(state, fact, at);
|
|
state = next;
|
|
trace.push((label.to_string(), action));
|
|
continue;
|
|
}
|
|
let Some(line) = entry.get("line").and_then(Value::as_str) else { continue };
|
|
if let Some(hour) = writes_hour_cache(line) {
|
|
ttl_1h = hour;
|
|
}
|
|
let (label, fact) = match read_usage(line) {
|
|
Usage::Request { id, prefix } => (format!("request:{prefix}"), Fact::Request { id, prefix }),
|
|
Usage::Echo => ("echo".to_string(), Fact::Echo),
|
|
Usage::Result { ok, blocked } => (format!("result:{}", if blocked { "blocked" } else if ok { "ok" } else { "error" }), Fact::Result { ok, blocked, ttl_1h }),
|
|
Usage::Other | Usage::Unreadable => ("other".to_string(), Fact::Tick),
|
|
};
|
|
let (next, action) = decide(state, fact, at);
|
|
state = next;
|
|
trace.push((label, action));
|
|
}
|
|
(trace, state)
|
|
}
|
|
|
|
fn kinds(trace: &[(String, Do)]) -> Vec<&str> {
|
|
trace.iter().map(|(label, _)| label.as_str()).collect()
|
|
}
|
|
|
|
/// The indexes at which the decision acted, and how.
|
|
fn acted(trace: &[(String, Do)]) -> Vec<(usize, Do)> {
|
|
trace.iter().enumerate().filter(|(_, (_, action))| *action != Do::None).map(|(at, (_, action))| (at, action.clone())).collect()
|
|
}
|
|
|
|
const FOLDED: &str = include_str!("claude-2.1.312-live-session-folded.jsonl");
|
|
const QUEUED: [&str; 2] = [include_str!("claude-2.1.312-live-session-queued.jsonl"), include_str!("claude-2.1.312-live-session-queued-scrub.jsonl")];
|
|
const THREE_QUEUED: [&str; 2] = [include_str!("claude-2.1.312-live-session-three-queued.jsonl"), include_str!("claude-2.1.312-live-session-three-queued-scrub.jsonl")];
|
|
const SERIALIZED: [&str; 2] = [include_str!("claude-2.1.312-live-session-serialized.jsonl"), include_str!("claude-2.1.312-live-session-serialized-scrub.jsonl")];
|
|
const CEILING_FOLDED: &str = include_str!("claude-2.1.312-live-session-ceiling-flush-folded.jsonl");
|
|
const CEILING_QUEUED: [&str; 2] = [include_str!("claude-2.1.312-live-session-ceiling-flush-queued.jsonl"), include_str!("claude-2.1.312-live-session-ceiling-flush-queued-scrub.jsonl")];
|
|
const API_ERROR: [&str; 2] = [include_str!("claude-2.1.312-live-session-api-error-first.jsonl"), include_str!("claude-2.1.312-live-session-api-error-first-scrub.jsonl")];
|
|
const BLOCKING: [&str; 2] = [include_str!("claude-2.1.312-live-session-blocking-limit.jsonl"), include_str!("claude-2.1.312-live-session-blocking-limit-scrub.jsonl")];
|
|
const COMPACTION_OFF: [&str; 2] = [include_str!("claude-2.1.312-live-session-compaction-off-360k.jsonl"), include_str!("claude-2.1.312-live-session-compaction-off-360k-scrub.jsonl")];
|
|
|
|
#[test]
|
|
fn a_line_written_during_a_tool_call_is_folded_one_result_and_its_echo_before_the_request_that_carries_it() {
|
|
let trace = replay(FOLDED);
|
|
// The tool_result's own user line is no echo: it is a foreign owner's array content.
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:10", "other", "other", "other", "echo", "request:10", "result:ok"]);
|
|
assert!(acted(&trace).is_empty(), "{:?}", acted(&trace));
|
|
}
|
|
|
|
#[test]
|
|
fn a_line_written_while_the_last_request_is_in_flight_is_queued_two_results_and_its_echo_after_the_first() {
|
|
for stream in QUEUED {
|
|
let trace = replay(stream);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:10", "result:ok", "other", "echo", "request:10", "result:ok"]);
|
|
assert!(acted(&trace).is_empty(), "{:?}", acted(&trace));
|
|
}
|
|
}
|
|
|
|
fn sent(jsonl: &str) -> usize {
|
|
jsonl.lines().filter(|entry| serde_json::from_str::<Value>(entry).unwrap().get("sent").is_some()).count()
|
|
}
|
|
|
|
#[test]
|
|
fn lines_written_behind_an_in_flight_request_come_back_as_one_echo_which_is_why_one_write_is_outstanding() {
|
|
for stream in THREE_QUEUED {
|
|
let trace = replay(stream);
|
|
let echoes = kinds(&trace).iter().filter(|kind| **kind == "echo").count();
|
|
assert_eq!((sent(stream), echoes), (3, 2), "three lines written, two echoes: counting echoes against lines written drifts for good");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn with_one_write_outstanding_every_write_is_echoed_once_and_the_flush_is_reached_and_answered() {
|
|
for stream in SERIALIZED {
|
|
let (trace, state) = replay_state(stream);
|
|
assert_eq!(
|
|
kinds(&trace),
|
|
["other", "want", "want", "echo", "request:10", "result:ok", "other", "echo", "request:300000", "result:ok", "flush-asked", "other", "echo", "request:10", "result:ok"]
|
|
);
|
|
// Both briefs wait for the start message's echo and go as one line; the flush is written at the ceiling request; the result after its echo closes stdin.
|
|
assert_eq!(acted(&trace), vec![(3, Do::Write { tells: true, flush: None }), (8, flush(Why::Ceiling)), (14, Do::CloseStdin)]);
|
|
assert_eq!((sent(stream), state.written, state.consumed), (3, 3, 3), "every line written has its own echo");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_flush_folded_into_a_running_turn_is_answered_at_its_one_result() {
|
|
let trace = replay(CEILING_FOLDED);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:300000", "other", "other", "other", "echo", "request:10", "result:ok"]);
|
|
assert_eq!(acted(&trace), vec![(2, flush(Why::Ceiling)), (8, Do::CloseStdin)], "never a grace interrupt");
|
|
}
|
|
|
|
#[test]
|
|
fn the_flush_queued_behind_the_turn_that_reached_the_ceiling_is_answered_at_its_own_second_result() {
|
|
for stream in CEILING_QUEUED {
|
|
let trace = replay(stream);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:300000", "result:ok", "other", "echo", "request:10", "result:ok"]);
|
|
// The first result ends the turn the flush was queued behind: it closes nothing.
|
|
assert_eq!(acted(&trace), vec![(2, flush(Why::Ceiling)), (7, Do::CloseStdin)], "never a grace interrupt");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_api_error_before_any_answer_closes_the_session_at_once_with_no_flush() {
|
|
for stream in API_ERROR {
|
|
let trace = replay(stream);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "other", "result:error"]);
|
|
assert_eq!(acted(&trace), vec![(3, Do::CloseStdin)]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_context_over_the_blocking_limit_is_a_blocked_result_that_closes_the_session() {
|
|
for stream in BLOCKING {
|
|
let trace = replay(stream);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:990000", "result:ok", "other", "echo", "other", "result:blocked"]);
|
|
assert_eq!(acted(&trace), vec![(2, flush(Why::Ceiling)), (7, Do::CloseStdin)]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn with_compaction_off_a_request_at_360k_is_sent_and_answered_not_compacted_or_blocked() {
|
|
for stream in COMPACTION_OFF {
|
|
let trace = replay(stream);
|
|
assert_eq!(kinds(&trace), ["other", "echo", "request:360000", "result:ok", "other", "echo", "request:10", "result:ok"]);
|
|
assert_eq!(acted(&trace), vec![(2, flush(Why::Ceiling)), (7, Do::CloseStdin)]);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_brief_the_run_claims_is_in_the_task_once_and_not_again_under_also_open() {
|
|
let mind = TestMind::seeded();
|
|
mind.committed(vec![spec("x"), run("persona", RunTurn::PersonaTurn, RunOperator::Mind, &[])]);
|
|
persona_commit(&mind, vec![brief("claimed", "Do the claimed thing.")]);
|
|
mind.committed(vec![run("selfrun-a", RunTurn::SelfRun, RunOperator::Mind, &[brief_ref("claimed")])]);
|
|
let ran = carry_with(&Seen::of(&mind), vec![Out(echo())], Some(0), false);
|
|
let start = &ran.written()[0];
|
|
assert_eq!(start.matches("claimed follow-up").count(), 1, "{start}");
|
|
assert!(start.contains("Do the claimed thing.") && !start.contains("Also open for you"), "{start}");
|
|
}
|
|
|
|
/// The Persona's boundary: the bubblewrap wrapper `ClaudeSpawner` runs when it has a sandbox.
|
|
#[cfg(unix)]
|
|
mod sandbox {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_sandbox_arguments_are_pinned() {
|
|
let args = sandbox_args(Path::new("/var/lib/mind-x/sandbox"), Path::new("/run/mind-x"));
|
|
let expected: Vec<String> = [
|
|
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--tmpfs", "/run", "--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve", "--ro-bind", "/run/mind-x", "/run/mind-x", "--bind",
|
|
"/run/mind-x/claude-config", "/run/mind-x/claude-config", "--tmpfs", "/etc/gamecult", "--tmpfs", "/var/lib", "--bind", "/var/lib/mind-x/sandbox", "/var/lib/mind-x/sandbox", "--unshare-pid", "--die-with-parent", "--new-session", "--chdir", "/var/lib/mind-x/sandbox", "--",
|
|
]
|
|
.map(String::from)
|
|
.to_vec();
|
|
assert_eq!(args, expected);
|
|
// The credentials are hidden before the sandbox and the scratch are bound back, and the network is not unshared.
|
|
let at = |needle: &str| args.iter().position(|arg| arg == needle).unwrap();
|
|
assert!(at("/run") < at("/run/mind-x") && at("/var/lib") < at("/var/lib/mind-x/sandbox"));
|
|
// The scratch is read-only to her: no plain bind of it, so she cannot plant a link where the organ writes.
|
|
assert!(!args.windows(3).any(|w| w[0] == "--bind" && w[1] == "/run/mind-x"), "{args:?}");
|
|
assert!(!args.iter().any(|arg| arg == "--unshare-net" || arg == "--unshare-all"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_sandboxed_spawner_runs_bwrap_with_its_arguments_then_claude_and_the_child_sees_no_credential() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let (bin, scratch, sandbox) = (dir.path().join("bin"), dir.path().join("scratch"), dir.path().join("sandbox"));
|
|
for made in [&bin, &scratch, &sandbox] {
|
|
std::fs::create_dir(made).unwrap();
|
|
}
|
|
let (argv, environment) = (dir.path().join("argv"), dir.path().join("environment"));
|
|
let fake = bin.join("bwrap");
|
|
std::fs::write(&fake, format!("#!/bin/sh\nprintf '%s\n' \"$@\" > {}\nenv > {}\n", argv.display(), environment.display())).unwrap();
|
|
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
|
|
let mut unit: Vec<(std::ffi::OsString, std::ffi::OsString)> = ["ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", "UNLISTED_VARIABLE"].iter().map(|name| ((*name).into(), format!("canary-{name}").into())).collect();
|
|
unit.push(("PATH".into(), format!("{}:/usr/bin:/bin", bin.display()).into()));
|
|
unit.push((SCRATCH_VARIABLE.into(), scratch.clone().into_os_string()));
|
|
let spawner = ClaudeSpawner::in_sandbox(unit, "MIND_PERSONA_CONFIG_SEED", Some(sandbox.clone())).unwrap();
|
|
let mut child = spawner.spawn(&["--flag".to_string(), "value".to_string()], &token_holding("tok-canary")).unwrap();
|
|
while !matches!(child.next(StdDuration::from_secs(20)), Read::Exited(_) | Read::Timeout) {}
|
|
let recorded: Vec<String> = std::fs::read_to_string(&argv).unwrap().lines().map(String::from).collect();
|
|
let mut expected = sandbox_args(&sandbox, &scratch);
|
|
expected.extend(["claude", "--flag", "value"].map(String::from));
|
|
assert_eq!(recorded, expected, "bwrap, its arguments, then claude and its own");
|
|
let seen = std::fs::read_to_string(&environment).unwrap();
|
|
assert!(!seen.contains("canary") && !seen.contains("UNLISTED"), "the child's environment is the allow-list: {seen}");
|
|
}
|
|
}
|