use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::entities::attachment::Attachment;
use crate::entities::chat_file::ChatFile;
use crate::entities::message::{Message, MessageRole};
use crate::entities::profile::{CharacterNames, Profile};
use crate::entities::sampling::SamplingConfig;
use crate::entities::subagent::{RunOutcome, SubagentRun};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Chat {
#[serde(default = "chat_schema_v1")]
pub v: u32,
pub id: Uuid,
pub profile_id: Uuid,
pub title: String,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub system_message: String,
#[serde(default)]
pub character_names: CharacterNames,
#[serde(default)]
pub messages: Vec<Message>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampling_override: Option<SamplingConfig>,
#[serde(default)]
pub draft: String,
#[serde(default, skip_serializing_if = "FeedView::is_default")]
pub feed_view: FeedView,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub children_expanded: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files: Vec<ChatFile>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub deleted: Vec<DeletedExchange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reflected_upto: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reflected_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compaction: Option<Compaction>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub renamed_manually: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unread: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<crate::entities::workspace::Workspace>,
#[serde(default)]
pub is_hidden: bool,
}
fn chat_schema_v1() -> u32 {
1
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Compaction {
pub summary: String,
pub upto: usize,
pub boundary_id: Uuid,
pub compacted_at: DateTime<Utc>,
pub rolls: u32,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct FeedView {
#[serde(default)]
pub thoughts: bool,
#[serde(default)]
pub tools: bool,
}
impl FeedView {
pub fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct DeletedExchange {
pub deleted_at: DateTime<Utc>,
pub messages: Vec<Message>,
pub draft: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cause: Option<DeletedCause>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DeletedCause {
DeleteExchange,
Regenerate,
Rewrite,
}
impl Chat {
pub fn from_profile(profile: &Profile, title: impl Into<String>) -> Self {
let now = Utc::now();
Self {
v: crate::shared::storage::schema::CHAT_SCHEMA,
id: Uuid::new_v4(),
profile_id: profile.id,
title: title.into(),
created_at: now,
modified_at: now,
system_message: profile.default_system_message.clone(),
character_names: profile.character_names.clone(),
messages: Vec::new(),
sampling_override: None,
draft: String::new(),
feed_view: FeedView::default(),
children_expanded: false,
attachments: Vec::new(),
files: Vec::new(),
deleted: Vec::new(),
compaction: None,
reflected_upto: None,
reflected_at: None,
renamed_manually: false,
unread: false,
workspace: None,
is_hidden: false,
}
}
pub fn push_message(&mut self, message: Message) {
self.messages.push(message);
self.modified_at = Utc::now();
}
pub fn list_file(&mut self, file: ChatFile) -> bool {
if self
.files
.iter()
.any(|f| crate::entities::chat_file::same_name(&f.name, &file.name))
{
return false;
}
self.files.push(file);
true
}
pub fn is_pristine(&self) -> bool {
self.messages.is_empty() && self.deleted.is_empty() && self.compaction.is_none()
}
pub fn record_deleted(&mut self, messages: Vec<Message>, draft: String, cause: DeletedCause) {
if messages.is_empty() {
return;
}
self.deleted.insert(
0,
DeletedExchange {
deleted_at: Utc::now(),
messages,
draft,
cause: Some(cause),
},
);
}
pub fn compaction_view(&self, enabled: bool) -> Option<(&str, usize)> {
if !enabled {
return None;
}
let c = self.compaction.as_ref()?;
let upto = if self
.messages
.get(c.upto)
.is_some_and(|m| m.id == c.boundary_id)
{
c.upto
} else {
self.messages.iter().position(|m| m.id == c.boundary_id)?
};
(upto > 0).then_some((c.summary.as_str(), upto))
}
pub fn summary(&self) -> ChatSummary {
ChatSummary {
id: self.id,
profile_id: self.profile_id,
title: self.title.clone(),
created_at: self.created_at,
modified_at: self.modified_at,
message_count: visible_message_count(&self.messages),
children: self.children().map(ChildSummary::of).collect(),
children_expanded: self.children_expanded,
unread: self.unread,
}
}
pub fn children(&self) -> impl Iterator<Item = &SubagentRun> {
self.messages
.iter()
.flat_map(|m| m.tool_calls.iter())
.filter_map(|r| r.subagent.as_deref())
}
pub fn child(&self, id: Uuid) -> Option<&SubagentRun> {
self.children().find(|r| r.id == id)
}
pub fn child_mut(&mut self, id: Uuid) -> Option<&mut SubagentRun> {
self.messages
.iter_mut()
.flat_map(|m| m.tool_calls.iter_mut())
.filter_map(|r| r.subagent.as_deref_mut())
.find(|r| r.id == id)
}
pub fn child_mut_including_deleted(&mut self, id: Uuid) -> Option<&mut SubagentRun> {
self.messages
.iter_mut()
.chain(self.deleted.iter_mut().flat_map(|d| d.messages.iter_mut()))
.flat_map(|m| m.tool_calls.iter_mut())
.filter_map(|r| r.subagent.as_deref_mut())
.find(|r| r.id == id)
}
pub fn reid_children(&mut self) {
let runs = self
.messages
.iter_mut()
.chain(self.deleted.iter_mut().flat_map(|d| d.messages.iter_mut()))
.flat_map(|m| m.tool_calls.iter_mut())
.filter_map(|r| r.subagent.as_deref_mut());
for run in runs {
run.id = Uuid::new_v4();
}
}
}
pub fn visible_message_count(messages: &[Message]) -> usize {
visible_row_count(messages.iter().map(|m| (m.role, m.new_bubble)))
}
pub fn visible_row_count(rows: impl IntoIterator<Item = (MessageRole, bool)>) -> usize {
let mut count = 0;
let mut in_assistant_bubble = false;
for (role, new_bubble) in rows {
match role {
MessageRole::Tool => {}
MessageRole::User | MessageRole::System => {
count += 1;
in_assistant_bubble = false;
}
MessageRole::Assistant => {
if !in_assistant_bubble || new_bubble {
count += 1;
}
in_assistant_bubble = true;
}
}
}
count
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChatSummary {
pub id: Uuid,
#[serde(default)]
pub profile_id: Uuid,
pub title: String,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
pub message_count: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub children: Vec<ChildSummary>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub children_expanded: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub unread: bool,
}
impl ChatSummary {
#[cfg(test)]
pub fn fixture(title: &str) -> Self {
Self {
id: Uuid::new_v4(),
profile_id: Uuid::nil(),
title: title.to_string(),
created_at: Utc::now(),
modified_at: Utc::now(),
message_count: 0,
children: Vec::new(),
children_expanded: false,
unread: false,
}
}
pub fn child_card(&self, child: &ChildSummary) -> ChatSummary {
ChatSummary {
id: child.id,
profile_id: self.profile_id,
title: child.title.clone(),
created_at: child.created_at,
modified_at: child.finished_at.unwrap_or(child.created_at),
message_count: child.message_count,
children: Vec::new(),
children_expanded: false,
unread: false,
}
}
pub fn child_ids(&self) -> impl Iterator<Item = Uuid> + '_ {
self.children.iter().map(|c| c.id)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChildSummary {
pub id: Uuid,
pub title: String,
pub created_at: DateTime<Utc>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub finished_at: Option<DateTime<Utc>>,
pub message_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outcome: Option<RunOutcome>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub running: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub background: bool,
}
impl ChildSummary {
pub fn of(run: &SubagentRun) -> Self {
Self {
id: run.id,
title: run.title.clone(),
created_at: run.created_at,
finished_at: run.finished_at,
message_count: visible_message_count(&run.messages),
outcome: run.outcome,
running: false,
background: run.background,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn workspace_is_additive_and_round_trips() {
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "t");
let json = serde_json::to_string(&chat).unwrap();
assert!(
!json.contains("workspace"),
"a chat with no project must not write the key: {json}"
);
let old: Chat = serde_json::from_str(&json).unwrap();
assert_eq!(old.workspace, None);
let mut attached = chat.clone();
attached.workspace = Some(crate::entities::workspace::Workspace::new("D:/proj"));
let json = serde_json::to_string(&attached).unwrap();
assert!(json.contains("workspace"), "{json}");
let back: Chat = serde_json::from_str(&json).unwrap();
assert_eq!(back.workspace.unwrap().root, "D:/proj");
}
#[test]
fn children_expanded_is_additive_and_round_trips() {
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "t");
let json = serde_json::to_value(&chat).unwrap();
assert!(
json.get("children_expanded").is_none(),
"a chat nobody expanded writes no new key: {json}"
);
let old: Chat = serde_json::from_value(json).unwrap();
assert!(!old.children_expanded, "old files load collapsed");
let mut expanded = chat;
expanded.children_expanded = true;
let json = serde_json::to_value(&expanded).unwrap();
assert_eq!(json.get("children_expanded"), Some(&true.into()));
let back: Chat = serde_json::from_value(json).unwrap();
assert!(back.children_expanded);
assert!(
back.summary().children_expanded,
"the card carries the fold"
);
let summary_json = serde_json::to_value(back.summary()).unwrap();
let summary: ChatSummary = serde_json::from_value(summary_json).unwrap();
assert!(summary.children_expanded);
}
#[test]
fn unread_is_additive_and_round_trips() {
let profile = Profile::new("P", "sys");
let chat = Chat::from_profile(&profile, "t");
let json = serde_json::to_value(&chat).unwrap();
assert!(
json.get("unread").is_none(),
"a chat with nothing unread writes no new key: {json}"
);
let old: Chat = serde_json::from_value(json).unwrap();
assert!(!old.unread, "old files load read");
let mut marked = chat;
marked.unread = true;
let json = serde_json::to_value(&marked).unwrap();
assert_eq!(json.get("unread"), Some(&true.into()));
let back: Chat = serde_json::from_value(json).unwrap();
assert!(back.unread);
assert!(back.summary().unread, "the card carries the mark");
let summary_json = serde_json::to_value(back.summary()).unwrap();
let summary: ChatSummary = serde_json::from_value(summary_json).unwrap();
assert!(summary.unread);
}
use super::*;
use crate::entities::message::Message;
fn compacted(n: usize, upto: usize) -> Chat {
let p = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&p, "c");
for i in 0..n {
chat.push_message(Message::user(format!("u{i}")));
chat.push_message(Message::assistant(format!("a{i}")));
}
chat.compaction = Some(Compaction {
summary: "ранее обсудили X".into(),
upto,
boundary_id: chat.messages[upto].id,
compacted_at: Utc::now(),
rolls: 1,
});
chat
}
#[test]
fn compaction_view_is_inert_when_the_switch_is_off() {
let chat = compacted(4, 4);
assert_eq!(chat.compaction_view(false), None);
assert_eq!(chat.compaction_view(true), Some(("ранее обсудили X", 4)));
}
#[test]
fn compaction_view_refinds_the_boundary_after_an_index_shift() {
let mut chat = compacted(4, 4);
chat.messages.remove(0);
assert_eq!(chat.compaction_view(true), Some(("ранее обсудили X", 3)));
}
#[test]
fn compaction_view_drops_a_summary_whose_boundary_is_gone() {
let mut chat = compacted(4, 4);
chat.messages.remove(4);
assert_eq!(chat.compaction_view(true), None);
}
#[test]
fn compaction_view_ignores_a_summary_that_covers_nothing() {
let mut chat = compacted(4, 4);
let first = chat.messages[0].id;
chat.compaction.as_mut().unwrap().upto = 0;
chat.compaction.as_mut().unwrap().boundary_id = first;
assert_eq!(chat.compaction_view(true), None);
}
#[test]
fn compaction_reads_old_chat_files_and_stays_out_of_new_ones() {
let p = Profile::new("P", "sys");
let chat = Chat::from_profile(&p, "c");
let json = serde_json::to_value(&chat).unwrap();
assert!(
json.get("compaction").is_none(),
"a chat that was never compacted must write no new key"
);
let back: Chat = serde_json::from_value(json).unwrap();
assert!(back.compaction.is_none());
}
#[test]
fn feed_view_reads_old_chat_files_and_stays_out_of_new_ones() {
let p = Profile::new("P", "sys");
let chat = Chat::from_profile(&p, "Чат");
let mut json: serde_json::Value = serde_json::to_value(&chat).unwrap();
assert!(
json.get("feed_view").is_none(),
"a chat nobody expanded anything in writes no new key: {json}"
);
let back: Chat = serde_json::from_value(json.clone()).unwrap();
assert_eq!(back.feed_view, FeedView::default());
let mut expanded = chat;
expanded.feed_view = FeedView {
thoughts: true,
tools: false,
};
json = serde_json::to_value(&expanded).unwrap();
assert!(json.get("feed_view").is_some());
let back: Chat = serde_json::from_value(json).unwrap();
assert_eq!(back.feed_view, expanded.feed_view);
}
#[test]
fn from_profile_copies_fields_but_not_sampling() {
let mut p = Profile::new("Carlos", "Ты — Карлос.");
p.default_sampling = Some(SamplingConfig {
temperature: Some(0.9),
..Default::default()
});
let chat = Chat::from_profile(&p, "Новый чат");
assert_eq!(chat.profile_id, p.id);
assert_eq!(chat.system_message, "Ты — Карлос.");
assert_eq!(chat.character_names, p.character_names);
assert_eq!(chat.sampling_override, None);
assert!(chat.messages.is_empty());
}
#[test]
fn push_message_updates_modified() {
let p = Profile::new("X", "s");
let mut chat = Chat::from_profile(&p, "t");
let before = chat.modified_at;
chat.push_message(Message::user("hi"));
assert_eq!(chat.messages.len(), 1);
assert!(chat.modified_at >= before);
}
#[test]
fn visible_message_count_folds_rounds_and_tool_results() {
let mut messages = vec![
Message::user("q"),
Message::assistant(""),
Message::new(MessageRole::Tool, "result"),
Message::assistant("the answer"),
];
assert_eq!(visible_message_count(&messages), 2);
let mut followup = Message::assistant("more");
followup.new_bubble = true;
messages.push(followup);
assert_eq!(visible_message_count(&messages), 3);
messages.push(Message::new(MessageRole::System, "sys"));
assert_eq!(visible_message_count(&messages), 4);
messages.push(Message::assistant("post-note"));
assert_eq!(visible_message_count(&messages), 5);
messages.push(Message::user("q2"));
messages.push(Message::assistant("a2"));
assert_eq!(visible_message_count(&messages), 7);
}
#[test]
fn summaries_count_visible_messages_not_rows() {
let p = Profile::new("P", "sys");
let mut chat = Chat::from_profile(&p, "t");
assert_eq!(chat.summary().message_count, 0);
chat.push_message(Message::user("q"));
chat.push_message(Message::assistant(""));
chat.push_message(Message::new(MessageRole::Tool, "result"));
chat.push_message(Message::assistant("a"));
assert_eq!(chat.summary().message_count, 2);
let mut run = SubagentRun::fixture("R", &["instruction"]);
run.messages.push(Message::assistant(""));
run.messages.push(Message::new(MessageRole::Tool, "result"));
run.messages.push(Message::assistant("verdict"));
assert_eq!(ChildSummary::of(&run).message_count, 2);
}
#[test]
fn record_deleted_appends_with_draft_and_skips_empty() {
let p = Profile::new("X", "s");
let mut chat = Chat::from_profile(&p, "t");
chat.record_deleted(vec![], "ignored".into(), DeletedCause::DeleteExchange);
assert!(chat.deleted.is_empty());
chat.record_deleted(
vec![Message::user("hi"), Message::assistant("hello")],
"набранный, но не отправленный текст".into(),
DeletedCause::DeleteExchange,
);
assert_eq!(chat.deleted.len(), 1);
assert_eq!(chat.deleted[0].messages.len(), 2);
assert_eq!(chat.deleted[0].draft, "набранный, но не отправленный текст");
assert_eq!(chat.deleted[0].cause, Some(DeletedCause::DeleteExchange));
}
#[test]
fn is_pristine_tracks_conversation_content_only() {
let p = Profile::new("X", "s");
let mut chat = Chat::from_profile(&p, "t");
chat.draft = "typed but unsent".into();
chat.sampling_override = Some(SamplingConfig::default());
chat.renamed_manually = true;
assert!(chat.is_pristine(), "user-side state is not content");
let mut with_message = Chat::from_profile(&p, "t");
with_message.push_message(Message::assistant("Здравствуйте!"));
assert!(!with_message.is_pristine());
let mut with_tombstone = Chat::from_profile(&p, "t");
with_tombstone.record_deleted(
vec![Message::user("hi")],
String::new(),
DeletedCause::DeleteExchange,
);
assert!(!with_tombstone.is_pristine());
let mut with_summary = Chat::from_profile(&p, "t");
with_summary.compaction = Some(Compaction {
summary: "сводка".into(),
upto: 0,
boundary_id: Uuid::new_v4(),
compacted_at: Utc::now(),
rolls: 1,
});
assert!(!with_summary.is_pristine());
}
#[test]
fn renamed_manually_is_additive_and_round_trips() {
let old = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"profile_id": "00000000-0000-0000-0000-000000000002",
"title": "old chat",
"created_at": "2026-01-01T00:00:00Z",
"modified_at": "2026-01-01T00:00:00Z",
"system_message": "s",
"messages": []
}"#;
let loaded: Chat = serde_json::from_str(old).unwrap();
assert!(!loaded.renamed_manually);
let p = Profile::new("X", "s");
let chat = Chat::from_profile(&p, "t");
let json = serde_json::to_string(&chat).unwrap();
assert!(
!json.contains("renamed_manually"),
"an untouched chat must write no new key: {json}"
);
let mut renamed = chat;
renamed.renamed_manually = true;
let json = serde_json::to_string(&renamed).unwrap();
let back: Chat = serde_json::from_str(&json).unwrap();
assert!(back.renamed_manually);
}
#[test]
fn deleted_empty_is_not_serialized() {
let p = Profile::new("X", "s");
let chat = Chat::from_profile(&p, "t");
let json = serde_json::to_string(&chat).unwrap();
assert!(!json.contains("deleted"));
}
#[test]
fn files_are_additive_and_not_serialized_when_empty() {
use crate::entities::chat_file::{ChatFile, FileOrigin};
let p = Profile::new("X", "s");
let chat = Chat::from_profile(&p, "t");
let json = serde_json::to_string(&chat).unwrap();
assert!(!json.contains("\"files\""), "no files, no key: {json}");
let old = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"profile_id": "00000000-0000-0000-0000-000000000002",
"title": "old chat",
"created_at": "2026-01-01T00:00:00Z",
"modified_at": "2026-01-01T00:00:00Z",
"system_message": "s",
"messages": []
}"#;
let loaded: Chat = serde_json::from_str(old).unwrap();
assert!(loaded.files.is_empty());
let mut with = chat.clone();
with.files.push(ChatFile::new(
"chart.png",
FileOrigin::Sandbox,
b"\x89PNG\r\n\x1a\n",
));
let json = serde_json::to_string(&with).unwrap();
let back: Chat = serde_json::from_str(&json).unwrap();
assert_eq!(back.files, with.files);
}
#[test]
fn attachments_are_additive_and_not_serialized_when_empty() {
use crate::entities::attachment::{AttachMode, Attachment};
let p = Profile::new("X", "s");
let chat = Chat::from_profile(&p, "t");
assert!(chat.attachments.is_empty());
let json = serde_json::to_string(&chat).unwrap();
assert!(
!json.contains("attachments"),
"empty list doesn't clutter the file"
);
let old = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"profile_id": "00000000-0000-0000-0000-000000000002",
"title": "old chat",
"created_at": "2026-01-01T00:00:00Z",
"modified_at": "2026-01-01T00:00:00Z",
"system_message": "s",
"messages": []
}"#;
let loaded: Chat = serde_json::from_str(old).unwrap();
assert!(loaded.attachments.is_empty());
let mut with = chat.clone();
with.attachments.push(Attachment::new(
"notes.md",
"/tmp/notes.md",
"содержимое".into(),
20,
AttachMode::Inline,
));
let json = serde_json::to_string(&with).unwrap();
let back: Chat = serde_json::from_str(&json).unwrap();
assert_eq!(with, back);
}
#[test]
fn serde_roundtrip() {
let p = Profile::new("X", "s");
let mut chat = Chat::from_profile(&p, "t");
chat.push_message(Message::user("hi"));
chat.push_message(Message::assistant("hello"));
chat.record_deleted(
vec![Message::user("удалённое")],
"черновик".into(),
DeletedCause::Regenerate,
);
let json = serde_json::to_string(&chat).unwrap();
let back: Chat = serde_json::from_str(&json).unwrap();
assert_eq!(chat, back);
}
#[test]
fn deserializes_old_json_without_reflection_watermark() {
let json = r#"{
"id": "00000000-0000-0000-0000-000000000001",
"profile_id": "00000000-0000-0000-0000-000000000002",
"title": "старый чат",
"created_at": "2026-01-01T00:00:00Z",
"modified_at": "2026-01-01T00:00:00Z",
"system_message": "s",
"messages": []
}"#;
let chat: Chat = serde_json::from_str(json).unwrap();
assert_eq!(chat.reflected_upto, None);
assert_eq!(chat.reflected_at, None);
let out = serde_json::to_string(&chat).unwrap();
assert!(!out.contains("reflected_upto"));
assert!(!out.contains("reflected_at"));
}
}