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}