klieo-runlog 3.3.1

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! Test helpers for downstream consumers that want to drive klieo's
//! deterministic [`replay`](crate::replay::replay) engine without
//! hand-wiring a [`FakeLlmClient`].
//!
//! Gated by the `test-utils` feature so it stays out of production
//! release artefacts.

use crate::types::{RunLog, StepKind};
use klieo_core::test_utils::{FakeLlmClient, FakeLlmStep};

/// Build a [`FakeLlmClient`] whose script replays every `LlmCall`
/// step of `log` in order. Each `step.output` (the recorded assistant
/// text, as a `serde_json::Value::String` when applicable) becomes
/// one [`FakeLlmStep::Text`] entry.
///
/// Pairs with [`replay`](crate::replay::replay) for record-then-
/// replay regression tests: persist a real run to a
/// [`RunLogStore`](crate::store::RunLogStore), load it back in a
/// second process, hand the projected log here, and drive the
/// agent under test against the resulting fake.
///
/// Steps whose `output` is not a JSON string are serialised back to
/// their on-wire JSON text so the fake's response equals the
/// recorded value byte-for-byte. Tool-call steps are ignored —
/// pair with a [`FakeToolInvoker`](klieo_core::test_utils::FakeToolInvoker)
/// configured separately if the run exercises tools.
///
/// ```no_run
/// # use klieo_runlog::types::RunLog;
/// # fn ex(log: &RunLog) {
/// use klieo_runlog::test_utils::fake_llm_from_runlog;
/// let llm = fake_llm_from_runlog("replay-fake", log);
/// # let _ = llm;
/// # }
/// ```
pub fn fake_llm_from_runlog(name: impl Into<String>, log: &RunLog) -> FakeLlmClient {
    let steps: Vec<FakeLlmStep> = log
        .steps
        .iter()
        .filter(|s| s.kind == StepKind::LlmCall)
        .map(|s| {
            let text = s
                .output
                .as_str()
                .map(String::from)
                .unwrap_or_else(|| s.output.to_string());
            FakeLlmStep::Text(text)
        })
        .collect();
    FakeLlmClient::new(name).with_steps(steps)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{RunStatus, Step, Usage};
    use chrono::Utc;
    use klieo_core::llm::ChatRequest;
    use klieo_core::{LlmClient, RunId};

    fn step(idx: u32, kind: StepKind, output: serde_json::Value) -> Step {
        Step {
            idx,
            kind,
            name: None,
            prompt_tokens: None,
            completion_tokens: None,
            cost_usd: None,
            input: serde_json::Value::Null,
            output,
            error: None,
            latency: std::time::Duration::ZERO,
            span_id: None,
        }
    }

    fn run_log_with_mixed_steps() -> RunLog {
        let now = Utc::now();
        RunLog {
            run_id: RunId::new(),
            agent: "t".into(),
            started_at: now,
            finished_at: Some(now),
            status: RunStatus::Completed,
            steps: vec![
                step(0, StepKind::LlmCall, serde_json::json!("hello")),
                step(1, StepKind::ToolCall, serde_json::json!({"hit": true})),
                step(2, StepKind::LlmCall, serde_json::json!("world")),
            ],
            tokens: Usage::default(),
            cost_estimate: None,
        }
    }

    #[tokio::test]
    async fn fake_llm_from_runlog_filters_to_llm_call_steps_only() {
        let log = run_log_with_mixed_steps();
        let fake = fake_llm_from_runlog("replay-test", &log);
        assert_eq!(LlmClient::name(&fake), "replay-test");

        // ToolCall step at idx 1 must NOT consume a slot in the
        // fake's response queue — three steps in, two responses out.
        let r1 = fake.complete(ChatRequest::new(vec![])).await.unwrap();
        assert_eq!(
            r1.message.content, "hello",
            "first replay = first LlmCall.output"
        );
        let r2 = fake.complete(ChatRequest::new(vec![])).await.unwrap();
        assert_eq!(
            r2.message.content, "world",
            "second replay = second LlmCall.output (skipping ToolCall)"
        );
        // Third call exhausts the script — the fake surfaces this
        // as a BadRequest error per FakeLlmClient's contract.
        let err = fake.complete(ChatRequest::new(vec![])).await;
        assert!(
            err.is_err(),
            "script of two responses must exhaust on third call"
        );
    }

    #[tokio::test]
    async fn fake_llm_from_runlog_serialises_non_string_output_as_json() {
        let now = Utc::now();
        let log = RunLog {
            run_id: RunId::new(),
            agent: "t".into(),
            started_at: now,
            finished_at: Some(now),
            status: RunStatus::Completed,
            steps: vec![step(0, StepKind::LlmCall, serde_json::json!({"k": 1}))],
            tokens: Usage::default(),
            cost_estimate: None,
        };
        let fake = fake_llm_from_runlog("replay-json", &log);
        let r = fake.complete(ChatRequest::new(vec![])).await.unwrap();
        assert_eq!(
            r.message.content, "{\"k\":1}",
            "non-string output must serialise back to its JSON text"
        );
    }
}