Skip to main content

data_connector/
core.rs

1// core.rs
2//
3// Core types for the data connector module.
4// Contains all traits, data types, error types, and IDs for all storage backends.
5//
6// Structure:
7// 1. Conversation types + trait
8// 2. ConversationItem types + trait
9// 3. Response types + trait
10
11use std::{
12    collections::{HashMap, HashSet},
13    fmt::{Display, Formatter, Write},
14};
15
16use async_trait::async_trait;
17use chrono::{DateTime, Utc};
18use rand::RngCore;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map as JsonMap, Value};
21
22// ============================================================================
23// Shared helpers
24// ============================================================================
25
26/// Generate a 50-character hex string from 25 cryptographically random bytes.
27/// Used by both `ConversationId::new()` and `make_item_id()`.
28fn random_hex_id() -> String {
29    let mut rng = rand::rng();
30    let mut bytes = [0u8; 25];
31    rng.fill_bytes(&mut bytes);
32    let mut hex_string = String::with_capacity(50);
33    for b in &bytes {
34        // Writing to a String is infallible; discard the always-Ok result.
35        let _ = write!(hex_string, "{b:02x}");
36    }
37    hex_string
38}
39
40// ============================================================================
41// PART 1: Conversation Storage
42// ============================================================================
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
45pub struct ConversationId(pub String);
46
47impl ConversationId {
48    pub fn new() -> Self {
49        Self(format!("conv_{}", random_hex_id()))
50    }
51}
52
53impl Default for ConversationId {
54    fn default() -> Self {
55        Self::new()
56    }
57}
58
59impl From<String> for ConversationId {
60    fn from(value: String) -> Self {
61        Self(value)
62    }
63}
64
65impl From<&str> for ConversationId {
66    fn from(value: &str) -> Self {
67        Self(value.to_string())
68    }
69}
70
71impl Display for ConversationId {
72    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
73        f.write_str(&self.0)
74    }
75}
76
77/// Metadata payload persisted with a conversation
78pub type ConversationMetadata = JsonMap<String, Value>;
79
80/// Input payload for creating a conversation
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub struct NewConversation {
83    /// Optional conversation ID (if None, a random ID will be generated)
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub id: Option<ConversationId>,
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub metadata: Option<ConversationMetadata>,
88}
89
90/// Stored conversation data structure
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct Conversation {
93    pub id: ConversationId,
94    pub created_at: DateTime<Utc>,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub metadata: Option<ConversationMetadata>,
97}
98
99impl Conversation {
100    pub fn new(new_conversation: NewConversation) -> Self {
101        Self {
102            id: new_conversation.id.unwrap_or_default(),
103            created_at: Utc::now(),
104            metadata: new_conversation.metadata,
105        }
106    }
107
108    pub fn with_parts(
109        id: ConversationId,
110        created_at: DateTime<Utc>,
111        metadata: Option<ConversationMetadata>,
112    ) -> Self {
113        Self {
114            id,
115            created_at,
116            metadata,
117        }
118    }
119}
120
121/// Result alias for conversation storage operations
122pub type ConversationResult<T> = Result<T, ConversationStorageError>;
123
124/// Error type for conversation storage operations
125#[derive(Debug, thiserror::Error)]
126pub enum ConversationStorageError {
127    #[error("Conversation not found: {0}")]
128    ConversationNotFound(String),
129
130    #[error("Storage error: {0}")]
131    StorageError(String),
132
133    #[error("Serialization error: {0}")]
134    SerializationError(#[from] serde_json::Error),
135}
136
137/// Trait describing the CRUD interface for conversation storage backends
138#[async_trait]
139pub trait ConversationStorage: Send + Sync + 'static {
140    async fn create_conversation(&self, input: NewConversation)
141        -> ConversationResult<Conversation>;
142
143    async fn get_conversation(
144        &self,
145        id: &ConversationId,
146    ) -> ConversationResult<Option<Conversation>>;
147
148    async fn update_conversation(
149        &self,
150        id: &ConversationId,
151        metadata: Option<ConversationMetadata>,
152    ) -> ConversationResult<Option<Conversation>>;
153
154    async fn delete_conversation(&self, id: &ConversationId) -> ConversationResult<bool>;
155}
156
157// ============================================================================
158// PART 2: ConversationItem Storage
159// ============================================================================
160
161#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
162pub struct ConversationItemId(pub String);
163
164impl Display for ConversationItemId {
165    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
166        f.write_str(&self.0)
167    }
168}
169
170impl From<String> for ConversationItemId {
171    fn from(value: String) -> Self {
172        Self(value)
173    }
174}
175
176impl From<&str> for ConversationItemId {
177    fn from(value: &str) -> Self {
178        Self(value.to_string())
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ConversationItem {
184    pub id: ConversationItemId,
185    pub response_id: Option<String>,
186    pub item_type: String,
187    pub role: Option<String>,
188    pub content: Value,
189    pub status: Option<String>,
190    pub created_at: DateTime<Utc>,
191}
192
193#[derive(Debug, Clone, Serialize, Deserialize)]
194pub struct NewConversationItem {
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub id: Option<ConversationItemId>,
197    pub response_id: Option<String>,
198    pub item_type: String,
199    pub role: Option<String>,
200    pub content: Value,
201    pub status: Option<String>,
202}
203
204#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
205pub enum SortOrder {
206    Asc,
207    Desc,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct ListParams {
212    pub limit: usize,
213    pub order: SortOrder,
214    pub after: Option<String>, // item_id cursor
215}
216
217pub type ConversationItemResult<T> = Result<T, ConversationItemStorageError>;
218
219#[derive(Debug, thiserror::Error)]
220pub enum ConversationItemStorageError {
221    #[error("Not found: {0}")]
222    NotFound(String),
223
224    #[error("Storage error: {0}")]
225    StorageError(String),
226
227    #[error("Serialization error: {0}")]
228    SerializationError(#[from] serde_json::Error),
229}
230
231#[async_trait]
232pub trait ConversationItemStorage: Send + Sync + 'static {
233    async fn create_item(
234        &self,
235        item: NewConversationItem,
236    ) -> ConversationItemResult<ConversationItem>;
237
238    async fn link_item(
239        &self,
240        conversation_id: &ConversationId,
241        item_id: &ConversationItemId,
242        added_at: DateTime<Utc>,
243    ) -> ConversationItemResult<()>;
244
245    /// Batch-link multiple items to a conversation in a single operation.
246    /// Default implementation loops over `link_item`; backends may override
247    /// with a more efficient batched approach.
248    async fn link_items(
249        &self,
250        conversation_id: &ConversationId,
251        items: &[(ConversationItemId, DateTime<Utc>)],
252    ) -> ConversationItemResult<()> {
253        for (item_id, added_at) in items {
254            self.link_item(conversation_id, item_id, *added_at).await?;
255        }
256        Ok(())
257    }
258
259    async fn list_items(
260        &self,
261        conversation_id: &ConversationId,
262        params: ListParams,
263    ) -> ConversationItemResult<Vec<ConversationItem>>;
264
265    /// Get a single item by ID
266    async fn get_item(
267        &self,
268        item_id: &ConversationItemId,
269    ) -> ConversationItemResult<Option<ConversationItem>>;
270
271    /// Check if an item is linked to a conversation
272    async fn is_item_linked(
273        &self,
274        conversation_id: &ConversationId,
275        item_id: &ConversationItemId,
276    ) -> ConversationItemResult<bool>;
277
278    /// Delete an item link from a conversation (does not delete the item itself)
279    async fn delete_item(
280        &self,
281        conversation_id: &ConversationId,
282        item_id: &ConversationItemId,
283    ) -> ConversationItemResult<()>;
284}
285
286/// Helper to build id prefix based on item_type
287pub fn make_item_id(item_type: &str) -> ConversationItemId {
288    let hex_string = random_hex_id();
289
290    let prefix = match item_type {
291        "message" => "msg",
292        "reasoning" => "rs",
293        "mcp_call" => "mcp",
294        "mcp_list_tools" => "mcpl",
295        "function_call" => "fc",
296        other => {
297            // Fallback: first 3 letters of type or "itm"
298            let fallback: String = other.chars().take(3).collect();
299            if fallback.is_empty() {
300                return ConversationItemId(format!("itm_{hex_string}"));
301            }
302            return ConversationItemId(format!("{fallback}_{hex_string}"));
303        }
304    };
305    ConversationItemId(format!("{prefix}_{hex_string}"))
306}
307
308// ============================================================================
309// PART 3: Response Storage
310// ============================================================================
311
312/// Response identifier
313#[derive(Debug, Clone, Hash, Eq, PartialEq, Serialize, Deserialize)]
314pub struct ResponseId(pub String);
315
316impl ResponseId {
317    pub fn new() -> Self {
318        Self(ulid::Ulid::new().to_string())
319    }
320}
321
322impl Display for ResponseId {
323    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
324        f.write_str(&self.0)
325    }
326}
327
328impl Default for ResponseId {
329    fn default() -> Self {
330        Self::new()
331    }
332}
333
334impl From<String> for ResponseId {
335    fn from(value: String) -> Self {
336        Self(value)
337    }
338}
339
340impl From<&str> for ResponseId {
341    fn from(value: &str) -> Self {
342        Self(value.to_string())
343    }
344}
345
346/// Stored response data
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct StoredResponse {
349    /// Unique response ID
350    pub id: ResponseId,
351
352    /// ID of the previous response in the chain (if any)
353    pub previous_response_id: Option<ResponseId>,
354
355    /// Input items as JSON array
356    pub input: Value,
357
358    /// When this response was created
359    pub created_at: DateTime<Utc>,
360
361    /// Safety identifier for content moderation
362    pub safety_identifier: Option<String>,
363
364    /// Model used for generation
365    pub model: Option<String>,
366
367    /// Conversation id if associated with a conversation
368    #[serde(default)]
369    pub conversation_id: Option<String>,
370
371    /// Raw OpenAI response payload
372    #[serde(default)]
373    pub raw_response: Value,
374}
375
376impl StoredResponse {
377    pub fn new(previous_response_id: Option<ResponseId>) -> Self {
378        Self {
379            id: ResponseId::new(),
380            previous_response_id,
381            input: Value::Array(vec![]),
382            created_at: Utc::now(),
383            safety_identifier: None,
384            model: None,
385            conversation_id: None,
386            raw_response: Value::Null,
387        }
388    }
389}
390
391/// Response chain - a sequence of related responses
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct ResponseChain {
394    /// The responses in chronological order
395    pub responses: Vec<StoredResponse>,
396
397    /// Metadata about the chain
398    pub metadata: HashMap<String, Value>,
399}
400
401impl Default for ResponseChain {
402    fn default() -> Self {
403        Self::new()
404    }
405}
406
407impl ResponseChain {
408    pub fn new() -> Self {
409        Self {
410            responses: Vec::new(),
411            metadata: HashMap::new(),
412        }
413    }
414
415    /// Get the ID of the most recent response in the chain
416    pub fn latest_response_id(&self) -> Option<&ResponseId> {
417        self.responses.last().map(|r| &r.id)
418    }
419
420    /// Add a response to the chain
421    pub fn add_response(&mut self, response: StoredResponse) {
422        self.responses.push(response);
423    }
424
425    /// Build context from the chain for the next request
426    pub fn build_context(&self, max_responses: Option<usize>) -> Vec<(Value, Value)> {
427        let responses = if let Some(max) = max_responses {
428            let start = self.responses.len().saturating_sub(max);
429            &self.responses[start..]
430        } else {
431            &self.responses[..]
432        };
433
434        responses
435            .iter()
436            .map(|r| {
437                let output = r
438                    .raw_response
439                    .get("output")
440                    .cloned()
441                    .unwrap_or(Value::Array(vec![]));
442                (r.input.clone(), output)
443            })
444            .collect()
445    }
446}
447
448/// Error type for response storage operations
449#[derive(Debug, thiserror::Error)]
450pub enum ResponseStorageError {
451    #[error("Response not found: {0}")]
452    ResponseNotFound(String),
453
454    #[error("Invalid chain: {0}")]
455    InvalidChain(String),
456
457    #[error("Storage error: {0}")]
458    StorageError(String),
459
460    #[error("Serialization error: {0}")]
461    SerializationError(#[from] serde_json::Error),
462}
463
464pub type ResponseResult<T> = Result<T, ResponseStorageError>;
465
466/// Trait for response storage
467#[async_trait]
468pub trait ResponseStorage: Send + Sync {
469    /// Store a new response
470    async fn store_response(&self, response: StoredResponse) -> ResponseResult<ResponseId>;
471
472    /// Get a response by ID
473    async fn get_response(
474        &self,
475        response_id: &ResponseId,
476    ) -> ResponseResult<Option<StoredResponse>>;
477
478    /// Delete a response
479    async fn delete_response(&self, response_id: &ResponseId) -> ResponseResult<()>;
480
481    /// Get the chain of responses leading to a given response.
482    ///
483    /// Walks `previous_response_id` links from the given response backwards,
484    /// collecting up to `max_depth` responses (or unlimited if `None`).
485    /// Returns responses in chronological order (oldest first).
486    ///
487    /// The default implementation calls `self.get_response()` in a loop with
488    /// cycle detection to prevent infinite loops from self-referencing chains.
489    /// Backends that can walk the chain more efficiently (e.g. with a single
490    /// lock or a recursive SQL query) should override this.
491    async fn get_response_chain(
492        &self,
493        response_id: &ResponseId,
494        max_depth: Option<usize>,
495    ) -> ResponseResult<ResponseChain> {
496        let mut chain = ResponseChain::new();
497        let mut current_id = Some(response_id.clone());
498        let mut seen = HashSet::new();
499
500        while let Some(ref lookup_id) = current_id {
501            if let Some(limit) = max_depth {
502                if seen.len() >= limit {
503                    break;
504                }
505            }
506
507            // Cycle detection: error if we've already visited this ID.
508            if !seen.insert(lookup_id.clone()) {
509                return Err(ResponseStorageError::InvalidChain(format!(
510                    "cycle detected at response {}",
511                    lookup_id.0
512                )));
513            }
514
515            let fetched = self.get_response(lookup_id).await?;
516            match fetched {
517                Some(response) => {
518                    current_id.clone_from(&response.previous_response_id);
519                    chain.responses.push(response);
520                }
521                None => break,
522            }
523        }
524
525        chain.responses.reverse();
526        Ok(chain)
527    }
528
529    /// List recent responses for a safety identifier
530    async fn list_identifier_responses(
531        &self,
532        identifier: &str,
533        limit: Option<usize>,
534    ) -> ResponseResult<Vec<StoredResponse>>;
535
536    /// Delete all responses for a safety identifier
537    async fn delete_identifier_responses(&self, identifier: &str) -> ResponseResult<usize>;
538}
539
540impl Default for StoredResponse {
541    fn default() -> Self {
542        Self::new(None)
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use std::collections::HashSet;
549
550    use super::*;
551
552    // ========================================================================
553    // ConversationId tests
554    // ========================================================================
555
556    #[test]
557    fn conversation_id_new_has_conv_prefix() {
558        let id = ConversationId::new();
559        assert!(
560            id.0.starts_with("conv_"),
561            "ConversationId should start with 'conv_', got: {id}"
562        );
563    }
564
565    #[test]
566    fn conversation_id_new_generates_unique_ids() {
567        let ids: HashSet<String> = (0..100).map(|_| ConversationId::new().0).collect();
568        assert_eq!(ids.len(), 100, "all 100 ConversationIds should be unique");
569    }
570
571    #[test]
572    fn conversation_id_new_has_consistent_length() {
573        // "conv_" (5 chars) + 50 hex chars = 55 total
574        for _ in 0..10 {
575            let id = ConversationId::new();
576            assert_eq!(
577                id.0.len(),
578                55,
579                "ConversationId should be 55 chars (conv_ + 50 hex), got {} chars: {id}",
580                id.0.len()
581            );
582        }
583    }
584
585    #[test]
586    fn conversation_id_default_works_same_as_new() {
587        let id = ConversationId::default();
588        assert!(
589            id.0.starts_with("conv_"),
590            "Default ConversationId should start with 'conv_', got: {id}"
591        );
592        assert_eq!(id.0.len(), 55, "Default ConversationId should be 55 chars");
593    }
594
595    #[test]
596    fn conversation_id_from_string() {
597        let id = ConversationId::from("my_custom_id".to_string());
598        assert_eq!(id.0, "my_custom_id");
599    }
600
601    #[test]
602    fn conversation_id_from_str() {
603        let id = ConversationId::from("my_custom_id");
604        assert_eq!(id.0, "my_custom_id");
605    }
606
607    #[test]
608    fn conversation_id_display() {
609        let id = ConversationId::from("conv_abc123");
610        assert_eq!(format!("{id}"), "conv_abc123");
611    }
612
613    // ========================================================================
614    // ConversationItemId tests
615    // ========================================================================
616
617    #[test]
618    fn conversation_item_id_from_string() {
619        let id = ConversationItemId::from("item_123".to_string());
620        assert_eq!(id.0, "item_123");
621    }
622
623    #[test]
624    fn conversation_item_id_from_str() {
625        let id = ConversationItemId::from("item_456");
626        assert_eq!(id.0, "item_456");
627    }
628
629    #[test]
630    fn conversation_item_id_display() {
631        let id = ConversationItemId::from("msg_abc");
632        assert_eq!(format!("{id}"), "msg_abc");
633    }
634
635    // ========================================================================
636    // ResponseId tests
637    // ========================================================================
638
639    #[test]
640    fn response_id_new_generates_valid_ulid() {
641        let id = ResponseId::new();
642        // ULID strings are 26 characters, uppercase alphanumeric (Crockford Base32)
643        assert_eq!(
644            id.0.len(),
645            26,
646            "ULID string should be 26 chars, got {} chars: {}",
647            id.0.len(),
648            id.0
649        );
650        assert!(
651            id.0.chars().all(|c| c.is_ascii_alphanumeric()),
652            "ULID should contain only alphanumeric characters, got: {}",
653            id.0
654        );
655    }
656
657    #[test]
658    fn response_id_new_generates_unique_ids() {
659        let ids: HashSet<String> = (0..100).map(|_| ResponseId::new().0).collect();
660        assert_eq!(ids.len(), 100, "all 100 ResponseIds should be unique");
661    }
662
663    #[test]
664    fn response_id_default_works_same_as_new() {
665        let id = ResponseId::default();
666        assert_eq!(id.0.len(), 26, "Default ResponseId should be 26-char ULID");
667    }
668
669    #[test]
670    fn response_id_from_string() {
671        let id = ResponseId::from("resp_custom".to_string());
672        assert_eq!(id.0, "resp_custom");
673    }
674
675    #[test]
676    fn response_id_from_str() {
677        let id = ResponseId::from("resp_custom");
678        assert_eq!(id.0, "resp_custom");
679    }
680
681    // ========================================================================
682    // make_item_id() tests
683    // ========================================================================
684
685    #[test]
686    fn make_item_id_message_prefix() {
687        let id = make_item_id("message");
688        assert!(
689            id.0.starts_with("msg_"),
690            "message type should produce 'msg_' prefix, got: {id}"
691        );
692    }
693
694    #[test]
695    fn make_item_id_reasoning_prefix() {
696        let id = make_item_id("reasoning");
697        assert!(
698            id.0.starts_with("rs_"),
699            "reasoning type should produce 'rs_' prefix, got: {id}"
700        );
701    }
702
703    #[test]
704    fn make_item_id_mcp_call_prefix() {
705        let id = make_item_id("mcp_call");
706        assert!(
707            id.0.starts_with("mcp_"),
708            "mcp_call type should produce 'mcp_' prefix, got: {id}"
709        );
710    }
711
712    #[test]
713    fn make_item_id_mcp_list_tools_prefix() {
714        let id = make_item_id("mcp_list_tools");
715        assert!(
716            id.0.starts_with("mcpl_"),
717            "mcp_list_tools type should produce 'mcpl_' prefix, got: {id}"
718        );
719    }
720
721    #[test]
722    fn make_item_id_function_call_prefix() {
723        let id = make_item_id("function_call");
724        assert!(
725            id.0.starts_with("fc_"),
726            "function_call type should produce 'fc_' prefix, got: {id}"
727        );
728    }
729
730    #[test]
731    fn make_item_id_unknown_type_uses_first_3_chars() {
732        let id = make_item_id("custom_type");
733        assert!(
734            id.0.starts_with("cus_"),
735            "unknown type 'custom_type' should produce 'cus_' prefix, got: {id}"
736        );
737    }
738
739    #[test]
740    fn make_item_id_empty_type_uses_itm() {
741        let id = make_item_id("");
742        assert!(
743            id.0.starts_with("itm_"),
744            "empty type string should produce 'itm_' prefix, got: {id}"
745        );
746    }
747
748    #[test]
749    fn make_item_id_correct_length() {
750        // Each known prefix: prefix + "_" + 50 hex chars
751        let test_cases = vec![
752            ("message", "msg_"),
753            ("reasoning", "rs_"),
754            ("mcp_call", "mcp_"),
755            ("mcp_list_tools", "mcpl_"),
756            ("function_call", "fc_"),
757        ];
758
759        for (item_type, prefix) in test_cases {
760            let id = make_item_id(item_type);
761            let expected_len = prefix.len() + 50;
762            assert_eq!(
763                id.0.len(),
764                expected_len,
765                "make_item_id(\"{item_type}\") should be {expected_len} chars ('{prefix}' + 50 hex), got {} chars: {id}",
766                id.0.len()
767            );
768        }
769
770        // Unknown type: first 3 chars + "_" + 50 hex = 54 chars
771        let id = make_item_id("custom_type");
772        assert_eq!(
773            id.0.len(),
774            54,
775            "unknown type should be 54 chars (3 char prefix + '_' + 50 hex), got {} chars: {id}",
776            id.0.len()
777        );
778
779        // Empty type: "itm_" + 50 hex = 54 chars
780        let id = make_item_id("");
781        assert_eq!(
782            id.0.len(),
783            54,
784            "empty type should be 54 chars ('itm_' + 50 hex), got {} chars: {id}",
785            id.0.len()
786        );
787    }
788
789    // ========================================================================
790    // Conversation tests
791    // ========================================================================
792
793    #[test]
794    fn conversation_new_generates_id_if_none_provided() {
795        let conv = Conversation::new(NewConversation {
796            id: None,
797            metadata: None,
798        });
799        assert!(
800            conv.id.0.starts_with("conv_"),
801            "should generate a ConversationId when none provided, got: {}",
802            conv.id
803        );
804    }
805
806    #[test]
807    fn conversation_new_uses_provided_id() {
808        let custom_id = ConversationId::from("my_conv_id");
809        let conv = Conversation::new(NewConversation {
810            id: Some(custom_id.clone()),
811            metadata: None,
812        });
813        assert_eq!(conv.id, custom_id, "should use the provided ConversationId");
814    }
815
816    #[test]
817    fn conversation_with_parts_preserves_all_fields() {
818        let id = ConversationId::from("test_id");
819        let created_at = Utc::now();
820        let mut metadata = ConversationMetadata::new();
821        metadata.insert("key".to_string(), Value::String("value".to_string()));
822
823        let conv = Conversation::with_parts(id.clone(), created_at, Some(metadata.clone()));
824
825        assert_eq!(conv.id, id);
826        assert_eq!(conv.created_at, created_at);
827        assert_eq!(conv.metadata, Some(metadata));
828    }
829
830    // ========================================================================
831    // StoredResponse tests
832    // ========================================================================
833
834    #[test]
835    fn stored_response_new_none_has_no_previous() {
836        let resp = StoredResponse::new(None);
837        assert!(
838            resp.previous_response_id.is_none(),
839            "new(None) should have no previous_response_id"
840        );
841    }
842
843    #[test]
844    fn stored_response_new_some_has_correct_previous() {
845        let prev_id = ResponseId::from("prev_123");
846        let resp = StoredResponse::new(Some(prev_id.clone()));
847        assert_eq!(
848            resp.previous_response_id,
849            Some(prev_id),
850            "new(Some(id)) should set previous_response_id"
851        );
852    }
853
854    #[test]
855    fn stored_response_default_works() {
856        let resp = StoredResponse::default();
857        assert!(
858            resp.previous_response_id.is_none(),
859            "default() should have no previous_response_id"
860        );
861        assert_eq!(
862            resp.input,
863            Value::Array(vec![]),
864            "default input should be empty array"
865        );
866        assert_eq!(
867            resp.raw_response,
868            Value::Null,
869            "default raw_response should be Null"
870        );
871    }
872
873    // ========================================================================
874    // ResponseChain tests
875    // ========================================================================
876
877    #[test]
878    fn response_chain_new_creates_empty_chain() {
879        let chain = ResponseChain::new();
880        assert!(
881            chain.responses.is_empty(),
882            "new chain should have no responses"
883        );
884        assert!(
885            chain.metadata.is_empty(),
886            "new chain should have no metadata"
887        );
888    }
889
890    #[test]
891    fn response_chain_add_response_appends() {
892        let mut chain = ResponseChain::new();
893        let r1 = StoredResponse::new(None);
894        let r2 = StoredResponse::new(None);
895        let r1_id = r1.id.clone();
896        let r2_id = r2.id.clone();
897
898        chain.add_response(r1);
899        assert_eq!(chain.responses.len(), 1, "chain should have 1 response");
900
901        chain.add_response(r2);
902        assert_eq!(chain.responses.len(), 2, "chain should have 2 responses");
903        assert_eq!(chain.responses[0].id, r1_id, "first response should be r1");
904        assert_eq!(chain.responses[1].id, r2_id, "second response should be r2");
905    }
906
907    #[test]
908    fn response_chain_latest_response_id_returns_last() {
909        let mut chain = ResponseChain::new();
910        let r1 = StoredResponse::new(None);
911        let r2 = StoredResponse::new(None);
912        let r2_id = r2.id.clone();
913
914        chain.add_response(r1);
915        chain.add_response(r2);
916
917        assert_eq!(
918            chain.latest_response_id(),
919            Some(&r2_id),
920            "latest_response_id should return the last response's ID"
921        );
922    }
923
924    #[test]
925    fn response_chain_latest_response_id_returns_none_for_empty() {
926        let chain = ResponseChain::new();
927        assert_eq!(
928            chain.latest_response_id(),
929            None,
930            "latest_response_id should return None for empty chain"
931        );
932    }
933
934    #[test]
935    fn response_chain_build_context_returns_input_output_pairs() {
936        use serde_json::json;
937
938        let mut chain = ResponseChain::new();
939
940        let mut r1 = StoredResponse::new(None);
941        r1.input = Value::String("input1".to_string());
942        r1.raw_response = json!({"output": "output1"});
943
944        let mut r2 = StoredResponse::new(None);
945        r2.input = Value::String("input2".to_string());
946        r2.raw_response = json!({"output": "output2"});
947
948        chain.add_response(r1);
949        chain.add_response(r2);
950
951        let context = chain.build_context(None);
952        assert_eq!(context.len(), 2, "should return 2 pairs");
953        assert_eq!(context[0].0, Value::String("input1".to_string()));
954        assert_eq!(context[0].1, Value::String("output1".to_string()));
955        assert_eq!(context[1].0, Value::String("input2".to_string()));
956        assert_eq!(context[1].1, Value::String("output2".to_string()));
957    }
958
959    #[test]
960    fn response_chain_build_context_with_max_responses_limits_output() {
961        use serde_json::json;
962
963        let mut chain = ResponseChain::new();
964
965        for i in 0..5 {
966            let mut resp = StoredResponse::new(None);
967            resp.input = Value::String(format!("input{i}"));
968            resp.raw_response = json!({"output": format!("output{i}")});
969            chain.add_response(resp);
970        }
971
972        let context = chain.build_context(Some(2));
973        assert_eq!(context.len(), 2, "should return only 2 most recent pairs");
974        // Should be the last 2 responses (index 3 and 4)
975        assert_eq!(context[0].0, Value::String("input3".to_string()));
976        assert_eq!(context[0].1, Value::String("output3".to_string()));
977        assert_eq!(context[1].0, Value::String("input4".to_string()));
978        assert_eq!(context[1].1, Value::String("output4".to_string()));
979    }
980}