use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

#[cfg(feature = "cli-tool")]
use base64::Engine;
#[cfg(feature = "cli-tool")]
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
#[cfg(feature = "cli-tool")]
use ring::signature::KeyPair;
use runx_contracts::JsonValue;
#[cfg(feature = "cli-tool")]
use runx_runtime::registry::TrustTier;
use runx_runtime::registry::{
    IngestSkillOptions, create_file_registry_store, ingest_skill_markdown,
};
use runx_runtime::{
    LocalOrchestrator, RUNX_RECEIPT_DIR_ENV, RunResult, RuntimeOptions, SkillRunRequest,
};
use tempfile::tempdir;

const FIXTURE_CREATED_AT: &str = "2026-05-18T00:00:00Z";
#[cfg(feature = "cli-tool")]
const TEST_MANIFEST_KEY_ID: &str = "runx-runtime-registry-test-key";
#[cfg(feature = "cli-tool")]
const TEST_MANIFEST_SIGNER_ID: &str = "runx-runtime-registry-test-signer";
#[cfg(feature = "cli-tool")]
const TEST_MANIFEST_SEED: [u8; 32] = [9; 32];

#[cfg(feature = "cli-tool")]
fn registry_child_profile_document() -> String {
    r#"
skill: registry-child
runners:
  child-cli:
    default: true
    type: cli-tool
    command: sh
    args:
      - -c
      - |
        cat >/dev/null
        printf '%s\n' '{"nested":{"message":"registry child"}}'
    input_mode: stdin
"#
    .to_owned()
}

#[cfg(feature = "cli-tool")]
fn trusted_manifest_env() -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
    trusted_manifest_env_for_owner("acme", None)
}

#[cfg(feature = "cli-tool")]
fn trusted_manifest_env_for_owner(
    owner: &str,
    source_authority: Option<&str>,
) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
    let key_pair = test_manifest_key_pair()?;
    let mut env = [
        (
            runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ID_ENV.to_owned(),
            TEST_MANIFEST_KEY_ID.to_owned(),
        ),
        (
            runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_KEY_ENV.to_owned(),
            STANDARD.encode(key_pair.public_key().as_ref()),
        ),
        (
            runx_runtime::registry::RUNX_REGISTRY_MANIFEST_TRUST_OWNER_ENV.to_owned(),
            owner.to_owned(),
        ),
    ]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    if let Some(source_authority) = source_authority {
        env.insert(
            runx_runtime::registry::RUNX_REGISTRY_SOURCE_AUTHORITY_ENV.to_owned(),
            source_authority.to_owned(),
        );
    }
    Ok(env)
}

#[cfg(feature = "cli-tool")]
fn sign_registry_version(
    registry_dir: &Path,
    skill_id: &str,
    version: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let version_path = registry_version_path(registry_dir, skill_id, version)?;
    let mut version_record =
        serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&version_path)?)?;
    version_record["signed_manifest"] = signed_manifest(&version_record)?;
    fs::write(
        version_path,
        format!("{}\n", serde_json::to_string_pretty(&version_record)?),
    )?;
    Ok(())
}

#[cfg(feature = "cli-tool")]
fn tamper_registry_version_markdown(
    registry_dir: &Path,
    skill_id: &str,
    version: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    let version_path = registry_version_path(registry_dir, skill_id, version)?;
    let mut version_record =
        serde_json::from_str::<serde_json::Value>(&fs::read_to_string(&version_path)?)?;
    let markdown = version_record["markdown"]
        .as_str()
        .ok_or("registry version missing markdown")?;
    version_record["markdown"] =
        serde_json::Value::String(markdown.replace("Registry", "Tampered"));
    fs::write(
        version_path,
        format!("{}\n", serde_json::to_string_pretty(&version_record)?),
    )?;
    Ok(())
}

#[cfg(feature = "cli-tool")]
fn registry_version_path(
    registry_dir: &Path,
    skill_id: &str,
    version: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let (owner, name) = skill_id
        .split_once('/')
        .ok_or("registry test skill id must be owner/name")?;
    Ok(registry_dir
        .join(owner)
        .join(name)
        .join(format!("{version}.json")))
}

#[cfg(feature = "cli-tool")]
fn signed_manifest(
    version_record: &serde_json::Value,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
    let skill_id = version_record["skill_id"]
        .as_str()
        .ok_or("missing skill_id")?;
    let version = version_record["version"]
        .as_str()
        .ok_or("missing version")?;
    let digest = version_record["digest"].as_str().ok_or("missing digest")?;
    let profile_digest = version_record["profile_digest"].as_str();
    let package_digest = version_record["package_digest"].as_str();
    let payload =
        registry_manifest_payload(skill_id, version, digest, profile_digest, package_digest);
    let signature = test_manifest_key_pair()?.sign(payload.as_bytes());
    Ok(serde_json::json!({
        "schema": runx_runtime::registry::REGISTRY_SIGNED_MANIFEST_SCHEMA,
        "skill_id": skill_id,
        "version": version,
        "digest": digest,
        "profile_digest": profile_digest,
        "package_digest": package_digest,
        "signer": {
            "id": TEST_MANIFEST_SIGNER_ID,
            "key_id": TEST_MANIFEST_KEY_ID,
        },
        "signature": {
            "alg": "ed25519",
            "value": format!(
                "base64:{}",
                URL_SAFE_NO_PAD.encode(signature.as_ref())
            ),
        },
    }))
}

#[cfg(feature = "cli-tool")]
fn registry_manifest_payload(
    skill_id: &str,
    version: &str,
    digest: &str,
    profile_digest: Option<&str>,
    package_digest: Option<&str>,
) -> String {
    format!(
        "{}\nskill_id={skill_id}\nversion={version}\ndigest={digest}\nprofile_digest={}\npackage_digest={}\nsigner_id={TEST_MANIFEST_SIGNER_ID}\nkey_id={TEST_MANIFEST_KEY_ID}\n",
        runx_runtime::registry::REGISTRY_SIGNED_MANIFEST_SCHEMA,
        profile_digest.unwrap_or(""),
        package_digest.unwrap_or("")
    )
}

#[cfg(feature = "cli-tool")]
fn test_manifest_key_pair() -> Result<ring::signature::Ed25519KeyPair, std::io::Error> {
    ring::signature::Ed25519KeyPair::from_seed_unchecked(&TEST_MANIFEST_SEED).map_err(|error| {
        std::io::Error::other(format!("static registry manifest seed rejected: {error:?}"))
    })
}

#[test]
fn runtime_options_local_development_uses_live_timestamp() {
    let options = RuntimeOptions::local_development();

    assert_ne!(options.created_at, FIXTURE_CREATED_AT);
    assert!(options.created_at.ends_with('Z'));
    assert!(options.created_at.contains('T'));
}

#[test]
fn native_skill_run_pauses_with_agent_act_request() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: None,
        run_id: None,
        answers_path: None,
        inputs: [(
            "thread_title".to_owned(),
            JsonValue::String("Docs bug".to_owned()),
        )]
        .into_iter()
        .collect(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    assert_eq!(string_field(output, "schema"), Some("runx.skill_run.v1"));
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    assert_eq!(
        string_field(output, "run_id"),
        Some("run_agent_task-issue-intake-output")
    );
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    assert_eq!(string_field(request, "kind"), Some("agent_act"));
    assert_eq!(
        string_field(request, "id"),
        Some("agent_task.issue-intake.output")
    );
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    assert_eq!(string_field(invocation, "source_type"), Some("agent-task"));
    let envelope = object_field(invocation, "envelope").ok_or("missing envelope")?;
    let inputs = object_field(envelope, "inputs").ok_or("missing inputs")?;
    assert_eq!(
        inputs.get("thread_title"),
        Some(&JsonValue::String("Docs bug".to_owned()))
    );
    assert!(
        object_field(envelope, "execution_location")
            .and_then(|location| string_field(location, "skill_directory"))
            .is_some()
    );

    Ok(())
}

