Skip to main content

bn/
history.rs

1use std::fs::OpenOptions;
2use std::io::Write;
3use std::path::Path;
4
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct AgentHistoryEntry {
9    pub bean_id: String,
10    pub title: String,
11    pub attempt: u32,
12    pub success: bool,
13    pub duration_secs: u64,
14    pub tokens: u64,
15    pub cost: f64,
16    pub tool_count: usize,
17    pub error: Option<String>,
18    pub model: String,
19    pub timestamp: String,
20}
21
22/// Append a completion record to `.beans/agent_history.jsonl`.
23///
24/// Gracefully swallows errors — logging should never crash the agent.
25pub fn append_history(beans_dir: &Path, entry: &AgentHistoryEntry) {
26    let _ = try_append(beans_dir, entry);
27}
28
29fn try_append(
30    beans_dir: &Path,
31    entry: &AgentHistoryEntry,
32) -> Result<(), Box<dyn std::error::Error>> {
33    let path = beans_dir.join("agent_history.jsonl");
34    let line = serde_json::to_string(entry)?;
35    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
36    writeln!(file, "{}", line)?;
37    Ok(())
38}
39
40#[cfg(test)]
41mod tests {
42    use super::*;
43    use std::fs;
44    use tempfile::TempDir;
45
46    fn make_entry(success: bool) -> AgentHistoryEntry {
47        AgentHistoryEntry {
48            bean_id: "42".to_string(),
49            title: "Test bean".to_string(),
50            attempt: 1,
51            success,
52            duration_secs: 30,
53            tokens: 5000,
54            cost: 0.03,
55            tool_count: 12,
56            error: None,
57            model: "default".to_string(),
58            timestamp: "2026-03-03T00:00:00Z".to_string(),
59        }
60    }
61
62    #[test]
63    fn append_creates_file_and_writes_valid_jsonl() {
64        let dir = TempDir::new().unwrap();
65        let beans_dir = dir.path().join(".beans");
66        fs::create_dir(&beans_dir).unwrap();
67
68        let entry = make_entry(true);
69        append_history(&beans_dir, &entry);
70
71        let content = fs::read_to_string(beans_dir.join("agent_history.jsonl")).unwrap();
72        let lines: Vec<&str> = content.lines().collect();
73        assert_eq!(lines.len(), 1);
74
75        let parsed: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
76        assert_eq!(parsed["bean_id"], "42");
77        assert_eq!(parsed["success"], true);
78        assert_eq!(parsed["tokens"], 5000);
79        assert_eq!(parsed["cost"], 0.03);
80    }
81
82    #[test]
83    fn append_appends_multiple_lines() {
84        let dir = TempDir::new().unwrap();
85        let beans_dir = dir.path().join(".beans");
86        fs::create_dir(&beans_dir).unwrap();
87
88        append_history(&beans_dir, &make_entry(true));
89        append_history(&beans_dir, &make_entry(false));
90
91        let content = fs::read_to_string(beans_dir.join("agent_history.jsonl")).unwrap();
92        let lines: Vec<&str> = content.lines().collect();
93        assert_eq!(lines.len(), 2);
94
95        let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
96        let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
97        assert_eq!(first["success"], true);
98        assert_eq!(second["success"], false);
99    }
100
101    #[test]
102    fn append_error_field_serialized_when_present() {
103        let dir = TempDir::new().unwrap();
104        let beans_dir = dir.path().join(".beans");
105        fs::create_dir(&beans_dir).unwrap();
106
107        let mut entry = make_entry(false);
108        entry.error = Some("Exit code 1".to_string());
109        append_history(&beans_dir, &entry);
110
111        let content = fs::read_to_string(beans_dir.join("agent_history.jsonl")).unwrap();
112        let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
113        assert_eq!(parsed["error"], "Exit code 1");
114    }
115
116    #[test]
117    fn append_error_field_null_when_none() {
118        let dir = TempDir::new().unwrap();
119        let beans_dir = dir.path().join(".beans");
120        fs::create_dir(&beans_dir).unwrap();
121
122        append_history(&beans_dir, &make_entry(true));
123
124        let content = fs::read_to_string(beans_dir.join("agent_history.jsonl")).unwrap();
125        let parsed: serde_json::Value = serde_json::from_str(content.trim()).unwrap();
126        assert!(parsed["error"].is_null());
127    }
128
129    #[test]
130    fn append_swallows_errors_on_missing_dir() {
131        let dir = TempDir::new().unwrap();
132        let bogus = dir.path().join("nonexistent");
133
134        // Should not panic
135        append_history(&bogus, &make_entry(true));
136    }
137}