looprs-core 0.5.1

Domain types, ports, and macros for looprs
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};

use crate::types::ToolId;

const OUTPUT_PREVIEW_LEN: usize = 500;

/// A captured observation from tool usage in a session
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Observation {
    pub tool_name: String,
    pub input: Value,
    pub output: String,
    pub tool_use_id: Option<ToolId>,
    pub timestamp: u64,
    pub session_id: String,
    pub context: Option<String>,
}

impl Observation {
    pub fn new(
        tool_name: String,
        input: Value,
        output: String,
        tool_use_id: Option<ToolId>,
        session_id: String,
    ) -> Self {
        let timestamp = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);

        Observation {
            tool_name,
            input,
            output,
            tool_use_id,
            timestamp,
            session_id,
            context: None,
        }
    }

    pub fn with_context(mut self, context: String) -> Self {
        self.context = Some(context);
        self
    }

    pub fn to_description(&self) -> String {
        let input_str = serde_json::to_string_pretty(&self.input).unwrap_or_default();
        let output_preview = if self.output.len() > OUTPUT_PREVIEW_LEN {
            format!("{}...", &self.output[..OUTPUT_PREVIEW_LEN])
        } else {
            self.output.clone()
        };

        let time_str = match SystemTime::UNIX_EPOCH
            .checked_add(std::time::Duration::from_secs(self.timestamp))
        {
            Some(t) => format!("{t:?}"),
            None => format!("{} (unix timestamp)", self.timestamp),
        };

        format!(
            "**Tool:** {}\n{}**Time:** {}\n\n**Input:**\n```\n{}\n```\n\n**Output:**\n```\n{}\n```{}",
            self.tool_name,
            self.tool_use_id
                .as_ref()
                .map(|id| format!("**Tool Use ID:** {id}\n"))
                .unwrap_or_default(),
            time_str,
            input_str,
            output_preview,
            if let Some(ctx) = &self.context {
                format!("\n\n**Context:** {ctx}")
            } else {
                String::new()
            }
        )
    }

    pub fn to_title(&self) -> String {
        if let Some(ctx) = &self.context {
            format!("Observation: {}", ctx.chars().take(60).collect::<String>())
        } else {
            format!("Observation: {}", self.tool_name)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn observation_creation() {
        let obs = Observation::new(
            "bash".to_string(),
            serde_json::json!({"command": "cargo test"}),
            "test result: ok".to_string(),
            Some(ToolId::new("tool_123")),
            "sess-123".to_string(),
        );

        assert_eq!(obs.tool_name, "bash");
        assert_eq!(
            obs.tool_use_id.map(|v| v.as_str().to_string()),
            Some("tool_123".to_string())
        );
        assert_eq!(obs.session_id, "sess-123");
        assert!(obs.context.is_none());
    }

    #[test]
    fn observation_with_context() {
        let obs = Observation::new(
            "bash".to_string(),
            serde_json::json!({}),
            "output".to_string(),
            None,
            "sess-123".to_string(),
        )
        .with_context("Testing changes".to_string());

        assert_eq!(obs.context, Some("Testing changes".to_string()));
    }

    #[test]
    fn observation_description() {
        let obs = Observation::new(
            "bash".to_string(),
            serde_json::json!({"command": "test"}),
            "success".to_string(),
            Some(ToolId::new("tool_7")),
            "sess-123".to_string(),
        )
        .with_context("Test execution".to_string());

        let desc = obs.to_description();
        assert!(desc.contains("bash"));
        assert!(desc.contains("tool_7"));
        assert!(desc.contains("success"));
        assert!(desc.contains("Test execution"));
    }

    #[test]
    fn title_with_context() {
        let obs = Observation::new(
            "bash".to_string(),
            serde_json::json!({}),
            "output".to_string(),
            None,
            "sess-123".to_string(),
        )
        .with_context("Fixed parser edge case".to_string());

        let title = obs.to_title();
        assert!(title.contains("Fixed parser edge case"));
        assert!(title.starts_with("Observation:"));
    }
}