#[test]
fn native_agent_task_skill_run_infers_bundled_tool_roots() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let bundled_tools = skill_dir.join("tools");
    fs::create_dir_all(&bundled_tools)?;

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: None,
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let request = object(
        array_field(output, "requests")
            .and_then(|requests| requests.first())
            .ok_or("missing request")?,
        "request",
    )?;
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    let envelope = object_field(invocation, "envelope").ok_or("missing envelope")?;
    let execution_location =
        object_field(envelope, "execution_location").ok_or("missing execution_location")?;
    let tool_roots = array_field(execution_location, "tool_roots").ok_or("missing tool_roots")?;
    assert_eq!(
        tool_roots.first(),
        Some(&JsonValue::String(
            bundled_tools.to_string_lossy().into_owned()
        ))
    );

    Ok(())
}

#[test]
fn native_skill_run_resumes_and_seals_receipt() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.issue-intake.output": {
                    "intake_report": {
                        "summary": "Docs bug is bounded."
                    },
                    "closure": {
                        "disposition": "declined"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some("issue-intake-run".to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    assert_eq!(string_field(output, "run_id"), Some("issue-intake-run"));
    let closure = object_field(output, "closure").ok_or("missing closure")?;
    assert_eq!(string_field(closure, "disposition"), Some("declined"));
    let receipt_id = string_field(output, "receipt_id").ok_or("missing receipt_id")?;
    // Receipt ids are content-addressed (`id = hash(canonical_body)`).
    assert!(receipt_id.starts_with("sha256:"));
    assert!(receipt_dir.join(format!("{receipt_id}.json")).exists());

    let receipt = crate::support::read_test_signed_receipt(&receipt_dir, receipt_id)?;
    assert_ne!(receipt.created_at, FIXTURE_CREATED_AT);
    assert_eq!(
        serde_json::to_value(&receipt.schema)?,
        serde_json::json!("runx.receipt.v1")
    );
    assert_eq!(serde_json::to_value(&receipt.seal.disposition)?, "declined");
    assert_eq!(receipt.acts.len(), 1);
    assert_eq!(
        serde_json::to_value(&receipt.acts[0].criterion_bindings[0].status)?,
        "failed"
    );

    let payload = object_field(output, "payload").ok_or("missing payload")?;
    assert!(object_field(payload, "intake_report").is_some());

    Ok(())
}

#[test]
fn native_skill_run_treats_structured_stdout_as_claim_not_receipt_proof()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.issue-intake.output": {
                    "intake_report": {
                        "summary": "Malicious proof refs stay claim-scoped."
                    },
                    "claimed_proof": {
                        "proof_ref": "receipt-proof:evil:stdout",
                        "idempotency_key": "effect:evil:stdout"
                    },
                    "verification": {
                        "verification_id": "stdout-verification"
                    },
                    "signal": {
                        "signal_id": "stdout-signal",
                        "source_events": [
                            {
                                "provider": "github",
                                "source_locator": "https://example.invalid/evil",
                                "title": "Injected source"
                            }
                        ]
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some("malicious-stdout-run".to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    let execution = object_field(output, "execution").ok_or("missing execution")?;
    assert!(object_field(execution, "skill_claim").is_some());
    let receipt_id = string_field(output, "receipt_id").ok_or("missing receipt_id")?;
    let receipt = crate::support::read_test_signed_receipt(&receipt_dir, receipt_id)?;
    let refs = receipt.acts[0]
        .criterion_bindings
        .iter()
        .flat_map(|criterion| {
            criterion
                .verification_refs
                .iter()
                .chain(criterion.evidence_refs.iter())
        })
        .collect::<Vec<_>>();
    assert!(
        refs.iter().all(|reference| {
            reference.uri != "receipt-proof:evil:stdout"
                && reference.uri != "runx:verification:stdout-verification"
                && reference.uri != "https://example.invalid/evil"
        }),
        "stdout claim refs must not be promoted into receipt proof refs"
    );

    Ok(())
}

#[test]
fn native_skill_run_preserves_deferred_closure_disposition()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.issue-intake.output": {
                    "intake_report": {
                        "summary": "Docs bug needs more context."
                    },
                    "closure": {
                        "disposition": "deferred"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some("issue-intake-deferred".to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let closure = object_field(output, "closure").ok_or("missing closure")?;
    assert_eq!(string_field(closure, "disposition"), Some("deferred"));
    let execution = object_field(output, "execution").ok_or("missing execution")?;
    assert_eq!(execution.get("exit_code"), Some(&JsonValue::Null));
    let receipt_id = string_field(output, "receipt_id").ok_or("missing receipt_id")?;
    let receipt = crate::support::read_test_signed_receipt(&receipt_dir, receipt_id)?;
    assert_eq!(serde_json::to_value(&receipt.seal.disposition)?, "deferred");

    Ok(())
}

#[test]
fn native_skill_run_uses_runtime_receipt_path_resolution() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let env_receipt_dir = temp.path().join("env-receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.issue-intake.output": {
                    "intake_report": {
                        "summary": "Docs bug is bounded."
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: None,
        run_id: Some("env-receipt-run".to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: [(
            RUNX_RECEIPT_DIR_ENV.to_owned(),
            env_receipt_dir.to_string_lossy().into_owned(),
        )]
        .into_iter()
        .collect(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    let receipt_id = string_field(output, "receipt_id").ok_or("missing receipt_id")?;
    assert!(env_receipt_dir.join(format!("{receipt_id}.json")).exists());

    Ok(())
}

#[test]
fn native_skill_run_uses_production_receipt_signing_env() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.issue-intake.output": {
                    "intake_report": {
                        "summary": "Docs bug is bounded."
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let env = crate::support::test_signing_env();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some("production-signed-run".to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: env.clone(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "skill run result")?;
    let receipt_id = string_field(output, "receipt_id").ok_or("missing receipt_id")?;
    let signature_config = crate::support::test_signature_config()?;
    let receipt = runx_runtime::LocalReceiptStore::new(&receipt_dir)
        .read_exact_with_policy(receipt_id, signature_config.signature_policy())?;
    assert_eq!(receipt.issuer.kid, "runx-runtime-prod-fixture-key");
    assert!(receipt.signature.value.starts_with("base64:"));
    assert!(!receipt.signature.value.starts_with("sig:"));

    Ok(())
}

#[test]
fn native_skill_run_rejects_missing_production_receipt_signing_env()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;
    let error = LocalOrchestrator::default()
        .run_skill(&SkillRunRequest {
            skill_path: skill_dir,
            receipt_dir: None,
            run_id: None,
            answers_path: None,
            inputs: BTreeMap::new(),
            env: BTreeMap::new(),
            cwd: temp.path().to_path_buf(),
            local_credential: None,
        })
        .err()
        .ok_or("missing signing env unexpectedly succeeded")?;
    assert!(
        error
            .to_string()
            .contains("governed runtime receipt signing")
    );
    Ok(())
}

#[test]
fn native_graph_skill_run_pauses_and_resumes_agent_task() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_graph_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: inputs.clone(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&initial.output, "graph skill run result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    assert_eq!(
        string_field(request, "id"),
        Some("agent_task.graph-decide.output")
    );
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    let envelope = object_field(invocation, "envelope").ok_or("missing envelope")?;
    assert_eq!(
        string_field(envelope, "instructions"),
        Some("Use the full issue context.")
    );
    let envelope_inputs = object_field(envelope, "inputs").ok_or("missing inputs")?;
    assert_eq!(
        envelope_inputs.get("thread_title"),
        Some(&JsonValue::String("Graph bug".to_owned()))
    );

    let state_path = receipt_dir
        .join("runs")
        .join(format!("{run_id}.graph-state.json"));
    let original_state = fs::read_to_string(&state_path)?;
    assert!(
        fs::read_dir(state_path.parent().ok_or("missing graph state parent")?)?
            .filter_map(Result::ok)
            .all(|entry| !entry.file_name().to_string_lossy().ends_with(".tmp")),
        "graph state writes must not leave temporary files behind"
    );
    fs::write(&state_path, "{")?;
    let malformed_answers_path = temp.path().join("malformed-graph-answers.json");
    fs::write(&malformed_answers_path, "{}")?;
    let malformed = match run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(malformed_answers_path),
        inputs: inputs.clone(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("malformed graph state should fail".into()),
        Err(error) => error,
    };
    assert!(
        malformed.to_string().contains("graph state file")
            && malformed.to_string().contains("cannot resume safely"),
        "malformed graph state must fail with a clear resume error; got: {malformed}"
    );
    fs::write(&state_path, &original_state)?;

    let mut mismatched_state: JsonValue = serde_json::from_str(&original_state)?;
    object_mut(&mut mismatched_state, "graph state")?.insert(
        "runner_name".to_owned(),
        JsonValue::String("other-runner".to_owned()),
    );
    fs::write(
        &state_path,
        serde_json::to_string_pretty(&mismatched_state)?,
    )?;
    let bad_answers_path = temp.path().join("bad-graph-answers.json");
    fs::write(&bad_answers_path, "{}")?;
    let mismatch = match run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(bad_answers_path),
        inputs: inputs.clone(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("mismatched graph state should fail".into()),
        Err(error) => error,
    };
    assert!(
        mismatch
            .to_string()
            .contains("graph state runner_name mismatch")
    );
    fs::write(&state_path, original_state)?;

    let answers_path = temp.path().join("graph-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.graph-decide.output": {
                    "approved": true,
                    "proof_ref": "receipt-proof:evil:step-output",
                    "receipt_id": "sha256:evil-step-output",
                    "result": {
                        "summary": "Graph fix authored."
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let resumed = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&resumed.output, "resumed graph skill run result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    assert!(!payload.contains_key("approved"));
    assert!(!payload.contains_key("proof_ref"));
    assert!(!payload.contains_key("receipt_id"));
    let decide_claim = step_claim(payload, "decide").ok_or("missing decide skill claim")?;
    let result = object_field(decide_claim, "result").ok_or("missing result")?;
    assert_eq!(string_field(result, "summary"), Some("Graph fix authored."));
    let step_outputs = object_field(payload, "step_outputs").ok_or("missing step_outputs")?;
    let decide = object_field(step_outputs, "decide").ok_or("missing decide step output")?;
    assert_eq!(string_field(decide, "status"), Some("success"));
    assert!(object_field(decide, "skill_claim").is_some());
    let declared_result = object_field(decide, "result").ok_or("missing declared result output")?;
    assert_eq!(
        string_field(declared_result, "summary"),
        Some("Graph fix authored.")
    );
    assert!(!decide.contains_key("approved"));
    assert!(!decide.contains_key("proof_ref"));
    assert!(!decide.contains_key("receipt_id"));

    Ok(())
}

#[test]
fn native_graph_transition_gate_allows_declared_agent_output()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_gated_agent_task_skill_with_field(temp.path(), "decide.approved")?;
    let receipt_dir = temp.path().join("receipts");

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let output = object(&initial.output, "gated graph result")?;
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;

    let answers_path = temp.path().join("gated-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.gated-decide.output": {
                    "approved": true,
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let resumed = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&resumed.output, "resumed gated graph result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    assert_eq!(
        string_field(request, "id"),
        Some("agent_task.gated-followup.output")
    );

    Ok(())
}

#[test]
fn native_graph_guard_rejects_skill_claim_as_fact() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_gated_agent_task_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let output = object(&initial.output, "gated graph result")?;
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;

    let answers_path = temp.path().join("gated-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.gated-decide.output": {
                    "approved": true,
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let blocked = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let output = object(&blocked.output, "blocked graph result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let closure = object_field(output, "closure").ok_or("missing closure")?;
    assert_eq!(string_field(closure, "disposition"), Some("blocked"));
    assert_eq!(string_field(closure, "reason_code"), Some("graph_blocked"));
    assert!(
        string_field(closure, "summary")
            .unwrap_or_default()
            .contains("guard 'decide.skill_claim.approved' is unresolved"),
        "unexpected closure: {closure:?}"
    );

    Ok(())
}

#[test]
fn native_graph_skill_run_pauses_and_resumes_nested_agent_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_agent_skill(temp.path(), "agent")?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Nested agent bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: inputs.clone(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&initial.output, "nested agent graph result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    assert_eq!(
        string_field(request, "id"),
        Some("agent.child-agent.output")
    );
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    assert_eq!(string_field(invocation, "source_type"), Some("agent"));

    let answers_path = temp.path().join("nested-agent-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent.child-agent.output": {
                    "result": {
                        "summary": "Nested agent fix authored."
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let resumed = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&resumed.output, "resumed nested agent graph result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let result = object_field(nested_claim, "result").ok_or("missing result")?;
    assert_eq!(
        string_field(result, "summary"),
        Some("Nested agent fix authored.")
    );
    let step_outputs = object_field(payload, "step_outputs").ok_or("missing step_outputs")?;
    assert!(object_field(step_outputs, "nested").is_some());

    Ok(())
}

#[test]
fn native_graph_skill_run_pauses_and_resumes_nested_agent_task_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_agent_skill(temp.path(), "agent-task")?;
    let receipt_dir = temp.path().join("receipts");

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&initial.output, "nested agent-task graph result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    assert_eq!(
        string_field(request, "id"),
        Some("agent_task.child-agent-task.output")
    );
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    assert_eq!(string_field(invocation, "source_type"), Some("agent-task"));

    let answers_path = temp.path().join("nested-agent-task-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.child-agent-task.output": {
                    "result": {
                        "summary": "Nested agent-task fix authored."
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let resumed = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&resumed.output, "resumed nested agent-task graph result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let result = object_field(nested_claim, "result").ok_or("missing result")?;
    assert_eq!(
        string_field(result, "summary"),
        Some("Nested agent-task fix authored.")
    );

    Ok(())
}

#[test]
fn graph_agent_task_injects_registry_skill_as_current_context()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        r#"---
name: taste-profile
description: Portable taste guidance for downstream agents.
source:
  type: agent
  agent: critic
  task: apply taste judgement
---
# Taste Profile

Prefer clear product taste over ornamental flourish. Flag incoherent hierarchy,
weak contrast, and interaction states that feel bolted on.
"#,
        IngestSkillOptions {
            owner: Some("runx".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            ..IngestSkillOptions::default()
        },
    )?;
    let skill_dir = write_graph_agent_task_with_context_skill(
        temp.path(),
        "registry:runx/taste-profile@1.0.0",
    )?;
    let env = [(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(temp.path().join("receipts")),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "registry context graph result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    assert_eq!(requests.len(), 1);
    let request = object(&requests[0], "request")?;
    let invocation = object_field(request, "invocation").ok_or("missing invocation")?;
    let envelope = object_field(invocation, "envelope").ok_or("missing envelope")?;
    let current_context =
        array_field(envelope, "current_context").ok_or("missing current_context")?;
    assert_eq!(current_context.len(), 1);
    let context_entry = object(&current_context[0], "skill context entry")?;
    assert_eq!(
        string_field(context_entry, "type"),
        Some("runx.skill.context")
    );
    let data = object_field(context_entry, "data").ok_or("missing context data")?;
    assert_eq!(string_field(data, "source"), Some("runx-registry"));
    assert_eq!(string_field(data, "skill_id"), Some("runx/taste-profile"));
    assert_eq!(string_field(data, "version"), Some("1.0.0"));
    assert!(
        string_field(data, "content").is_some_and(|content| content.contains("# Taste Profile"))
    );
    let meta = object_field(context_entry, "meta").ok_or("missing context meta")?;
    assert!(string_field(meta, "hash").is_some_and(|hash| hash.starts_with("sha256:")));

    Ok(())
}

#[test]
fn graph_agent_task_rejects_parent_path_context_skill() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let context_dir = temp.path().join("taste-profile");
    fs::create_dir_all(&context_dir)?;
    fs::write(
        context_dir.join("SKILL.md"),
        "---\nname: taste-profile\n---\n# Taste Profile\n",
    )?;
    let skill_dir = write_graph_agent_task_with_context_skill(temp.path(), "../taste-profile")?;

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(temp.path().join("receipts")),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("parent-path context skill should fail".into()),
        Err(error) => error,
    };

    assert!(
        error.to_string().contains("must not contain '..'"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[test]
fn graph_agent_task_rejects_graph_stage_context_skill() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_agent_task_with_context_skill(temp.path(), "context-stage")?;
    let stage_dir = skill_dir.join("context-stage");
    fs::create_dir_all(&stage_dir)?;
    fs::write(
        stage_dir.join("SKILL.md"),
        r#"---
name: context-stage
source:
  type: agent
  agent: builder
  task: internal implementation detail
---
# Context Stage
"#,
    )?;
    fs::write(
        stage_dir.join("X.yaml"),
        r#"skill: context-stage
catalog:
  kind: skill
  audience: builder
  visibility: internal
  role: graph-stage
  part_of:
    - graph-agent-context-skill
runners:
  main:
    default: true
    type: agent
    agent: builder
    task: internal implementation detail
"#,
    )?;

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(temp.path().join("receipts")),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("graph stage context skill should fail".into()),
        Err(error) => error,
    };

    assert!(
        error.to_string().contains("catalog.role=graph-stage"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[test]
fn graph_agent_task_rejects_registry_runtime_path_context_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        r#"---
name: runtime-helper
description: Internal runtime helper.
source:
  type: agent
  agent: builder
  task: internal helper
---
# Runtime Helper
"#,
        IngestSkillOptions {
            owner: Some("sourcey".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            profile_document: Some(
                r#"skill: runtime-helper
catalog:
  kind: skill
  audience: builder
  visibility: internal
  role: runtime-path
  part_of:
    - graph-agent-context-skill
runners:
  main:
    default: true
    type: agent
    agent: builder
    task: internal helper
"#
                .to_owned(),
            ),
            ..IngestSkillOptions::default()
        },
    )?;
    let skill_dir = write_graph_agent_task_with_context_skill(
        temp.path(),
        "registry:sourcey/runtime-helper@1.0.0",
    )?;
    let env = [(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(temp.path().join("receipts")),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("registry runtime-path context skill should fail".into()),
        Err(error) => error,
    };

    assert!(
        error.to_string().contains("catalog.role=runtime-path"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_executes_local_tool_step() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_tool_skill(temp.path())?;
    write_echo_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let tool_root = temp.path().join("tools");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph tool bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    let env = [(
        "RUNX_TOOL_ROOTS".to_owned(),
        tool_root.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph tool result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(string_field(echo, "message"), Some("Graph tool bug"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_resolves_agent_task_named_emit_context()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_agent_artifact_context_skill(temp.path())?;
    write_echo_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let tool_root = temp.path().join("tools");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.graph-author.output": {
                    "fix_bundle": {
                        "message": "Graph tool bug"
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let env = [(
        "RUNX_TOOL_ROOTS".to_owned(),
        tool_root.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    let pending = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: env.clone(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let pending_output = object(&pending.output, "pending graph agent artifact result")?;
    assert_eq!(string_field(pending_output, "status"), Some("needs_agent"));
    let run_id = string_field(pending_output, "run_id")
        .ok_or("pending graph agent artifact result missing run_id")?
        .to_owned();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph agent artifact result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(string_field(echo, "message"), Some("Graph tool bug"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_resume_preserves_initial_inputs_for_later_tool_steps()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_agent_then_input_tool_skill(temp.path())?;
    write_echo_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let tool_root = temp.path().join("tools");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.graph-author.output": {
                    "result": {
                        "accepted": true
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let env = [(
        "RUNX_TOOL_ROOTS".to_owned(),
        tool_root.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    let initial_inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph tool bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let pending = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: initial_inputs,
        env: env.clone(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let pending_output = object(&pending.output, "pending graph input resume result")?;
    assert_eq!(string_field(pending_output, "status"), Some("needs_agent"));
    let run_id = string_field(pending_output, "run_id")
        .ok_or("pending graph input resume result missing run_id")?
        .to_owned();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph input resume result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(string_field(echo, "message"), Some("Graph tool bug"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_resolves_agent_task_output_envelope_named_emit_context()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_agent_artifact_context_skill(temp.path())?;
    write_echo_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let tool_root = temp.path().join("tools");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.graph-author.output": {
                    "output": {
                        "fix_bundle": {
                            "message": "Graph tool bug"
                        }
                    },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let env = [(
        "RUNX_TOOL_ROOTS".to_owned(),
        tool_root.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    let pending = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: env.clone(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let pending_output = object(
        &pending.output,
        "pending graph agent artifact envelope result",
    )?;
    assert_eq!(string_field(pending_output, "status"), Some("needs_agent"));
    let run_id = string_field(pending_output, "run_id")
        .ok_or("pending graph agent artifact envelope result missing run_id")?
        .to_owned();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph agent artifact envelope result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(string_field(echo, "message"), Some("Graph tool bug"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_rejects_reserved_artifact_output_names()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_reserved_artifact_output_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let answers_path = temp.path().join("answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.graph-author.output": {
                    "result": "claimed",
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;
    let pending = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let pending_output = object(&pending.output, "pending reserved artifact result")?;
    let run_id = string_field(pending_output, "run_id")
        .ok_or("pending reserved artifact result missing run_id")?
        .to_owned();

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("reserved artifact output name unexpectedly succeeded".into()),
        Err(error) => error,
    };
    assert!(
        error
            .to_string()
            .contains("artifact output name \"status\" is reserved"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_omits_missing_optional_graph_input_references()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_optional_json_tool_skill(temp.path())?;
    write_optional_json_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let tool_root = temp.path().join("tools");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph optional JSON bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();
    let env = [(
        "RUNX_TOOL_ROOTS".to_owned(),
        tool_root.to_string_lossy().into_owned(),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph optional JSON tool result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(
        string_field(echo, "message"),
        Some("Graph optional JSON bug")
    );

    Ok(())
}

#[test]
fn native_graph_skill_run_requires_declared_graph_inputs() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_graph_required_input_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph required input result")?;
    assert_eq!(string_field(output, "status"), Some("needs_agent"));
    let requests = array_field(output, "requests").ok_or("missing requests")?;
    let request = object(&requests[0], "missing input request")?;
    assert_eq!(string_field(request, "id"), Some("graph.required-inputs"));
    assert_eq!(string_field(request, "kind"), Some("graph.required_inputs"));
    let missing = array_field(request, "missing_inputs").ok_or("missing input list")?;
    let lead = object(&missing[0], "lead missing input")?;
    assert_eq!(string_field(lead, "name"), Some("lead"));
    assert_eq!(string_field(lead, "type"), Some("json"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_uses_canonical_tool_root() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_tool_skill_under_skills(temp.path())?;
    write_echo_tool_at(&temp.path().join("tools/test/echo"), "root tools")?;
    write_echo_tool_at(
        &temp.path().join("packages/cli/tools/test/echo"),
        "stale copy",
    )?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph tool bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "graph tool result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let echo_claim = step_claim(payload, "echo").ok_or("missing echo skill claim")?;
    let echo = object_field(echo_claim, "echo").ok_or("missing echo")?;
    assert_eq!(string_field(echo, "message"), Some("root tools"));

    Ok(())
}

#[cfg(feature = "catalog")]
#[test]
fn native_graph_skill_run_merges_imported_graph_skill_tool_roots()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_importing_graph_with_bundled_tool(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Graph tool bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "nested graph tool root result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let child_steps = object_field(nested_claim, "step_outputs").ok_or("missing child steps")?;
    let child_echo_step = object_field(child_steps, "echo").ok_or("missing child echo step")?;
    let child_echo_claim =
        object_field(child_echo_step, "skill_claim").ok_or("missing child echo claim")?;
    let echo = object_field(child_echo_claim, "echo").ok_or("missing nested echo output")?;
    assert_eq!(
        string_field(echo, "message"),
        Some("Nested graph tool root bug")
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_executes_nested_cli_tool_skill() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_cli_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Nested graph bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "nested graph skill result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let nested = object_field(nested_claim, "nested").ok_or("missing nested output")?;
    assert_eq!(string_field(nested, "message"), Some("Nested graph bug"));
    let step_outputs = object_field(payload, "step_outputs").ok_or("missing step outputs")?;
    let nested_step = object_field(step_outputs, "nested").ok_or("missing nested step output")?;
    // The contract packet exposes the claim's `nested` field under the single
    // `{ data: ... }` envelope: `<step>.nested.data.message`.
    let declared_nested =
        object_field(nested_step, "nested").ok_or("missing exposed nested output")?;
    let declared_nested_data =
        object_field(declared_nested, "data").ok_or("missing exposed nested data envelope")?;
    assert_eq!(
        string_field(declared_nested_data, "message"),
        Some("Nested graph bug")
    );
    let root_receipt_id = string_field(output, "receipt_id").ok_or("missing receipt id")?;
    let steps = array_field(payload, "steps").ok_or("missing graph steps")?;
    let nested_step_summary = object(&steps[0], "nested step summary")?;
    let nested_receipt_id =
        string_field(nested_step_summary, "receipt_id").ok_or("missing nested receipt id")?;
    assert!(receipt_dir.join(format!("{root_receipt_id}.json")).exists());
    assert!(
        receipt_dir
            .join(format!("{nested_receipt_id}.json"))
            .exists()
    );

    let root_receipt = crate::support::read_test_signed_receipt(&receipt_dir, root_receipt_id)?;
    let child_receipt = crate::support::read_test_signed_receipt(&receipt_dir, nested_receipt_id)?;
    let child_refs = &root_receipt
        .lineage
        .as_ref()
        .ok_or("root receipt missing lineage")?
        .children;
    assert_eq!(child_refs.len(), 1);
    assert_eq!(
        child_refs[0].uri.as_str(),
        format!("runx:receipt:{nested_receipt_id}")
    );
    assert_eq!(
        child_refs[0].locator.as_deref(),
        Some(child_receipt.digest.as_str())
    );
    let parent_ref = child_receipt
        .lineage
        .as_ref()
        .and_then(|lineage| lineage.parent.as_ref())
        .ok_or("nested receipt missing parent lineage")?;
    assert_eq!(
        parent_ref.uri.as_str(),
        format!("runx:receipt:{root_receipt_id}")
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_executes_nested_registry_skill() -> Result<(), Box<dyn std::error::Error>>
{
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        "---\nname: registry-child\ndescription: Registry-backed nested child.\n---\n# Registry Child\n",
        IngestSkillOptions {
            owner: Some("acme".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            profile_document: Some(registry_child_profile_document()),
            ..IngestSkillOptions::default()
        },
    )?;
    sign_registry_version(&registry_dir, "acme/registry-child", "1.0.0")?;
    let skill_dir = write_graph_nested_registry_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let mut env = trusted_manifest_env()?;
    env.insert(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    );

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "nested registry skill result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested registry claim")?;
    let nested = object_field(nested_claim, "nested").ok_or("missing nested output")?;
    assert_eq!(string_field(nested, "message"), Some("registry child"));

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_rejects_env_promoted_official_nested_registry_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        "---\nname: registry-child\ndescription: Official registry-backed nested child.\n---\n# Registry Child\n",
        IngestSkillOptions {
            owner: Some("runx".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            profile_document: Some(registry_child_profile_document()),
            trust_tier: Some(TrustTier::FirstParty),
            ..IngestSkillOptions::default()
        },
    )?;
    sign_registry_version(&registry_dir, "runx/registry-child", "1.0.0")?;
    let skill_dir = write_graph_nested_registry_skill_with_ref(
        temp.path(),
        "registry:runx/registry-child@1.0.0",
    )?;
    let receipt_dir = temp.path().join("receipts");
    let mut env = trusted_manifest_env_for_owner("runx", Some("official_runx"))?;
    env.insert(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    );

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => {
            return Err(
                "env-promoted official nested registry skill unexpectedly succeeded".into(),
            );
        }
        Err(error) => error,
    };
    assert!(
        error.to_string().contains("trust configuration is invalid"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_rejects_unsigned_nested_registry_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        "---\nname: registry-child\ndescription: Registry-backed nested child.\n---\n# Registry Child\n",
        IngestSkillOptions {
            owner: Some("acme".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            profile_document: Some(registry_child_profile_document()),
            ..IngestSkillOptions::default()
        },
    )?;
    let skill_dir = write_graph_nested_registry_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let mut env = trusted_manifest_env()?;
    env.insert(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    );
    env.insert(
        "RUNX_REGISTRY_URL".to_owned(),
        "https://registry.example.test".to_owned(),
    );

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("unsigned nested registry skill unexpectedly succeeded".into()),
        Err(error) => error,
    };
    assert!(
        error.to_string().contains("signed manifest is required"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_rejects_tampered_nested_registry_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let registry_dir = temp.path().join("registry");
    let store = create_file_registry_store(&registry_dir);
    ingest_skill_markdown(
        &store,
        "---\nname: registry-child\ndescription: Registry-backed nested child.\n---\n# Registry Child\n",
        IngestSkillOptions {
            owner: Some("acme".to_owned()),
            version: Some("1.0.0".to_owned()),
            created_at: Some(FIXTURE_CREATED_AT.to_owned()),
            profile_document: Some(registry_child_profile_document()),
            ..IngestSkillOptions::default()
        },
    )?;
    sign_registry_version(&registry_dir, "acme/registry-child", "1.0.0")?;
    tamper_registry_version_markdown(&registry_dir, "acme/registry-child", "1.0.0")?;
    let skill_dir = write_graph_nested_registry_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let mut env = trusted_manifest_env()?;
    env.insert(
        "RUNX_REGISTRY_DIR".to_owned(),
        registry_dir.to_string_lossy().into_owned(),
    );

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env,
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("tampered nested registry skill unexpectedly succeeded".into()),
        Err(error) => error,
    };
    assert!(
        error.to_string().contains("digest mismatch"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_rejects_nested_registry_skill_without_registry_dir()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_registry_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");

    let error = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("nested registry skill unexpectedly succeeded".into()),
        Err(error) => error,
    };
    assert!(
        error
            .to_string()
            .contains("RUNX_REGISTRY_DIR is not configured"),
        "unexpected error: {error}"
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_does_not_rerun_final_step() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_cli_counter_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let count_file = temp.path().join("count.txt");
    let inputs = [(
        "count_file".to_owned(),
        JsonValue::String(count_file.to_string_lossy().into_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "counter graph skill result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    assert_eq!(fs::read_to_string(count_file)?, "1");

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_executes_graph_stage_cli_tool_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_stage_cli_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Stage graph bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "stage graph skill result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let nested = object_field(nested_claim, "nested").ok_or("missing nested output")?;
    assert_eq!(string_field(nested, "message"), Some("Stage graph bug"));
    let steps = array_field(payload, "steps").ok_or("missing graph steps")?;
    let nested_step_summary = object(&steps[0], "nested step summary")?;
    assert_eq!(
        string_field(nested_step_summary, "skill"),
        Some("child-echo")
    );

    Ok(())
}

#[cfg(feature = "cli-tool")]
#[test]
fn native_graph_skill_run_executes_nested_x_yaml_runner_skill()
-> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_nested_x_yaml_cli_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");
    let inputs = [(
        "thread_title".to_owned(),
        JsonValue::String("Runner manifest bug".to_owned()),
    )]
    .into_iter()
    .collect::<BTreeMap<_, _>>();

    let result = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: None,
        answers_path: None,
        inputs,
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;

    let output = object(&result.output, "nested X.yaml graph skill result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    let payload = object_field(output, "payload").ok_or("missing payload")?;
    let nested_claim = step_claim(payload, "nested").ok_or("missing nested skill claim")?;
    let nested = object_field(nested_claim, "nested").ok_or("missing nested output")?;
    assert_eq!(string_field(nested, "message"), Some("Runner manifest bug"));

    Ok(())
}

#[test]
fn native_skill_run_rejects_partial_continuation_shape() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_agent_task_skill(temp.path())?;

    let run_id_only = match run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: None,
        run_id: Some("issue-intake-run".to_owned()),
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("run-id without answers should fail".into()),
        Err(error) => error,
    };
    assert!(
        run_id_only
            .to_string()
            .contains("skill continuation requires both run_id and answers")
    );

    let answers_only = match run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: None,
        run_id: None,
        answers_path: Some(temp.path().join("answers.json")),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    }) {
        Ok(_) => return Err("answers without run-id should fail".into()),
        Err(error) => error,
    };
    assert!(
        answers_only
            .to_string()
            .contains("skill continuation requires both run_id and answers")
    );

    Ok(())
}

fn run_skill(request: SkillRunRequest) -> Result<RunResult, Box<dyn std::error::Error>> {
    let request = with_test_signing_env(request);
    LocalOrchestrator::default()
        .run_skill(&request)
        .map_err(|error| error.into())
}

fn with_test_signing_env(mut request: SkillRunRequest) -> SkillRunRequest {
    crate::support::insert_test_signing_env(&mut request.env);
    request
        .env
        .entry("RUNX_HOME".to_owned())
        .or_insert_with(|| request.cwd.join(".runx").to_string_lossy().into_owned());
    request
}

fn write_agent_task_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("issue-intake");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: issue-intake\n---\n# Issue Intake\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: issue-intake
runners:
  intake:
    default: true
    type: agent-task
    agent: builder
    task: issue-intake
    outputs:
      intake_report: object
    inputs:
      thread_title:
        type: string
        required: false
"#,
    )?;
    Ok(skill_dir.to_path_buf())
}

fn write_graph_agent_task_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-issue-to-pr");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-issue-to-pr\n---\n# Graph Issue To PR\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-issue-to-pr
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-issue-to-pr
      steps:
        - id: decide
          run:
            type: agent-task
            agent: builder
            task: graph-decide
            outputs:
              result: object
          instructions: Use the full issue context.
"#,
    )?;
    Ok(skill_dir.to_path_buf())
}

fn write_graph_agent_task_with_context_skill(
    root: &Path,
    context_skill: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-agent-context-skill");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-agent-context-skill\n---\n# Graph Agent Context Skill\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        format!(
            r#"
skill: graph-agent-context-skill
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-agent-context-skill
      steps:
        - id: apply_taste
          run:
            type: agent-task
            agent: builder
            task: apply taste guidance
            outputs:
              summary: string
          context_skills:
            - "{context_skill}"
"#
        ),
    )?;
    Ok(skill_dir.to_path_buf())
}

fn write_graph_gated_agent_task_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    write_graph_gated_agent_task_skill_with_field(root, "decide.skill_claim.approved")
}

fn write_graph_gated_agent_task_skill_with_field(
    root: &Path,
    gate_field: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-gated-agent-task");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-gated-agent-task\n---\n# Graph Gated Agent Step\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        format!(
            r#"
skill: graph-gated-agent-task
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-gated-agent-task
      steps:
        - id: decide
          run:
            type: agent-task
            agent: builder
            task: gated-decide
            outputs:
              approved: boolean
        - id: gated
          run:
            type: agent-task
            agent: builder
            task: gated-followup
            outputs:
              result: object
      policy:
        guards:
          - step: gated
            field: {gate_field}
            equals: true
            "#
        ),
    )?;
    Ok(skill_dir.to_path_buf())
}

fn write_graph_nested_agent_skill(
    root: &Path,
    source_type: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let child_name = match source_type {
        "agent" => "child-agent",
        "agent-task" => "child-agent-task",
        _ => return Err(format!("unsupported nested agent source type {source_type}").into()),
    };
    let child_dir = root.join(child_name);
    fs::create_dir_all(&child_dir)?;
    let source = if source_type == "agent-task" {
        r#"
source:
  type: agent-task
  agent: builder
  task: child-agent-task
  outputs:
    result: object
"#
    } else {
        r#"
source:
  type: agent
"#
    };
    fs::write(
        child_dir.join("SKILL.md"),
        format!(
            r#"---
name: {child_name}{source}---
# {child_name}
"#
        ),
    )?;

    let skill_dir = root.join(format!("graph-nested-{source_type}"));
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        format!("---\nname: graph-nested-{source_type}\n---\n# Graph Nested {source_type}\n"),
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        format!(
            r#"
skill: graph-nested-{source_type}
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-nested-{source_type}
      steps:
        - id: nested
          skill: ../{child_name}
          inputs:
            thread_title: $input.thread_title
"#
        ),
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_tool_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-tool");
    write_graph_tool_skill_at(&skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_tool_skill_under_skills(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("skills/graph-tool");
    write_graph_tool_skill_at(&skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_importing_graph_with_bundled_tool(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let parent_dir = root.join("skills/parent-board");
    let child_dir = root.join("skills/child-data");
    fs::create_dir_all(&parent_dir)?;
    fs::create_dir_all(&child_dir)?;
    fs::write(
        parent_dir.join("SKILL.md"),
        "---\nname: parent-board\n---\n# Parent Board\n",
    )?;
    fs::write(
        parent_dir.join("X.yaml"),
        r#"
skill: parent-board
runners:
  graph:
    default: true
    type: graph
    graph:
      name: parent-board
      steps:
        - id: nested
          skill: ../child-data
          inputs:
            message: $input.thread_title
"#,
    )?;

    fs::write(
        child_dir.join("SKILL.md"),
        "---\nname: child-data\n---\n# Child Data\n",
    )?;
    fs::write(
        child_dir.join("X.yaml"),
        r#"
skill: child-data
runners:
  graph:
    default: true
    type: graph
    graph:
      name: child-data
      steps:
        - id: echo
          tool: test.echo
          inputs:
            message: $input.message
"#,
    )?;
    write_echo_tool_at(
        &child_dir.join("tools/test/echo"),
        "Nested graph tool root bug",
    )?;
    Ok(parent_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_tool_skill_at(skill_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    fs::create_dir_all(skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-tool\n---\n# Graph Tool\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-tool
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-tool
      steps:
        - id: echo
          tool: test.echo
          inputs:
            message: $input.thread_title
"#,
    )?;
    Ok(skill_dir.to_path_buf())
}

#[cfg(feature = "catalog")]
fn write_graph_agent_artifact_context_skill(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-agent-artifact-context");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-agent-artifact-context\n---\n# Graph Agent Artifact Context\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-agent-artifact-context
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-agent-artifact-context
      steps:
        - id: author
          run:
            type: agent-task
            agent: builder
            task: graph-author
            outputs:
              fix_bundle: object
          artifacts:
            named_emits:
              fix_bundle: fix_bundle
        - id: echo
          tool: test.echo
          context:
            message: author.fix_bundle.data.message
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_agent_then_input_tool_skill(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-agent-then-input-tool");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-agent-then-input-tool\n---\n# Graph Agent Then Input Tool\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-agent-then-input-tool
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-agent-then-input-tool
      steps:
        - id: author
          run:
            type: agent-task
            agent: builder
            task: graph-author
            outputs:
              result: object
        - id: echo
          tool: test.echo
          inputs:
            message: $input.thread_title
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_reserved_artifact_output_skill(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-reserved-artifact-output");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-reserved-artifact-output\n---\n# Graph Reserved Artifact Output\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-reserved-artifact-output
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-reserved-artifact-output
      steps:
        - id: author
          run:
            type: agent-task
            agent: builder
            task: graph-author
            outputs:
              result: string
          artifacts:
            named_emits:
              status: result
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "catalog")]
fn write_graph_optional_json_tool_skill(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-optional-json-tool");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-optional-json-tool\n---\n# Graph Optional JSON Tool\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-optional-json-tool
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-optional-json-tool
      steps:
        - id: echo
          tool: test.optional-json
          inputs:
            message: $input.thread_title
            harness: $input.harness
"#,
    )?;
    Ok(skill_dir)
}

fn write_graph_required_input_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-required-input");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-required-input\n---\n# Graph Required Input\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-required-input
runners:
  graph:
    default: true
    type: graph
    inputs:
      lead:
        type: json
        required: true
        description: Lead packet to route.
    graph:
      name: graph-required-input
      steps:
        - id: approve
          run:
            type: approval
          inputs:
            gate_id: graph-required-input.approve
            reason: approve the graph
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "catalog")]
fn write_echo_tool(root: &Path) -> Result<(), Box<dyn std::error::Error>> {
    write_echo_tool_at(&root.join("tools/test/echo"), "Graph tool bug")
}

#[cfg(feature = "catalog")]
fn write_echo_tool_at(tool_dir: &Path, message: &str) -> Result<(), Box<dyn std::error::Error>> {
    fs::create_dir_all(tool_dir)?;
    fs::write(
        tool_dir.join("manifest.json"),
        r#"{
  "schema": "runx.tool.manifest.v1",
  "name": "test.echo",
  "source": {
    "type": "cli-tool",
    "command": "/bin/sh",
    "args": ["./run.sh"],
    "input_mode": "stdin"
  },
  "inputs": {
    "message": { "type": "string", "required": true }
  },
  "scopes": ["test.echo"]
}
"#,
    )?;
    fs::write(
        tool_dir.join("run.sh"),
        format!(
            r#"raw="$(cat)"
case "$raw" in
  *"Graph tool bug"*) printf '%s\n' '{{"echo":{{"message":"{}"}}}}' ;;
  *) printf '%s\n' '{{"echo":{{"message":"unexpected"}}}}' ;;
esac
"#,
            message
        ),
    )?;
    Ok(())
}

#[cfg(feature = "catalog")]
fn write_optional_json_tool(root: &Path) -> Result<(), Box<dyn std::error::Error>> {
    let tool_dir = root.join("tools/test/optional-json");
    fs::create_dir_all(&tool_dir)?;
    fs::write(
        tool_dir.join("manifest.json"),
        r#"{
  "schema": "runx.tool.manifest.v1",
  "name": "test.optional-json",
  "source": {
    "type": "cli-tool",
    "command": "/bin/sh",
    "args": ["./run.sh"],
    "input_mode": "stdin"
  },
  "inputs": {
    "message": { "type": "string", "required": true },
    "harness": { "type": "json", "required": false }
  },
  "scopes": ["test.optional-json"]
}
"#,
    )?;
    fs::write(
        tool_dir.join("run.sh"),
        r#"raw="$(cat)"
case "$raw" in
  *'$input.harness'*)
    printf '%s\n' '{"error":"unresolved harness reference reached tool input"}'
    exit 2
    ;;
  *'"harness"'*)
    printf '%s\n' '{"error":"optional harness should be omitted when absent"}'
    exit 3
    ;;
  *"Graph optional JSON bug"*)
    printf '%s\n' '{"echo":{"message":"Graph optional JSON bug"}}'
    ;;
  *)
    printf '%s\n' '{"echo":{"message":"unexpected"}}'
    ;;
esac
"#,
    )?;
    Ok(())
}

#[cfg(feature = "cli-tool")]
fn write_graph_nested_cli_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let child_dir = root.join("child-echo");
    fs::create_dir_all(&child_dir)?;
    fs::write(
        child_dir.join("SKILL.md"),
        r#"---
name: child-echo
source:
  type: cli-tool
  command: node
  args:
    - run.mjs
  input_mode: stdin
runx:
  artifacts:
    named_emits:
      nested: nested
---
# Child Echo
"#,
    )?;
    fs::write(
        child_dir.join("run.mjs"),
        r#"import fs from "node:fs";
const raw = fs.readFileSync(0, "utf8");
const input = raw.trim() ? JSON.parse(raw) : {};
console.log(JSON.stringify({ nested: { message: input.message } }));
"#,
    )?;

    let skill_dir = root.join("graph-nested-cli");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-nested-cli\n---\n# Graph Nested CLI\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-nested-cli
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-nested-cli
      steps:
        - id: nested
          skill: ../child-echo
          inputs:
            message: $input.thread_title
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "cli-tool")]
fn write_graph_nested_registry_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    write_graph_nested_registry_skill_with_ref(root, "registry:acme/registry-child@1.0.0")
}

#[cfg(feature = "cli-tool")]
fn write_graph_nested_registry_skill_with_ref(
    root: &Path,
    skill_ref: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-nested-registry");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-nested-registry\n---\n# Graph Nested Registry\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        format!(
            r#"
skill: graph-nested-registry
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-nested-registry
      steps:
        - id: nested
          skill: {skill_ref}
"#
        ),
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "cli-tool")]
fn write_graph_stage_cli_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-stage-cli");
    let stage_dir = skill_dir.join("graph/child-echo");
    fs::create_dir_all(&stage_dir)?;
    fs::write(
        stage_dir.join("SKILL.md"),
        r#"---
name: child-echo
source:
  type: cli-tool
  command: node
  args:
    - run.mjs
  input_mode: stdin
---
# Child Echo
"#,
    )?;
    fs::write(
        stage_dir.join("run.mjs"),
        r#"import fs from "node:fs";
const raw = fs.readFileSync(0, "utf8");
const input = raw.trim() ? JSON.parse(raw) : {};
console.log(JSON.stringify({ nested: { message: input.message } }));
"#,
    )?;

    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-stage-cli\n---\n# Graph Stage CLI\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-stage-cli
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-stage-cli
      steps:
        - id: nested
          skill: graph/child-echo
          inputs:
            message: $input.thread_title
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "cli-tool")]
fn write_graph_nested_cli_counter_skill(
    root: &Path,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let child_dir = root.join("child-counter");
    fs::create_dir_all(&child_dir)?;
    fs::write(
        child_dir.join("SKILL.md"),
        r#"---
name: child-counter
source:
  type: cli-tool
  command: node
  args:
    - run.mjs
  input_mode: stdin
---
# Child Counter
"#,
    )?;
    fs::write(
        child_dir.join("run.mjs"),
        r#"import fs from "node:fs";
const raw = fs.readFileSync(0, "utf8");
const input = raw.trim() ? JSON.parse(raw) : {};
const path = input.count_file;
let count = 0;
try {
  count = Number(fs.readFileSync(path, "utf8")) || 0;
} catch {}
count += 1;
fs.writeFileSync(path, String(count));
console.log(JSON.stringify({ counted: { count } }));
"#,
    )?;

    let skill_dir = root.join("graph-nested-cli-counter");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-nested-cli-counter\n---\n# Graph Nested CLI Counter\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-nested-cli-counter
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-nested-cli-counter
      steps:
        - id: counted
          skill: ../child-counter
          inputs:
            count_file: $input.count_file
"#,
    )?;
    Ok(skill_dir)
}

#[cfg(feature = "cli-tool")]
fn write_graph_nested_x_yaml_cli_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let child_dir = root.join("child-x-cli");
    fs::create_dir_all(&child_dir)?;
    fs::write(
        child_dir.join("SKILL.md"),
        "---\nname: child-x-cli\n---\n# Child X CLI\n",
    )?;
    fs::write(
        child_dir.join("X.yaml"),
        r#"
skill: child-x-cli
runners:
  child-cli:
    default: true
    type: cli-tool
    command: node
    args:
      - run.mjs
    input_mode: stdin
"#,
    )?;
    fs::write(
        child_dir.join("run.mjs"),
        r#"import fs from "node:fs";
const raw = fs.readFileSync(0, "utf8");
const input = raw.trim() ? JSON.parse(raw) : {};
console.log(JSON.stringify({ nested: { message: input.message } }));
"#,
    )?;

    let skill_dir = root.join("graph-nested-x-yaml-cli");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-nested-x-yaml-cli\n---\n# Graph Nested X YAML CLI\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-nested-x-yaml-cli
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-nested-x-yaml-cli
      steps:
        - id: nested
          skill: ../child-x-cli
          inputs:
            message: $input.thread_title
"#,
    )?;
    Ok(skill_dir)
}

fn object<'a>(
    value: &'a JsonValue,
    label: &str,
) -> Result<&'a runx_contracts::JsonObject, Box<dyn std::error::Error>> {
    match value {
        JsonValue::Object(object) => Ok(object),
        _ => Err(format!("{label} was not an object").into()),
    }
}

fn object_mut<'a>(
    value: &'a mut JsonValue,
    label: &str,
) -> Result<&'a mut runx_contracts::JsonObject, Box<dyn std::error::Error>> {
    match value {
        JsonValue::Object(object) => Ok(object),
        _ => Err(format!("{label} was not an object").into()),
    }
}

fn object_field<'a>(
    object: &'a runx_contracts::JsonObject,
    field: &str,
) -> Option<&'a runx_contracts::JsonObject> {
    match object.get(field) {
        Some(JsonValue::Object(value)) => Some(value),
        _ => None,
    }
}

fn step_claim<'a>(
    payload: &'a runx_contracts::JsonObject,
    step_id: &str,
) -> Option<&'a runx_contracts::JsonObject> {
    object_field(payload, "step_outputs")
        .and_then(|steps| object_field(steps, step_id))
        .and_then(|step| object_field(step, "skill_claim"))
}

fn array_field<'a>(
    object: &'a runx_contracts::JsonObject,
    field: &str,
) -> Option<&'a Vec<JsonValue>> {
    match object.get(field) {
        Some(JsonValue::Array(value)) => Some(value),
        _ => None,
    }
}

fn string_field<'a>(object: &'a runx_contracts::JsonObject, field: &str) -> Option<&'a str> {
    match object.get(field) {
        Some(JsonValue::String(value)) => Some(value),
        _ => None,
    }
}

fn write_graph_when_branch_skill(root: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
    let skill_dir = root.join("graph-when-branch");
    fs::create_dir_all(&skill_dir)?;
    fs::write(
        skill_dir.join("SKILL.md"),
        "---\nname: graph-when-branch\n---\n# Graph When Branch\n",
    )?;
    fs::write(
        skill_dir.join("X.yaml"),
        r#"
skill: graph-when-branch
runners:
  graph:
    default: true
    type: graph
    graph:
      name: graph-when-branch
      steps:
        - id: decide
          run:
            type: agent-task
            agent: builder
            task: when-decide
            outputs:
              verdict: string
        - id: branch_go
          when:
            field: decide.verdict
            equals: go
          run:
            type: agent-task
            agent: builder
            task: when-go
            outputs:
              result: object
        - id: branch_stop
          when:
            field: decide.verdict
            equals: stop
          run:
            type: agent-task
            agent: builder
            task: when-stop
            outputs:
              result: object
"#,
    )?;
    Ok(skill_dir.to_path_buf())
}

#[test]
fn native_graph_when_skips_unselected_branch() -> Result<(), Box<dyn std::error::Error>> {
    let temp = tempdir()?;
    let skill_dir = write_graph_when_branch_skill(temp.path())?;
    let receipt_dir = temp.path().join("receipts");

    let initial = run_skill(SkillRunRequest {
        skill_path: skill_dir.clone(),
        receipt_dir: Some(receipt_dir.clone()),
        run_id: None,
        answers_path: None,
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let output = object(&initial.output, "when graph result")?;
    let run_id = string_field(output, "run_id").ok_or("missing run_id")?;

    // answers for decide and the selected branch only; branch_stop gets no
    // answer, so the run can seal only if `when` skipped it.
    let answers_path = temp.path().join("when-answers.json");
    fs::write(
        &answers_path,
        serde_json::json!({
            "answers": {
                "agent_task.when-decide.output": {
                    "verdict": "go",
                    "closure": {
                        "disposition": "closed"
                    }
                },
                "agent_task.when-go.output": {
                    "result": { "ok": true },
                    "closure": {
                        "disposition": "closed"
                    }
                }
            }
        })
        .to_string(),
    )?;

    let sealed = run_skill(SkillRunRequest {
        skill_path: skill_dir,
        receipt_dir: Some(receipt_dir),
        run_id: Some(run_id.to_owned()),
        answers_path: Some(answers_path),
        inputs: BTreeMap::new(),
        env: BTreeMap::new(),
        cwd: temp.path().to_path_buf(),
        local_credential: None,
    })?;
    let output = object(&sealed.output, "sealed when graph result")?;
    assert_eq!(string_field(output, "status"), Some("sealed"));
    Ok(())
}
