Skip to main content

smooth_operator/
domain.rs

1//! Domain model for smooth-operator.
2//!
3//! These structs mirror `spec/domain/*.json` exactly (field names, optionality)
4//! and are storage-agnostic — no backend is named here. They are the shapes the
5//! `StorageAdapter` (see [`crate::adapter`]) reads and writes.
6//!
7//! Checkpoints are *not* redefined here: we re-use smooth-operator's
8//! [`Checkpoint`](smooth_operator_core::Checkpoint) directly so the engine plugs
9//! straight into the checkpoint slice. See [`crate::adapter`].
10
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15
16// Re-export the engine's Checkpoint so callers get the domain "Checkpoint"
17// from one place. spec/domain/checkpoint.schema.json documents this struct
18// as "the `Checkpoint` struct in the smooth-operator Rust crate".
19pub use smooth_operator_core::Checkpoint;
20
21/// The channel on which a conversation takes place.
22/// Mirrors `conversation.schema.json#/properties/platform`.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum Platform {
26    Web,
27    Messenger,
28    Instagram,
29    Email,
30    Discord,
31    Phone,
32    Sms,
33    Slack,
34    Whatsapp,
35    Tiktok,
36}
37
38/// A conversation thread between participants.
39/// Mirrors `conversation.schema.json`.
40#[derive(Debug, Clone, Serialize, Deserialize)]
41#[serde(rename_all = "camelCase")]
42pub struct Conversation {
43    pub id: String,
44    pub platform: Platform,
45    pub name: String,
46    pub organization_id: String,
47    pub idempotency_key: String,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub metadata_json: Option<Value>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub analytics_json: Option<Value>,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54}
55
56/// Participant role discriminator.
57/// Mirrors `participant.schema.json#/properties/type` (`user` | `ai-agent` | `human-agent`).
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum ParticipantType {
61    User,
62    AiAgent,
63    HumanAgent,
64}
65
66/// A participant in a conversation.
67/// Mirrors `participant.schema.json`.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(rename_all = "camelCase")]
70pub struct Participant {
71    pub id: String,
72    pub conversation_id: String,
73    pub organization_id: String,
74    #[serde(rename = "type")]
75    pub participant_type: ParticipantType,
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub external_id: Option<String>,
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub internal_id: Option<String>,
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub browser_fingerprint: Option<String>,
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub browser_info: Option<Value>,
84    pub name: String,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub email: Option<String>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub phone: Option<String>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub crm_contact_id: Option<String>,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub metadata_json: Option<Value>,
93    pub created_at: DateTime<Utc>,
94    pub updated_at: DateTime<Utc>,
95}
96
97/// Message direction relative to the platform.
98/// Mirrors `message.schema.json#/properties/direction`.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum Direction {
102    Inbound,
103    Outbound,
104}
105
106/// A single content element within a message.
107/// Mirrors `message.schema.json#/$defs/ContentItem`.
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct ContentItem {
111    /// Content item type discriminator. Currently only `"text"` is defined.
112    #[serde(rename = "type")]
113    pub item_type: String,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub text: Option<String>,
116}
117
118impl ContentItem {
119    /// Build a `text` content item.
120    pub fn text(text: impl Into<String>) -> Self {
121        Self {
122            item_type: "text".to_string(),
123            text: Some(text.into()),
124        }
125    }
126}
127
128/// Structured content of a message.
129/// Mirrors `message.schema.json#/$defs/MessageContent`.
130#[derive(Debug, Clone, Default, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct MessageContent {
133    #[serde(default)]
134    pub items: Vec<ContentItem>,
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub text: Option<String>,
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub structured_response: Option<Value>,
139}
140
141impl MessageContent {
142    /// Convenience: a single text item plus the flat-text mirror.
143    pub fn from_text(text: impl Into<String>) -> Self {
144        let text = text.into();
145        Self {
146            items: vec![ContentItem::text(text.clone())],
147            text: Some(text),
148            structured_response: None,
149        }
150    }
151}
152
153/// Abbreviated sender/recipient descriptor (wire shape).
154/// Mirrors `message.schema.json#/properties/from` (and `to`).
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct ParticipantRef {
158    pub id: String,
159    #[serde(rename = "type")]
160    pub participant_type: String,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub name: Option<String>,
163}
164
165/// A single message within a conversation.
166/// Mirrors `message.schema.json`.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168#[serde(rename_all = "camelCase")]
169pub struct Message {
170    pub id: String,
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub external_id: Option<String>,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub organization_id: Option<String>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub conversation_id: Option<String>,
177    pub direction: Direction,
178    pub content: MessageContent,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub from: Option<ParticipantRef>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub to: Option<ParticipantRef>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub metadata_json: Option<Value>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub analytics_json: Option<Value>,
187    pub created_at: DateTime<Utc>,
188    #[serde(default, skip_serializing_if = "Option::is_none")]
189    pub updated_at: Option<DateTime<Utc>>,
190}
191
192/// Lifecycle status of a session.
193/// Mirrors `session.schema.json#/properties/status`.
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum SessionStatus {
197    Active,
198    Idle,
199    Ended,
200}
201
202/// An AI conversation session — ties a conversation to a smooth-operator
203/// workflow thread via `thread_id`.
204/// Mirrors `session.schema.json`.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct Session {
208    pub session_id: String,
209    pub conversation_id: String,
210    pub agent_id: String,
211    pub agent_name: String,
212    pub user_participant_id: String,
213    pub agent_participant_id: String,
214    /// smooth-operator workflow thread identifier (the historical
215    /// `langgraph_thread_id`). Resumes agent state across turns.
216    pub thread_id: String,
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub status: Option<SessionStatus>,
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub token_count: Option<u64>,
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub message_count: Option<u64>,
223    #[serde(default, skip_serializing_if = "Option::is_none")]
224    pub metadata: Option<HashMap<String, Value>>,
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub created_at: Option<DateTime<Utc>>,
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub updated_at: Option<DateTime<Utc>>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub ended_at: Option<DateTime<Utc>>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub last_activity_at: Option<DateTime<Utc>>,
233}
234
235/// A source the agent used to ground its answer.
236///
237/// Mirrors `spec/domain/citation.schema.json` and the optional `citations`
238/// array on the terminal `eventual_response` event. Each citation points back at
239/// one retrieved knowledge-base document — the chunk the model read plus enough
240/// metadata to render an attribution link.
241///
242/// Citations are built from the
243/// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult)s that actually
244/// grounded a turn (see [`Citation::from_knowledge_result`] /
245/// [`From<KnowledgeResult>`]): `id` ← `document_id`, `title` ← `source`,
246/// `url` ← `source` when it is an `http(s)` URL (the GitHub blob/issue URL the
247/// connector stamps onto the document's `source` at ingest — see
248/// `docs/CONNECTORS.md`) else `None`, `snippet` ← the chunk truncated for
249/// display, `score` ← `score`.
250#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251pub struct Citation {
252    /// Stable identifier of the cited source document (the knowledge-base
253    /// `document_id`). Used to deduplicate citations within a turn.
254    pub id: String,
255    /// Human-readable label for the source — the document's source path or, for
256    /// web-sourced docs, the URL/title.
257    pub title: String,
258    /// Canonical link to the source, when one exists. For GitHub-sourced
259    /// documents this is the blob/issue URL stamped onto the document's `source`
260    /// at ingest. `None` for sources with no web location (e.g. uploaded files).
261    #[serde(default, skip_serializing_if = "Option::is_none")]
262    pub url: Option<String>,
263    /// The retrieved chunk text that grounded the answer, truncated for display.
264    pub snippet: String,
265    /// Relevance score of this source for the turn's query (the knowledge-base
266    /// similarity score). Higher is more relevant.
267    pub score: f32,
268}
269
270/// Max characters of a chunk to carry as a citation `snippet`. Bounds the size
271/// of the `eventual_response` payload; the full chunk lives in the KB.
272pub const CITATION_SNIPPET_MAX_CHARS: usize = 280;
273
274impl Citation {
275    /// Build a [`Citation`] from a knowledge-base
276    /// [`KnowledgeResult`](smooth_operator_core::KnowledgeResult).
277    ///
278    /// - `id` ← `document_id`
279    /// - `title` ← `source`
280    /// - `url` ← `source` when it parses as an `http`/`https` URL (the GitHub
281    ///   blob/issue URL the connector stamps onto `Document.source` at ingest —
282    ///   `docs/CONNECTORS.md`), otherwise `None` (e.g. a local `policies/x.md`
283    ///   path has no web location).
284    /// - `snippet` ← `chunk`, truncated to [`CITATION_SNIPPET_MAX_CHARS`] on a
285    ///   char boundary (an ellipsis appended when truncated).
286    /// - `score` ← `score`
287    #[must_use]
288    pub fn from_knowledge_result(result: &smooth_operator_core::KnowledgeResult) -> Self {
289        Self {
290            id: result.document_id.clone(),
291            title: result.source.clone(),
292            url: web_url(&result.source),
293            snippet: truncate_snippet(&result.chunk, CITATION_SNIPPET_MAX_CHARS),
294            score: result.score,
295        }
296    }
297}
298
299impl From<&smooth_operator_core::KnowledgeResult> for Citation {
300    fn from(result: &smooth_operator_core::KnowledgeResult) -> Self {
301        Self::from_knowledge_result(result)
302    }
303}
304
305impl From<smooth_operator_core::KnowledgeResult> for Citation {
306    fn from(result: smooth_operator_core::KnowledgeResult) -> Self {
307        Self::from_knowledge_result(&result)
308    }
309}
310
311/// Return `Some(source)` when `source` is an `http`/`https` URL (the citation's
312/// `url`), else `None`. GitHub-sourced documents carry the blob/issue URL in
313/// `Document.source`; local docs carry a path, which has no web location.
314fn web_url(source: &str) -> Option<String> {
315    if source.starts_with("http://") || source.starts_with("https://") {
316        Some(source.to_string())
317    } else {
318        None
319    }
320}
321
322/// Truncate `text` to at most `max` chars on a char boundary, appending `…`
323/// when truncation occurred. Empty/short text is returned unchanged.
324fn truncate_snippet(text: &str, max: usize) -> String {
325    if text.chars().count() <= max {
326        return text.to_string();
327    }
328    let mut out: String = text.chars().take(max).collect();
329    out.push('…');
330    out
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use serde_json::json;
337
338    fn ts() -> DateTime<Utc> {
339        DateTime::parse_from_rfc3339("2026-06-07T12:00:00Z")
340            .unwrap()
341            .with_timezone(&Utc)
342    }
343
344    #[test]
345    fn participant_serializes_camelcase_and_kebab_type() {
346        let p = Participant {
347            id: "p1".into(),
348            conversation_id: "c1".into(),
349            organization_id: "org1".into(),
350            participant_type: ParticipantType::AiAgent,
351            external_id: None,
352            internal_id: Some("agent-uuid".into()),
353            browser_fingerprint: None,
354            browser_info: None,
355            name: "Smantha".into(),
356            email: None,
357            phone: None,
358            crm_contact_id: None,
359            metadata_json: None,
360            created_at: ts(),
361            updated_at: ts(),
362        };
363        let v = serde_json::to_value(&p).unwrap();
364        // camelCase field names match the spec
365        assert!(v.get("conversationId").is_some());
366        assert!(v.get("organizationId").is_some());
367        assert!(v.get("internalId").is_some());
368        // `type` discriminator is kebab-cased per the enum spec
369        assert_eq!(v.get("type").unwrap(), &json!("ai-agent"));
370        // round-trip
371        let back: Participant = serde_json::from_value(v).unwrap();
372        assert_eq!(back.participant_type, ParticipantType::AiAgent);
373    }
374
375    #[test]
376    fn participant_type_variants_match_spec() {
377        assert_eq!(
378            serde_json::to_value(ParticipantType::User).unwrap(),
379            json!("user")
380        );
381        assert_eq!(
382            serde_json::to_value(ParticipantType::AiAgent).unwrap(),
383            json!("ai-agent")
384        );
385        assert_eq!(
386            serde_json::to_value(ParticipantType::HumanAgent).unwrap(),
387            json!("human-agent")
388        );
389    }
390
391    #[test]
392    fn message_serializes_direction_and_content_items() {
393        let m = Message {
394            id: "m1".into(),
395            external_id: None,
396            organization_id: Some("org1".into()),
397            conversation_id: Some("c1".into()),
398            direction: Direction::Inbound,
399            content: MessageContent::from_text("hello"),
400            from: Some(ParticipantRef {
401                id: "p1".into(),
402                participant_type: "user".into(),
403                name: Some("Visitor".into()),
404            }),
405            to: None,
406            metadata_json: None,
407            analytics_json: None,
408            created_at: ts(),
409            updated_at: None,
410        };
411        let v = serde_json::to_value(&m).unwrap();
412        assert_eq!(v.get("direction").unwrap(), &json!("inbound"));
413        assert_eq!(v["content"]["items"][0]["type"], json!("text"));
414        assert_eq!(v["content"]["items"][0]["text"], json!("hello"));
415        assert_eq!(v["content"]["text"], json!("hello"));
416        // `from` uses camelCase `id`/`type`
417        assert_eq!(v["from"]["type"], json!("user"));
418        let back: Message = serde_json::from_value(v).unwrap();
419        assert_eq!(back.direction, Direction::Inbound);
420    }
421
422    #[test]
423    fn session_uses_thread_id_camelcase() {
424        let s = Session {
425            session_id: "s1".into(),
426            conversation_id: "c1".into(),
427            agent_id: "a1".into(),
428            agent_name: "Smantha".into(),
429            user_participant_id: "pu".into(),
430            agent_participant_id: "pa".into(),
431            thread_id: "thread-xyz".into(),
432            status: Some(SessionStatus::Active),
433            token_count: Some(0),
434            message_count: Some(0),
435            metadata: None,
436            created_at: Some(ts()),
437            updated_at: Some(ts()),
438            ended_at: None,
439            last_activity_at: Some(ts()),
440        };
441        let v = serde_json::to_value(&s).unwrap();
442        assert!(v.get("sessionId").is_some());
443        assert!(v.get("conversationId").is_some());
444        assert!(v.get("userParticipantId").is_some());
445        assert!(v.get("agentParticipantId").is_some());
446        assert_eq!(v.get("threadId").unwrap(), &json!("thread-xyz"));
447        assert_eq!(v.get("status").unwrap(), &json!("active"));
448        let back: Session = serde_json::from_value(v).unwrap();
449        assert_eq!(back.thread_id, "thread-xyz");
450        assert_eq!(back.status, Some(SessionStatus::Active));
451    }
452
453    #[test]
454    fn conversation_platform_and_camelcase() {
455        let c = Conversation {
456            id: "c1".into(),
457            platform: Platform::Web,
458            name: "Lead chat".into(),
459            organization_id: "org1".into(),
460            idempotency_key: "idem-1".into(),
461            metadata_json: Some(json!({"campaign": "spring"})),
462            analytics_json: None,
463            created_at: ts(),
464            updated_at: ts(),
465        };
466        let v = serde_json::to_value(&c).unwrap();
467        assert_eq!(v.get("platform").unwrap(), &json!("web"));
468        assert!(v.get("organizationId").is_some());
469        assert!(v.get("idempotencyKey").is_some());
470        assert_eq!(v["metadataJson"]["campaign"], json!("spring"));
471        let back: Conversation = serde_json::from_value(v).unwrap();
472        assert_eq!(back.platform, Platform::Web);
473    }
474}