Skip to main content

chat_engine_sdk/
models.rs

1use serde::{Deserialize, Serialize};
2use time::OffsetDateTime;
3use toolkit_utils::SecretString;
4use uuid::Uuid;
5
6/// Tenant identifier. Opaque string from the auth token, used to scope all
7/// queries. Newtype distinguishes it from `UserId` at compile time so call
8/// sites cannot accidentally swap tenant and user arguments.
9///
10/// `#[serde(transparent)]` keeps the on-the-wire and DB JSON representation
11/// as a plain string, so this is a pure compile-time refinement.
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(transparent)]
14pub struct TenantId(String);
15
16impl TenantId {
17    /// Constructs a `TenantId`, rejecting empty strings.
18    ///
19    /// # Panics
20    ///
21    /// Panics if `s` is empty. An empty tenant id would silently scope queries
22    /// to no rows (or all rows, depending on the ORM) and so represents a
23    /// latent authorization bug; it must never reach the data layer.
24    #[must_use]
25    pub fn new(s: impl Into<String>) -> Self {
26        let s = s.into();
27        assert!(!s.is_empty(), "TenantId must not be empty");
28        Self(s)
29    }
30
31    #[must_use]
32    pub fn as_str(&self) -> &str {
33        &self.0
34    }
35
36    #[must_use]
37    pub fn into_inner(self) -> String {
38        self.0
39    }
40}
41
42impl From<String> for TenantId {
43    fn from(s: String) -> Self {
44        Self::new(s)
45    }
46}
47
48impl From<&str> for TenantId {
49    fn from(s: &str) -> Self {
50        Self::new(s)
51    }
52}
53
54impl AsRef<str> for TenantId {
55    fn as_ref(&self) -> &str {
56        &self.0
57    }
58}
59
60impl std::fmt::Display for TenantId {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(&self.0)
63    }
64}
65
66/// End-user identifier (opaque string from the auth token). Newtype
67/// distinguishes it from `TenantId` at compile time.
68///
69/// `#[serde(transparent)]` keeps the wire/DB representation as a plain string.
70#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
71#[serde(transparent)]
72pub struct UserId(String);
73
74impl UserId {
75    /// Constructs a `UserId`, rejecting empty strings.
76    ///
77    /// # Panics
78    ///
79    /// Panics if `s` is empty. An empty user id would defeat ownership checks
80    /// downstream and must never reach the data layer.
81    #[must_use]
82    pub fn new(s: impl Into<String>) -> Self {
83        let s = s.into();
84        assert!(!s.is_empty(), "UserId must not be empty");
85        Self(s)
86    }
87
88    #[must_use]
89    pub fn as_str(&self) -> &str {
90        &self.0
91    }
92
93    #[must_use]
94    pub fn into_inner(self) -> String {
95        self.0
96    }
97}
98
99impl From<String> for UserId {
100    fn from(s: String) -> Self {
101        Self::new(s)
102    }
103}
104
105impl From<&str> for UserId {
106    fn from(s: &str) -> Self {
107        Self::new(s)
108    }
109}
110
111impl AsRef<str> for UserId {
112    fn as_ref(&self) -> &str {
113        &self.0
114    }
115}
116
117impl std::fmt::Display for UserId {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(&self.0)
120    }
121}
122
123/// A chat session: the top-level container that groups a conversation's
124/// messages, tenant/user ownership, backend plugin binding, and lifecycle.
125///
126/// `Debug` is implemented manually to redact `share_token` — it is a
127/// cryptographic bearer secret that grants read-only access to the session
128/// and must never appear in logs, tracing spans, or test output.
129#[derive(Clone, Serialize, Deserialize)]
130pub struct Session {
131    /// Unique session identifier (primary key).
132    pub session_id: Uuid,
133    /// Tenant that owns the session; all queries are scoped by this value.
134    pub tenant_id: TenantId,
135    /// End-user who created the session (opaque string from the auth token).
136    pub user_id: UserId,
137    /// Optional client identifier (e.g., app/device) that initiated the session.
138    pub client_id: Option<String>,
139    /// Session type this session is bound to; determines which backend plugin
140    /// handles messages and which capabilities are exposed. May be `None` for
141    /// session types that haven't been configured yet.
142    pub session_type_id: Option<Uuid>,
143    /// Capability values (from the `Capability` schema declared by the plugin)
144    /// actually enabled for this session — typed as JSON because the shape is
145    /// plugin-defined. Use `CapabilityValue` for structured access.
146    pub enabled_capabilities: Option<serde_json::Value>,
147    /// Opaque per-session metadata (client-defined). Chat Engine never
148    /// interprets this field beyond storing/retrieving it. Also used internally
149    /// to persist `memory_strategy`, `retention_policy`, and `share_expires_at`
150    /// under reserved keys.
151    pub metadata: Option<serde_json::Value>,
152    /// Current lifecycle state (active / archived / soft_deleted / hard_deleted).
153    pub lifecycle_state: LifecycleState,
154    /// Cryptographically-random token granting read-only access to a shared
155    /// view of this session. Present only while sharing is active.
156    #[serde(
157        default,
158        serialize_with = "toolkit_utils::secret_string::serialize_option_exposed"
159    )]
160    pub share_token: Option<SecretString>,
161    /// Creation timestamp (UTC, RFC3339 on the wire).
162    #[serde(with = "time::serde::rfc3339")]
163    pub created_at: OffsetDateTime,
164    /// Last-modified timestamp (UTC, RFC3339 on the wire).
165    #[serde(with = "time::serde::rfc3339")]
166    pub updated_at: OffsetDateTime,
167}
168
169impl std::fmt::Debug for Session {
170    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
171        // `share_token` is a bearer secret that grants read-only access to
172        // this session — anyone with the value can hijack the share link.
173        // We surface presence/absence so observability is preserved without
174        // leaking the token.
175        let share_token_redacted: Option<&'static str> =
176            self.share_token.as_ref().map(|_| "<redacted>");
177        f.debug_struct("Session")
178            .field("session_id", &self.session_id)
179            .field("tenant_id", &self.tenant_id)
180            .field("user_id", &self.user_id)
181            .field("client_id", &self.client_id)
182            .field("session_type_id", &self.session_type_id)
183            .field("enabled_capabilities", &self.enabled_capabilities)
184            .field("metadata", &self.metadata)
185            .field("lifecycle_state", &self.lifecycle_state)
186            .field("share_token", &share_token_redacted)
187            .field("created_at", &self.created_at)
188            .field("updated_at", &self.updated_at)
189            .finish()
190    }
191}
192
193/// Lifecycle state of a session.
194///
195/// Allowed transitions:
196/// - `Active` ↔ `Archived`, `Active` → `SoftDeleted`, `Active` → `HardDeleted`
197/// - `Archived` → `SoftDeleted`, `Archived` → `HardDeleted`
198/// - `SoftDeleted` → `Active`, `SoftDeleted` → `HardDeleted`
199/// - `HardDeleted` is terminal
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum LifecycleState {
203    /// Session is live and accepts reads/writes.
204    Active,
205    /// Session is hidden from default listings but remains readable; can be
206    /// restored to `Active`.
207    Archived,
208    /// Session marked for deletion; hidden from listings, reversible via restore.
209    SoftDeleted,
210    /// Session physically deleted (terminal state); messages and subtree gone.
211    HardDeleted,
212}
213
214impl LifecycleState {
215    /// Canonical lowercase string representation (DB storage format).
216    #[must_use]
217    pub fn as_str(&self) -> &'static str {
218        match self {
219            Self::Active => "active",
220            Self::Archived => "archived",
221            Self::SoftDeleted => "soft_deleted",
222            Self::HardDeleted => "hard_deleted",
223        }
224    }
225
226    /// Parse from lowercase string (returns `None` for unknown values).
227    #[must_use]
228    pub fn from_str_value(s: &str) -> Option<Self> {
229        match s {
230            "active" => Some(Self::Active),
231            "archived" => Some(Self::Archived),
232            "soft_deleted" => Some(Self::SoftDeleted),
233            "hard_deleted" => Some(Self::HardDeleted),
234            _ => None,
235        }
236    }
237
238    /// Check whether a transition from `self` to `target` is valid per the
239    /// session lifecycle state machine.
240    #[must_use]
241    pub fn can_transition_to(&self, target: &Self) -> bool {
242        matches!(
243            (self, target),
244            (Self::Active, Self::Archived)
245                | (Self::Active, Self::SoftDeleted)
246                | (Self::Active, Self::HardDeleted)
247                | (Self::Archived, Self::Active)
248                | (Self::Archived, Self::SoftDeleted)
249                | (Self::Archived, Self::HardDeleted)
250                | (Self::SoftDeleted, Self::Active)
251                | (Self::SoftDeleted, Self::HardDeleted)
252        )
253    }
254}
255
256impl std::fmt::Display for LifecycleState {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.write_str(self.as_str())
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::LifecycleState;
265
266    #[test]
267    fn every_documented_valid_transition_is_allowed() {
268        // Mirrors the doc comment on `LifecycleState` exactly — change one,
269        // change the other.
270        let valid_edges = [
271            (LifecycleState::Active, LifecycleState::Archived),
272            (LifecycleState::Active, LifecycleState::SoftDeleted),
273            (LifecycleState::Active, LifecycleState::HardDeleted),
274            (LifecycleState::Archived, LifecycleState::Active),
275            (LifecycleState::Archived, LifecycleState::SoftDeleted),
276            (LifecycleState::Archived, LifecycleState::HardDeleted),
277            (LifecycleState::SoftDeleted, LifecycleState::Active),
278            (LifecycleState::SoftDeleted, LifecycleState::HardDeleted),
279        ];
280        for (from, to) in valid_edges {
281            assert!(
282                from.can_transition_to(&to),
283                "{from:?} -> {to:?} should be a valid transition"
284            );
285        }
286    }
287
288    #[test]
289    fn representative_invalid_transitions_are_rejected() {
290        // HardDeleted is terminal — nothing leaves it.
291        assert!(!LifecycleState::HardDeleted.can_transition_to(&LifecycleState::Active));
292        assert!(!LifecycleState::HardDeleted.can_transition_to(&LifecycleState::Archived));
293        // Self-loops are not real transitions.
294        assert!(!LifecycleState::Active.can_transition_to(&LifecycleState::Active));
295    }
296}
297
298/// A registered session type — pairs a human-readable name with the backend
299/// plugin instance that will process its sessions' messages.
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct SessionType {
302    /// Unique session type identifier (primary key).
303    pub session_type_id: Uuid,
304    /// Human-readable name used by developers when registering a type.
305    pub name: String,
306    /// GTS plugin instance ID bound to this session type. `None` means the
307    /// type is registered but not yet wired to a backend.
308    pub plugin_instance_id: Option<String>,
309    /// Creation timestamp (UTC).
310    #[serde(with = "time::serde::rfc3339")]
311    pub created_at: OffsetDateTime,
312    /// Last-modified timestamp (UTC).
313    #[serde(with = "time::serde::rfc3339")]
314    pub updated_at: OffsetDateTime,
315}
316
317/// A message node in the immutable conversation tree.
318///
319/// Messages form a DAG rooted at the session: each message (except the first)
320/// has a `parent_message_id`, and siblings sharing the same parent are
321/// *variants* differentiated by `variant_index`. Exactly one sibling per parent
322/// is `is_active=true`, which defines the current conversation path.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct Message {
325    /// Unique message identifier (primary key).
326    pub message_id: Uuid,
327    /// Session this message belongs to.
328    pub session_id: Uuid,
329    /// Owning tenant, denormalized from the parent session so message-scoped
330    /// queries and sharding don't require a join. When set, always equals the
331    /// session's `tenant_id`. `None` only for legacy rows persisted before the
332    /// column existed (not yet backfilled).
333    #[serde(default)]
334    pub tenant_id: Option<TenantId>,
335    /// Author of this specific message (not the session owner). Set to the
336    /// authenticated user for `user`-role messages; `None` for `assistant` /
337    /// `system` messages (machine-generated, no human author) and un-backfilled
338    /// legacy rows. Enables author attribution in multi-user / shared sessions.
339    #[serde(default)]
340    pub user_id: Option<UserId>,
341    /// Parent message in the tree; `None` for the first (root) message.
342    pub parent_message_id: Option<Uuid>,
343    /// Ordinal among siblings sharing the same `parent_message_id` within the
344    /// same session. Starts at 0 and increments per recreate.
345    #[serde(default)]
346    pub variant_index: u32,
347    /// True if this variant is currently on the active conversation path.
348    /// Exactly one sibling per parent should be active.
349    #[serde(default)]
350    pub is_active: bool,
351    /// Who produced the message: user / assistant / system.
352    pub role: MessageRole,
353    /// Ordered, typed body fragments. The parts in `number` order form the
354    /// message body (replaces the former single `content` blob). Empty only
355    /// for a freshly-created assistant stub before its text part is persisted.
356    #[serde(default)]
357    pub parts: Vec<MessagePart>,
358    /// External file UUIDs referenced by this message. Chat Engine forwards
359    /// them opaquely — file content is never fetched by Chat Engine itself.
360    #[serde(default)]
361    pub file_ids: Vec<Uuid>,
362    /// Per-message metadata (model used, finish_reason, usage, etc.). Typed as
363    /// JSON because it is plugin-defined.
364    pub metadata: Option<serde_json::Value>,
365    /// `true` once the assistant finished streaming (or the message was
366    /// persisted whole). User messages are always complete on creation.
367    #[serde(default = "default_true")]
368    pub is_complete: bool,
369    /// Hide this message from client UIs (e.g., system messages, internal
370    /// summaries that should not appear in the transcript).
371    #[serde(default)]
372    pub is_hidden_from_user: bool,
373    /// Exclude this message from the history sent to backend plugins
374    /// (e.g., messages already covered by a newer summary).
375    #[serde(default)]
376    pub is_hidden_from_backend: bool,
377    /// Creation timestamp (UTC).
378    #[serde(with = "time::serde::rfc3339")]
379    pub created_at: OffsetDateTime,
380    /// Last-modified timestamp (UTC). Typically changes only when an assistant
381    /// placeholder is filled in.
382    #[serde(with = "time::serde::rfc3339")]
383    pub updated_at: OffsetDateTime,
384}
385
386fn default_true() -> bool {
387    true
388}
389
390/// Message author role.
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
392#[serde(rename_all = "snake_case")]
393pub enum MessageRole {
394    /// End-user input.
395    User,
396    /// Model/plugin-generated response.
397    Assistant,
398    /// Internal/system message (summaries, tool output, injected context).
399    System,
400}
401
402/// Type discriminant for a [`MessagePart`]. The base set is fixed; plugin
403/// vendors extend it via GTS without forking Chat Engine core.
404#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
405#[serde(rename_all = "snake_case")]
406pub enum MessagePartType {
407    /// Plain text: `{ text, title? }`.
408    Text,
409    /// Code block: `{ language, code }`.
410    Code,
411    /// One or more image references: `{ images: [{ image_id, mime_type?, .. }] }`.
412    Images,
413    /// One or more video references: `{ videos: [{ video_id, mime_type?, .. }] }`.
414    Videos,
415    /// Link preview cards: `{ links: [{ url, title?, .. }] }`.
416    Links,
417    /// Progress/status indicators: `{ statuses: [{ code, detail? }] }`.
418    Statuses,
419}
420
421/// The wire / plugin shape of a message part before persistence: a `type`
422/// plus its typed `content`. Chat Engine assigns `id` and `number` on persist
423/// and returns a full [`MessagePart`].
424#[derive(Debug, Clone, Serialize, Deserialize)]
425pub struct MessagePartInput {
426    /// Discriminates the `content` shape.
427    #[serde(rename = "type")]
428    pub part_type: MessagePartType,
429    /// Typed payload; shape determined by `part_type`. Kept as JSON because the
430    /// per-type shapes are plugin-extensible (validated structurally, not here).
431    pub content: serde_json::Value,
432    /// Document citations attached to this part (meaningful for `text` parts).
433    #[serde(default, skip_serializing_if = "Vec::is_empty")]
434    pub file_citations: Vec<FileCitation>,
435    /// Web-page citations attached to this part (meaningful for `text` parts).
436    #[serde(default, skip_serializing_if = "Vec::is_empty")]
437    pub link_citations: Vec<LinkCitation>,
438    /// Lightweight URL references attached to this part.
439    #[serde(default, skip_serializing_if = "Vec::is_empty")]
440    pub references: Vec<LinkReference>,
441}
442
443/// A persisted, ordered fragment of a message body.
444///
445/// A message owns one or more parts; the parts in `number` order are the
446/// message body. Persisted in the `message_parts` table with a CASCADE foreign
447/// key to `messages` (see DESIGN `cpt-cf-chat-engine-design-entity-message-part`).
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct MessagePart {
450    /// Unique part identifier (primary key).
451    pub id: Uuid,
452    /// Message this part belongs to.
453    pub message_id: Uuid,
454    /// Discriminates the `content` shape.
455    #[serde(rename = "type")]
456    pub part_type: MessagePartType,
457    /// Typed payload; shape determined by `part_type`.
458    pub content: serde_json::Value,
459    /// 0-based ordinal within the message; unique per message.
460    pub number: u32,
461    /// Document citations attached to this part (only `text` parts carry these).
462    #[serde(default, skip_serializing_if = "Vec::is_empty")]
463    pub file_citations: Vec<FileCitation>,
464    /// Web-page citations attached to this part.
465    #[serde(default, skip_serializing_if = "Vec::is_empty")]
466    pub link_citations: Vec<LinkCitation>,
467    /// Lightweight URL references attached to this part.
468    #[serde(default, skip_serializing_if = "Vec::is_empty")]
469    pub references: Vec<LinkReference>,
470}
471
472impl MessagePart {
473    /// Convenience constructor for a `text` part with the given body (no
474    /// attached citations/references).
475    #[must_use]
476    pub fn text(id: Uuid, message_id: Uuid, number: u32, text: impl Into<String>) -> Self {
477        Self {
478            id,
479            message_id,
480            part_type: MessagePartType::Text,
481            content: serde_json::json!({ "text": text.into() }),
482            number,
483            file_citations: Vec::new(),
484            link_citations: Vec::new(),
485            references: Vec::new(),
486        }
487    }
488}
489
490/// Per-marker source-location anchor, parallel to one entry in a citation's
491/// `text_positions`. Forwarded verbatim from the plugin (see DESIGN
492/// `cpt-cf-chat-engine-design-entity-citations`).
493#[derive(Debug, Clone, Serialize, Deserialize)]
494pub struct TextPositionAnchor {
495    /// Zero-indexed start offset of the cited fragment in the source text.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub char_start: Option<i64>,
498    /// Exclusive end offset of the cited fragment in the source text.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub char_end: Option<i64>,
501    /// Verbatim cited text at the `[char_start..char_end]` slice.
502    #[serde(default)]
503    pub quote: String,
504    /// Chunk identifier for this occurrence's source passage.
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub chunk_id: Option<String>,
507    /// First ~200 chars of this occurrence's chunk, for hover.
508    #[serde(default, skip_serializing_if = "Option::is_none")]
509    pub chunk_preview: Option<String>,
510}
511
512/// A citation into a retrieved document, attached to a `text` message part.
513///
514/// Supplied by the backend plugin and stored verbatim — Chat Engine does not
515/// generate or interpret citations. `index` matches a `[N]` marker in the part
516/// text (1-indexed), sharing one namespace with [`LinkCitation`].
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct FileCitation {
519    /// Plugin-assigned id, unique per message.
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub citation_id: Option<String>,
522    /// Matches the `[N]` token in the part text (1-indexed).
523    #[serde(default, skip_serializing_if = "Option::is_none")]
524    pub index: Option<u32>,
525    /// Source document id.
526    pub document_id: String,
527    /// Source document name.
528    pub document_name: String,
529    /// Human-readable document title, when known.
530    #[serde(default, skip_serializing_if = "Option::is_none")]
531    pub document_title: Option<String>,
532    /// Document source / venue, when known.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub source: Option<String>,
535    /// Quoted text from the document.
536    #[serde(default)]
537    pub quote: String,
538    /// Zero-indexed start offset into the source plain text.
539    #[serde(default, skip_serializing_if = "Option::is_none")]
540    pub char_start: Option<i64>,
541    /// Exclusive end offset into the source plain text.
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub char_end: Option<i64>,
544    /// Source chunk identifier.
545    #[serde(default, skip_serializing_if = "Option::is_none")]
546    pub chunk_id: Option<String>,
547    /// First ~200 chars of the chunk, for hover.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub chunk_preview: Option<String>,
550    /// Full chunk body (text) or image URL (when `chunk_type = "image"`).
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub chunk_content: Option<String>,
553    /// Content type of the cited chunk: `"text"` (default) or `"image"`.
554    #[serde(default = "default_chunk_type")]
555    pub chunk_type: String,
556    /// Source page number (1-indexed), when applicable.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub page: Option<i32>,
559    /// Video timestamp in seconds, when applicable.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub timestamp: Option<f64>,
562    /// Highlighted spans within the cited chunk (plugin-defined shape).
563    #[serde(default, skip_serializing_if = "Vec::is_empty")]
564    pub highlights: Vec<serde_json::Value>,
565    /// `direct_quote` / `paraphrase` / `data_reference` / `methodology_reference`.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub reference_type: Option<String>,
568    /// Character offsets in the part text where this citation's `[index]` marker
569    /// appears. Pre-computed by the plugin; the engine forwards them verbatim.
570    #[serde(default)]
571    pub text_positions: Vec<u32>,
572    /// Per-marker source anchors, parallel to `text_positions`.
573    #[serde(default)]
574    pub text_position_anchors: Vec<TextPositionAnchor>,
575    /// Opaque plugin metadata; forwarded but not interpreted.
576    #[serde(default, skip_serializing_if = "Option::is_none")]
577    pub meta: Option<serde_json::Value>,
578}
579
580/// A citation into a web page, attached to a `text` message part. Shares the
581/// `[N]` index namespace with [`FileCitation`].
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct LinkCitation {
584    /// Plugin-assigned id.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub citation_id: Option<String>,
587    /// Matches the `[N]` token in the part text (1-indexed).
588    #[serde(default, skip_serializing_if = "Option::is_none")]
589    pub index: Option<u32>,
590    /// Cited page URL.
591    pub url: String,
592    /// Cited page title.
593    pub title: String,
594    /// Snippet / preview from the page.
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub preview_text: Option<String>,
597    /// Favicon URL.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub favicon_url: Option<String>,
600    /// Cited text.
601    #[serde(default, skip_serializing_if = "Option::is_none")]
602    pub quote: Option<String>,
603    /// Zero-indexed start offset into the source plain text.
604    #[serde(default, skip_serializing_if = "Option::is_none")]
605    pub char_start: Option<i64>,
606    /// Exclusive end offset.
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub char_end: Option<i64>,
609    /// Citation kind label.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub reference_type: Option<String>,
612    /// Character offsets in the part text where this citation's `[index]` marker
613    /// appears. Plugin-provided; forwarded verbatim.
614    #[serde(default)]
615    pub text_positions: Vec<u32>,
616}
617
618/// A lightweight URL badge attached to a `text` message part (no quote/anchor).
619#[derive(Debug, Clone, Serialize, Deserialize)]
620pub struct LinkReference {
621    /// Reference title.
622    #[serde(default)]
623    pub title: String,
624    /// Reference URL.
625    pub url: String,
626    /// Preview text.
627    #[serde(default)]
628    pub preview_text: String,
629    /// Character offsets in the part text where the badge appears.
630    #[serde(default)]
631    pub position: Vec<u32>,
632    /// Highlight spans for the preview (plugin-defined shape).
633    #[serde(default, skip_serializing_if = "Vec::is_empty")]
634    pub preview_highlights: Vec<serde_json::Value>,
635    /// Reference type: `"url"` (default) / `"document"` / `"internal"`.
636    #[serde(default = "default_ref_type")]
637    pub ref_type: String,
638    /// Additional metadata (e.g. `entity_id` for document references).
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub ref_meta: Option<serde_json::Value>,
641    /// Per-part ordinal so positional `[N]` → `refs[N-1]` is stable.
642    #[serde(default)]
643    pub idx: u32,
644}
645
646fn default_chunk_type() -> String {
647    String::from("text")
648}
649
650fn default_ref_type() -> String {
651    String::from("url")
652}
653
654/// Schema declaration of a capability supported by a backend plugin.
655///
656/// Returned from `on_session_type_configured` / `on_session_created` /
657/// `on_session_updated` to tell Chat Engine *what is tunable*. Chat Engine
658/// stores these in `session.enabled_capabilities` and exposes the menu to
659/// clients. See also `CapabilityValue` for the chosen-value counterpart.
660#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct Capability {
662    /// Capability identifier (e.g., `"model"`, `"temperature"`, `"stream"`).
663    pub name: String,
664    /// Schema descriptor for allowed values — plugin-defined JSON. Typical
665    /// shape: `{ type: "enum", enum_values: [...], default_value: ... }` or
666    /// `{ type: "float", min: 0.0, max: 2.0, default_value: 0.7 }`.
667    pub value: serde_json::Value,
668}
669
670/// A concrete capability value chosen by the client for a specific call.
671///
672/// Passed in `PluginCallContext.enabled_capabilities` — Chat Engine forwards
673/// these to the plugin so it knows which options were selected. Compare with
674/// `Capability` which is the schema side.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct CapabilityValue {
677    /// Must match a capability `name` previously declared by the plugin.
678    pub name: String,
679    /// The chosen value (e.g., `"gpt-4"`, `0.9`, `false`). Must validate
680    /// against the schema in the corresponding `Capability.value`.
681    pub value: serde_json::Value,
682}
683
684/// Summary of one variant at a given tree position — returned when listing
685/// variants for a message so clients can render navigation UI.
686#[derive(Debug, Clone, Serialize, Deserialize)]
687pub struct VariantInfo {
688    /// Variant's message ID (the sibling itself).
689    pub message_id: Uuid,
690    /// Ordinal of this variant among siblings.
691    pub variant_index: u32,
692    /// How many variants exist at this position (including this one).
693    pub total_variants: u32,
694    /// True iff this variant is currently on the active path.
695    pub is_active: bool,
696}
697
698/// Per-session memory strategy controlling how much conversation history is
699/// sent to the backend plugin on each call.
700#[derive(Debug, Clone, Serialize, Deserialize)]
701#[serde(tag = "type", rename_all = "snake_case")]
702pub enum MemoryStrategy {
703    /// Send the entire active path.
704    Full,
705    /// Send only the most recent `window_size` messages.
706    SlidingWindow {
707        /// Number of recent messages to keep; must be ≥ 1.
708        window_size: u32,
709    },
710    /// Send AI-generated summary + the last `recent_messages_to_keep` messages.
711    Summarized {
712        /// Number of most-recent messages to preserve unsummarized; must be ≥ 2.
713        recent_messages_to_keep: u32,
714    },
715}
716
717/// Message retention policy — when messages in a session should be cleaned up.
718#[derive(Debug, Clone, Serialize, Deserialize)]
719#[serde(tag = "type", rename_all = "snake_case")]
720pub enum RetentionPolicy {
721    /// Keep messages forever (default).
722    None,
723    /// Delete messages older than `max_age_days`.
724    AgeBased {
725        /// Maximum age in days before cleanup; must be ≥ 1.
726        max_age_days: u32,
727    },
728    /// Keep at most `max_message_count` messages; oldest are cleaned up first.
729    CountBased {
730        /// Maximum number of retained messages; must be ≥ 1.
731        max_message_count: u32,
732    },
733}
734
735/// Plugin health status returned by `ChatEngineBackendPlugin::health_check`.
736#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
737#[serde(rename_all = "snake_case")]
738pub enum HealthStatus {
739    /// Plugin is fully operational.
740    Healthy,
741    /// Plugin is reachable but reporting partial degradation.
742    Degraded,
743    /// Plugin is unreachable or reporting failure.
744    Unhealthy,
745}
746
747/// NDJSON streaming event emitted by a plugin during response generation.
748///
749/// Serialized with a `"type"` discriminator: `"start"`, `"chunk"`, `"complete"`,
750/// or `"error"`. A well-formed response stream is: one `Start` → zero or more
751/// `Chunk` → one `Complete` (or `Error` at any point).
752#[derive(Debug, Clone, Serialize, Deserialize)]
753#[serde(tag = "type", rename_all = "snake_case")]
754pub enum StreamingEvent {
755    /// Marks the beginning of an assistant message's stream.
756    Start(StreamingStartEvent),
757    /// A partial text content chunk; multiple `Chunk`s concatenate to the full
758    /// text of the assistant's primary `text` part.
759    Chunk(StreamingChunkEvent),
760    /// Transient progress indicator (e.g. `thinking`, `analyzing`). Streamed to
761    /// the client but **not** persisted with the message.
762    Status(StreamingStatusEvent),
763    /// Emits a complete typed message part (image/video/link/code/…) to append
764    /// to the message document. Persisted with the message on finalize.
765    Part(StreamingPartEvent),
766    /// Mid-stream citations/references attached to a part (vs. the batch set on
767    /// `Complete`). Persisted with that part on finalize.
768    Citation(StreamingCitationEvent),
769    /// Opaque assistant-message state patch; merged into the message metadata.
770    State(StreamingStateEvent),
771    /// Session-scoped metadata patch; merged into the owning session's metadata.
772    SessionMeta(StreamingSessionMetaEvent),
773    /// Tool-invocation trace (e.g. file search); recorded in message metadata.
774    Tool(StreamingToolEvent),
775    /// Stream completed successfully; may carry final metadata.
776    Complete(StreamingCompleteEvent),
777    /// Stream terminated with an error; no more events follow.
778    Error(StreamingErrorEvent),
779}
780
781/// Opens a stream for a given assistant message.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783#[serde(rename_all = "snake_case")]
784pub struct StreamingStartEvent {
785    /// ID of the assistant message being streamed.
786    pub message_id: Uuid,
787}
788
789/// A single text fragment appended to the assistant message in flight.
790#[derive(Debug, Clone, Serialize, Deserialize)]
791#[serde(rename_all = "snake_case")]
792pub struct StreamingChunkEvent {
793    /// ID of the assistant message this chunk belongs to.
794    pub message_id: Uuid,
795    /// Text payload to append to the message content.
796    pub chunk: String,
797}
798
799/// Signals the assistant message is fully persisted and the stream is closing.
800#[derive(Debug, Clone, Serialize, Deserialize)]
801#[serde(rename_all = "snake_case")]
802pub struct StreamingCompleteEvent {
803    /// ID of the completed assistant message.
804    pub message_id: Uuid,
805    /// Final plugin-defined metadata (model used, finish_reason, token usage,
806    /// etc.). Omitted from the wire when `None`.
807    #[serde(skip_serializing_if = "Option::is_none")]
808    pub metadata: Option<serde_json::Value>,
809    /// Document citations for the completed assistant `text` part. Persisted
810    /// with the text part on finalize (see FR-023).
811    #[serde(default, skip_serializing_if = "Vec::is_empty")]
812    pub file_citations: Vec<FileCitation>,
813    /// Web-page citations for the completed assistant `text` part.
814    #[serde(default, skip_serializing_if = "Vec::is_empty")]
815    pub link_citations: Vec<LinkCitation>,
816    /// URL references for the completed assistant `text` part.
817    #[serde(default, skip_serializing_if = "Vec::is_empty")]
818    pub references: Vec<LinkReference>,
819}
820
821/// Signals a mid-stream failure; the assistant message may be incomplete.
822#[derive(Debug, Clone, Serialize, Deserialize)]
823#[serde(rename_all = "snake_case")]
824pub struct StreamingErrorEvent {
825    /// ID of the assistant message that failed to stream.
826    pub message_id: Uuid,
827    /// Human-readable error description (may include plugin error code).
828    pub error: String,
829}
830
831/// Transient progress indicator (`StreamingEvent::Status`). Streamed to the
832/// client for live feedback but not persisted with the finalized message.
833#[derive(Debug, Clone, Serialize, Deserialize)]
834#[serde(rename_all = "snake_case")]
835pub struct StreamingStatusEvent {
836    /// ID of the assistant message this status pertains to.
837    pub message_id: Uuid,
838    /// Machine-readable status code (e.g. `thinking`, `analyzing`, `searching`).
839    pub code: String,
840    /// Optional human-readable detail.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub detail: Option<String>,
843}
844
845/// Emits a complete typed message part mid-stream (`StreamingEvent::Part`):
846/// images, videos, links, code, or an additional text part. The part is
847/// appended to the message document and persisted on finalize.
848#[derive(Debug, Clone, Serialize, Deserialize)]
849#[serde(rename_all = "snake_case")]
850pub struct StreamingPartEvent {
851    /// ID of the assistant message this part belongs to.
852    pub message_id: Uuid,
853    /// The typed part to append (type + content + optional citations).
854    pub part: MessagePartInput,
855}
856
857/// Mid-stream citations/references attached to a part
858/// (`StreamingEvent::Citation`). Unlike the batch set carried on `Complete`,
859/// these arrive incrementally; they target the part at `part_number`.
860#[derive(Debug, Clone, Serialize, Deserialize)]
861#[serde(rename_all = "snake_case")]
862pub struct StreamingCitationEvent {
863    /// ID of the assistant message these citations belong to.
864    pub message_id: Uuid,
865    /// Target part index (defaults to the primary text part, `0`).
866    #[serde(default)]
867    pub part_number: i32,
868    /// Document citations to append to the target part.
869    #[serde(default, skip_serializing_if = "Vec::is_empty")]
870    pub file_citations: Vec<FileCitation>,
871    /// Web-page citations to append to the target part.
872    #[serde(default, skip_serializing_if = "Vec::is_empty")]
873    pub link_citations: Vec<LinkCitation>,
874    /// URL references to append to the target part.
875    #[serde(default, skip_serializing_if = "Vec::is_empty")]
876    pub references: Vec<LinkReference>,
877}
878
879/// Opaque assistant-message state patch (`StreamingEvent::State`); merged into
880/// the finalized message's metadata under `state`.
881#[derive(Debug, Clone, Serialize, Deserialize)]
882#[serde(rename_all = "snake_case")]
883pub struct StreamingStateEvent {
884    /// ID of the assistant message whose state changed.
885    pub message_id: Uuid,
886    /// Arbitrary state object the client maintains alongside the document.
887    pub state: serde_json::Value,
888}
889
890/// Session-scoped metadata patch (`StreamingEvent::SessionMeta`); shallow-merged
891/// into the owning session's `metadata` while the message streams.
892#[derive(Debug, Clone, Serialize, Deserialize)]
893#[serde(rename_all = "snake_case")]
894pub struct StreamingSessionMetaEvent {
895    /// ID of the assistant message that triggered the session update.
896    pub message_id: Uuid,
897    /// Partial session metadata to shallow-merge into `session.metadata`.
898    pub patch: serde_json::Value,
899}
900
901/// Tool-invocation trace (`StreamingEvent::Tool`), e.g. a file search the
902/// plugin ran; appended to the finalized message's metadata under `tools`.
903#[derive(Debug, Clone, Serialize, Deserialize)]
904#[serde(rename_all = "snake_case")]
905pub struct StreamingToolEvent {
906    /// ID of the assistant message this tool call belongs to.
907    pub message_id: Uuid,
908    /// Tool identifier (e.g. `file_search`).
909    pub tool: String,
910    /// Tool-specific payload (query, results, status).
911    pub payload: serde_json::Value,
912}
913
914#[cfg(test)]
915mod streaming_event_wire_format_tests {
916    //! Pins the on-wire JSON shape of `StreamingEvent` and its payload structs
917    //! to the snake_case contract documented in `api/README.md`, `api/http-protocol.json`,
918    //! and ADR-0006 §Streaming Event Types. If you find yourself updating these
919    //! tests to change `message_id` → `messageId` (or similar), update the
920    //! OpenAPI spec and announce a breaking wire-protocol change first.
921
922    use super::{
923        StreamingChunkEvent, StreamingCompleteEvent, StreamingErrorEvent, StreamingEvent,
924        StreamingStartEvent,
925    };
926    use uuid::Uuid;
927
928    fn fixed_id() -> Uuid {
929        Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap()
930    }
931
932    #[test]
933    fn start_event_serializes_with_snake_case() {
934        let json = serde_json::to_value(StreamingEvent::Start(StreamingStartEvent {
935            message_id: fixed_id(),
936        }))
937        .unwrap();
938        assert_eq!(
939            json,
940            serde_json::json!({
941                "type": "start",
942                "message_id": "00000000-0000-0000-0000-000000000001",
943            })
944        );
945    }
946
947    #[test]
948    fn chunk_event_serializes_with_snake_case() {
949        let json = serde_json::to_value(StreamingEvent::Chunk(StreamingChunkEvent {
950            message_id: fixed_id(),
951            chunk: "hello".into(),
952        }))
953        .unwrap();
954        assert_eq!(
955            json,
956            serde_json::json!({
957                "type": "chunk",
958                "message_id": "00000000-0000-0000-0000-000000000001",
959                "chunk": "hello",
960            })
961        );
962    }
963
964    #[test]
965    fn complete_event_serializes_with_snake_case() {
966        let json = serde_json::to_value(StreamingEvent::Complete(StreamingCompleteEvent {
967            message_id: fixed_id(),
968            metadata: Some(serde_json::json!({ "usage": { "input_units": 1 } })),
969            file_citations: vec![],
970            link_citations: vec![],
971            references: vec![],
972        }))
973        .unwrap();
974        assert_eq!(
975            json,
976            serde_json::json!({
977                "type": "complete",
978                "message_id": "00000000-0000-0000-0000-000000000001",
979                "metadata": { "usage": { "input_units": 1 } },
980            })
981        );
982    }
983
984    #[test]
985    fn complete_event_omits_metadata_when_none() {
986        let json = serde_json::to_value(StreamingEvent::Complete(StreamingCompleteEvent {
987            message_id: fixed_id(),
988            metadata: None,
989            file_citations: vec![],
990            link_citations: vec![],
991            references: vec![],
992        }))
993        .unwrap();
994        assert_eq!(
995            json,
996            serde_json::json!({
997                "type": "complete",
998                "message_id": "00000000-0000-0000-0000-000000000001",
999            })
1000        );
1001    }
1002
1003    #[test]
1004    fn error_event_serializes_with_snake_case() {
1005        let json = serde_json::to_value(StreamingEvent::Error(StreamingErrorEvent {
1006            message_id: fixed_id(),
1007            error: "upstream timeout".into(),
1008        }))
1009        .unwrap();
1010        assert_eq!(
1011            json,
1012            serde_json::json!({
1013                "type": "error",
1014                "message_id": "00000000-0000-0000-0000-000000000001",
1015                "error": "upstream timeout",
1016            })
1017        );
1018    }
1019}
1020
1021#[cfg(test)]
1022mod id_validation_tests {
1023    use super::{TenantId, UserId};
1024
1025    #[test]
1026    fn tenant_id_accepts_non_empty() {
1027        assert_eq!(TenantId::new("t").as_str(), "t");
1028        assert_eq!(TenantId::from(String::from("t")).as_str(), "t");
1029        assert_eq!(TenantId::from("t").as_str(), "t");
1030    }
1031
1032    #[test]
1033    #[should_panic(expected = "TenantId must not be empty")]
1034    fn tenant_id_new_rejects_empty() {
1035        drop(TenantId::new(""));
1036    }
1037
1038    #[test]
1039    #[should_panic(expected = "TenantId must not be empty")]
1040    fn tenant_id_from_string_rejects_empty() {
1041        drop(TenantId::from(String::new()));
1042    }
1043
1044    #[test]
1045    #[should_panic(expected = "TenantId must not be empty")]
1046    fn tenant_id_from_str_rejects_empty() {
1047        drop(TenantId::from(""));
1048    }
1049
1050    #[test]
1051    fn user_id_accepts_non_empty() {
1052        assert_eq!(UserId::new("u").as_str(), "u");
1053        assert_eq!(UserId::from(String::from("u")).as_str(), "u");
1054        assert_eq!(UserId::from("u").as_str(), "u");
1055    }
1056
1057    #[test]
1058    #[should_panic(expected = "UserId must not be empty")]
1059    fn user_id_new_rejects_empty() {
1060        drop(UserId::new(""));
1061    }
1062
1063    #[test]
1064    #[should_panic(expected = "UserId must not be empty")]
1065    fn user_id_from_string_rejects_empty() {
1066        drop(UserId::from(String::new()));
1067    }
1068
1069    #[test]
1070    #[should_panic(expected = "UserId must not be empty")]
1071    fn user_id_from_str_rejects_empty() {
1072        drop(UserId::from(""));
1073    }
1074}