//! Golden-envelope tests: pin the exact bytes the framework emits for the
//! canonical invocation classes. The envelope is hand-serialized
//! (`src/envelope.rs`), so a field reorder or rename is a breaking change
//! agents feel instantly — these tests turn schema drift into a red test
//! instead of a downstream agent outage, and double as a living spec.
//!
//! Timestamps are pinned via `SOURCE_DATE_EPOCH=0` (set per test process;
//! `cargo nextest` runs each test in its own process).
use agcli::{ActionParam, AgentCli, Command, CommandError, CommandOutput, ExitCode, NextAction};
use serde_json::json;
fn pin_clock() {
// SAFETY: nextest runs each test in its own process; no other thread is
// reading the environment concurrently at this point.
unsafe { std::env::set_var("SOURCE_DATE_EPOCH", "0") };
}
/// The fixed calc-shaped CLI every golden case runs against.
fn golden_cli() -> AgentCli {
AgentCli::new("calc", "Agent-native calculator")
.version("1.0.0")
.command(
Command::new("add", "Add two numbers")
.usage("calc add <a> <b>")
.handler(|req, _ctx| {
let a = req.arg_parse::<f64>(0, "a");
let b = req.arg_parse::<f64>(1, "b");
Box::pin(async move {
let sum = a? + b?;
Ok(CommandOutput::new(json!({
"operation": "add",
"result": sum,
}))
.next_action(
NextAction::new("calc add <a> <b>", "Add two more numbers")
.with_param("a", ActionParam::new().value(json!(sum)))
.with_param("b", ActionParam::new().required(true)),
))
})
}),
)
.command(
Command::new("fail", "Always fails with a typed error")
.usage("calc fail")
.handler(|_req, _ctx| {
Box::pin(async move {
Err::<CommandOutput, _>(
CommandError::new(
"the thing was not found",
"NOT_FOUND",
"Create the thing first with `calc add <a> <b>`.",
)
.exit_code(ExitCode::NOT_FOUND),
)
})
}),
)
}
async fn run(args: &[&str]) -> String {
pin_clock();
golden_cli().run_argv(args.iter().copied()).await.to_json()
}
/// Each case: (name, argv, expected exact JSON line).
/// Regenerate an entry by running the invocation under `SOURCE_DATE_EPOCH=0`
/// and reading stdout — but treat any diff as a schema break to justify.
macro_rules! golden {
($name:ident, $args:expr, $expected:expr) => {
#[tokio::test]
async fn $name() {
let actual = run($args).await;
assert_eq!(actual, $expected, "envelope bytes drifted");
}
};
}
golden!(
success_envelope,
&["calc", "add", "1", "2"],
r#"{"ok":true,"command":"calc add 1 2","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"operation":"add","result":3.0},"next_actions":[{"command":"calc add <a> <b>","description":"Add two more numbers","params":{"a":{"value":3.0},"b":{"required":true}}}]}"#
);
golden!(
version_flag,
&["calc", "--version"],
r#"{"ok":true,"command":"calc --version","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"name":"calc","version":"1.0.0"},"next_actions":[{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
version_positional_alias,
&["calc", "version"],
r#"{"ok":true,"command":"calc version","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"name":"calc","version":"1.0.0"},"next_actions":[{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
help_command,
&["calc", "help", "add"],
r#"{"ok":true,"command":"calc help add","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"description":"Add two numbers","name":"add","subcommands":[],"usage":"calc add <a> <b>"},"next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
missing_arg_error,
&["calc", "add", "1"],
r#"{"ok":false,"command":"calc add 1","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"missing argument <b>","code":"MISSING_ARG","retryable":false},"fix":"Provide <b> as positional argument 1.","next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
invalid_arg_error,
&["calc", "add", "foo", "bar"],
r#"{"ok":false,"command":"calc add foo bar","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"argument <a> is not valid: \"foo\"","code":"INVALID_ARG","retryable":false},"fix":"Pass a valid value for <a>.","next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
unknown_command_error,
&["calc", "plus", "1", "2"],
r#"{"ok":false,"command":"calc plus 1 2","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"unknown command: plus","code":"UNKNOWN_COMMAND","retryable":false},"fix":"Valid commands: add, fail.","next_actions":[{"command":"calc add <a> <b>","description":"Add two numbers","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc fail","description":"Always fails with a typed error"}]}"#
);
golden!(
extra_arg_error,
&["calc", "add", "1", "2", "3"],
r#"{"ok":false,"command":"calc add 1 2 3","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"unexpected extra argument(s): \"3\" (`add` takes 2 positional argument(s); got 3)","code":"EXTRA_ARG","retryable":false},"fix":"Nothing was run. Re-invoke matching the usage template `calc add <a> <b>`, or drop the extra argument(s).","next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
unknown_flag_error,
&["calc", "add", "1", "2", "--bogus"],
r#"{"ok":false,"command":"calc add 1 2 --bogus","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"unknown flag(s): --bogus","code":"UNKNOWN_FLAG","retryable":false},"fix":"`add` takes no flags of its own. Reserved agent flags (--select, --compact, --quiet, --dry-run, --yes, --no-input, --no-cache, --no-color, --stdin, --json, --version) are accepted on every command.","next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
select_projection,
&["calc", "add", "1", "2", "--select=result"],
r#"{"ok":true,"command":"calc add 1 2 --select=result","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"result":3.0},"next_actions":[{"command":"calc add <a> <b>","description":"Add two more numbers","params":{"a":{"value":3.0},"b":{"required":true}}}]}"#
);
golden!(
select_no_match_warns_instead_of_wiping,
&["calc", "add", "1", "2", "--select=bogus"],
r#"{"ok":true,"command":"calc add 1 2 --select=bogus","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"operation":"add","result":3.0,"select_warning":"--select=bogus matched no fields. Available top-level fields: operation, result. Returning the full result; re-run --select with a valid field name."},"next_actions":[{"command":"calc add <a> <b>","description":"Add two more numbers","params":{"a":{"value":3.0},"b":{"required":true}}}]}"#
);
golden!(
quiet_strips_next_actions,
&["calc", "add", "1", "2", "--quiet"],
r#"{"ok":true,"command":"calc add 1 2 --quiet","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"operation":"add","result":3.0},"next_actions":[]}"#
);
golden!(
dry_run_unsupported_error,
&["calc", "add", "1", "2", "--dry-run"],
r#"{"ok":false,"command":"calc add 1 2 --dry-run","timestamp":"1970-01-01T00:00:00Z","exit_code":2,"error":{"message":"`add` does not support --dry-run","code":"DRY_RUN_UNSUPPORTED","retryable":false},"fix":"Nothing was changed. This command has no preview mode: run it without --dry-run to execute it, or inspect current state first with a read command from next_actions.","next_actions":[{"command":"calc add <a> <b>","description":"Run this command template","params":{"a":{"required":true},"b":{"required":true}}},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
golden!(
handler_error_with_typed_exit,
&["calc", "fail"],
r#"{"ok":false,"command":"calc fail","timestamp":"1970-01-01T00:00:00Z","exit_code":3,"error":{"message":"the thing was not found","code":"NOT_FOUND","retryable":false},"fix":"Create the thing first with `calc add <a> <b>`.","next_actions":[{"command":"calc fail","description":"Run this command template"},{"command":"calc","description":"Inspect the full command tree"}]}"#
);
/// The list case gets its own CLI: adding an `ls` command to [`golden_cli`]
/// would shift the "Valid commands" fix line in every other golden.
fn list_golden_cli() -> AgentCli {
AgentCli::new("notes", "Agent-native notes")
.version("1.0.0")
.command(
Command::new("ls", "List notes")
.usage("notes ls")
.handler(|_req, _ctx| {
Box::pin(async move {
Ok(CommandOutput::list(vec![
json!({ "id": 1, "title": "first", "done": false }),
json!({ "id": 2, "title": "second", "done": true }),
]))
})
}),
)
}
/// Pins the full list contract: the `items`/`count`/`total`/`truncated` shape,
/// the `fields` row schema as sorted `--select` dot paths, and the pre-filled
/// `--select` advertisement that re-runs this invocation projected.
#[tokio::test]
async fn list_envelope() {
pin_clock();
let actual = list_golden_cli().run_argv(["notes", "ls"]).await.to_json();
assert_eq!(
actual,
r#"{"ok":true,"command":"notes ls","timestamp":"1970-01-01T00:00:00Z","exit_code":0,"result":{"count":2,"fields":["items.done","items.id","items.title"],"items":[{"done":false,"id":1,"title":"first"},{"done":true,"id":2,"title":"second"}],"total":2,"truncated":false},"next_actions":[{"command":"notes ls","description":"Run this command template"},{"command":"notes","description":"Inspect the full command tree"},{"command":"notes ls --select=<fields>","description":"Re-run projected to only the fields you need — smaller result, same data","params":{"fields":{"description":"Comma-separated subset of: items.done, items.id, items.title (dot paths project each row)","required":true}}}]}"#,
"envelope bytes drifted"
);
}
/// The root tree is large; rather than pinning the whole line, pin its
/// structural contract: key order-independent presence of every section plus
/// determinism across two builds.
#[tokio::test]
async fn root_tree_structure_and_determinism() {
let first = run(&["calc"]).await;
let second = run(&["calc"]).await;
assert_eq!(first, second, "root tree must be byte-deterministic");
let value: serde_json::Value = serde_json::from_str(&first).expect("valid JSON");
for key in [
"description",
"version",
"commands",
"agent_flags",
"exit_codes",
"error_codes",
] {
assert!(
value["result"].get(key).is_some(),
"root tree lost its `{key}` section"
);
}
}