use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::types::ToolId;
const OUTPUT_PREVIEW_LEN: usize = 500;
#[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:"));
}
}