kaizen-cli 0.1.46

Distributable agent observability: real-time-tailable sessions, agile-style retros, and repo-level improvement (Cursor, Claude Code, Codex). SQLite, redact before any sync you enable.
Documentation
use super::*;
use kaizen::visualization::SessionSearchInput;

#[path = "search_edge.rs"]
mod edge;

#[test]
fn sessions_page_reports_filtered_total_and_next_offset() -> anyhow::Result<()> {
    let (_tmp, store) = seeded_store(31)?;
    let first = build_report(&store, search_query("", 0, 30))?;
    let last = build_report(&store, search_query("", 30, 30))?;
    assert_eq!(
        (first.sessions.len(), first.session_page.filtered_total),
        (30, 31)
    );
    assert_eq!(
        (first.session_page.offset, first.session_page.next_offset),
        (0, Some(30))
    );
    assert_eq!(
        (last.sessions.len(), last.session_page.next_offset),
        (1, None)
    );
    Ok(())
}

#[test]
fn prompt_match_ranks_before_newer_metadata_match() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let store = Store::open(&tmp.path().join("k.db"))?;
    seed_named(
        &store,
        "prompt",
        1,
        "codex",
        "gpt",
        Some("needle in prompt"),
        None,
    )?;
    seed_named(&store, "metadata", 2, "needle-agent", "gpt", None, None)?;
    let report = build_report(&store, search_query("needle", 0, 30))?;
    assert_eq!(ids(&report), ["prompt", "metadata"]);
    assert_eq!(report.session_page.filtered_total, 2);
    Ok(())
}

#[test]
fn metadata_search_is_case_insensitive() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let store = Store::open(&tmp.path().join("k.db"))?;
    seed_named(
        &store,
        "mixed",
        1,
        "Claude-Code",
        "Sonnet",
        None,
        Some("Feature/ABC"),
    )?;
    let report = build_report(&store, search_query("cLaUdE-cOdE", 0, 30))?;
    assert_eq!(ids(&report), ["mixed"]);
    Ok(())
}

#[test]
fn tool_name_matches_session() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let store = Store::open(&tmp.path().join("k.db"))?;
    seed_named(&store, "tool", 1, "codex", "gpt", None, None)?;
    store.append_event(&tool_event("tool", "Read_File"))?;
    let report = build_report(&store, search_query("read_file", 0, 30))?;
    assert_eq!(ids(&report), ["tool"]);
    Ok(())
}

#[test]
fn reopen_backfills_prompt_projection_idempotently() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let path = tmp.path().join("k.db");
    let store = Store::open(&path)?;
    seed_named(
        &store,
        "old",
        1,
        "codex",
        "gpt",
        Some("historic prompt"),
        None,
    )?;
    drop(store);
    rusqlite::Connection::open(&path)?.execute("DELETE FROM session_search_prompts", [])?;
    let store = Store::open(&path)?;
    assert_eq!(
        ids(&build_report(&store, search_query("historic", 0, 30))?),
        ["old"]
    );
    drop(store);
    Store::open(&path)?;
    Ok(())
}

#[test]
fn pruning_session_removes_prompt_projection() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let path = tmp.path().join("k.db");
    let store = Store::open(&path)?;
    seed_named(&store, "old", 1, "codex", "gpt", Some("remove me"), None)?;
    store.prune_sessions_started_before(2)?;
    drop(store);
    let count: i64 = rusqlite::Connection::open(path)?.query_row(
        "SELECT COUNT(*) FROM session_search_prompts",
        [],
        |row| row.get(0),
    )?;
    assert_eq!(count, 0);
    Ok(())
}

#[test]
fn empty_search_orders_newest_then_id() -> anyhow::Result<()> {
    let tmp = tempfile::tempdir()?;
    let store = Store::open(&tmp.path().join("k.db"))?;
    seed_named(&store, "b", 2, "codex", "gpt", None, None)?;
    seed_named(&store, "a", 2, "codex", "gpt", None, None)?;
    seed_named(&store, "old", 1, "codex", "gpt", None, None)?;
    assert_eq!(
        ids(&build_report(&store, search_query("", 0, 30))?),
        ["a", "b", "old"]
    );
    Ok(())
}

fn seeded_store(count: u64) -> anyhow::Result<(tempfile::TempDir, Store)> {
    let tmp = tempfile::tempdir()?;
    let store = Store::open(&tmp.path().join("k.db"))?;
    (0..count)
        .try_for_each(|n| seed_named(&store, &format!("s{n:02}"), n, "codex", "gpt", None, None))?;
    Ok((tmp, store))
}

fn seed_named(
    store: &Store,
    id: &str,
    at: u64,
    agent: &str,
    model: &str,
    prompt: Option<&str>,
    branch: Option<&str>,
) -> anyhow::Result<()> {
    let mut row = session(id, SessionStatus::Done);
    (row.started_at_ms, row.agent, row.model, row.branch) = (
        at,
        agent.into(),
        Some(model.into()),
        branch.map(str::to_string),
    );
    store.upsert_session(&row)?;
    prompt
        .map(|value| store.append_event(&prompt_event(id, value)))
        .transpose()?;
    Ok(())
}

fn search_query(q: &str, offset: usize, limit: usize) -> VisualizationQuery {
    let mut value = query(None);
    value.include_activity = false;
    value.limits.sessions = limit;
    value.session_search = SessionSearchInput {
        q: q.into(),
        offset,
    };
    value
}

fn ids(report: &kaizen::visualization::VisualizationReport) -> Vec<&str> {
    report.sessions.iter().map(|row| row.id.as_str()).collect()
}

fn prompt_event(id: &str, prompt: &str) -> Event {
    let mut value = event_at(id, 0, 1);
    value.kind = EventKind::Lifecycle;
    value.tool = None;
    value.payload = json!({"prompt": prompt});
    value
}

fn tool_event(id: &str, tool: &str) -> Event {
    let mut value = event_at(id, 0, 1);
    value.kind = EventKind::ToolCall;
    value.tool = Some(tool.into());
    value
}