klieo 3.3.1

Open-source Rust agent framework — typed agents, durable inter-agent comms, local-first.
Documentation
//! Opt-in SQLite `RunLogStore` (`AppBuilder::sqlite_runlog`) + episodes-only
//! auto-persist on `App::run` completion.
//!
//! Contract under test:
//!   - A run that records episodes to `ctx.episodic` is auto-projected into the
//!     wired store, keyed by the run's own `RunId`, on `App::run` completion.
//!   - The auto-persist is EPISODES-ONLY: it replays the run-keyed episode
//!     timeline and prices against an empty `LlmIo` sidecar. It never drains the
//!     shared live-capture buffer (that would mis-attribute LLM cost across
//!     runs), so per-call token cost stays the existing manual capture path.
//!   - A run with ZERO episodes writes no log (skip-empty).
//!
//! Each assertion re-opens the store from the SAME file path: the SQLite store
//! owns its own connection, so a fresh open proves the write hit durable
//! storage rather than a per-connection view.

#![cfg(all(feature = "runlog-sqlite", feature = "test-utils"))]

use std::sync::Arc;

use async_trait::async_trait;
use klieo::runlog::{RunLogStore, RunStatus, SqliteRunLogStore};
use klieo::test_utils::{FakeLlmClient, FakeToolInvoker};
use klieo::{Agent, AgentContext, Episode, RunId, ToolDef};

/// Test agent that records a given episode timeline to `ctx.episodic` and
/// returns its own `RunId` so the test can address the persisted log.
struct RecordingAgent {
    episodes: Vec<Episode>,
}

#[async_trait]
impl Agent for RecordingAgent {
    type Input = ();
    type Output = RunId;
    type Error = klieo::Error;

    fn name(&self) -> &str {
        "recorder"
    }

    fn system_prompt(&self) -> &str {
        ""
    }

    fn tools(&self) -> &[ToolDef] {
        &[]
    }

    async fn run(&self, ctx: AgentContext, _input: ()) -> Result<RunId, Self::Error> {
        let run_id = ctx.run_id;
        for episode in &self.episodes {
            ctx.episodic
                .record(run_id, episode.clone())
                .await
                .expect("episode record must succeed in the in-memory store");
        }
        Ok(run_id)
    }
}

async fn app_with_runlog(store_path: &std::path::Path) -> klieo::App {
    klieo::App::builder()
        .llm(Arc::new(FakeLlmClient::new("fake")))
        .memory(
            klieo::memory_sqlite::MemorySqlite::new(
                ":memory:",
                Arc::new(klieo::memory_sqlite::DummyEmbedder),
            )
            .await
            .unwrap(),
        )
        .bus(klieo::bus_memory::MemoryBus::new())
        .tools_invoker(Arc::new(FakeToolInvoker::new()))
        .sqlite_runlog(store_path.to_path_buf())
        .build()
        .await
        .unwrap()
}

#[tokio::test]
async fn run_with_episodes_auto_persists_a_runlog() {
    let dir = tempfile::tempdir().unwrap();
    let store_path = dir.path().join("runlog.db");

    let app = app_with_runlog(&store_path).await;
    let agent = RecordingAgent {
        episodes: vec![
            Episode::Started {
                agent: "recorder".into(),
            },
            Episode::Completed,
        ],
    };

    let run_id = app.run(&agent, ()).await.unwrap();

    // Re-open from the same file: a fresh connection proves the write is durable.
    let store = SqliteRunLogStore::new(&store_path).await.unwrap();
    let persisted = store
        .get(run_id)
        .await
        .unwrap()
        .expect("a run that recorded episodes must auto-persist a RunLog");

    assert_eq!(persisted.run_id, run_id);
    assert_eq!(persisted.agent, "recorder");
    assert_eq!(
        persisted.status,
        RunStatus::Completed,
        "the projected log must reflect the recorded Started→Completed timeline"
    );
}

#[tokio::test]
async fn run_without_episodes_persists_nothing() {
    let dir = tempfile::tempdir().unwrap();
    let store_path = dir.path().join("runlog.db");

    let app = app_with_runlog(&store_path).await;
    let agent = RecordingAgent { episodes: vec![] };

    let run_id = app.run(&agent, ()).await.unwrap();

    let store = SqliteRunLogStore::new(&store_path).await.unwrap();
    let persisted = store.get(run_id).await.unwrap();
    assert!(
        persisted.is_none(),
        "a run that recorded zero episodes must write no RunLog (skip-empty)"
    );
}