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}