mod support;
use std::path::Path;
use serde_json::Value;
use support::{code, run, stderr, TempDir, EXIT_FAILURE, EXIT_OK};
fn source(persistent: &str, body: &str) -> String {
format!(
r#"language 0.2
package test.recall
agent Counter(note: text) -> log<text> {{
memory {{
working ephemeral {{ scratch: text }}
persistent {{ {persistent} }}
}}
budget {{ steps <= 3 }}
policy {{ network deny }}
flow {{
state.scratch = note
{body}
emit log = state.scratch
}}
}}
"#
)
}
fn project(tag: &str, persistent: &str, body: &str) -> TempDir {
let dir = TempDir::new(tag);
std::fs::write(dir.path().join("main.ing"), source(persistent, body))
.expect("writing the agent");
std::fs::write(
dir.path().join("empty.json"),
r#"{"cassetteVersion":"0.1","agent":"test.recall.Counter","interactions":[]}"#,
)
.expect("writing the cassette");
dir
}
fn once(dir: &Path, note: &str, extra: &[&str]) -> std::process::Output {
let main = dir.join("main.ing");
let cassette = dir.join("empty.json");
let out = dir.join("out");
let mut args: Vec<String> = vec![
"run".into(),
main.display().to_string(),
"--input".into(),
format!("note={note}"),
"--provider".into(),
"replay".into(),
"--cassette".into(),
cassette.display().to_string(),
"--out-dir".into(),
out.display().to_string(),
"--events".into(),
"quiet".into(),
];
args.extend(extra.iter().map(|argument| (*argument).to_string()));
let borrowed: Vec<&str> = args.iter().map(String::as_str).collect();
run(&borrowed, None)
}
fn store(dir: &Path) -> Value {
let path = dir
.join("target")
.join("ingot")
.join("memory")
.join("test.recall.Counter.json");
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|error| panic!("reading {}: {error}", path.display()));
serde_json::from_str(&text).expect("the store is JSON")
}
#[test]
fn the_second_run_sees_what_the_first_one_wrote() {
let dir = project(
"memory-persists",
"depth: int = 0",
" memory.depth = memory.depth + 1",
);
let first = once(dir.path(), "a", &[]);
assert_eq!(code(&first), EXIT_OK, "{}", stderr(&first));
assert_eq!(store(dir.path())["fields"]["depth"], 1);
let second = once(dir.path(), "b", &[]);
assert_eq!(code(&second), EXIT_OK, "{}", stderr(&second));
assert_eq!(
store(dir.path())["fields"]["depth"],
2,
"the second run started from the declared value instead of the store"
);
}
#[test]
fn a_store_records_the_declaration_it_was_written_under() {
let dir = project(
"memory-shape",
"depth: int = 0",
" memory.depth = memory.depth + 1",
);
once(dir.path(), "a", &[]);
assert_eq!(store(dir.path())["shape"]["depth"], "int");
assert_eq!(store(dir.path())["kind"], "memory");
assert_eq!(store(dir.path())["agent"], "test.recall.Counter");
}
#[test]
fn a_changed_declaration_is_refused_and_the_message_names_the_field() {
let dir = project(
"memory-changed",
"depth: int = 0",
" memory.depth = memory.depth + 1",
);
assert_eq!(code(&once(dir.path(), "a", &[])), EXIT_OK);
std::fs::write(
dir.path().join("main.ing"),
source("height: int = 0", " memory.height = memory.height + 1"),
)
.expect("rewriting the agent");
let refused = once(dir.path(), "b", &[]);
assert_eq!(code(&refused), EXIT_FAILURE);
let text = stderr(&refused);
assert!(
text.contains("written for a different declaration"),
"{text}"
);
assert!(text.contains("added:"), "{text}");
assert!(text.contains("height"), "{text}");
assert!(text.contains("removed:"), "{text}");
assert!(text.contains("depth"), "{text}");
assert!(text.contains("--migrate-memory"), "{text}");
assert_eq!(store(dir.path())["shape"]["depth"], "int");
}
#[test]
fn migrating_drops_what_no_longer_fits_and_says_so() {
let dir = project(
"memory-migrate",
"depth: int = 0, kept: int = 0",
" memory.depth = memory.depth + 1\n memory.kept = 5",
);
assert_eq!(code(&once(dir.path(), "a", &[])), EXIT_OK);
assert_eq!(store(dir.path())["fields"]["kept"], 5);
std::fs::write(
dir.path().join("main.ing"),
source(
"height: int = 0, kept: int = 0",
" memory.height = memory.height + 1",
),
)
.expect("rewriting the agent");
let migrated = once(dir.path(), "b", &["--migrate-memory"]);
assert_eq!(code(&migrated), EXIT_OK, "{}", stderr(&migrated));
let text = stderr(&migrated);
assert!(text.contains("warning:"), "{text}");
assert!(text.contains("dropped"), "{text}");
let after = store(dir.path());
assert_eq!(after["fields"]["kept"], 5, "a matching field survived");
assert!(
after["fields"].get("depth").is_none(),
"the dropped field is gone: {after}"
);
assert_eq!(after["shape"]["height"], "int");
}
#[test]
fn no_memory_starts_from_the_declared_values_and_leaves_the_store_alone() {
let dir = project(
"memory-disabled",
"depth: int = 0",
" memory.depth = memory.depth + 1",
);
assert_eq!(code(&once(dir.path(), "a", &[])), EXIT_OK);
assert_eq!(store(dir.path())["fields"]["depth"], 1);
let disabled = once(dir.path(), "b", &["--no-memory"]);
assert_eq!(code(&disabled), EXIT_OK, "{}", stderr(&disabled));
assert_eq!(
store(dir.path())["fields"]["depth"],
1,
"--no-memory wrote to the store"
);
}
#[test]
fn an_agent_that_declares_no_persistent_block_opens_no_store() {
let dir = TempDir::new("memory-absent");
std::fs::write(
dir.path().join("main.ing"),
r#"language 0.2
package test.recall
agent Counter(note: text) -> log<text> {
memory { working ephemeral { scratch: text } }
policy { network deny }
flow {
state.scratch = note
emit log = state.scratch
}
}
"#,
)
.expect("writing the agent");
std::fs::write(
dir.path().join("empty.json"),
r#"{"cassetteVersion":"0.1","agent":"test.recall.Counter","interactions":[]}"#,
)
.expect("writing the cassette");
let output = once(dir.path(), "a", &[]);
assert_eq!(code(&output), EXIT_OK, "{}", stderr(&output));
assert!(
!dir.path().join("target/ingot/memory").exists(),
"this affects exactly the programs that asked for it"
);
}
fn python() -> Option<String> {
for candidate in ["python3", "python"] {
let ok = std::process::Command::new(candidate)
.arg("--version")
.output()
.map(|output| output.status.success())
.unwrap_or(false);
if ok {
return Some(candidate.to_string());
}
}
if std::env::var_os("INGOT_REQUIRE_PYTHON").is_some() {
panic!("INGOT_REQUIRE_PYTHON is set and neither `python3` nor `python` is on PATH");
}
eprintln!("skipping: no python3 on PATH");
None
}
#[test]
fn a_generated_program_keeps_memory_across_runs_too() {
let Some(python) = python() else { return };
let dir = project(
"memory-python",
"depth: int = 0",
" memory.depth = memory.depth + 1",
);
let built = dir.path().join("py");
let build = run(
&[
"build",
&dir.path().join("main.ing").display().to_string(),
"--target",
"python",
"--out-dir",
&built.display().to_string(),
],
None,
);
assert_eq!(code(&build), EXIT_OK, "{}", stderr(&build));
let program = std::fs::read_dir(&built)
.expect("the build wrote something")
.filter_map(Result::ok)
.map(|entry| entry.path())
.find(|path| path.extension().is_some_and(|ext| ext == "py"))
.expect("one generated program");
let store_path = dir.path().join("py-store.json");
let once = |note: &str| {
std::process::Command::new(&python)
.arg(&program)
.args(["--input", &format!("note={note}")])
.args([
"--cassette",
&dir.path().join("empty.json").display().to_string(),
])
.args(["--memory", &store_path.display().to_string()])
.args(["--events", "quiet"])
.args([
"--out-dir",
&dir.path().join("py-out").display().to_string(),
])
.env("PYTHONIOENCODING", "utf-8")
.output()
.expect("python must be runnable")
};
let first = once("a");
assert!(
first.status.success(),
"{}",
String::from_utf8_lossy(&first.stderr)
);
let after_first: Value =
serde_json::from_str(&std::fs::read_to_string(&store_path).expect("a store was written"))
.expect("the store is JSON");
assert_eq!(after_first["fields"]["depth"], 1);
assert_eq!(after_first["kind"], "memory");
assert_eq!(after_first["shape"]["depth"], "int");
let second = once("b");
assert!(
second.status.success(),
"{}",
String::from_utf8_lossy(&second.stderr)
);
let after_second: Value = serde_json::from_str(
&std::fs::read_to_string(&store_path).expect("the store is still there"),
)
.expect("the store is JSON");
assert_eq!(after_second["fields"]["depth"], 2);
}