Skip to main content

hyper_agent_notify/
thinking_log.rs

1use std::collections::HashMap;
2use std::fs;
3use std::io::{BufRead, BufReader, Write};
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use tokio::sync::Mutex as TokioMutex;
8
9// ---------------------------------------------------------------------------
10// Types
11// ---------------------------------------------------------------------------
12
13/// Decision action from Claude's thinking process.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15#[serde(rename_all = "camelCase")]
16pub struct Decision {
17    pub action: String,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub symbol: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub size: Option<f64>,
22    /// Additional decision metadata.
23    #[serde(flatten)]
24    pub extra: HashMap<String, serde_json::Value>,
25}
26
27/// Execution result of an order.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct Execution {
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub order_id: Option<String>,
33    pub status: String,
34    /// Additional execution metadata.
35    #[serde(flatten)]
36    pub extra: HashMap<String, serde_json::Value>,
37}
38
39/// Status of a thinking log entry, used for frontend color-coding.
40///
41/// - `Order` (blue): an order was placed
42/// - `NoAction` (gray): Claude decided to do nothing
43/// - `RiskBlocked` (red): risk check blocked the action
44/// - `Error` (orange): an error occurred
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46#[serde(rename_all = "snake_case")]
47pub enum ThinkingLogStatus {
48    Order,
49    NoAction,
50    RiskBlocked,
51    Error,
52}
53
54/// A single thinking log entry capturing Claude's full reasoning for one
55/// agent loop iteration.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(rename_all = "camelCase")]
58pub struct ThinkingLogEntry {
59    pub timestamp: String,
60    pub agent_id: String,
61    pub market_summary: String,
62    pub claude_reasoning: String,
63    pub decision: Decision,
64    pub risk_check: String,
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub execution: Option<Execution>,
67    pub status: ThinkingLogStatus,
68    /// Optional error message when status == Error.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub error_message: Option<String>,
71    /// Optional technical indicator snapshot for the iteration.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub technical_snapshot: Option<String>,
74}
75
76/// Query parameters for retrieving thinking logs.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ThinkingLogQuery {
80    pub agent_id: String,
81    pub limit: Option<usize>,
82    pub offset: Option<usize>,
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub status_filter: Option<ThinkingLogStatus>,
85}
86
87/// Paginated response for thinking log queries.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ThinkingLogPage {
91    pub entries: Vec<ThinkingLogEntry>,
92    pub total: usize,
93    pub limit: usize,
94    pub offset: usize,
95}
96
97// ---------------------------------------------------------------------------
98// ThinkingLogStore
99// ---------------------------------------------------------------------------
100
101/// Manages thinking logs per agent, backed by JSONL files on disk.
102pub struct ThinkingLogStore {
103    /// In-memory cache of logs per agent_id for fast querying.
104    cache: TokioMutex<HashMap<String, Vec<ThinkingLogEntry>>>,
105}
106
107impl ThinkingLogStore {
108    pub fn new() -> Self {
109        Self {
110            cache: TokioMutex::new(HashMap::new()),
111        }
112    }
113
114    /// Append a thinking log entry. Writes to disk (JSONL) and updates the
115    /// in-memory cache.
116    pub async fn append(&self, entry: ThinkingLogEntry) -> Result<(), String> {
117        // Write to disk first
118        write_thinking_log(&entry)?;
119
120        // Update cache
121        let mut cache = self.cache.lock().await;
122        cache
123            .entry(entry.agent_id.clone())
124            .or_insert_with(Vec::new)
125            .push(entry);
126
127        Ok(())
128    }
129
130    /// Get paginated thinking logs for an agent (newest first).
131    pub async fn get_logs(&self, agent_id: &str, limit: usize, offset: usize) -> ThinkingLogPage {
132        let mut cache = self.cache.lock().await;
133
134        // Ensure cache is populated from disk
135        if !cache.contains_key(agent_id) {
136            let entries = load_thinking_logs(agent_id);
137            cache.insert(agent_id.to_string(), entries);
138        }
139
140        let entries = cache.get(agent_id).cloned().unwrap_or_default();
141        let total = entries.len();
142
143        // Reverse for newest-first, then apply offset/limit
144        let page: Vec<ThinkingLogEntry> =
145            entries.into_iter().rev().skip(offset).take(limit).collect();
146
147        ThinkingLogPage {
148            entries: page,
149            total,
150            limit,
151            offset,
152        }
153    }
154
155    /// Get a single log entry by agent_id and index (0 = newest).
156    pub async fn get_detail(&self, agent_id: &str, index: usize) -> Option<ThinkingLogEntry> {
157        let mut cache = self.cache.lock().await;
158
159        if !cache.contains_key(agent_id) {
160            let entries = load_thinking_logs(agent_id);
161            cache.insert(agent_id.to_string(), entries);
162        }
163
164        let entries = cache.get(agent_id)?;
165        let total = entries.len();
166
167        // index 0 = newest = entries[total - 1]
168        if index < total {
169            Some(entries[total - 1 - index].clone())
170        } else {
171            None
172        }
173    }
174
175    /// Search thinking logs for an agent by a text query.
176    /// Searches in market_summary, claude_reasoning, and decision.action.
177    pub async fn search(
178        &self,
179        agent_id: &str,
180        query: &str,
181        limit: usize,
182        status_filter: Option<&ThinkingLogStatus>,
183    ) -> Vec<ThinkingLogEntry> {
184        let mut cache = self.cache.lock().await;
185
186        if !cache.contains_key(agent_id) {
187            let entries = load_thinking_logs(agent_id);
188            cache.insert(agent_id.to_string(), entries);
189        }
190
191        let entries = cache.get(agent_id).cloned().unwrap_or_default();
192        let query_lower = query.to_lowercase();
193
194        entries
195            .into_iter()
196            .rev()
197            .filter(|e| {
198                // Status filter
199                if let Some(sf) = status_filter {
200                    if &e.status != sf {
201                        return false;
202                    }
203                }
204                // Text search
205                if query.is_empty() {
206                    return true;
207                }
208                e.market_summary.to_lowercase().contains(&query_lower)
209                    || e.claude_reasoning.to_lowercase().contains(&query_lower)
210                    || e.decision.action.to_lowercase().contains(&query_lower)
211                    || e.agent_id.to_lowercase().contains(&query_lower)
212                    || e.error_message
213                        .as_ref()
214                        .map(|m| m.to_lowercase().contains(&query_lower))
215                        .unwrap_or(false)
216                    || e.technical_snapshot
217                        .as_ref()
218                        .map(|s| s.to_lowercase().contains(&query_lower))
219                        .unwrap_or(false)
220            })
221            .take(limit)
222            .collect()
223    }
224
225    /// Get all entries for an agent (loading from disk if needed).
226    /// Returns entries in chronological order (oldest first).
227    pub async fn get_all_entries(&self, agent_id: &str) -> Vec<ThinkingLogEntry> {
228        let mut cache = self.cache.lock().await;
229
230        if !cache.contains_key(agent_id) {
231            let entries = load_thinking_logs(agent_id);
232            cache.insert(agent_id.to_string(), entries);
233        }
234
235        cache.get(agent_id).cloned().unwrap_or_default()
236    }
237
238    /// Get all known agent IDs from the cache.
239    /// Note: only returns IDs for agents that have been loaded into cache.
240    pub async fn get_cached_agent_ids(&self) -> Vec<String> {
241        let cache = self.cache.lock().await;
242        cache.keys().cloned().collect()
243    }
244
245    /// Filter logs by status.
246    pub async fn filter_by_status(
247        &self,
248        agent_id: &str,
249        status: &ThinkingLogStatus,
250        limit: usize,
251        offset: usize,
252    ) -> ThinkingLogPage {
253        let mut cache = self.cache.lock().await;
254
255        if !cache.contains_key(agent_id) {
256            let entries = load_thinking_logs(agent_id);
257            cache.insert(agent_id.to_string(), entries);
258        }
259
260        let entries = cache.get(agent_id).cloned().unwrap_or_default();
261        let filtered: Vec<ThinkingLogEntry> = entries
262            .into_iter()
263            .filter(|e| &e.status == status)
264            .collect();
265
266        let total = filtered.len();
267        let page: Vec<ThinkingLogEntry> = filtered
268            .into_iter()
269            .rev()
270            .skip(offset)
271            .take(limit)
272            .collect();
273
274        ThinkingLogPage {
275            entries: page,
276            total,
277            limit,
278            offset,
279        }
280    }
281}
282
283// ---------------------------------------------------------------------------
284// Persistence helpers
285// ---------------------------------------------------------------------------
286
287/// Directory for thinking log files.
288fn thinking_log_dir() -> PathBuf {
289    let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
290    path.push("hyper-agent");
291    path.push("thinking-logs");
292    let _ = fs::create_dir_all(&path);
293    path
294}
295
296fn thinking_log_path(agent_id: &str) -> PathBuf {
297    thinking_log_dir().join(format!("{}.jsonl", agent_id))
298}
299
300/// Append a thinking log entry to the agent's JSONL file.
301fn write_thinking_log(entry: &ThinkingLogEntry) -> Result<(), String> {
302    let path = thinking_log_path(&entry.agent_id);
303    let json = serde_json::to_string(entry)
304        .map_err(|e| format!("Failed to serialize thinking log: {}", e))?;
305
306    let mut file = fs::OpenOptions::new()
307        .create(true)
308        .append(true)
309        .open(&path)
310        .map_err(|e| format!("Failed to open thinking log file: {}", e))?;
311
312    writeln!(file, "{}", json).map_err(|e| format!("Failed to write thinking log: {}", e))?;
313
314    Ok(())
315}
316
317/// Load all thinking log entries for an agent from disk.
318fn load_thinking_logs(agent_id: &str) -> Vec<ThinkingLogEntry> {
319    let path = thinking_log_path(agent_id);
320    let file = match fs::File::open(&path) {
321        Ok(f) => f,
322        Err(_) => return Vec::new(),
323    };
324
325    let reader = BufReader::new(file);
326    let mut entries = Vec::new();
327
328    for line in reader.lines() {
329        let line = match line {
330            Ok(l) => l,
331            Err(_) => continue,
332        };
333        if line.trim().is_empty() {
334            continue;
335        }
336        match serde_json::from_str::<ThinkingLogEntry>(&line) {
337            Ok(entry) => entries.push(entry),
338            Err(_) => continue, // skip malformed lines
339        }
340    }
341
342    entries
343}
344
345// ---------------------------------------------------------------------------
346// Tests
347// ---------------------------------------------------------------------------
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::fs;
353
354    fn make_entry(agent_id: &str, status: ThinkingLogStatus, action: &str) -> ThinkingLogEntry {
355        ThinkingLogEntry {
356            timestamp: chrono::Utc::now().to_rfc3339(),
357            agent_id: agent_id.to_string(),
358            market_summary: "BTC at 95k, funding rate low".to_string(),
359            claude_reasoning: "Based on technical analysis, trend is up".to_string(),
360            decision: Decision {
361                action: action.to_string(),
362                symbol: Some("BTC-PERP".to_string()),
363                size: Some(0.05),
364                extra: HashMap::new(),
365            },
366            risk_check: "passed".to_string(),
367            execution: Some(Execution {
368                order_id: Some("0xabc123".to_string()),
369                status: "filled".to_string(),
370                extra: HashMap::new(),
371            }),
372            status,
373            error_message: None,
374            technical_snapshot: None,
375        }
376    }
377
378    fn cleanup_log(agent_id: &str) {
379        let _ = fs::remove_file(thinking_log_path(agent_id));
380    }
381
382    // --- Serialization tests ---
383
384    #[test]
385    fn test_thinking_log_status_serialization() {
386        assert_eq!(
387            serde_json::to_string(&ThinkingLogStatus::Order).unwrap(),
388            "\"order\""
389        );
390        assert_eq!(
391            serde_json::to_string(&ThinkingLogStatus::NoAction).unwrap(),
392            "\"no_action\""
393        );
394        assert_eq!(
395            serde_json::to_string(&ThinkingLogStatus::RiskBlocked).unwrap(),
396            "\"risk_blocked\""
397        );
398        assert_eq!(
399            serde_json::to_string(&ThinkingLogStatus::Error).unwrap(),
400            "\"error\""
401        );
402    }
403
404    #[test]
405    fn test_thinking_log_status_deserialization() {
406        let s: ThinkingLogStatus = serde_json::from_str("\"order\"").unwrap();
407        assert_eq!(s, ThinkingLogStatus::Order);
408
409        let s: ThinkingLogStatus = serde_json::from_str("\"no_action\"").unwrap();
410        assert_eq!(s, ThinkingLogStatus::NoAction);
411
412        let s: ThinkingLogStatus = serde_json::from_str("\"risk_blocked\"").unwrap();
413        assert_eq!(s, ThinkingLogStatus::RiskBlocked);
414
415        let s: ThinkingLogStatus = serde_json::from_str("\"error\"").unwrap();
416        assert_eq!(s, ThinkingLogStatus::Error);
417    }
418
419    #[test]
420    fn test_thinking_log_entry_serialization() {
421        let entry = make_entry("agent_001", ThinkingLogStatus::Order, "buy");
422        let json = serde_json::to_value(&entry).unwrap();
423
424        assert_eq!(json["agentId"], "agent_001");
425        assert_eq!(json["marketSummary"], "BTC at 95k, funding rate low");
426        assert_eq!(json["decision"]["action"], "buy");
427        assert_eq!(json["decision"]["symbol"], "BTC-PERP");
428        assert_eq!(json["decision"]["size"], 0.05);
429        assert_eq!(json["riskCheck"], "passed");
430        assert_eq!(json["execution"]["orderId"], "0xabc123");
431        assert_eq!(json["execution"]["status"], "filled");
432        assert_eq!(json["status"], "order");
433    }
434
435    #[test]
436    fn test_thinking_log_entry_deserialization() {
437        let json = serde_json::json!({
438            "timestamp": "2026-03-09T14:32:00Z",
439            "agentId": "agent_001",
440            "marketSummary": "BTC at 95k",
441            "claudeReasoning": "Trend is up",
442            "decision": { "action": "buy", "symbol": "BTC-PERP", "size": 0.05 },
443            "riskCheck": "passed",
444            "execution": { "orderId": "0x123", "status": "filled" },
445            "status": "order"
446        });
447        let entry: ThinkingLogEntry = serde_json::from_value(json).unwrap();
448        assert_eq!(entry.agent_id, "agent_001");
449        assert_eq!(entry.decision.action, "buy");
450        assert_eq!(entry.decision.symbol, Some("BTC-PERP".to_string()));
451        assert_eq!(entry.status, ThinkingLogStatus::Order);
452    }
453
454    #[test]
455    fn test_thinking_log_entry_no_execution() {
456        let mut entry = make_entry("agent_002", ThinkingLogStatus::NoAction, "hold");
457        entry.execution = None;
458        let json = serde_json::to_value(&entry).unwrap();
459        assert!(json.get("execution").is_none());
460    }
461
462    #[test]
463    fn test_thinking_log_entry_with_error() {
464        let mut entry = make_entry("agent_003", ThinkingLogStatus::Error, "buy");
465        entry.error_message = Some("Connection timeout".to_string());
466        entry.execution = None;
467        let json = serde_json::to_value(&entry).unwrap();
468        assert_eq!(json["status"], "error");
469        assert_eq!(json["errorMessage"], "Connection timeout");
470    }
471
472    #[test]
473    fn test_decision_extra_fields() {
474        let mut extra = HashMap::new();
475        extra.insert(
476            "leverage".to_string(),
477            serde_json::Value::Number(serde_json::Number::from(5)),
478        );
479        let decision = Decision {
480            action: "buy".to_string(),
481            symbol: Some("ETH-PERP".to_string()),
482            size: Some(1.0),
483            extra,
484        };
485        let json = serde_json::to_value(&decision).unwrap();
486        assert_eq!(json["leverage"], 5);
487    }
488
489    // --- Persistence tests ---
490
491    #[test]
492    fn test_write_and_load_thinking_log() {
493        let agent_id = "test-thinking-log-persistence";
494        cleanup_log(agent_id);
495
496        let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
497        write_thinking_log(&entry1).unwrap();
498
499        let mut entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
500        entry2.execution = None;
501        write_thinking_log(&entry2).unwrap();
502
503        let loaded = load_thinking_logs(agent_id);
504        assert_eq!(loaded.len(), 2);
505        assert_eq!(loaded[0].decision.action, "buy");
506        assert_eq!(loaded[1].decision.action, "hold");
507
508        cleanup_log(agent_id);
509    }
510
511    #[test]
512    fn test_load_nonexistent_returns_empty() {
513        let entries = load_thinking_logs("nonexistent-agent-xyz");
514        assert!(entries.is_empty());
515    }
516
517    #[test]
518    fn test_write_thinking_log_creates_file() {
519        let agent_id = "test-thinking-log-create";
520        cleanup_log(agent_id);
521
522        let entry = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
523        write_thinking_log(&entry).unwrap();
524
525        assert!(thinking_log_path(agent_id).exists());
526        cleanup_log(agent_id);
527    }
528
529    // --- ThinkingLogStore tests ---
530
531    #[tokio::test]
532    async fn test_store_append_and_get() {
533        let agent_id = "test-store-append";
534        cleanup_log(agent_id);
535
536        let store = ThinkingLogStore::new();
537
538        let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
539        store.append(entry1).await.unwrap();
540
541        let entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
542        store.append(entry2).await.unwrap();
543
544        let page = store.get_logs(agent_id, 10, 0).await;
545        assert_eq!(page.total, 2);
546        assert_eq!(page.entries.len(), 2);
547        // Newest first
548        assert_eq!(page.entries[0].decision.action, "hold");
549        assert_eq!(page.entries[1].decision.action, "buy");
550
551        cleanup_log(agent_id);
552    }
553
554    #[tokio::test]
555    async fn test_store_pagination() {
556        let agent_id = "test-store-pagination";
557        cleanup_log(agent_id);
558
559        let store = ThinkingLogStore::new();
560
561        for i in 0..5 {
562            let entry = make_entry(agent_id, ThinkingLogStatus::Order, &format!("action_{}", i));
563            store.append(entry).await.unwrap();
564        }
565
566        let page = store.get_logs(agent_id, 2, 0).await;
567        assert_eq!(page.total, 5);
568        assert_eq!(page.entries.len(), 2);
569        assert_eq!(page.entries[0].decision.action, "action_4");
570        assert_eq!(page.entries[1].decision.action, "action_3");
571
572        let page = store.get_logs(agent_id, 2, 2).await;
573        assert_eq!(page.total, 5);
574        assert_eq!(page.entries.len(), 2);
575        assert_eq!(page.entries[0].decision.action, "action_2");
576        assert_eq!(page.entries[1].decision.action, "action_1");
577
578        let page = store.get_logs(agent_id, 2, 4).await;
579        assert_eq!(page.entries.len(), 1);
580        assert_eq!(page.entries[0].decision.action, "action_0");
581
582        cleanup_log(agent_id);
583    }
584
585    #[tokio::test]
586    async fn test_store_get_detail() {
587        let agent_id = "test-store-detail";
588        cleanup_log(agent_id);
589
590        let store = ThinkingLogStore::new();
591
592        let entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
593        store.append(entry1).await.unwrap();
594
595        let entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
596        store.append(entry2).await.unwrap();
597
598        let detail = store.get_detail(agent_id, 0).await.unwrap();
599        assert_eq!(detail.decision.action, "hold");
600
601        let detail = store.get_detail(agent_id, 1).await.unwrap();
602        assert_eq!(detail.decision.action, "buy");
603
604        assert!(store.get_detail(agent_id, 2).await.is_none());
605
606        cleanup_log(agent_id);
607    }
608
609    #[tokio::test]
610    async fn test_store_search_by_text() {
611        let agent_id = "test-store-search";
612        cleanup_log(agent_id);
613
614        let store = ThinkingLogStore::new();
615
616        let mut entry1 = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
617        entry1.market_summary = "BTC breaking 95k resistance".to_string();
618        store.append(entry1).await.unwrap();
619
620        let mut entry2 = make_entry(agent_id, ThinkingLogStatus::NoAction, "hold");
621        entry2.market_summary = "ETH consolidating at 3k".to_string();
622        store.append(entry2).await.unwrap();
623
624        let results = store.search(agent_id, "BTC", 50, None).await;
625        assert_eq!(results.len(), 1);
626        assert_eq!(results[0].decision.action, "buy");
627
628        let results = store.search(agent_id, "ETH", 50, None).await;
629        assert_eq!(results.len(), 1);
630        assert_eq!(results[0].decision.action, "hold");
631
632        let results = store.search(agent_id, "technical", 50, None).await;
633        assert_eq!(results.len(), 2);
634
635        let results = store.search(agent_id, "", 50, None).await;
636        assert_eq!(results.len(), 2);
637
638        cleanup_log(agent_id);
639    }
640
641    #[tokio::test]
642    async fn test_store_search_with_status_filter() {
643        let agent_id = "test-store-search-filter";
644        cleanup_log(agent_id);
645
646        let store = ThinkingLogStore::new();
647
648        store
649            .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
650            .await
651            .unwrap();
652        store
653            .append(make_entry(agent_id, ThinkingLogStatus::NoAction, "hold"))
654            .await
655            .unwrap();
656        store
657            .append(make_entry(agent_id, ThinkingLogStatus::RiskBlocked, "buy"))
658            .await
659            .unwrap();
660
661        let results = store
662            .search(agent_id, "", 50, Some(&ThinkingLogStatus::Order))
663            .await;
664        assert_eq!(results.len(), 1);
665        assert_eq!(results[0].status, ThinkingLogStatus::Order);
666
667        let results = store
668            .search(agent_id, "", 50, Some(&ThinkingLogStatus::RiskBlocked))
669            .await;
670        assert_eq!(results.len(), 1);
671        assert_eq!(results[0].status, ThinkingLogStatus::RiskBlocked);
672
673        cleanup_log(agent_id);
674    }
675
676    #[tokio::test]
677    async fn test_store_filter_by_status() {
678        let agent_id = "test-store-filter-status";
679        cleanup_log(agent_id);
680
681        let store = ThinkingLogStore::new();
682
683        store
684            .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
685            .await
686            .unwrap();
687        store
688            .append(make_entry(agent_id, ThinkingLogStatus::NoAction, "hold"))
689            .await
690            .unwrap();
691        store
692            .append(make_entry(agent_id, ThinkingLogStatus::Order, "sell"))
693            .await
694            .unwrap();
695        store
696            .append(make_entry(agent_id, ThinkingLogStatus::RiskBlocked, "buy"))
697            .await
698            .unwrap();
699
700        let page = store
701            .filter_by_status(agent_id, &ThinkingLogStatus::Order, 10, 0)
702            .await;
703        assert_eq!(page.total, 2);
704        assert_eq!(page.entries.len(), 2);
705        assert_eq!(page.entries[0].decision.action, "sell");
706        assert_eq!(page.entries[1].decision.action, "buy");
707
708        cleanup_log(agent_id);
709    }
710
711    #[tokio::test]
712    async fn test_store_search_limit() {
713        let agent_id = "test-store-search-limit";
714        cleanup_log(agent_id);
715
716        let store = ThinkingLogStore::new();
717
718        for _ in 0..10 {
719            store
720                .append(make_entry(agent_id, ThinkingLogStatus::Order, "buy"))
721                .await
722                .unwrap();
723        }
724
725        let results = store.search(agent_id, "", 3, None).await;
726        assert_eq!(results.len(), 3);
727
728        cleanup_log(agent_id);
729    }
730
731    #[tokio::test]
732    async fn test_store_loads_from_disk_on_cache_miss() {
733        let agent_id = "test-store-disk-load";
734        cleanup_log(agent_id);
735
736        let entry = make_entry(agent_id, ThinkingLogStatus::Order, "buy");
737        write_thinking_log(&entry).unwrap();
738
739        let store = ThinkingLogStore::new();
740
741        let page = store.get_logs(agent_id, 10, 0).await;
742        assert_eq!(page.total, 1);
743        assert_eq!(page.entries[0].decision.action, "buy");
744
745        cleanup_log(agent_id);
746    }
747
748    #[tokio::test]
749    async fn test_store_empty_agent() {
750        let store = ThinkingLogStore::new();
751
752        let page = store.get_logs("no-such-agent-thinking", 10, 0).await;
753        assert_eq!(page.total, 0);
754        assert!(page.entries.is_empty());
755    }
756
757    #[test]
758    fn test_thinking_log_query_serialization() {
759        let query = ThinkingLogQuery {
760            agent_id: "agent_001".to_string(),
761            limit: Some(20),
762            offset: Some(5),
763            status_filter: Some(ThinkingLogStatus::Order),
764        };
765        let json = serde_json::to_value(&query).unwrap();
766        assert_eq!(json["agentId"], "agent_001");
767        assert_eq!(json["limit"], 20);
768        assert_eq!(json["offset"], 5);
769        assert_eq!(json["statusFilter"], "order");
770    }
771
772    #[test]
773    fn test_thinking_log_page_serialization() {
774        let page = ThinkingLogPage {
775            entries: vec![make_entry("a", ThinkingLogStatus::Order, "buy")],
776            total: 100,
777            limit: 10,
778            offset: 0,
779        };
780        let json = serde_json::to_value(&page).unwrap();
781        assert_eq!(json["total"], 100);
782        assert_eq!(json["limit"], 10);
783        assert_eq!(json["offset"], 0);
784        assert_eq!(json["entries"].as_array().unwrap().len(), 1);
785    }
786}