remem-ai 0.5.141

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use super::*;
use crate::memory::suppression::{create_suppression, parse_target, SuppressRequest};
use crate::user_context::claims::{
    create_manual_claim, suppress_claim, ManualClaimRequest, UserContextClaimType,
    UserContextSensitivity,
};

fn summary_migrated_conn() -> Result<Connection> {
    let conn = Connection::open_in_memory()?;
    crate::migrate::run_migrations(&conn)?;
    Ok(conn)
}

#[test]
fn refresh_compiles_sources_and_filters_unsafe_claims() -> Result<()> {
    let conn = summary_migrated_conn()?;
    let active = create_manual_claim(
        &conn,
        &claim_request("Prefer concise reviews", UserContextSensitivity::Normal),
    )?;
    let personal = create_manual_claim(
        &conn,
        &claim_request(
            "Prefer Chinese for architecture",
            UserContextSensitivity::Personal,
        ),
    )?;
    let suppressed = create_manual_claim(
        &conn,
        &claim_request("Do not include", UserContextSensitivity::Normal),
    )?;
    suppress_claim(&conn, suppressed.id)?;
    create_manual_claim(
        &conn,
        &claim_request("Sensitive identity", UserContextSensitivity::Sensitive),
    )?;
    create_manual_claim(
        &conn,
        &ManualClaimRequest {
            text: "Expired goal",
            valid_to_epoch: Some(1),
            ..claim_request("Expired goal", UserContextSensitivity::Normal)
        },
    )?;
    insert_summary_memory_source(
        &conn,
        10,
        "/repo",
        "Architecture",
        "Use source-backed summaries",
    )?;
    insert_summary_workstream_source(&conn, 20, "/repo", "Ship profile summaries")?;
    insert_summary_session_source(&conn, 30, "/repo", "Reviewed user context design")?;

    let summary = refresh_summary(&conn, &summary_request("/repo"))?;

    assert!(summary.summary_text.contains("Prefer concise reviews"));
    assert!(!summary.summary_text.contains("Prefer Chinese"));
    assert!(summary.summary_text.contains("Use source-backed summaries"));
    assert!(!summary.summary_text.contains("Do not include"));
    assert!(!summary.summary_text.contains("Sensitive identity"));
    assert_eq!(summary.source_claim_ids, vec![active.id]);
    assert_eq!(summary.source_memory_ids, vec![10]);
    assert_eq!(summary.source_activity_refs.len(), 2);

    let sources = load_summary_sources(&conn, &summary_request("/repo"), true)?;
    assert_eq!(sources.included_claims.len(), 1);
    assert!(sources
        .dropped_claims
        .iter()
        .any(|source| source.id == personal.id && source.reason == "sensitivity:personal"));
    assert!(sources
        .dropped_claims
        .iter()
        .any(|source| source.reason == "status:suppressed"));
    assert!(sources
        .dropped_claims
        .iter()
        .any(|source| source.reason == "sensitivity:sensitive"));
    assert!(sources
        .dropped_claims
        .iter()
        .any(|source| source.reason == "expired"));
    Ok(())
}

#[test]
fn sources_resolve_stored_ids_after_source_status_changes() -> Result<()> {
    let conn = summary_migrated_conn()?;
    let active = create_manual_claim(
        &conn,
        &claim_request("Keep source provenance", UserContextSensitivity::Normal),
    )?;
    insert_summary_memory_source(&conn, 10, "/repo", "Architecture", "Original memory source")?;
    insert_summary_workstream_source(&conn, 20, "/repo", "Ship profile summaries")?;
    let summary = refresh_summary(&conn, &summary_request("/repo"))?;

    suppress_claim(&conn, active.id)?;
    conn.execute("UPDATE memories SET status = 'stale' WHERE id = 10", [])?;

    let sources = load_summary_sources(&conn, &summary_request("/repo"), true)?;

    assert_eq!(sources.included_claims.len(), 1);
    assert_eq!(sources.included_claims[0].id, active.id);
    assert_eq!(sources.included_claims[0].status, "suppressed");
    assert_eq!(sources.included_memories.len(), 1);
    assert_eq!(sources.included_memories[0].id, 10);
    assert_eq!(sources.included_memories[0].status, "stale");
    assert_eq!(sources.included_activity_refs, summary.source_activity_refs);
    assert!(sources.dropped_claims.is_empty());
    Ok(())
}

