test(agents): bind draft readiness contract

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-09-06 22:57:12 -07:00
parent a03278afc8
commit ea15b45577
5 changed files with 180 additions and 3 deletions

View File

@@ -257,9 +257,9 @@ pub fn get_baked_build_env() -> Vec<BakedEnvEntry> {
/// patch to one exact saved agent. Projection reuses the production create,
/// update, effective-config, harness, and readiness owners.
#[tauri::command]
pub async fn evaluate_agent_readiness_draft(
pub async fn evaluate_agent_readiness_draft<R: tauri::Runtime>(
draft: AgentReadinessDraft,
app: AppHandle,
app: tauri::AppHandle<R>,
_state: State<'_, AppState>,
) -> Result<AgentReadinessEvaluation, String> {
tokio::task::spawn_blocking(move || {

View File

@@ -12,6 +12,52 @@ use std::sync::Mutex;
static GOOSE_PATH_ROOT_LOCK: Mutex<()> = Mutex::new(());
struct EnvVarGuard {
key: &'static str,
prior: Option<std::ffi::OsString>,
}
impl EnvVarGuard {
fn set(key: &'static str, value: &std::path::Path) -> Self {
let prior = std::env::var_os(key);
std::env::set_var(key, value);
Self { key, prior }
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
match &self.prior {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
struct ReadinessIpcFixture {
app: tauri::App<tauri::test::MockRuntime>,
_temp: tempfile::TempDir,
_home: EnvVarGuard,
_xdg: EnvVarGuard,
}
fn readiness_ipc_fixture() -> ReadinessIpcFixture {
let temp = tempfile::tempdir().expect("temp app data root");
let home = EnvVarGuard::set("HOME", temp.path());
let xdg = EnvVarGuard::set("XDG_DATA_HOME", temp.path());
let app = tauri::test::mock_builder()
.manage(crate::app_state::build_app_state())
.invoke_handler(tauri::generate_handler![evaluate_agent_readiness_draft])
.build(tauri::test::mock_context(tauri::test::noop_assets()))
.expect("mock app builds");
ReadinessIpcFixture {
app,
_temp: temp,
_home: home,
_xdg: xdg,
}
}
/// Run a test body with GOOSE_PATH_ROOT set to a non-existent path so that the
/// goose config file read returns `None`. Restores the prior value on exit.
fn with_no_goose_config<T>(body: impl FnOnce() -> T) -> T {
@@ -162,6 +208,100 @@ fn persona_without_model() -> AgentDefinition {
}
}
#[test]
fn production_handler_registers_readiness_command() {
let lib = include_str!("../lib.rs");
let handler_start = lib
.find(".invoke_handler(tauri::generate_handler![")
.expect("production invoke handler exists");
let handler = &lib[handler_start..];
assert!(
handler.contains("evaluate_agent_readiness_draft,"),
"removing readiness from the production Tauri handler must fail this test"
);
}
#[test]
fn readiness_command_is_registered_and_never_persists_its_draft() {
let _env_guard = crate::managed_agents::lock_env_mutex();
let fixture = readiness_ipc_fixture();
let mut saved = agent_record();
saved.pubkey = "a".repeat(64);
saved.persona_id = None;
saved.agent_command = "buzz-agent".to_string();
saved.model = None;
saved.provider = None;
crate::managed_agents::save_managed_agents(fixture.app.handle(), &[saved.clone()])
.expect("seed saved agent");
let global = GlobalAgentConfig {
provider: Some("openrouter".to_string()),
model: Some("openrouter/model".to_string()),
env_vars: std::collections::BTreeMap::from([(
"OPENROUTER_API_KEY".to_string(),
"sk-global".to_string(),
)]),
preferred_runtime: None,
};
let global_path = crate::managed_agents::managed_agents_base_dir(fixture.app.handle())
.expect("managed agents dir")
.join("global-agent-config.json");
std::fs::write(
global_path,
serde_json::to_vec_pretty(&global).expect("serialize global config"),
)
.expect("seed global config");
// Stabilize the store's ordinary built-in-definition reconciliation before
// measuring the readiness command's own persistence behavior.
crate::managed_agents::load_personas(fixture.app.handle()).expect("warm built-in definitions");
let before = std::fs::read(
crate::managed_agents::storage::managed_agents_store_path(fixture.app.handle())
.expect("store path"),
)
.expect("seeded store bytes");
let webview = tauri::WebviewWindowBuilder::new(
&fixture.app,
"main",
tauri::WebviewUrl::App("index.html".into()),
)
.build()
.expect("mock webview");
let response = tauri::test::get_ipc_response(
&webview,
tauri::webview::InvokeRequest {
cmd: "evaluate_agent_readiness_draft".into(),
callback: tauri::ipc::CallbackFn(0),
error: tauri::ipc::CallbackFn(1),
url: "tauri://localhost".parse().expect("invoke url"),
body: tauri::ipc::InvokeBody::Json(serde_json::json!({
"draft": {
"kind": "existing",
"config": {
"pubkey": saved.pubkey
}
}
})),
headers: Default::default(),
invoke_key: tauri::test::INVOKE_KEY.to_string(),
},
)
.expect("registered readiness command succeeds")
.deserialize::<serde_json::Value>()
.expect("structured readiness response");
assert_eq!(response["ready"], true);
assert_eq!(response["requirements"], serde_json::json!([]));
let after = std::fs::read(
crate::managed_agents::storage::managed_agents_store_path(fixture.app.handle())
.expect("store path"),
)
.expect("store bytes after preview");
assert_eq!(
after, before,
"readiness preview must not persist the patch"
);
}
/// A post-spawn session cache whose live model is `current_model` and whose
/// `model_overridden` flag records whether a `SwitchModel` control signal set
/// it (the live-switch signal).

View File

@@ -208,6 +208,29 @@ mod tests {
}
}
#[cfg(unix)]
#[test]
fn login_probe_fails_closed_when_success_output_exceeds_bound() {
use std::fs;
use std::os::unix::fs::PermissionsExt;
let temp = tempfile::tempdir().expect("temp dir");
let script_path = temp.path().join("noisy-login-probe");
fs::write(
&script_path,
"#!/bin/sh\nhead -c 1100000 /dev/zero\nexit 0\n",
)
.expect("write noisy probe");
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))
.expect("chmod noisy probe");
assert_eq!(
super::login_probe(&script_path, &["noisy-login-probe"], None),
ProbeOutcome::LoggedOut,
"login readiness must stay bound even when a successful CLI floods output"
);
}
#[cfg(unix)]
#[test]
fn login_probe_logged_out_on_nonzero_without_config_signal() {

View File

@@ -211,7 +211,7 @@ pub struct UpdateManagedAgentRequest {
#[serde(default)]
pub name: Option<String>,
/// Absent = don't touch. null = clear to agent default. "id" = set.
#[serde(default)]
#[serde(default, deserialize_with = "crate::util::double_option")]
pub model: Option<Option<String>>,
#[serde(default)]
pub system_prompt: Option<Option<String>>,

View File

@@ -209,6 +209,20 @@ fn validate_respond_to_allowlist_accepts_empty() {
assert!(result.is_empty());
}
#[test]
fn update_request_model_tristate_matches_provider_contract() {
let absent: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey":"agent"}"#).expect("absent model");
let clear: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey":"agent","model":null}"#).expect("null model");
let set: super::UpdateManagedAgentRequest =
serde_json::from_str(r#"{"pubkey":"agent","model":"model-id"}"#).expect("model value");
assert_eq!(absent.model, None);
assert_eq!(clear.model, Some(None));
assert_eq!(set.model, Some(Some("model-id".to_string())));
}
#[test]
fn update_request_provider_tristate_absent_means_no_touch() {
// A JSON payload with no "provider" key deserialized with `None` —