ziyuan-cli 1.2.0

紫垣 ZiYuan:用中国古代朝廷角色组织多智能体协作的命令行工具,改动须用户审阅批准才写入文件。
//! 稳定性约定(docs/stability.md)的契约测试:以命令行黑盒方式核对 1.0 起冻结的接口。
//! 这里列出的只能增加,不能删改;新增字段、子命令或环境变量无须改动本文件。

use serde_json::Value;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use ziyuan_core::{AgentId, Capability, CourtEvent, Department, Seal, TokenUsage};
use ziyuan_governance::Court;
use ziyuan_storage::{EventLog, EventStore};

const BIN: &str = env!("CARGO_BIN_EXE_ziyuan");

fn ziyuan(dir: &Path, args: &[&str]) -> Output {
    let mut command = Command::new(BIN);
    command.args(args).current_dir(dir);
    // vars_os:环境中可能有非 UTF-8 的变量,vars() 遇到会 panic。
    for (key, _) in std::env::vars_os() {
        if key.to_string_lossy().starts_with("ZIYUAN_") {
            command.env_remove(key);
        }
    }
    command.output().expect("能运行 ziyuan")
}

fn ok(dir: &Path, args: &[&str]) -> Output {
    let out = ziyuan(dir, args);
    assert_eq!(
        out.status.code(),
        Some(0),
        "{args:?}: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    out
}

fn json(dir: &Path, command: &str) -> Value {
    serde_json::from_slice(&ok(dir, &[command, "--json"]).stdout)
        .unwrap_or_else(|e| panic!("{command} --json 不是 JSON:{e}"))
}

/// `value`(对象,或数组的第一项)须含 `keys` 中的全部键。
fn has_keys(what: &str, value: &Value, keys: &[&str]) {
    let object = match value {
        Value::Array(items) => items.first().unwrap_or_else(|| panic!("{what} 为空")),
        other => other,
    };
    let object = object
        .as_object()
        .unwrap_or_else(|| panic!("{what} 不是对象"));
    let missing: Vec<_> = keys.iter().filter(|k| !object.contains_key(**k)).collect();
    assert!(missing.is_empty(), "{what} 缺少冻结的字段 {missing:?}");
}

fn temp_court(name: &str) -> PathBuf {
    let dir = std::env::temp_dir().join(format!("ziyuan-contract-{name}-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(&dir).unwrap();
    ok(&dir, &["init"]);
    dir
}

const TASK_KEYS: &[&str] = &[
    "id",
    "text",
    "department",
    "status",
    "worker",
    "seal",
    "usage",
    "review",
    "censure",
    "changes",
    "parent",
    "split",
    "urgent",
    "courier",
    "draft",
    "needs_approval",
    "suspicions",
    "template",
    "debates",
    "budget",
    "model",
    "tool_calls",
    "tool_failures",
];
const OFFICIAL_KEYS: &[&str] = &[
    "id",
    "name",
    "courtesy",
    "art",
    "speciality",
    "office",
    "model",
    "ceiling",
    "record",
    "purged",
    "demotion",
    "persona",
];

#[test]
fn json_outputs_keep_their_frozen_fields() {
    let dir = temp_court("json");
    ok(&dir, &["edict", "--queue", "检查 unsafe"]);
    ok(&dir, &["run"]);
    ok(&dir, &["review", "1", "accept"]);

    let court = json(&dir, "court");
    has_keys(
        "court",
        &court,
        &[
            "mode",
            "modes",
            "departments",
            "offices",
            "officials",
            "tasks",
            "task_total",
            "open_total",
            "retreat",
            "treasury",
            "usage",
            "vacancies",
            "templates",
        ],
    );
    has_keys("court.mode", &court["mode"], &["code", "label"]);
    has_keys(
        "court.departments",
        &court["departments"],
        &["code", "label", "route"],
    );
    has_keys(
        "court.offices",
        &court["offices"],
        &["code", "label", "rank", "department"],
    );
    has_keys("court.officials", &court["officials"], OFFICIAL_KEYS);
    has_keys("court.tasks", &court["tasks"], TASK_KEYS);
    has_keys("officials", &json(&dir, "officials"), OFFICIAL_KEYS);
    has_keys("tasks", &json(&dir, "tasks"), TASK_KEYS);
    has_keys(
        "history",
        &json(&dir, "history"),
        &["seq", "kind", "text", "time"],
    );
    has_keys(
        "records",
        &json(&dir, "records"),
        &[
            "task",
            "department",
            "edict",
            "worker",
            "handler",
            "summary",
            "verdict",
        ],
    );
    has_keys(
        "migrate",
        &json(&dir, "migrate"),
        &[
            "format",
            "version",
            "events",
            "readable",
            "readable_since",
            "first_needing",
            "problem",
            "torn_tail",
            "huidian",
        ],
    );
    for command in ["flags", "mcp"] {
        assert!(json(&dir, command).is_array(), "{command} --json 应为数组");
    }
    std::fs::remove_dir_all(dir).unwrap();
}

#[test]
fn exit_codes_distinguish_usage_errors_from_runtime_errors() {
    let dir = temp_court("exit");
    assert_eq!(ziyuan(&dir, &["court"]).status.code(), Some(0));
    assert_eq!(ziyuan(&dir, &["no-such-command"]).status.code(), Some(2));
    assert_eq!(ziyuan(&dir, &["court", "extra"]).status.code(), Some(2));
    assert_eq!(
        ziyuan(&dir, &["review", "99", "accept"]).status.code(),
        Some(1)
    );
    std::fs::remove_dir_all(dir).unwrap();
}

#[test]
fn run_explains_when_only_blocked_subtasks_remain() {
    let dir = temp_court("dag-blocked-run");
    let mut log = EventLog::new(&dir);
    let existing = log.load().unwrap();
    let mut court = Court::replay(&existing).unwrap();
    let edict = court
        .new_edict(
            Department::Works,
            "改全库".into(),
            Seal {
                capabilities: [Capability::ReadFile, Capability::WriteFile].into(),
                scope: vec![".".into()],
                mcp: Default::default(),
            },
        )
        .unwrap();
    let parent = edict.id;
    let mut events = vec![CourtEvent::EdictIssued(Box::new(edict))];
    court.apply(&events[0]).unwrap();
    let split = court
        .propose_split_serial_writes(
            parent,
            vec![(AgentId(4), "改甲".into()), (AgentId(4), "改乙".into())],
            "御定分办".into(),
            None,
            TokenUsage::default(),
        )
        .unwrap();
    for event in split {
        court.apply(&event).unwrap();
        events.push(event);
    }
    let subtask = court.task(parent).unwrap().split.as_ref().unwrap().subtasks[0];
    let failure = CourtEvent::TaskFailed {
        task: subtask,
        reason: "测试上游失败".into(),
    };
    court.apply(&failure).unwrap();
    events.push(failure);
    log.append(existing.len(), &events).unwrap();

    let output = ok(&dir, &["run"]);
    let stdout = String::from_utf8(output.stdout).unwrap();
    assert!(stdout.contains("受阻"), "{stdout}");
    assert!(stdout.contains("ziyuan tasks"), "{stdout}");
    std::fs::remove_dir_all(dir).unwrap();
}

#[test]
fn version_and_help_list_the_frozen_commands_and_environment() {
    let dir = std::env::temp_dir();
    let version = String::from_utf8(ok(&dir, &["--version"]).stdout).unwrap();
    assert_eq!(
        version.trim(),
        format!("ziyuan {}", env!("CARGO_PKG_VERSION"))
    );
    let help = String::from_utf8(ok(&dir, &["help"]).stdout).unwrap();
    for command in [
        "init",
        "court",
        "officials",
        "recruit",
        "rename",
        "appoint",
        "dismiss",
        "purge",
        "reform",
        "edict",
        "template",
        "run",
        "tasks",
        "diff",
        "apply",
        "discard",
        "review",
        "cancel",
        "approve",
        "draft",
        "redraft",
        "split",
        "debate",
        "censor",
        "recheck",
        "acquit",
        "recommend",
        "examine",
        "kaocheng",
        "nominate",
        "cane",
        "pardon",
        "retreat",
        "abolish",
        "budget",
        "model",
        "ceiling",
        "exam",
        "crown",
        "post",
        "urgent",
        "github",
        "changelog",
        "history",
        "records",
        "migrate",
        "flags",
        "mcp",
        "serve",
        "tui",
        "login",
        "logout",
        "completions",
        "version",
        "help",
    ] {
        assert!(
            help.contains(&format!("ziyuan {command}")),
            "help 未列出 {command}"
        );
    }
    for var in [
        "ZIYUAN_LLM_MODEL",
        "ZIYUAN_LLM_BASE_URL",
        "ZIYUAN_LLM_API_KEY",
        "ZIYUAN_LLM_PROVIDER",
        "ZIYUAN_LLM_PRICE",
        "ZIYUAN_LLM_RETRIES",
        "ZIYUAN_MAX_STEPS",
        "ZIYUAN_CONTEXT_TOKENS",
        "ZIYUAN_TEST_COMMAND",
        "ZIYUAN_TZ",
        "ZIYUAN_GITHUB_REPO",
        "ZIYUAN_GITHUB_TOKEN",
        "ZIYUAN_CONFIG_DIR",
        "ZIYUAN_SANDBOX",
    ] {
        assert!(help.contains(var), "help 未列出 {var}");
    }
}

#[test]
fn invalid_context_threshold_does_not_start_or_change_tasks() {
    let dir = temp_court("context");
    ok(&dir, &["edict", "--queue", "检查上下文"]);
    let before = EventLog::new(&dir).load().unwrap();
    for value in ["", "0", "-1", "1.5", "abc", "18446744073709551616"] {
        let mut command = Command::new(BIN);
        command.current_dir(&dir).arg("run");
        for (key, _) in std::env::vars_os() {
            if key.to_string_lossy().starts_with("ZIYUAN_") {
                command.env_remove(key);
            }
        }
        let out = command
            .env("ZIYUAN_LLM_MODEL", "context-test")
            .env("ZIYUAN_LLM_BASE_URL", "http://127.0.0.1:9/v1")
            .env("ZIYUAN_CONTEXT_TOKENS", value)
            .output()
            .unwrap();
        assert!(!out.status.success(), "{value:?}");
        assert!(String::from_utf8_lossy(&out.stderr).contains("ZIYUAN_CONTEXT_TOKENS 须为正整数"));
        assert_eq!(EventLog::new(&dir).load().unwrap(), before);
    }
    std::fs::remove_dir_all(dir).unwrap();
}