use assay_core::agent_assertions::model::TraceAssertion;
fn parse(yaml: &str) -> Result<TraceAssertion, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
#[track_caller]
fn assert_rejects_naming(yaml: &str, offending_key: &str, case: &str) {
match parse(yaml) {
Ok(v) => panic!("{case}: expected rejection, but parsed into {v:?}"),
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains(offending_key),
"{case}: rejected, but the message does not name `{offending_key}`: {msg}"
);
}
}
}
#[track_caller]
fn assert_accepts(yaml: &str, case: &str) {
if let Err(e) = parse(yaml) {
panic!("{case}: expected this to parse, got {e}");
}
}
#[test]
fn trace_must_call_tool_rejects_unknown_key() {
assert_rejects_naming(
"type: trace_must_call_tool\ntool: delete_database\nmax_calls: 0\n",
"max_calls",
"trace_must_call_tool / the documented max_calls inversion",
);
assert_rejects_naming(
"type: trace_must_call_tool\ntool: web_search\nmin_call: 2\n",
"min_call",
"trace_must_call_tool / misspelled min_calls",
);
assert_rejects_naming(
"type: trace_must_call_tool\ntool_name: web_search\n",
"tool_name",
"trace_must_call_tool / documented tool_name",
);
}
#[test]
fn trace_must_not_call_tool_rejects_unknown_key() {
assert_rejects_naming(
"type: trace_must_not_call_tool\ntool: rm\nmax_calls: 0\n",
"max_calls",
"trace_must_not_call_tool / stray key",
);
}
#[test]
fn trace_tool_sequence_rejects_unknown_key() {
assert_rejects_naming(
"type: trace_tool_sequence\nsequence: [a, b]\nallow_other_tool: true\n",
"allow_other_tool",
"trace_tool_sequence / misspelled allow_other_tools",
);
assert_rejects_naming(
"type: trace_tool_sequence\nsequence: [a, b]\nallow_other_tools: false\nmode: loose\n",
"mode",
"trace_tool_sequence / documented mode key",
);
}
#[test]
fn trace_max_steps_rejects_unknown_key() {
assert_rejects_naming(
"type: trace_max_steps\nmax: 10\nsteps: 10\n",
"steps",
"trace_max_steps / stray key",
);
}
#[test]
fn args_valid_rejects_unknown_key() {
assert_rejects_naming(
"type: args_valid\ntool: t\ntest_arg: {a: 1}\n",
"test_arg",
"args_valid / misspelled test_args",
);
assert_rejects_naming(
"type: args_valid\ntool: t\ntest_args: {a: 1}\nexpekt: pass\n",
"expekt",
"args_valid / misspelled expect",
);
}
#[test]
fn sequence_valid_rejects_unknown_key() {
assert_rejects_naming(
"type: sequence_valid\ntest_trace_row: []\n",
"test_trace_row",
"sequence_valid / misspelled test_trace_raw",
);
}
#[test]
fn tool_blocklist_rejects_unknown_key_despite_having_no_required_field() {
assert_rejects_naming(
"type: tool_blocklist\nblocked: [rm]\n",
"blocked",
"tool_blocklist / `blocked` at the top level instead of inside policy",
);
assert_rejects_naming(
"type: tool_blocklist\ntest_tool_call: [rm]\n",
"test_tool_call",
"tool_blocklist / misspelled test_tool_calls",
);
}
#[test]
fn every_variant_still_parses_in_its_documented_form() {
for (case, yaml) in [
(
"trace_must_call_tool",
"type: trace_must_call_tool\ntool: web_search\nmin_calls: 1\n",
),
(
"trace_must_call_tool without min_calls",
"type: trace_must_call_tool\ntool: web_search\n",
),
(
"trace_must_not_call_tool",
"type: trace_must_not_call_tool\ntool: delete_database\n",
),
(
"trace_tool_sequence",
"type: trace_tool_sequence\nsequence: [search, summarize]\nallow_other_tools: true\n",
),
("trace_max_steps", "type: trace_max_steps\nmax: 10\n"),
(
"args_valid",
"type: args_valid\ntool: t\ntest_args: {percent: 10}\npolicy: {schema: {}}\nexpect: pass\n",
),
(
"sequence_valid",
"type: sequence_valid\ntest_trace_raw: [{tool: a}]\npolicy: {regex: '^a$'}\nexpect: pass\n",
),
(
"tool_blocklist",
"type: tool_blocklist\ntest_tool_calls: [rm]\npolicy: {blocked: [rm]}\nexpect: fail\n",
),
("tool_blocklist bare", "type: tool_blocklist\n"),
] {
assert_accepts(yaml, case);
}
}
#[test]
fn nested_free_form_values_stay_unconstrained() {
assert_accepts(
"type: args_valid\ntool: t\ntest_args: {anything: {nested: [1, 2]}, weird_key: true}\npolicy: {schema: {properties: {x: {type: number}}}, extra_policy_key: 1}\n",
"args_valid / free-form nested keys",
);
assert_accepts(
"type: tool_blocklist\npolicy: {blocked: [rm], unknown_policy_key: 1}\n",
"tool_blocklist / free-form policy keys",
);
}
fn write_config(dir: &std::path::Path, assertion_block: &str) -> std::path::PathBuf {
let path = dir.join("eval.yaml");
std::fs::write(
&path,
format!(
"configVersion: 1\nsuite: unknown_field_probe\nmodel: dummy\ntests:\n - id: t1\n input: hello\n assertions:\n{assertion_block}"
),
)
.expect("write config");
path
}
#[test]
fn load_config_rejects_an_unknown_key_inside_an_assertion() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = write_config(
tmp.path(),
" - type: trace_must_call_tool\n tool: delete_database\n max_calls: 0\n",
);
for strict in [false, true] {
let err = assay_core::config::load_config(&path, false, strict)
.expect_err(&format!("strict={strict}: expected load_config to reject"));
let msg = err.to_string();
assert!(
msg.contains("max_calls"),
"strict={strict}: rejected but did not name the offending key: {msg}"
);
}
}
#[test]
fn load_config_still_accepts_a_well_formed_assertion() {
let tmp = tempfile::tempdir().expect("tempdir");
let path = write_config(
tmp.path(),
" - type: trace_must_call_tool\n tool: web_search\n min_calls: 1\n",
);
assay_core::config::load_config(&path, false, false).expect("well-formed assertion must load");
}
#[test]
fn first_party_suites_still_load() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("repo root");
let suites = ["tests/fp_suite.yaml", "tests/regex_compatibility.yaml"];
let mut loaded = 0;
for name in suites {
let path = repo_root.join(name);
assert!(
path.exists(),
"{name} not found at {}; this test's coverage would otherwise be silently empty",
path.display()
);
assay_core::config::load_config(&path, false, false).unwrap_or_else(|e| {
panic!("{name} no longer loads after the unknown-field guard: {e}")
});
loaded += 1;
}
assert_eq!(
loaded,
suites.len(),
"not every first-party suite was exercised"
);
}
#[test]
fn the_documented_reason_errored_calls_still_count_is_still_true() {
let row = serde_json::to_value(assay_core::storage::rows::ToolCallRow {
id: 1,
step_id: "s".into(),
episode_id: "e".into(),
tool_name: Some("t".into()),
call_index: Some(0),
args: None,
result: None,
})
.expect("ToolCallRow serializes");
let fields: Vec<&str> = row
.as_object()
.expect("ToolCallRow is a struct")
.keys()
.map(|k| k.as_str())
.collect();
for outcome_ish in ["status", "error", "ok", "success", "outcome", "failed"] {
assert!(
!fields.contains(&outcome_ish),
"ToolCallRow gained a `{outcome_ish}` field. `docs/metrics/index.md` says an errored \
call satisfies `trace_must_call_tool` because no such column exists; update the doc \
(and consider whether the assertion should now distinguish them). Fields: {fields:?}"
);
}
}