ailloli_ui_core/
chat.rs

1//! Transport-neutral chat identifiers, snapshots, and reducer events.
2//!
3//! The types in this module contain no provider handles or I/O. They model an
4//! ordered UI snapshot that a provider adapter can update through [`ChatEvent`].
5
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// Stable identifier of one chat session.
11///
12/// Values are opaque strings. [`ChatSessionId::new`] performs no validation.
13///
14/// # Examples
15///
16/// ```
17/// use ailloli_ui_core::ChatSessionId;
18///
19/// assert_eq!(ChatSessionId::from_index(7).as_str(), "chat_session_0000000000000007");
20/// ```
21#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
22pub struct ChatSessionId(String);
23
24/// Stable identifier of one ordered message or status item.
25///
26/// Values are opaque strings. [`ChatItemId::new`] performs no validation.
27///
28/// # Examples
29///
30/// ```
31/// use ailloli_ui_core::ChatItemId;
32///
33/// let id = ChatItemId::new("provider-item-42");
34/// assert_eq!(id.as_str(), "provider-item-42");
35/// ```
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37pub struct ChatItemId(String);
38
39/// Correlation identifier for one provider request.
40///
41/// Values are opaque strings. [`ChatRequestId::new`] performs no validation.
42///
43/// # Examples
44///
45/// ```
46/// use ailloli_ui_core::ChatRequestId;
47///
48/// assert_eq!(ChatRequestId::from_index(2).as_str(), "chat_request_0000000000000002");
49/// ```
50#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51pub struct ChatRequestId(String);
52
53/// Implements the shared opaque-string API for the three typed chat IDs.
54macro_rules! impl_chat_id {
55    ($ty:ident, $prefix:literal) => {
56        impl $ty {
57            /// Wraps an opaque identifier exactly as supplied.
58            ///
59            /// Empty strings and provider-specific formats are accepted.
60            ///
61            /// # Examples
62            ///
63            /// ```
64            /// use ailloli_ui_core::ChatSessionId;
65            /// assert_eq!(ChatSessionId::new("provider-42").as_str(), "provider-42");
66            /// ```
67            pub fn new(value: impl Into<String>) -> Self {
68                Self(value.into())
69            }
70
71            /// Builds a deterministic ID from a zero-based or monotonic index.
72            ///
73            /// The representation is the type-specific prefix, an underscore,
74            /// and exactly 16 lowercase hexadecimal digits.
75            ///
76            /// # Examples
77            ///
78            /// ```
79            /// use ailloli_ui_core::ChatSessionId;
80            /// assert_eq!(ChatSessionId::from_index(1).as_str(), "chat_session_0000000000000001");
81            /// ```
82            pub fn from_index(index: u64) -> Self {
83                Self(format!("{}_{:016x}", $prefix, index))
84            }
85
86            /// Returns the opaque identifier without allocation.
87            ///
88            /// # Examples
89            ///
90            /// ```
91            /// use ailloli_ui_core::ChatItemId;
92            /// assert_eq!(ChatItemId::new("item-1").as_str(), "item-1");
93            /// ```
94            pub fn as_str(&self) -> &str {
95                &self.0
96            }
97        }
98
99        impl fmt::Display for $ty {
100            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101                f.write_str(&self.0)
102            }
103        }
104    };
105}
106
107impl_chat_id!(ChatSessionId, "chat_session");
108impl_chat_id!(ChatItemId, "chat_item");
109impl_chat_id!(ChatRequestId, "chat_request");
110
111/// Semantic author of a [`ChatMessage`].
112///
113/// # Examples
114///
115/// ```
116/// use ailloli_ui_core::ChatRole;
117///
118/// assert_eq!(ChatRole::default(), ChatRole::User);
119/// let provider_output = ChatRole::Assistant;
120/// assert_ne!(provider_output, ChatRole::Tool);
121/// ```
122#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
123#[serde(rename_all = "snake_case")]
124pub enum ChatRole {
125    /// Provider or application instructions outside the user/assistant exchange.
126    System,
127    /// Human-authored input; this is the default role.
128    #[default]
129    User,
130    /// Model-authored output.
131    Assistant,
132    /// Output produced by, or sent back from, a tool.
133    Tool,
134}
135
136/// Presentation category of a chat item.
137///
138/// # Examples
139///
140/// ```
141/// use ailloli_ui_core::ChatMessageKind;
142///
143/// assert_eq!(ChatMessageKind::default(), ChatMessageKind::Text);
144/// let command = ChatMessageKind::Command;
145/// assert_ne!(command, ChatMessageKind::FileChange);
146/// ```
147#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
148#[serde(rename_all = "snake_case")]
149pub enum ChatMessageKind {
150    /// Ordinary natural-language content; this is the default.
151    #[default]
152    Text,
153    /// Model reasoning or an equivalent provider explanation.
154    Reasoning,
155    /// A shell command or command execution record.
156    Command,
157    /// A proposed or completed file modification.
158    FileChange,
159    /// A tool invocation and its structured status.
160    ToolCall,
161    /// Non-message progress or lifecycle information.
162    Status,
163    /// Error content that should be presented as an item.
164    Error,
165}
166
167/// Delivery lifecycle of one [`ChatMessage`].
168///
169/// # Examples
170///
171/// ```
172/// use ailloli_ui_core::ChatMessageStatus;
173///
174/// assert_eq!(ChatMessageStatus::default(), ChatMessageStatus::Complete);
175/// assert_ne!(ChatMessageStatus::Streaming, ChatMessageStatus::Pending);
176/// ```
177#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ChatMessageStatus {
180    /// The item is queued but provider processing has not begun.
181    Pending,
182    /// More content may be appended or replace the current item.
183    Streaming,
184    /// The item is final; this is the default.
185    #[default]
186    Complete,
187    /// Production of this item failed.
188    Failed,
189}
190
191/// Aggregate lifecycle of a [`ChatSessionState`].
192///
193/// # Examples
194///
195/// ```
196/// use ailloli_ui_core::ChatSessionStatus;
197///
198/// assert_eq!(ChatSessionStatus::default(), ChatSessionStatus::Idle);
199/// let waiting = ChatSessionStatus::Waiting;
200/// assert_ne!(waiting, ChatSessionStatus::Running);
201/// ```
202#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
203#[serde(rename_all = "snake_case")]
204pub enum ChatSessionStatus {
205    /// No session setup or request is currently active; this is the default.
206    #[default]
207    Idle,
208    /// The session can accept a new request.
209    Ready,
210    /// A request or streamed response is in progress.
211    Running,
212    /// Progress is paused pending user, tool, or provider input.
213    Waiting,
214    /// The most recent session operation failed.
215    Failed,
216    /// The most recent request reached a terminal successful state.
217    Completed,
218}
219
220/// One provider-neutral item in a chat's stable display order.
221///
222/// Fields are public to support serialization and snapshot construction. The
223/// type does not enforce combinations of role, kind, request, and status.
224///
225/// # Examples
226///
227/// ```
228/// use ailloli_ui_core::{
229///     ChatItemId, ChatMessage, ChatMessageStatus, ChatRequestId,
230/// };
231///
232/// let message = ChatMessage::assistant(ChatItemId::from_index(1), "Hello")
233///     .request_id(ChatRequestId::from_index(4))
234///     .status(ChatMessageStatus::Streaming);
235/// assert_eq!(message.text, "Hello");
236/// ```
237#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
238pub struct ChatMessage {
239    /// Stable identity used to replace streamed updates in place.
240    pub id: ChatItemId,
241    /// Semantic author of the item.
242    pub role: ChatRole,
243    /// Presentation category of the item.
244    pub kind: ChatMessageKind,
245    /// Current UTF-8 content; an empty string is valid for a newly started stream.
246    pub text: String,
247    /// Correlated request, or `None` for uncorrelated history/status entries.
248    pub request_id: Option<ChatRequestId>,
249    /// Current delivery lifecycle; constructors default to [`ChatMessageStatus::Complete`].
250    pub status: ChatMessageStatus,
251}
252
253impl ChatMessage {
254    /// Creates a complete, uncorrelated chat item.
255    ///
256    /// Use [`Self::request_id`] and [`Self::status`] to override those defaults.
257    ///
258    /// # Examples
259    ///
260    /// ```
261    /// use ailloli_ui_core::{ChatItemId, ChatMessage, ChatMessageKind, ChatMessageStatus, ChatRole};
262    /// let message = ChatMessage::new(ChatItemId::new("1"), ChatRole::System, ChatMessageKind::Status, "Ready");
263    /// assert_eq!(message.status, ChatMessageStatus::Complete);
264    /// ```
265    pub fn new(
266        id: ChatItemId,
267        role: ChatRole,
268        kind: ChatMessageKind,
269        text: impl Into<String>,
270    ) -> Self {
271        Self {
272            id,
273            role,
274            kind,
275            text: text.into(),
276            request_id: None,
277            status: ChatMessageStatus::Complete,
278        }
279    }
280
281    /// Creates a complete text item with [`ChatRole::User`].
282    ///
283    /// # Examples
284    ///
285    /// ```
286    /// use ailloli_ui_core::{ChatItemId, ChatMessage, ChatRole};
287    /// assert_eq!(ChatMessage::user(ChatItemId::new("1"), "Hello").role, ChatRole::User);
288    /// ```
289    pub fn user(id: ChatItemId, text: impl Into<String>) -> Self {
290        Self::new(id, ChatRole::User, ChatMessageKind::Text, text)
291    }
292
293    /// Creates a complete text item with [`ChatRole::Assistant`].
294    ///
295    /// # Examples
296    ///
297    /// ```
298    /// use ailloli_ui_core::{ChatItemId, ChatMessage, ChatRole};
299    /// assert_eq!(ChatMessage::assistant(ChatItemId::new("1"), "Hello").role, ChatRole::Assistant);
300    /// ```
301    pub fn assistant(id: ChatItemId, text: impl Into<String>) -> Self {
302        Self::new(id, ChatRole::Assistant, ChatMessageKind::Text, text)
303    }
304
305    /// Associates this message with `request_id`, replacing any previous ID.
306    ///
307    /// # Examples
308    ///
309    /// ```
310    /// use ailloli_ui_core::{ChatItemId, ChatMessage, ChatRequestId};
311    /// let request = ChatRequestId::new("request-1");
312    /// let message = ChatMessage::user(ChatItemId::new("1"), "Hello").request_id(request.clone());
313    /// assert_eq!(message.request_id, Some(request));
314    /// ```
315    pub fn request_id(mut self, request_id: ChatRequestId) -> Self {
316        self.request_id = Some(request_id);
317        self
318    }
319
320    /// Sets the delivery status, replacing the constructor default.
321    ///
322    /// # Examples
323    ///
324    /// ```
325    /// use ailloli_ui_core::{ChatItemId, ChatMessage, ChatMessageStatus};
326    /// let message = ChatMessage::assistant(ChatItemId::new("1"), "").status(ChatMessageStatus::Streaming);
327    /// assert_eq!(message.status, ChatMessageStatus::Streaming);
328    /// ```
329    pub fn status(mut self, status: ChatMessageStatus) -> Self {
330        self.status = status;
331        self
332    }
333}
334
335/// Serializable, ordered state of one chat session.
336///
337/// The public fields allow adapters to restore provider snapshots directly.
338/// [`Self::apply_event`] provides the canonical incremental update semantics.
339///
340/// # Examples
341///
342/// ```
343/// use ailloli_ui_core::{
344///     ChatEvent, ChatItemId, ChatSessionId, ChatSessionState,
345/// };
346///
347/// let mut session = ChatSessionState::new(ChatSessionId::from_index(1), "Demo");
348/// assert!(session.apply_event(ChatEvent::MessageAdded(
349///     ailloli_ui_core::ChatMessage::user(ChatItemId::from_index(1), "Hi"),
350/// )));
351/// assert_eq!(session.summary().message_count, 1);
352/// ```
353#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
354pub struct ChatSessionState {
355    /// Stable session identity.
356    pub id: ChatSessionId,
357    /// Consumer-facing title; an empty title is allowed.
358    pub title: String,
359    /// Aggregate lifecycle of the session.
360    pub status: ChatSessionStatus,
361    /// Items in insertion order; replacing an existing ID preserves its index.
362    pub messages: Vec<ChatMessage>,
363    /// In-flight provider request, or `None` when no request is tracked.
364    pub active_request: Option<ChatRequestId>,
365    /// Latest session-level failure text, or `None` when absent or cleared.
366    pub last_error: Option<String>,
367}
368
369impl ChatSessionState {
370    /// Creates an empty session in [`ChatSessionStatus::Ready`].
371    ///
372    /// # Examples
373    ///
374    /// ```
375    /// use ailloli_ui_core::{ChatSessionId, ChatSessionState, ChatSessionStatus};
376    /// let session = ChatSessionState::new(ChatSessionId::new("session-1"), "Demo");
377    /// assert_eq!(session.status, ChatSessionStatus::Ready);
378    /// ```
379    pub fn new(id: ChatSessionId, title: impl Into<String>) -> Self {
380        Self {
381            id,
382            title: title.into(),
383            status: ChatSessionStatus::Ready,
384            messages: Vec::new(),
385            active_request: None,
386            last_error: None,
387        }
388    }
389
390    /// Builds a compact owned summary of the current snapshot.
391    ///
392    /// The preview is `None` when there are no messages. Otherwise it contains
393    /// at most 72 Unicode scalar values from the final message followed by
394    /// `...` when truncated; an empty final message yields `Some("")`.
395    ///
396    /// # Performance
397    ///
398    /// Clones the ID and title and counts the final message text to determine
399    /// whether truncation is required.
400    ///
401    /// # Examples
402    ///
403    /// ```
404    /// use ailloli_ui_core::{ChatSessionId, ChatSessionState};
405    /// let summary = ChatSessionState::new(ChatSessionId::new("session-1"), "Demo").summary();
406    /// assert_eq!(summary.message_count, 0);
407    /// ```
408    pub fn summary(&self) -> ChatSessionSummary {
409        ChatSessionSummary {
410            id: self.id.clone(),
411            title: self.title.clone(),
412            status: self.status,
413            message_count: self.messages.len(),
414            last_message_preview: self
415                .messages
416                .last()
417                .map(|message| preview_text(&message.text, 72)),
418        }
419    }
420
421    /// Returns an updated clone, leaving this snapshot unchanged.
422    ///
423    /// This has the same event semantics as [`Self::apply_event`].
424    ///
425    /// # Performance
426    ///
427    /// Clones the complete session before applying the event.
428    ///
429    /// # Examples
430    ///
431    /// ```
432    /// use ailloli_ui_core::{ChatEvent, ChatSessionId, ChatSessionState, ChatSessionStatus};
433    /// let session = ChatSessionState::new(ChatSessionId::new("session-1"), "Demo");
434    /// let failed = session.with_event(ChatEvent::Failed { message: "offline".into() });
435    /// assert_eq!(failed.status, ChatSessionStatus::Failed);
436    /// assert_eq!(session.status, ChatSessionStatus::Ready);
437    /// ```
438    pub fn with_event(&self, event: ChatEvent) -> Self {
439        let mut next = self.clone();
440        next.apply_event(event);
441        next
442    }
443
444    /// Applies one reducer event and reports whether the snapshot changed.
445    ///
446    /// Message IDs are upserted: an existing item is replaced at its current
447    /// index and a new item is appended. A delta for an unknown item creates a
448    /// streaming assistant text item without a request ID. Finishing an
449    /// assistant item marks the session completed and clears the active request
450    /// even when that item is unknown. Finishing a request clears
451    /// `active_request` only when IDs match, but always marks the session
452    /// completed. [`ChatEvent::RequestStarted`] is the event that clears
453    /// `last_error`; ordinary message events preserve it.
454    ///
455    /// # Performance
456    ///
457    /// Clones the complete pre-event state to compute the boolean result and
458    /// performs a linear search through messages for each upsert or lookup.
459    ///
460    /// # Examples
461    ///
462    /// ```
463    /// use ailloli_ui_core::{
464    ///     ChatEvent, ChatItemId, ChatMessageKind, ChatRequestId,
465    ///     ChatSessionId, ChatSessionState,
466    /// };
467    ///
468    /// let mut chat = ChatSessionState::new(ChatSessionId::from_index(1), "Demo");
469    /// let item = ChatItemId::from_index(1);
470    /// chat.apply_event(ChatEvent::AssistantMessageStarted {
471    ///     item_id: item.clone(),
472    ///     request_id: ChatRequestId::from_index(1),
473    ///     kind: ChatMessageKind::Text,
474    /// });
475    /// chat.apply_event(ChatEvent::AssistantMessageDelta {
476    ///     item_id: item.clone(),
477    ///     delta: "Hello".into(),
478    /// });
479    /// assert_eq!(chat.message(&item).unwrap().text, "Hello");
480    /// ```
481    pub fn apply_event(&mut self, event: ChatEvent) -> bool {
482        let before = self.clone();
483        match event {
484            ChatEvent::MessageAdded(message) => {
485                self.upsert_message(message);
486                self.status = ChatSessionStatus::Ready;
487            }
488            ChatEvent::UserMessageSubmitted {
489                item_id,
490                request_id,
491                text,
492            } => {
493                self.active_request = Some(request_id.clone());
494                self.status = ChatSessionStatus::Running;
495                self.upsert_message(ChatMessage::user(item_id, text).request_id(request_id));
496            }
497            ChatEvent::AssistantMessageStarted {
498                item_id,
499                request_id,
500                kind,
501            } => {
502                self.active_request = Some(request_id.clone());
503                self.status = ChatSessionStatus::Running;
504                self.upsert_message(
505                    ChatMessage::new(item_id, ChatRole::Assistant, kind, "")
506                        .request_id(request_id)
507                        .status(ChatMessageStatus::Streaming),
508                );
509            }
510            ChatEvent::AssistantMessageDelta { item_id, delta } => {
511                self.status = ChatSessionStatus::Running;
512                let mut message = self.message(&item_id).cloned().unwrap_or_else(|| {
513                    ChatMessage::new(
514                        item_id.clone(),
515                        ChatRole::Assistant,
516                        ChatMessageKind::Text,
517                        "",
518                    )
519                    .status(ChatMessageStatus::Streaming)
520                });
521                message.text.push_str(&delta);
522                if message.status == ChatMessageStatus::Complete {
523                    message.status = ChatMessageStatus::Streaming;
524                }
525                self.upsert_message(message);
526            }
527            ChatEvent::AssistantMessageFinished { item_id } => {
528                if let Some(mut message) = self.message(&item_id).cloned() {
529                    message.status = ChatMessageStatus::Complete;
530                    self.upsert_message(message);
531                }
532                self.status = ChatSessionStatus::Completed;
533                self.active_request = None;
534            }
535            ChatEvent::RequestStarted { request_id } => {
536                self.active_request = Some(request_id);
537                self.status = ChatSessionStatus::Running;
538                self.last_error = None;
539            }
540            ChatEvent::RequestFinished { request_id } => {
541                if self.active_request.as_ref() == Some(&request_id) {
542                    self.active_request = None;
543                }
544                self.status = ChatSessionStatus::Completed;
545            }
546            ChatEvent::StatusChanged(status) => {
547                self.status = status;
548            }
549            ChatEvent::Failed { message } => {
550                self.status = ChatSessionStatus::Failed;
551                self.last_error = Some(message);
552                self.active_request = None;
553            }
554        }
555        before != *self
556    }
557
558    /// Finds a message by ID without changing insertion order.
559    ///
560    /// Returns `None` if the ID has not been observed. Lookup is linear in the
561    /// number of messages.
562    ///
563    /// # Examples
564    ///
565    /// ```
566    /// use ailloli_ui_core::{ChatEvent, ChatItemId, ChatMessage, ChatSessionId, ChatSessionState};
567    /// let id = ChatItemId::new("item-1");
568    /// let mut session = ChatSessionState::new(ChatSessionId::new("session-1"), "Demo");
569    /// session.apply_event(ChatEvent::MessageAdded(ChatMessage::user(id.clone(), "Hello")));
570    /// assert_eq!(session.message(&id).unwrap().text, "Hello");
571    /// ```
572    pub fn message(&self, item_id: &ChatItemId) -> Option<&ChatMessage> {
573        self.messages.iter().find(|message| &message.id == item_id)
574    }
575
576    /// Replaces an existing ID in place or appends a previously unseen item.
577    fn upsert_message(&mut self, message: ChatMessage) {
578        if let Some(index) = self
579            .messages
580            .iter()
581            .position(|existing| existing.id == message.id)
582        {
583            self.messages[index] = message;
584        } else {
585            self.messages.push(message);
586        }
587    }
588}
589
590/// Compact owned projection of a [`ChatSessionState`] for lists and navigation.
591///
592/// # Examples
593///
594/// ```
595/// use ailloli_ui_core::{ChatSessionId, ChatSessionState, ChatSessionStatus};
596///
597/// let summary = ChatSessionState::new(ChatSessionId::from_index(1), "Demo").summary();
598/// assert_eq!(summary.status, ChatSessionStatus::Ready);
599/// assert_eq!(summary.last_message_preview, None);
600/// ```
601#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
602pub struct ChatSessionSummary {
603    /// Stable session identity.
604    pub id: ChatSessionId,
605    /// Consumer-facing session title.
606    pub title: String,
607    /// Aggregate session lifecycle.
608    pub status: ChatSessionStatus,
609    /// Number of ordered items in the source snapshot.
610    pub message_count: usize,
611    /// Truncated final-message text, or `None` when the session has no messages.
612    pub last_message_preview: Option<String>,
613}
614
615/// Incremental change understood by [`ChatSessionState::apply_event`].
616///
617/// # Examples
618///
619/// ```
620/// use ailloli_ui_core::{ChatEvent, ChatRequestId};
621///
622/// let event = ChatEvent::RequestStarted {
623///     request_id: ChatRequestId::from_index(3),
624/// };
625/// assert!(matches!(event, ChatEvent::RequestStarted { .. }));
626/// ```
627#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
628pub enum ChatEvent {
629    /// Upserts a complete caller-constructed item and marks the session ready.
630    MessageAdded(ChatMessage),
631    /// Adds/replaces a user text item and starts the correlated request.
632    UserMessageSubmitted {
633        /// Stable ID of the user item.
634        item_id: ChatItemId,
635        /// Request that becomes active.
636        request_id: ChatRequestId,
637        /// Complete user-authored UTF-8 text.
638        text: String,
639    },
640    /// Creates/replaces an empty streaming assistant item and starts its request.
641    AssistantMessageStarted {
642        /// Stable ID of the assistant item.
643        item_id: ChatItemId,
644        /// Request that becomes active and is attached to the item.
645        request_id: ChatRequestId,
646        /// Presentation category of the streamed item.
647        kind: ChatMessageKind,
648    },
649    /// Appends a UTF-8 fragment to an assistant item, creating it if absent.
650    AssistantMessageDelta {
651        /// Stable ID of the assistant item.
652        item_id: ChatItemId,
653        /// Fragment appended exactly as supplied; an empty fragment is allowed.
654        delta: String,
655    },
656    /// Marks an existing item complete and ends the active session request.
657    AssistantMessageFinished {
658        /// Item to finish; an unknown ID still completes the session.
659        item_id: ChatItemId,
660    },
661    /// Starts tracking a request and clears the previous session error.
662    RequestStarted {
663        /// Request that becomes active.
664        request_id: ChatRequestId,
665    },
666    /// Marks the session completed and conditionally clears a matching request.
667    RequestFinished {
668        /// Completed request; a non-matching ID leaves `active_request` intact.
669        request_id: ChatRequestId,
670    },
671    /// Replaces only the aggregate session status.
672    StatusChanged(ChatSessionStatus),
673    /// Records a session-level failure and clears the active request.
674    Failed {
675        /// Human-readable provider or adapter error text.
676        message: String,
677    },
678}
679
680/// Truncates text at a Unicode scalar-value boundary and appends `...` if needed.
681fn preview_text(text: &str, max_chars: usize) -> String {
682    let mut out = String::new();
683    for ch in text.chars().take(max_chars) {
684        out.push(ch);
685    }
686    if text.chars().count() > max_chars {
687        out.push_str("...");
688    }
689    out
690}
691
692#[cfg(test)]
693mod tests {
694    //! Covers typed ID serialization, streamed upserts, ordering, and previews.
695
696    use super::*;
697
698    #[test]
699    fn chat_ids_are_typed_serializable_and_stable() {
700        let session = ChatSessionId::from_index(7);
701        let item = ChatItemId::new("item-x");
702        let request = ChatRequestId::from_index(2);
703
704        assert_eq!(session.as_str(), "chat_session_0000000000000007");
705        assert_eq!(item.as_str(), "item-x");
706        assert_eq!(request.as_str(), "chat_request_0000000000000002");
707
708        let json = serde_json::to_string(&session).expect("serialize");
709        assert_eq!(json, "\"chat_session_0000000000000007\"");
710        let roundtrip: ChatSessionId = serde_json::from_str(&json).expect("deserialize");
711        assert_eq!(roundtrip, session);
712    }
713
714    #[test]
715    fn chat_streaming_events_accumulate_by_replacing_items() {
716        let mut session = ChatSessionState::new(ChatSessionId::from_index(1), "Chat 1");
717        let request = ChatRequestId::from_index(1);
718        let item = ChatItemId::from_index(1);
719
720        assert!(session.apply_event(ChatEvent::AssistantMessageStarted {
721            item_id: item.clone(),
722            request_id: request.clone(),
723            kind: ChatMessageKind::Text,
724        }));
725        assert!(session.apply_event(ChatEvent::AssistantMessageDelta {
726            item_id: item.clone(),
727            delta: "Hel".into(),
728        }));
729        assert!(session.apply_event(ChatEvent::AssistantMessageDelta {
730            item_id: item.clone(),
731            delta: "lo".into(),
732        }));
733        assert!(session.apply_event(ChatEvent::AssistantMessageFinished {
734            item_id: item.clone(),
735        }));
736
737        assert_eq!(session.messages.len(), 1);
738        let message = session.message(&item).expect("streamed message");
739        assert_eq!(message.text, "Hello");
740        assert_eq!(message.status, ChatMessageStatus::Complete);
741        assert_eq!(message.request_id.as_ref(), Some(&request));
742        assert_eq!(session.status, ChatSessionStatus::Completed);
743    }
744
745    #[test]
746    fn chat_summary_keeps_stable_order_and_preview() {
747        let mut session = ChatSessionState::new(ChatSessionId::from_index(1), "Chat 1");
748        session.apply_event(ChatEvent::MessageAdded(ChatMessage::user(
749            ChatItemId::from_index(1),
750            "first",
751        )));
752        session.apply_event(ChatEvent::MessageAdded(ChatMessage::assistant(
753            ChatItemId::from_index(2),
754            "second message",
755        )));
756
757        let summary = session.summary();
758        assert_eq!(summary.message_count, 2);
759        assert_eq!(
760            summary.last_message_preview.as_deref(),
761            Some("second message")
762        );
763        assert_eq!(session.messages[0].id, ChatItemId::from_index(1));
764        assert_eq!(session.messages[1].id, ChatItemId::from_index(2));
765    }
766}