newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
use super::*;

#[test]
fn action_prompt_is_atomic_and_metadata_is_content_free() {
    let secret = "ship the private parser change to /top-secret";
    let intake = PromptIntake::analyze(secret);

    assert_eq!(intake.disposition(), PromptDisposition::Act);
    assert_eq!(intake.atomic_asks().len(), 1);
    intake.validate().unwrap();
    let artifact_metadata = intake.artifact_metadata();
    assert_eq!(
        artifact_metadata["schema"],
        "prompt_comprehension_manifest_v3"
    );
    let metadata = artifact_metadata.to_string();
    assert!(!metadata.contains("private parser"));
    assert!(metadata.contains("atomic_ask_digests"));
    let card = intake.model_card();
    assert!(card.starts_with(PROMPT_COMPREHENSION_MODEL_CARD_PREFIX));
    assert!(!card.contains("private parser"));
}

#[test]
fn empty_headless_input_is_a_bounded_ask_not_act() {
    let empty = PromptIntake::analyze("   \n");
    assert_eq!(empty.disposition(), PromptDisposition::Ask);
    assert_eq!(empty.manifest().pending_decision_count(), 1);
    assert!(empty.clarification_batch().contains("non-empty task"));
    assert_eq!(
        empty
            .resolve_with_operator_answer("Explain receipts.")
            .disposition(),
        PromptDisposition::Explain
    );
}

#[test]
fn unresolved_choice_becomes_a_bounded_ask_then_explicit_answer_acts() {
    let intake = PromptIntake::analyze(
        "Implement either SQLite or Postgres; create the migration and open a PR.",
    );

    assert_eq!(intake.disposition(), PromptDisposition::Ask);
    assert_eq!(intake.atomic_asks().len(), 2);
    assert!(intake.clarification_batch().contains("SQLite"));
    assert_eq!(
        intake
            .resolve_with_operator_answer("continue")
            .disposition(),
        PromptDisposition::Ask,
        "an acknowledgement cannot choose a concrete implementation"
    );
    let resolved = intake.resolve_with_operator_answer("1: SQLite");
    assert_eq!(resolved.disposition(), PromptDisposition::Act);
    assert_eq!(resolved.manifest().pending_decision_count(), 0);
    assert_eq!(
        resolved.artifact_metadata()["decision_source_counts"]["operator"],
        1
    );
    resolved.validate().unwrap();
}

#[test]
fn multiple_decisions_require_explicit_ordinal_mapping() {
    let intake = PromptIntake::analyze(
        "Choose either SQLite or Postgres. Select either staging or production.",
    );
    assert_eq!(intake.manifest().pending_decision_count(), 2);
    assert_eq!(
        intake
            .resolve_with_operator_answer("SQLite\nproduction")
            .disposition(),
        PromptDisposition::Ask
    );
    assert_eq!(
        intake
            .resolve_with_operator_answer("1: SQLite\n2: production")
            .disposition(),
        PromptDisposition::Act
    );
}

#[test]
fn intake_overflow_remains_ask_and_cannot_be_answered_in_place() {
    let prompt = (0..=MAX_CONCRETE_DECISIONS)
        .map(|i| format!("Choose either option-{i}-a or option-{i}-b."))
        .collect::<Vec<_>>()
        .join("\n");
    let intake = PromptIntake::analyze(&prompt);

    assert_eq!(intake.disposition(), PromptDisposition::Ask);
    assert!(
        intake
            .manifest()
            .decisions()
            .iter()
            .any(super::DecisionLock::is_overflow),
        "a truncated decision set must retain an explicit overflow lock"
    );
    assert_eq!(
        intake
            .resolve_with_operator_answer("1: option-0-a")
            .disposition(),
        PromptDisposition::Ask,
        "the overflow lock cannot be converted into Act by a partial answer"
    );
}

#[test]
fn atomic_ask_overflow_remains_ask() {
    let prompt = (0..=MAX_ATOMIC_ASKS)
        .map(|i| format!("Implement bounded item {i}."))
        .collect::<Vec<_>>()
        .join("\n");
    let intake = PromptIntake::analyze(&prompt);

    assert_eq!(intake.atomic_asks().len(), MAX_ATOMIC_ASKS);
    assert_eq!(intake.disposition(), PromptDisposition::Ask);
    assert!(intake.manifest().decisions().iter().any(|decision| {
        decision.is_overflow() && decision.status() == super::DecisionStatus::Pending
    }));
}

#[test]
fn ordinal_answers_are_relative_to_the_pending_batch() {
    assert_eq!(
        super::explicit_answer_indices("1: continue", &[4]),
        Some(vec![4]),
        "the first displayed clarification must resolve the first pending decision, not raw decision zero"
    );
    assert_eq!(
        super::explicit_answer_indices("1: one\n2: two", &[2, 6]),
        Some(vec![2, 6])
    );
}