#[test]
fn refresh_excludes_policy_suppressed_claims_and_memories() -> Result<()> {
    let conn = summary_migrated_conn()?;
    let visible_claim = create_manual_claim(
        &conn,
        &claim_request("Prefer visible summaries", UserContextSensitivity::Normal),
    )?;
    let hidden_claim = create_manual_claim(
        &conn,
        &claim_request(
            "Do not summarize this claim",
            UserContextSensitivity::Normal,
        ),
    )?;
    create_manual_claim(
        &conn,
        &claim_request(
            "Do not summarize this secret pattern",
            UserContextSensitivity::Normal,
        ),
    )?;
    insert_summary_memory_source(
        &conn,
        10,
        "/repo",
        "Visible memory",
        "Visible memory source",
    )?;
    insert_summary_memory_source(&conn, 11, "/repo", "Hidden memory", "Hidden memory source")?;
    create_suppression(
        &conn,
        &SuppressRequest {
            target: parse_target(&format!("claim:{}", hidden_claim.id))?,
            reason: Some("not relevant"),
            actor: Some("test"),
        },
    )?;
    create_suppression(
        &conn,
        &SuppressRequest {
            target: parse_target("memory:11")?,
            reason: Some("stale"),
            actor: Some("test"),
        },
    )?;
    create_suppression(
        &conn,
        &SuppressRequest {
            target: parse_target("pattern:secret pattern")?,
            reason: Some("too noisy"),
            actor: Some("test"),
        },
    )?;

    let summary = refresh_summary(&conn, &summary_request("/repo"))?;

    assert_eq!(summary.source_claim_ids, vec![visible_claim.id]);
    assert_eq!(summary.source_memory_ids, vec![10]);
    assert!(summary.summary_text.contains("Prefer visible summaries"));
    assert!(!summary.summary_text.contains("Do not summarize"));
    assert!(!summary.summary_text.contains("secret pattern"));
    assert!(!summary.summary_text.contains("Hidden memory source"));
    Ok(())
}

#[test]
fn load_active_summary_hides_text_after_policy_suppresses_source() -> Result<()> {
    let conn = summary_migrated_conn()?;
    let claim = create_manual_claim(
        &conn,
        &claim_request("Old profile text", UserContextSensitivity::Normal),
    )?;
    insert_summary_memory_source(&conn, 10, "/repo", "Old memory", "Old memory text")?;
    let summary = refresh_summary(&conn, &summary_request("/repo"))?;
    assert!(summary.summary_text.contains("Old profile text"));
    assert!(load_active_summary(&conn, &summary_request("/repo"))?.is_some());

    create_suppression(
        &conn,
        &SuppressRequest {
            target: parse_target(&format!("claim:{}", claim.id))?,
            reason: Some("do not show"),
            actor: Some("test"),
        },
    )?;

    assert!(load_active_summary(&conn, &summary_request("/repo"))?.is_none());
    let sources = load_summary_sources(&conn, &summary_request("/repo"), false)?;
    assert!(sources.summary.is_none());
    assert!(!sources
        .included_claims
        .iter()
        .any(|item| item.claim_text.contains("Old profile text")));
    let audit_sources = load_summary_sources(&conn, &summary_request("/repo"), true)?;
    assert_eq!(
        audit_sources.summary.as_ref().map(|item| item.id),
        Some(summary.id)
    );
    Ok(())
}

#[test]
fn generator_failure_keeps_previous_active_summary() -> Result<()> {
    let conn = summary_migrated_conn()?;
    create_manual_claim(
        &conn,
        &claim_request("Keep this summary", UserContextSensitivity::Normal),
    )?;
    let first = refresh_summary(&conn, &summary_request("/repo"))?;

    let err =
        refresh_summary_with_generator(&conn, &summary_request("/repo"), |_project, _sources| {
            bail!("model provider unavailable")
        })
        .expect_err("summary generator failure should fail closed");
    assert!(err.to_string().contains("generate profile summary"));
    let current = load_active_summary(&conn, &summary_request("/repo"))?
        .ok_or_else(|| anyhow::anyhow!("previous summary missing"))?;
    assert_eq!(current.id, first.id);
    assert_eq!(current.summary_text, first.summary_text);
    Ok(())
}

