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 Default for ResponseId {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328impl From<String> for ResponseId {
329    fn from(value: String) -> Self {
330        Self(value)
331    }
332}
333
334impl From<&str> for ResponseId {
335    fn from(value: &str) -> Self {
336        Self(value.to_string())
337    }
338}
339
340/// Stored response data
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct StoredResponse {
343    /// Unique response ID
344    pub id: ResponseId,
345
346    /// ID of the previous response in the chain (if any)
347    pub previous_response_id: Option<ResponseId>,
348
349    /// Input items as JSON array
350    pub input: Value,
351
352    /// When this response was created
353    pub created_at: DateTime<Utc>,
354
355    /// Safety identifier for content moderation
356    pub safety_identifier: Option<String>,
357
358    /// Model used for generation
359    pub model: Option<String>,
360
361    /// Conversation id if associated with a conversation
362    #[serde(default)]
363    pub conversation_id: Option<String>,
364
365    /// Raw OpenAI response payload
366    #[serde(default)]
367    pub raw_response: Value,
368}
369
370impl StoredResponse {
371    pub fn new(previous_response_id: Option<ResponseId>) -> Self {
372        Self {
373            id: ResponseId::new(),
374            previous_response_id,
375            input: Value::Array(vec![]),
376            created_at: Utc::now(),
377            safety_identifier: None,
378            model: None,
379            conversation_id: None,
380            raw_response: Value::Null,
381        }
382    }
383}
384
385/// Response chain - a sequence of related responses
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ResponseChain {
388    /// The responses in chronological order
389    pub responses: Vec<StoredResponse>,
390
391    /// Metadata about the chain
392    pub metadata: HashMap<String, Value>,
393}
394
395impl Default for ResponseChain {
396    fn default() -> Self {
397        Self::new()
398    }
399}
400
401impl ResponseChain {
402    pub fn new() -> Self {
403        Self {
404            responses: Vec::new(),
405            metadata: HashMap::new(),
406        }
407    }
408
409    /// Get the ID of the most recent response in the chain
410    pub fn latest_response_id(&self) -> Option<&ResponseId> {
411        self.responses.last().map(|r| &r.id)
412    }
413
414    /// Add a response to the chain
415    pub fn add_response(&mut self, response: StoredResponse) {
416        self.responses.push(response);
417    }
418
419    /// Build context from the chain for the next request
420    pub fn build_context(&self, max_responses: Option<usize>) -> Vec<(Value, Value)> {
421        let responses = if let Some(max) = max_responses {
422            let start = self.responses.len().saturating_sub(max);
423            &self.responses[start..]
424        } else {
425            &self.responses[..]
426        };
427
428        responses
429            .iter()
430            .map(|r| {
431                let output = r
432                    .raw_response
433                    .get("output")
434                    .cloned()
435                    .unwrap_or(Value::Array(vec![]));
436                (r.input.clone(), output)
437            })
438            .collect()
439    }
440}
441
442/// Error type for response storage operations
443#[derive(Debug, thiserror::Error)]
444pub enum ResponseStorageError {
445    #[error("Response not found: {0}")]
446    ResponseNotFound(String),
447
448    #[error("Invalid chain: {0}")]
449    InvalidChain(String),
450
451    #[error("Storage error: {0}")]
452    StorageError(String),
453
454    #[error("Serialization error: {0}")]
455    SerializationError(#[from] serde_json::Error),
456}
457
458pub type ResponseResult<T> = Result<T, ResponseStorageError>;
459
460/// Trait for response storage
461#[async_trait]
462pub trait ResponseStorage: Send + Sync {
463    /// Store a new response
464    async fn store_response(&self, response: StoredResponse) -> ResponseResult<ResponseId>;
465
466    /// Get a response by ID
467    async fn get_response(
468        &self,
469        response_id: &ResponseId,
470    ) -> ResponseResult<Option<StoredResponse>>;
471
472    /// Delete a response
473    async fn delete_response(&self, response_id: &ResponseId) -> ResponseResult<()>;
474
475    /// Get the chain of responses leading to a given response.
476    ///
477    /// Walks `previous_response_id` links from the given response backwards,
478    /// collecting up to `max_depth` responses (or unlimited if `None`).
479    /// Returns responses in chronological order (oldest first).
480    ///
481    /// The default implementation calls `self.get_response()` in a loop with
482    /// cycle detection to prevent infinite loops from self-referencing chains.
483    /// Backends that can walk the chain more efficiently (e.g. with a single
484    /// lock or a recursive SQL query) should override this.
485    async fn get_response_chain(
486        &self,
487        response_id: &ResponseId,
488        max_depth: Option<usize>,
489    ) -> ResponseResult<ResponseChain> {
490        let mut chain = ResponseChain::new();
491        let mut current_id = Some(response_id.clone());
492        let mut seen = HashSet::new();
493
494        while let Some(ref lookup_id) = current_id {
495            if let Some(limit) = max_depth {
496                if seen.len() >= limit {
497                    break;
498                }
499            }
500
501            // Cycle detection: error if we've already visited this ID.
502            if !seen.insert(lookup_id.clone()) {
503                return Err(ResponseStorageError::InvalidChain(format!(
504                    "cycle detected at response {}",
505                    lookup_id.0
506                )));
507            }
508
509            let fetched = self.get_response(lookup_id).await?;
510            match fetched {
511                Some(response) => {
512                    current_id.clone_from(&response.previous_response_id);
513                    chain.responses.push(response);
514                }
515                None => break,
516            }
517        }
518
519        chain.responses.reverse();
520        Ok(chain)
521    }
522
523    /// List recent responses for a safety identifier
524    async fn list_identifier_responses(
525        &self,
526        identifier: &str,
527        limit: Option<usize>,
528    ) -> ResponseResult<Vec<StoredResponse>>;
529
530    /// Delete all responses for a safety identifier
531    async fn delete_identifier_responses(&self, identifier: &str) -> ResponseResult<usize>;
532}
533
534// ============================================================================
535// PART 4: ConversationMemory Insert Seam
536// ============================================================================
537
538#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, PartialOrd, Ord)]
539pub struct ConversationMemoryId(pub String);
540
541impl From<String> for ConversationMemoryId {
542    fn from(value: String) -> Self {
543        Self(value)
544    }
545}
546
547impl From<&str> for ConversationMemoryId {
548    fn from(value: &str) -> Self {
549        Self(value.to_string())
550    }
551}
552
553impl Display for ConversationMemoryId {
554    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
555        f.write_str(&self.0)
556    }
557}
558
559#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560pub enum ConversationMemoryType {
561    #[serde(rename = "ONDEMAND")]
562    OnDemand,
563    #[serde(rename = "LTM")]
564    Ltm,
565    #[serde(rename = "STMO")]
566    Stmo,
567}
568
569#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
570pub enum ConversationMemoryStatus {
571    #[serde(rename = "READY")]
572    Ready,
573    #[serde(rename = "RUNNING")]
574    Running,
575    #[serde(rename = "SUCCESS")]
576    Success,
577    #[serde(rename = "FAILED")]
578    Failed,
579}
580
581/// Insert-only payload for creating a new conversation memory row.
582///
583/// `NewConversationMemory` intentionally omits database-managed fields such as
584/// `memory_id`, `created_at`, and `updated_at`. Callers should not set those
585/// values directly; they are created or maintained by the database/write path.
586/// When changing an existing record, use the appropriate update path rather
587/// than reusing this struct.
588#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
589pub struct NewConversationMemory {
590    pub conversation_id: ConversationId,
591    pub conversation_version: Option<i64>,
592    pub response_id: Option<ResponseId>,
593    pub memory_type: ConversationMemoryType,
594    pub status: ConversationMemoryStatus,
595    pub attempt: i64,
596    pub owner_id: Option<String>,
597    pub next_run_at: DateTime<Utc>,
598    pub lease_until: Option<DateTime<Utc>>,
599    pub content: Option<String>,
600    pub memory_config: Option<String>,
601    pub scope_id: Option<String>,
602    pub error_msg: Option<String>,
603}
604
605pub type ConversationMemoryResult<T> = Result<T, ConversationMemoryStorageError>;
606
607#[derive(Debug, thiserror::Error)]
608pub enum ConversationMemoryStorageError {
609    #[error("Storage error: {0}")]
610    StorageError(String),
611
612    #[error("Serialization error: {0}")]
613    SerializationError(#[from] serde_json::Error),
614}
615
616#[async_trait]
617pub trait ConversationMemoryWriter: Send + Sync + 'static {
618    async fn create_memory(
619        &self,
620        input: NewConversationMemory,
621    ) -> ConversationMemoryResult<ConversationMemoryId>;
622}
623
624impl Default for StoredResponse {
625    fn default() -> Self {
626        Self::new(None)
627    }
628}
629
630#[cfg(test)]
631mod tests {
632    use std::collections::HashSet;
633
634    use super::*;
635
636    // ========================================================================
637    // ConversationId tests
638    // ========================================================================
639
640    #[test]
641    fn conversation_id_new_has_conv_prefix() {
642        let id = ConversationId::new();
643        assert!(
644            id.0.starts_with("conv_"),
645            "ConversationId should start with 'conv_', got: {id}"
646        );
647    }
648
649    #[test]
650    fn conversation_id_new_generates_unique_ids() {
651        let ids: HashSet<String> = (0..100).map(|_| ConversationId::new().0).collect();
652        assert_eq!(ids.len(), 100, "all 100 ConversationIds should be unique");
653    }
654
655    #[test]
656    fn conversation_id_new_has_consistent_length() {
657        // "conv_" (5 chars) + 50 hex chars = 55 total
658        for _ in 0..10 {
659            let id = ConversationId::new();
660            assert_eq!(
661                id.0.len(),
662                55,
663                "ConversationId should be 55 chars (conv_ + 50 hex), got {} chars: {id}",
664                id.0.len()
665            );
666        }
667    }
668
669    #[test]
670    fn conversation_id_default_works_same_as_new() {
671        let id = ConversationId::default();
672        assert!(
673            id.0.starts_with("conv_"),
674            "Default ConversationId should start with 'conv_', got: {id}"
675        );
676        assert_eq!(id.0.len(), 55, "Default ConversationId should be 55 chars");
677    }
678
679    #[test]
680    fn conversation_id_from_string() {
681        let id = ConversationId::from("my_custom_id".to_string());
682        assert_eq!(id.0, "my_custom_id");
683    }
684
685    #[test]
686    fn conversation_id_from_str() {
687        let id = ConversationId::from("my_custom_id");
688        assert_eq!(id.0, "my_custom_id");
689    }
690
691    #[test]
692    fn conversation_id_display() {
693        let id = ConversationId::from("conv_abc123");
694        assert_eq!(format!("{id}"), "conv_abc123");
695    }
696
697    // ========================================================================
698    // ConversationItemId tests
699    // ========================================================================
700
701    #[test]
702    fn conversation_item_id_from_string() {
703        let id = ConversationItemId::from("item_123".to_string());
704        assert_eq!(id.0, "item_123");
705    }
706
707    #[test]
708    fn conversation_item_id_from_str() {
709        let id = ConversationItemId::from("item_456");
710        assert_eq!(id.0, "item_456");
711    }
712
713    #[test]
714    fn conversation_item_id_display() {
715        let id = ConversationItemId::from("msg_abc");
716        assert_eq!(format!("{id}"), "msg_abc");
717    }
718
719    // ========================================================================
720    // ResponseId tests
721    // ========================================================================
722
723    #[test]
724    fn response_id_new_generates_valid_ulid() {
725        let id = ResponseId::new();
726        // ULID strings are 26 characters, uppercase alphanumeric (Crockford Base32)
727        assert_eq!(
728            id.0.len(),
729            26,
730            "ULID string should be 26 chars, got {} chars: {}",
731            id.0.len(),
732            id.0
733        );
734        assert!(
735            id.0.chars().all(|c| c.is_ascii_alphanumeric()),
736            "ULID should contain only alphanumeric characters, got: {}",
737            id.0
738        );
739    }
740
741    #[test]
742    fn response_id_new_generates_unique_ids() {
743        let ids: HashSet<String> = (0..100).map(|_| ResponseId::new().0).collect();
744        assert_eq!(ids.len(), 100, "all 100 ResponseIds should be unique");
745    }
746
747    #[test]
748    fn response_id_default_works_same_as_new() {
749        let id = ResponseId::default();
750        assert_eq!(id.0.len(), 26, "Default ResponseId should be 26-char ULID");
751    }
752
753    #[test]
754    fn response_id_from_string() {
755        let id = ResponseId::from("resp_custom".to_string());
756        assert_eq!(id.0, "resp_custom");
757    }
758
759    #[test]
760    fn response_id_from_str() {
761        let id = ResponseId::from("resp_custom");
762        assert_eq!(id.0, "resp_custom");
763    }
764
765    // ========================================================================
766    // make_item_id() tests
767    // ========================================================================
768
769    #[test]
770    fn make_item_id_message_prefix() {
771        let id = make_item_id("message");
772        assert!(
773            id.0.starts_with("msg_"),
774            "message type should produce 'msg_' prefix, got: {id}"
775        );
776    }
777
778    #[test]
779    fn make_item_id_reasoning_prefix() {
780        let id = make_item_id("reasoning");
781        assert!(
782            id.0.starts_with("rs_"),
783            "reasoning type should produce 'rs_' prefix, got: {id}"
784        );
785    }
786
787    #[test]
788    fn make_item_id_mcp_call_prefix() {
789        let id = make_item_id("mcp_call");
790        assert!(
791            id.0.starts_with("mcp_"),
792            "mcp_call type should produce 'mcp_' prefix, got: {id}"
793        );
794    }
795
796    #[test]
797    fn make_item_id_mcp_list_tools_prefix() {
798        let id = make_item_id("mcp_list_tools");
799        assert!(
800            id.0.starts_with("mcpl_"),
801            "mcp_list_tools type should produce 'mcpl_' prefix, got: {id}"
802        );
803    }
804
805    #[test]
806    fn make_item_id_function_call_prefix() {
807        let id = make_item_id("function_call");
808        assert!(
809            id.0.starts_with("fc_"),
810            "function_call type should produce 'fc_' prefix, got: {id}"
811        );
812    }
813
814    #[test]
815    fn make_item_id_unknown_type_uses_first_3_chars() {
816        let id = make_item_id("custom_type");
817        assert!(
818            id.0.starts_with("cus_"),
819            "unknown type 'custom_type' should produce 'cus_' prefix, got: {id}"
820        );
821    }
822
823    #[test]
824    fn make_item_id_empty_type_uses_itm() {
825        let id = make_item_id("");
826        assert!(
827            id.0.starts_with("itm_"),
828            "empty type string should produce 'itm_' prefix, got: {id}"
829        );
830    }
831
832    #[test]
833    fn make_item_id_correct_length() {
834        // Each known prefix: prefix + "_" + 50 hex chars
835        let test_cases = vec![
836            ("message", "msg_"),
837            ("reasoning", "rs_"),
838            ("mcp_call", "mcp_"),
839            ("mcp_list_tools", "mcpl_"),
840            ("function_call", "fc_"),
841        ];
842
843        for (item_type, prefix) in test_cases {
844            let id = make_item_id(item_type);
845            let expected_len = prefix.len() + 50;
846            assert_eq!(
847                id.0.len(),
848                expected_len,
849                "make_item_id(\"{item_type}\") should be {expected_len} chars ('{prefix}' + 50 hex), got {} chars: {id}",
850                id.0.len()
851            );
852        }
853
854        // Unknown type: first 3 chars + "_" + 50 hex = 54 chars
855        let id = make_item_id("custom_type");
856        assert_eq!(
857            id.0.len(),
858            54,
859            "unknown type should be 54 chars (3 char prefix + '_' + 50 hex), got {} chars: {id}",
860            id.0.len()
861        );
862
863        // Empty type: "itm_" + 50 hex = 54 chars
864        let id = make_item_id("");
865        assert_eq!(
866            id.0.len(),
867            54,
868            "empty type should be 54 chars ('itm_' + 50 hex), got {} chars: {id}",
869            id.0.len()
870        );
871    }
872
873    // ========================================================================
874    // Conversation tests
875    // ========================================================================
876
877    #[test]
878    fn conversation_new_generates_id_if_none_provided() {
879        let conv = Conversation::new(NewConversation {
880            id: None,
881            metadata: None,
882        });
883        assert!(
884            conv.id.0.starts_with("conv_"),
885            "should generate a ConversationId when none provided, got: {}",
886            conv.id
887        );
888    }
889
890    #[test]
891    fn conversation_new_uses_provided_id() {
892        let custom_id = ConversationId::from("my_conv_id");
893        let conv = Conversation::new(NewConversation {
894            id: Some(custom_id.clone()),
895            metadata: None,
896        });
897        assert_eq!(conv.id, custom_id, "should use the provided ConversationId");
898    }
899
900    #[test]
901    fn conversation_with_parts_preserves_all_fields() {
902        let id = ConversationId::from("test_id");
903        let created_at = Utc::now();
904        let mut metadata = ConversationMetadata::new();
905        metadata.insert("key".to_string(), Value::String("value".to_string()));
906
907        let conv = Conversation::with_parts(id.clone(), created_at, Some(metadata.clone()));
908
909        assert_eq!(conv.id, id);
910        assert_eq!(conv.created_at, created_at);
911        assert_eq!(conv.metadata, Some(metadata));
912    }
913
914    // ========================================================================
915    // StoredResponse tests
916    // ========================================================================
917
918    #[test]
919    fn stored_response_new_none_has_no_previous() {
920        let resp = StoredResponse::new(None);
921        assert!(
922            resp.previous_response_id.is_none(),
923            "new(None) should have no previous_response_id"
924        );
925    }
926
927    #[test]
928    fn stored_response_new_some_has_correct_previous() {
929        let prev_id = ResponseId::from("prev_123");
930        let resp = StoredResponse::new(Some(prev_id.clone()));
931        assert_eq!(
932            resp.previous_response_id,
933            Some(prev_id),
934            "new(Some(id)) should set previous_response_id"
935        );
936    }
937
938    #[test]
939    fn stored_response_default_works() {
940        let resp = StoredResponse::default();
941        assert!(
942            resp.previous_response_id.is_none(),
943            "default() should have no previous_response_id"
944        );
945        assert_eq!(
946            resp.input,
947            Value::Array(vec![]),
948            "default input should be empty array"
949        );
950        assert_eq!(
951            resp.raw_response,
952            Value::Null,
953            "default raw_response should be Null"
954        );
955    }
956
957    // ========================================================================
958    // ResponseChain tests
959    // ========================================================================
960
961    #[test]
962    fn response_chain_new_creates_empty_chain() {
963        let chain = ResponseChain::new();
964        assert!(
965            chain.responses.is_empty(),
966            "new chain should have no responses"
967        );
968        assert!(
969            chain.metadata.is_empty(),
970            "new chain should have no metadata"
971        );
972    }
973
974    #[test]
975    fn response_chain_add_response_appends() {
976        let mut chain = ResponseChain::new();
977        let r1 = StoredResponse::new(None);
978        let r2 = StoredResponse::new(None);
979        let r1_id = r1.id.clone();
980        let r2_id = r2.id.clone();
981
982        chain.add_response(r1);
983        assert_eq!(chain.responses.len(), 1, "chain should have 1 response");
984
985        chain.add_response(r2);
986        assert_eq!(chain.responses.len(), 2, "chain should have 2 responses");
987        assert_eq!(chain.responses[0].id, r1_id, "first response should be r1");
988        assert_eq!(chain.responses[1].id, r2_id, "second response should be r2");
989    }
990
991    #[test]
992    fn response_chain_latest_response_id_returns_last() {
993        let mut chain = ResponseChain::new();
994        let r1 = StoredResponse::new(None);
995        let r2 = StoredResponse::new(None);
996        let r2_id = r2.id.clone();
997
998        chain.add_response(r1);
999        chain.add_response(r2);
1000
1001        assert_eq!(
1002            chain.latest_response_id(),
1003            Some(&r2_id),
1004            "latest_response_id should return the last response's ID"
1005        );
1006    }
1007
1008    #[test]
1009    fn response_chain_latest_response_id_returns_none_for_empty() {
1010        let chain = ResponseChain::new();
1011        assert_eq!(
1012            chain.latest_response_id(),
1013            None,
1014            "latest_response_id should return None for empty chain"
1015        );
1016    }
1017
1018    #[test]
1019    fn response_chain_build_context_returns_input_output_pairs() {
1020        use serde_json::json;
1021
1022        let mut chain = ResponseChain::new();
1023
1024        let mut r1 = StoredResponse::new(None);
1025        r1.input = Value::String("input1".to_string());
1026        r1.raw_response = json!({"output": "output1"});
1027
1028        let mut r2 = StoredResponse::new(None);
1029        r2.input = Value::String("input2".to_string());
1030        r2.raw_response = json!({"output": "output2"});
1031
1032        chain.add_response(r1);
1033        chain.add_response(r2);
1034
1035        let context = chain.build_context(None);
1036        assert_eq!(context.len(), 2, "should return 2 pairs");
1037        assert_eq!(context[0].0, Value::String("input1".to_string()));
1038        assert_eq!(context[0].1, Value::String("output1".to_string()));
1039        assert_eq!(context[1].0, Value::String("input2".to_string()));
1040        assert_eq!(context[1].1, Value::String("output2".to_string()));
1041    }
1042
1043    #[test]
1044    fn response_chain_build_context_with_max_responses_limits_output() {
1045        use serde_json::json;
1046
1047        let mut chain = ResponseChain::new();
1048
1049        for i in 0..5 {
1050            let mut resp = StoredResponse::new(None);
1051            resp.input = Value::String(format!("input{i}"));
1052            resp.raw_response = json!({"output": format!("output{i}")});
1053            chain.add_response(resp);
1054        }
1055
1056        let context = chain.build_context(Some(2));
1057        assert_eq!(context.len(), 2, "should return only 2 most recent pairs");
1058        // Should be the last 2 responses (index 3 and 4)
1059        assert_eq!(context[0].0, Value::String("input3".to_string()));
1060        assert_eq!(context[0].1, Value::String("output3".to_string()));
1061        assert_eq!(context[1].0, Value::String("input4".to_string()));
1062        assert_eq!(context[1].1, Value::String("output4".to_string()));
1063    }
1064
1065    // ========================================================================
1066    // ConversationMemory seam tests
1067    // ========================================================================
1068
1069    #[test]
1070    fn conversation_memory_status_serializes_to_expected_uppercase_values() {
1071        assert_eq!(
1072            serde_json::to_string(&ConversationMemoryStatus::Ready).unwrap(),
1073            "\"READY\""
1074        );
1075        assert_eq!(
1076            serde_json::to_string(&ConversationMemoryStatus::Running).unwrap(),
1077            "\"RUNNING\""
1078        );
1079        assert_eq!(
1080            serde_json::to_string(&ConversationMemoryStatus::Success).unwrap(),
1081            "\"SUCCESS\""
1082        );
1083        assert_eq!(
1084            serde_json::to_string(&ConversationMemoryStatus::Failed).unwrap(),
1085            "\"FAILED\""
1086        );
1087    }
1088
1089    #[test]
1090    fn conversation_memory_type_serializes_to_expected_uppercase_values() {
1091        assert_eq!(
1092            serde_json::to_string(&ConversationMemoryType::OnDemand).unwrap(),
1093            "\"ONDEMAND\""
1094        );
1095        assert_eq!(
1096            serde_json::to_string(&ConversationMemoryType::Ltm).unwrap(),
1097            "\"LTM\""
1098        );
1099        assert_eq!(
1100            serde_json::to_string(&ConversationMemoryType::Stmo).unwrap(),
1101            "\"STMO\""
1102        );
1103    }
1104
1105    #[test]
1106    fn new_conversation_memory_keeps_insert_only_fields() {
1107        let input = NewConversationMemory {
1108            conversation_id: ConversationId::from("conv_123"),
1109            conversation_version: Some(7),
1110            response_id: Some(ResponseId::from("resp_123")),
1111            memory_type: ConversationMemoryType::Ltm,
1112            status: ConversationMemoryStatus::Ready,
1113            attempt: 0,
1114            owner_id: None,
1115            next_run_at: Utc::now(),
1116            lease_until: None,
1117            content: None,
1118            memory_config: None,
1119            scope_id: None,
1120            error_msg: None,
1121        };
1122
1123        assert_eq!(input.attempt, 0);
1124        assert_eq!(input.conversation_id, ConversationId::from("conv_123"));
1125        assert_eq!(input.response_id, Some(ResponseId::from("resp_123")));
1126        assert!(input.lease_until.is_none());
1127    }
1128}