looprs_core/
observation.rs1use serde::{Deserialize, Serialize};
2use serde_json::Value;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::types::ToolId;
6
7const OUTPUT_PREVIEW_LEN: usize = 500;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Observation {
12 pub tool_name: String,
13 pub input: Value,
14 pub output: String,
15 pub tool_use_id: Option<ToolId>,
16 pub timestamp: u64,
17 pub session_id: String,
18 pub context: Option<String>,
19}
20
21impl Observation {
22 pub fn new(
23 tool_name: String,
24 input: Value,
25 output: String,
26 tool_use_id: Option<ToolId>,
27 session_id: String,
28 ) -> Self {
29 let timestamp = SystemTime::now()
30 .duration_since(UNIX_EPOCH)
31 .map(|d| d.as_secs())
32 .unwrap_or(0);
33
34 Observation {
35 tool_name,
36 input,
37 output,
38 tool_use_id,
39 timestamp,
40 session_id,
41 context: None,
42 }
43 }
44
45 pub fn with_context(mut self, context: String) -> Self {
46 self.context = Some(context);
47 self
48 }
49
50 pub fn to_description(&self) -> String {
51 let input_str = serde_json::to_string_pretty(&self.input).unwrap_or_default();
52 let output_preview = if self.output.len() > OUTPUT_PREVIEW_LEN {
53 format!("{}...", &self.output[..OUTPUT_PREVIEW_LEN])
54 } else {
55 self.output.clone()
56 };
57
58 let time_str = match SystemTime::UNIX_EPOCH
59 .checked_add(std::time::Duration::from_secs(self.timestamp))
60 {
61 Some(t) => format!("{t:?}"),
62 None => format!("{} (unix timestamp)", self.timestamp),
63 };
64
65 format!(
66 "**Tool:** {}\n{}**Time:** {}\n\n**Input:**\n```\n{}\n```\n\n**Output:**\n```\n{}\n```{}",
67 self.tool_name,
68 self.tool_use_id
69 .as_ref()
70 .map(|id| format!("**Tool Use ID:** {id}\n"))
71 .unwrap_or_default(),
72 time_str,
73 input_str,
74 output_preview,
75 if let Some(ctx) = &self.context {
76 format!("\n\n**Context:** {ctx}")
77 } else {
78 String::new()
79 }
80 )
81 }
82
83 pub fn to_title(&self) -> String {
84 if let Some(ctx) = &self.context {
85 format!("Observation: {}", ctx.chars().take(60).collect::<String>())
86 } else {
87 format!("Observation: {}", self.tool_name)
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn observation_creation() {
98 let obs = Observation::new(
99 "bash".to_string(),
100 serde_json::json!({"command": "cargo test"}),
101 "test result: ok".to_string(),
102 Some(ToolId::new("tool_123")),
103 "sess-123".to_string(),
104 );
105
106 assert_eq!(obs.tool_name, "bash");
107 assert_eq!(
108 obs.tool_use_id.map(|v| v.as_str().to_string()),
109 Some("tool_123".to_string())
110 );
111 assert_eq!(obs.session_id, "sess-123");
112 assert!(obs.context.is_none());
113 }
114
115 #[test]
116 fn observation_with_context() {
117 let obs = Observation::new(
118 "bash".to_string(),
119 serde_json::json!({}),
120 "output".to_string(),
121 None,
122 "sess-123".to_string(),
123 )
124 .with_context("Testing changes".to_string());
125
126 assert_eq!(obs.context, Some("Testing changes".to_string()));
127 }
128
129 #[test]
130 fn observation_description() {
131 let obs = Observation::new(
132 "bash".to_string(),
133 serde_json::json!({"command": "test"}),
134 "success".to_string(),
135 Some(ToolId::new("tool_7")),
136 "sess-123".to_string(),
137 )
138 .with_context("Test execution".to_string());
139
140 let desc = obs.to_description();
141 assert!(desc.contains("bash"));
142 assert!(desc.contains("tool_7"));
143 assert!(desc.contains("success"));
144 assert!(desc.contains("Test execution"));
145 }
146
147 #[test]
148 fn title_with_context() {
149 let obs = Observation::new(
150 "bash".to_string(),
151 serde_json::json!({}),
152 "output".to_string(),
153 None,
154 "sess-123".to_string(),
155 )
156 .with_context("Fixed parser edge case".to_string());
157
158 let title = obs.to_title();
159 assert!(title.contains("Fixed parser edge case"));
160 assert!(title.starts_with("Observation:"));
161 }
162}