#[test]
fn edit_preserves_source_ids_and_supersedes_previous_version() -> Result<()> {
    let conn = summary_migrated_conn()?;
    create_manual_claim(
        &conn,
        &claim_request("Original source", UserContextSensitivity::Normal),
    )?;
    let first = refresh_summary(&conn, &summary_request("/repo"))?;
    let edited = edit_summary(
        &conn,
        &SummaryEditRequest {
            owner_scope: None,
            owner_key: None,
            project: "/repo",
            text: "Edited user-visible summary",
        },
    )?;

    assert_eq!(edited.version, first.version + 1);
    assert_eq!(edited.summary_text, "Edited user-visible summary");
    assert_eq!(edited.source_claim_ids, first.source_claim_ids);
    let old_status: String = conn.query_row(
        "SELECT status FROM user_context_summaries WHERE id = ?1",
        [first.id],
        |row| row.get(0),
    )?;
    assert_eq!(old_status, "superseded");
    Ok(())
}

#[test]
fn source_json_parsers_reject_invalid_shapes_and_source_ids() {
    let err = parse_ids("source_claim_ids_json", "{\"id\":1}")
        .expect_err("source ids must be encoded as an array");
    assert!(err.to_string().contains("JSON integer array"));
    let err = parse_ids("source_claim_ids_json", "[1,\"two\"]")
        .expect_err("source ids must contain integers");
    assert!(err.to_string().contains("JSON integer array"));
    let err = parse_ids("source_claim_ids_json", "[1,0]").expect_err("source ids must be positive");
    assert!(err.to_string().contains("positive integer"));
    let err = parse_activity_refs("{}").expect_err("activity refs must be encoded as an array");
    assert!(err.to_string().contains("JSON array"));
    let err = parse_activity_refs(r#"[{"kind":"workstream","id":0,"label":"x"}]"#)
        .expect_err("activity refs must use positive ids");
    assert!(err.to_string().contains("positive id"));
    let err = parse_activity_refs(r#"[{"kind":"","id":1,"label":"x"}]"#)
        .expect_err("activity refs must include a kind");
    assert!(err.to_string().contains("positive id"));
}

fn summary_request(project: &str) -> SummaryRequest<'_> {
    SummaryRequest {
        owner_scope: None,
        owner_key: None,
        project,
    }
}

fn claim_request(text: &str, sensitivity: UserContextSensitivity) -> ManualClaimRequest<'_> {
    ManualClaimRequest {
        text,
        owner_scope: None,
        owner_key: None,
        claim_type: UserContextClaimType::Preference,
        claim_key: None,
        confidence: 1.0,
        sensitivity,
        valid_from_epoch: None,
        valid_to_epoch: None,
    }
}

fn insert_summary_memory_source(
    conn: &Connection,
    id: i64,
    project: &str,
    title: &str,
    text: &str,
) -> Result<()> {
    conn.execute(
        "INSERT INTO memories
         (id, session_id, project, topic_key, title, content, memory_type, files,
          created_at_epoch, updated_at_epoch, status, branch, scope, source_project,
          target_project, owner_scope, owner_key)
         VALUES (?1, NULL, ?2, NULL, ?3, ?4, 'decision', NULL, 10, 10, 'active',
                 NULL, 'project', ?2, ?2, 'repo', ?2)",
        params![id, project, title, text],
    )?;
    Ok(())
}

fn insert_summary_workstream_source(
    conn: &Connection,
    id: i64,
    project: &str,
    title: &str,
) -> Result<()> {
    conn.execute(
        "INSERT INTO workstreams
         (id, project, title, status, created_at_epoch, updated_at_epoch,
          source_project, target_project, owner_scope, owner_key)
         VALUES (?1, ?2, ?3, 'active', 10, 10, ?2, ?2, 'repo', ?2)",
        params![id, project, title],
    )?;
    Ok(())
}

fn insert_summary_session_source(
    conn: &Connection,
    id: i64,
    project: &str,
    request: &str,
) -> Result<()> {
    conn.execute(
        "INSERT INTO session_summaries
         (id, memory_session_id, project, request, created_at_epoch,
          source_project, target_project, owner_scope, owner_key)
         VALUES (?1, 'session-1', ?2, ?3, 10, ?2, ?2, 'repo', ?2)",
        params![id, project, request],
    )?;
    Ok(())
}