defect-agent 0.1.0-alpha.5

Core agent runtime for defect: turn loop, context compaction, tools and session orchestration.
Documentation
use super::*;
use crate::llm::{MessageContent, Role};

fn user(text: &str) -> Message {
    Message {
        role: Role::User,
        content: vec![MessageContent::Text {
            text: text.to_string(),
        }]
        .into(),
    }
}

#[test]
fn append_then_snapshot() {
    let h = VecHistory::new();
    h.append(user("hi"));
    h.append(user("there"));
    let snap = h.snapshot();
    assert_eq!(snap.len(), 2);
}

#[test]
fn token_estimate_none_when_empty() {
    let h = VecHistory::new();
    assert!(h.token_estimate().is_none());
}

#[test]
fn token_estimate_char_heuristic_without_baseline() {
    // No real baseline: entire snapshot falls back to chars/4.
    let h = VecHistory::new();
    h.append(user(&"a".repeat(40))); // 40 chars → 10 tokens
    assert_eq!(h.token_estimate(), Some(10));
}

#[test]
fn record_input_tokens_becomes_baseline_plus_increment() {
    let h = VecHistory::new();
    h.append(user("seed"));
    // Baseline is 1000; subsequent messages use character-based incremental accumulation.
    h.record_input_tokens(1_000);
    assert_eq!(h.token_estimate(), Some(1_000));
    h.append(user(&"b".repeat(40))); // +10 tokens
    assert_eq!(h.token_estimate(), Some(1_010));
}

#[test]
fn record_input_tokens_refreshes_baseline_and_resets_increment() {
    let h = VecHistory::new();
    h.record_input_tokens(1_000);
    h.append(user(&"b".repeat(40))); // +10
    assert_eq!(h.token_estimate(), Some(1_010));
    // A new real round-trip: baseline refreshed, increment reset to zero.
    h.record_input_tokens(2_000);
    assert_eq!(h.token_estimate(), Some(2_000));
}

#[test]
fn replace_swaps_messages_and_clears_baseline() {
    let h = VecHistory::new();
    h.append(user("old one"));
    h.append(user("old two"));
    h.record_input_tokens(5_000);
    assert_eq!(h.token_estimate(), Some(5_000));

    h.replace(vec![user(&"c".repeat(80))]); // 80 chars → 20 tokens
    let snap = h.snapshot();
    assert_eq!(snap.len(), 1);
    // Baseline cleared → full character heuristic.
    assert_eq!(h.token_estimate(), Some(20));
}

fn assistant(text: &str) -> Message {
    Message {
        role: Role::Assistant,
        content: vec![MessageContent::Text {
            text: text.to_string(),
        }]
        .into(),
    }
}

#[test]
fn splice_prefix_replaces_head_keeps_tail() {
    let h = VecHistory::new();
    h.append(user("turn one"));
    h.append(assistant("reply one"));
    h.append(user("turn two"));
    h.append(assistant("reply two"));

    // Drop the first 2 entries (turn one + reply one), replace them with a summary, and
    // keep the remaining 2.
    let dropped = h.splice_prefix(2, assistant("[summary]"));
    assert_eq!(dropped, 2);

    let snap = h.snapshot();
    assert_eq!(snap.len(), 3); // summary + 2 retained
    assert_eq!(snap[0].role, Role::Assistant);
    assert!(matches!(
        &snap[0].content[0],
        MessageContent::Text { text } if text == "[summary]"
    ));
    assert!(matches!(
        &snap[1].content[0],
        MessageContent::Text { text } if text == "turn two"
    ));
}

#[test]
fn splice_prefix_preserves_tail_appended_during_flight() {
    // Simulate background compaction: drop_count=2 was computed on the old snapshot
    // (len=2), but the frontend appended 2 tail messages before the write-back —
    // splice_prefix must preserve them.
    let h = VecHistory::new();
    h.append(user("old one"));
    h.append(assistant("old reply"));
    // Append two messages from the frontend while compaction is in flight.
    h.append(user("new one"));
    h.append(assistant("new reply"));

    let dropped = h.splice_prefix(2, assistant("[summary]"));
    assert_eq!(dropped, 2);

    let snap = h.snapshot();
    // The summary and the two new trailing messages added during that time must never be
    // lost.
    assert_eq!(snap.len(), 3);
    assert!(matches!(
        &snap[1].content[0],
        MessageContent::Text { text } if text == "new one"
    ));
    assert!(matches!(
        &snap[2].content[0],
        MessageContent::Text { text } if text == "new reply"
    ));
}

#[test]
#[should_panic(expected = "splice_prefix invariant violated")]
fn splice_prefix_overlong_drop_count_trips_invariant_in_debug() {
    // A `drop_count` exceeding the current length means someone deleted a middle message
    // mid-flight, violating the single-flight invariant. In debug builds the
    // `debug_assert` fires (this test asserts it does); in release builds `clamp` handles
    // it.
    let h = VecHistory::new();
    h.append(user("only one"));
    let _ = h.splice_prefix(99, assistant("[summary]"));
}

#[test]
fn len_and_is_empty_track_messages() {
    let h = VecHistory::new();
    assert_eq!(h.len(), 0);
    assert!(h.is_empty());
    h.append(user("one"));
    h.append(user("two"));
    assert_eq!(h.len(), 2);
    assert!(!h.is_empty());
}

#[test]
fn truncate_rolls_back_to_boundary() {
    // Mirrors the turn rollback: record the length before a turn, append the turn's
    // messages, then truncate back on failure.
    let h = VecHistory::new();
    h.append(user("committed one"));
    h.append(assistant("reply one"));
    let boundary = h.len();

    // A failed turn appends a prompt (and would append more).
    h.append(user("failed prompt"));
    assert_eq!(h.len(), 3);

    h.truncate(boundary);
    let snap = h.snapshot();
    assert_eq!(snap.len(), 2);
    assert!(matches!(
        &snap[1].content[0],
        MessageContent::Text { text } if text == "reply one"
    ));
}

#[test]
fn truncate_noop_when_len_ge_current() {
    let h = VecHistory::new();
    h.append(user("a"));
    h.append(user("b"));
    h.truncate(2); // equal → no-op
    h.truncate(99); // greater → no-op
    assert_eq!(h.len(), 2);
}

#[test]
fn truncate_clears_baseline() {
    let h = VecHistory::new();
    h.append(user("seed"));
    h.record_input_tokens(5_000);
    assert_eq!(h.token_estimate(), Some(5_000));

    // Append then roll back: baseline is cleared, falling back to char heuristic over the
    // remaining message ("seed" = 4 chars → 1 token).
    h.append(user(&"x".repeat(40)));
    h.truncate(1);
    assert_eq!(h.token_estimate(), Some(1));
}

#[test]
fn splice_prefix_clears_baseline() {
    let h = VecHistory::new();
    h.append(user("seed one"));
    h.append(user("seed two"));
    h.record_input_tokens(5_000);
    assert_eq!(h.token_estimate(), Some(5_000));

    h.splice_prefix(1, assistant(&"c".repeat(80))); // summary: 80 chars → 20 tokens
    // Baseline cleared → full character heuristic (summary 20 + "seed two" 8 chars → 2).
    assert_eq!(h.token_estimate(), Some(22));
}